Compare commits

...
Author SHA1 Message Date
mregosar 48241f0784 Merge fix/sicherheit-und-stabilitaet in main 2026-11-01 20:25:45 +01:00
mregosar e18b8b4430 Absicherung: Socket-Eingaben, Geheimnisse in Fehlern, Logparser, Start/Stopp, RCON, Stream-Zugang
- Socket-Nachrichten werden geprüft, Handlerfehler abgefangen, Abfragen pro Verbindung begrenzt
  (vorher beendete stream:subscribe mit null ohne Anmeldung das Backend).
- Fehlermeldungen von Prozessen ohne Befehlszeile und mit geschwärzten Passwörtern/Tokens.
- Logparser wertet nur ganze Systemzeilen nach dem Zeitstempel aus; Chat kann keine Map beenden.
- Verspätet ankommende Starts nach Stopp, Neuanforderung oder Löschen bauen ihren Container ab.
- RCON: kaputte Pakete trennen nur die Verbindung, Befehle ohne Antwort gelten als Fehler.
- Organisatoren bekommen einen Stream-Schlüssel nur für ihr Match statt des globalen STREAM_KEY.
- Rate-Limiter mit regelmäßiger Bereinigung und Obergrenze, alte Admin-Sitzungen werden entfernt.
2026-11-01 20:25:45 +01:00
mregosar 35bdfc1f67 Merge feature/einladungslinks-bei-teams in main 2026-11-01 20:09:36 +01:00
mregosar f64da5ac86 Lobby: Einladungslinks unter dem jeweiligen Team
Der Organisator sieht Code, Link, Kopieren und Neuer Code jetzt unten in der Team-Karte. Im Bereich Einladungen bleiben Zuschauer-, Stream- und Organisator-Links.
2026-11-01 20:09:35 +01:00
mregosar 141c5597c4 Merge feature/lobbys-aufraeumen in main 2026-11-01 19:54:09 +01:00
mregosar bd022e043e Offene Lobbys nach 3 Stunden samt Server löschen (LOBBY_MAX_HOURS)
Der Aufräum-Lauf löscht Lobbys, deren Veto nie gestartet wurde, nach LOBBY_MAX_HOURS (Standard 3, 0 = nie) seit dem Erstellen und stoppt ihren Server. Matches im Veto, im Spiel oder abgeschlossen bleiben unberührt.
2026-11-01 19:54:09 +01:00
mregosar 5503995791 Merge fix/keine-map-abstimmung in main 2026-11-01 19:44:25 +01:00
mregosar be42beb260 Keine Map-Abstimmung nach Spielende (mp_endmatch_votenextmap 0)
Gilt für Match- und Messer-Server. Die nächste Map legt das Veto fest, die App lädt sie selbst.
2026-11-01 19:44:25 +01:00
mregosar cc6e7f65be Merge fix/messerrunde-wiederholen in main 2026-11-01 19:28:41 +01:00
mregosar 1492c280d5 Messerrunde: Einrichten bei Fehlern automatisch wiederholen
Nach einer frischen Installation dauert das erste Laden einer Map oft länger als die Wartezeit. Die App versucht das Einrichten jetzt bis zu dreimal (alle 10 s). Scheitern alle Versuche, zeigt die Lobby den Fehler und dem Organisator den Knopf "Messerrunde neu einrichten".
2026-11-01 19:23:52 +01:00
mregosar 934d989572 Merge feature/beamer-diashow in main 2026-11-01 19:01:41 +01:00
mregosar dc601a32fe Beamer-Diashow der laufenden Matches unter /beamer
Ein Match pro Folie, groß und aus der Ferne lesbar: Teams, Serienstand, aktuelle Map mit Rundenstand und Seiten, Maps der Serie, im Veto wer dran ist. Zum Schluss eine Folie mit Lobbys und Ergebnissen. Schaltet automatisch weiter (?sekunden=), Vollbild per Klick oder F, Pfeiltasten und Leertaste. In der Kopfzeile verlinkt.
2026-11-01 19:00:59 +01:00
mregosar 6a922f80de Merge fix/start-warteschlange in main 2026-11-01 18:50:35 +01:00
mregosar d36dcacd39 Fix: Starts meiden hochfahrende Hosts, Messerrunde zählt nicht als Map-Ende
- Neue Server meiden Hosts, auf denen gerade ein Container per SteamCMD lädt
  (Warteschlange im Prozess und laufende Starts laut Datenbank). Vorher warteten
  alle Starts hinter einem langen Download, obwohl andere Hosts frei waren.
- Vor dem Einrichten des Matches wird eine laufende Messerrunde in die
  Aufwärmphase gesetzt, und Logzeilen zählen erst, wenn die Map eingerichtet ist.
  Vorher wurde das Ende der Messerrunde als Ende von Map 1 gewertet und die
  nächste Map automatisch geladen.
2026-11-01 18:50:12 +01:00
mregosar 148cd99856 Merge feature/messer-match-server in main
# Conflicts:
#	client/src/styles.css
2026-11-01 18:26:42 +01:00
mregosar e18a6cc0c9 Merge feature/spieldateien-update in main 2026-11-01 18:26:35 +01:00
mregosar 13320313ec Messerrunde auf dem eigenen Match-Server, danach derselbe Server fürs Match
Bei Seitenwahl per Messerrunde richtet die App den Match-Server in der Lobby als
Messer-Server ein (Passwort des Matches, Aufwärmphase ohne Ende). Spieler sehen die
Verbindungsdaten in der Lobby, der Organisator startet bzw. wiederholt die Runde.
Nach dem Veto wird derselbe Server fürs Match eingerichtet, die Messer-Einstellungen
werden zurückgenommen.
2026-11-01 18:25:07 +01:00
mregosar 3c5a9b4162 Admin: Spieldateien auf allen Hosts per SteamCMD aktualisieren, mit Prozentanzeige
Ein kurzlebiger Container je Host aktualisiert CS2 im Spieldateien-Volume. Hosts mit
laufenden Servern werden ausgelassen (erzwingbar), während des Updates startet dort
kein neuer Server. Fertig nur, wenn Steam die App als vollständig installiert meldet.
2026-11-01 18:22:23 +01:00
mregosar 2c57b8c170 Merge feature/container-konsole in main 2026-11-01 17:18:02 +01:00
mregosar 7fe4c2ec79 Merge feature/startseite-ohne-messer in main 2026-11-01 17:18:02 +01:00
mregosar ee9c488a7e Admin: Live-Konsole der Container und Live-Log der Docker-Hosts (nur lesen, Passwörter ausgeblendet) 2026-11-01 17:16:58 +01:00
mregosar af2ecf7a3a Startseite: Messer-Server-Liste entfernt, sie steht jetzt in der Lobby bei Messerrunde 2026-11-01 17:16:58 +01:00
mregosar fb96fb5d08 Merge feature/lobby-chat in main 2026-10-29 22:44:17 +01:00
mregosar c820ba9f12 Chat im Match: Match-Chat und Team-Chat in Tabs, Zuschauer ausgeschlossen, nach Matchende nur lesbar 2026-10-29 22:44:17 +01:00
mregosar e26c736f2e Merge feature/messer-veto in main
# Conflicts:
#	client/src/styles.css
2026-10-29 22:27:18 +01:00
mregosar a3471a2f3c Merge feature/startseite-spieler in main 2026-10-29 22:27:07 +01:00
mregosar cdf41df01c Veto: Seitenwahl per Messerrunde, freie Messer-Server in der Lobby, Organisator legt fest, wer beginnt 2026-10-29 22:27:07 +01:00
mregosar c62fe4ba1e Startseite neu: Spieler zuerst mit Code-Eingabe, eigenem Match und den vier Schritten 2026-10-29 22:27:06 +01:00
mregosar 7b6ce01d05 Merge feature/admin-sidebar in main 2026-10-29 22:04:24 +01:00
mregosar c4c763fa6c Merge feature/name-maxlan in main 2026-10-29 22:04:24 +01:00
mregosar 75b1701d8a Admin: Einstellungen mit Seitenleiste, Docker-Hosts und Messer-Server in die Einstellungen 2026-10-29 22:04:24 +01:00
mregosar ea06237846 Name: Maxlan statt MaxLAN auf der Webseite und in den Servernamen 2026-10-29 22:04:24 +01:00
mregosar 15997af23d Merge docs/spieler-anleitung in main 2026-10-29 21:29:52 +01:00
mregosar 51abc5b945 Doku: Anleitung für Spieler als PDF 2026-10-29 21:29:52 +01:00
mregosar fda3f4ea9c Merge feature/map-auswahl in main 2026-10-29 21:03:29 +01:00
mregosar 2d65634d6e Map-Pools: Overpass, Vertigo und Cache für Competitive, Map-Auswahl im Admin, Veto-Ablauf passend zur Map-Anzahl 2026-10-29 21:03:29 +01:00
mregosar e3834bb071 Merge docs/anleitungen-aktualisieren in main 2026-10-29 20:33:15 +01:00
mregosar 0ba60dd207 Merge feature/messer-server in main 2026-10-29 20:33:15 +01:00
mregosar 3c5b929845 Doku: Admin-Bereich, Messer-Server, Steam-LAN-Reiter, neue Variablen; Admin-Hilfe neu 2026-10-29 20:33:15 +01:00
mregosar c6c39b914f Messer-Server für die Seitenwahl: 5v5/2v2, im Admin startbar, setzen sich nach der Runde selbst zurück 2026-10-29 20:33:15 +01:00
mregosar 0bfac952b6 Merge feature/admin-filter-archiv in main 2026-10-29 20:33:14 +01:00
mregosar 7ae3efef60 Admin: Suche, Filter und Archiv in der Matchliste, Fehler direkt in der Karte 2026-10-29 20:33:14 +01:00
mregosar c50741e49c Merge fix/server-neustart in main 2026-10-29 16:09:45 +01:00
mregosar dfcca1be82 Server-Neustart im laufenden Match: Map wieder laden, volle Leerlauf-Frist ab Start 2026-10-29 16:09:45 +01:00
mregosar 1b3536452c Merge feature/steam-lan-browser in main 2026-10-29 14:34:51 +01:00
mregosar 0a40688945 Server im LAN-Reiter des Steam-Serverbrowsers: LAN-Ports, Host-Netz, Teamnamen, CSTV +100 2026-10-29 14:34:51 +01:00
mregosar 4521cdae72 Merge feature/admin-server-zeit in main 2026-10-29 13:54:46 +01:00
mregosar 479962dc8d Merge fix/alte-server-handles in main 2026-10-29 13:54:46 +01:00
mregosar b616bbafc1 Docker: alte Server ohne passenden Host-Namen über die Adresse finden 2026-10-29 13:54:46 +01:00
mregosar f022673c4d Admin: Datum und Uhrzeit der Server-Anforderung anzeigen 2026-10-29 13:54:45 +01:00
mregosar 3040124b33 Merge feature/admin-server-einstellungen in main 2026-10-29 13:32:48 +01:00
mregosar 929134f2bc Admin: Gameserver-Einstellungen, Docker-Hosts und Image-Update im Admin-Bereich 2026-10-29 13:32:05 +01:00
mregosar 472bda31a9 Merge feature/admin-server-adresse in main 2026-10-29 12:59:09 +01:00
mregosar ed65b92be1 Admin: Adresszeile kompakt gestalten, bei gestopptem Server ausblenden 2026-10-29 12:58:25 +01:00
mregosar beaf635c23 Speichermessung: Bind-Ordner eines Volumes auch ohne laufenden Container finden 2026-10-29 12:57:11 +01:00
mregosar f6735824cc Admin: freien Speicher dort messen, wo das Gameserver-Volume liegt 2026-10-29 12:55:23 +01:00
mregosar 2873b5ba3a Admin: IP und Ports des Servers in der Matchkarte anzeigen 2026-10-29 12:53:27 +01:00
mregosar 9bb878c641 Merge feature/host-disk in main 2026-10-29 10:30:02 +01:00
mregosar 1243e9c852 Admin: freier Speicher pro Docker-Host anzeigen
Die Karte Docker-Hosts zeigt den freien Speicher unter /var/lib/docker, per SSH
mit df gemessen und eine Minute zwischengespeichert. Unter 20 GB wird die
Zeile rot.
2026-10-29 10:30:01 +01:00
mregosar 844da4713d Merge feature/host-usage in main 2026-10-29 10:12:17 +01:00
mregosar 91f979945e Admin: Auslastung der Docker-Hosts mit den laufenden Matches
Die Admin-Übersicht zeigt pro Docker-Host belegte und freie Plätze und die
Matches, die dort laufen. Ohne konfigurierte Hosts bleibt die Karte aus.
2026-10-29 10:12:17 +01:00
mregosar c1c9250126 Merge feature/multi-docker-host in main 2026-10-29 10:00:20 +01:00
mregosar eda15bc585 Mehrere Docker-Hosts: Matches verteilt auf die Hosts starten
CS2_DOCKER_HOSTS nimmt eine Liste von Docker-Hosts. Jedes Match läuft auf
dem Host mit den meisten freien Ports und merkt sich seinen Host, damit
Status, Konfiguration und Stopp dort landen. Ohne die Variable bleibt alles
beim bisherigen einzelnen Host. deploy/docker-host-einrichten.sh richtet
einen Host per SSH ein.
2026-10-29 10:00:19 +01:00
mregosar 1a304cb398 Merge feature/public-live-view in main 2026-10-29 09:35:35 +01:00
mregosar cd7f7e0468 Merge feature/overview-live-cards in main 2026-10-29 09:35:35 +01:00
mregosar e1ee009063 Gäste sehen laufende Matches: Veto, Serienstand und Map-Reihenfolge
Ohne Zugang sind Veto und Serienstand sichtbar. Spielerlisten, Codes,
Verbindungsdaten und das Ereignisprotokoll bleiben Teilnehmern vorbehalten.
2026-10-29 09:35:35 +01:00
mregosar f90351dd8d Übersicht: laufende Matches als Karten mit Map, Stand und Serie 2026-10-29 09:29:12 +01:00
mregosar d2625d23ab Merge docs/admin-hilfe in main 2026-10-29 09:22:01 +01:00
mregosar 8b6abdb8b2 Merge feature/admin-status in main 2026-10-29 09:22:01 +01:00
mregosar eb80c4845c Docs: Hilfe für Turnier-Admins (PDF mit Screenshots) 2026-10-29 09:21:33 +01:00
mregosar 90996d1b0a Admin: Pausenanzeige, Live-Stand und passender Umschalter
Pausierte Matches zeigen ein Badge und den Knopf Fortsetzen. Laufende Matches
zeigen Map, Stand und Runde. Der Pausenzustand wird gespeichert und bei
Mapwechsel oder Serverstopp zurückgesetzt.
2026-10-29 09:21:32 +01:00
SPP UserandClaude Opus 5.5 da986d44c8 Merge feature/remove-real-logs in main
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 07:38:38 +01:00
SPP UserandClaude Opus 5.5 5383008c8e Echte Logdatei durch anonymisierte Fassung ersetzen
Die Testdatei enthielt Spielernamen und SteamID eines echten Accounts.
Sie ist jetzt durch eine Kopie mit Platzhaltern ersetzt, der Parser-Test
prüft weiterhin echte CS2-Logzeilen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 07:38:38 +01:00
SPP UserandClaude Opus 5.5 11ccbf01a1 Merge docs/reverse-proxy in main
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 07:32:41 +01:00
SPP UserandClaude Opus 5.5 5012e8b16d README: Betrieb hinter einem Reverse-Proxy
Abschnitt mit nginx-Beispiel, Hinweisen zu Subdomain, WebSockets,
PUBLIC_URL und TRUST_PROXY sowie zur Absicherung. TRUST_PROXY steht
jetzt auch in der Variablentabelle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 07:32:41 +01:00
SPP UserandClaude Opus 5.5 1744ff3f73 Merge feature/veto-timer-bar in main
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 14:02:46 +01:00
SPP UserandClaude Opus 5.5 d560c15fef Veto-Timer als Leiste in Teamfarbe
Die verbleibende Zeit des Veto-Schritts erscheint als Leiste mit
Sekundenanzeige, in den letzten 10 Sekunden rot. Der Server schickt
die Schrittdauer mit, damit die Leiste zu VETO_STEP_SECONDS passt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 14:02:46 +01:00
SPP UserandClaude Haiku 5.5 364c0b57c2 Merge feature/veto-timer in main
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-26 11:26:34 +01:00
SPP UserandClaude Haiku 5.5 9f2e810fa1 Merge feature/admin-settings in main
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-26 11:26:25 +01:00
SPP UserandClaude Haiku 5.5 e9efe38b76 Admin: Reiter Matches und Einstellungen, Discord-Embeds, CSTV-Zugang
Der Admin-Bereich bekommt Reiter. Die Discord-Einstellungen liegen
unter „Einstellungen“. Meldungen sind als Embeds formatiert, und die
Match-Startmeldung enthält den CSTV-Zugang (Passwort und Adresse).

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-26 11:03:54 +01:00
SPP UserandClaude Haiku 5.5 dea0618b69 Veto-Timer: 30 Sekunden pro Schritt, danach automatische Auswahl
Läuft die Frist eines Veto-Schritts ab (VETO_STEP_SECONDS, Standard 30,
0 = aus), wählt das Backend für das Team am Zug eine zufällige Map bzw.
Seite. Die Veto-Anzeige zeigt die verbleibende Zeit.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-26 11:02:33 +01:00
SPP UserandClaude Haiku 5.5 0197cd05ce Merge feature/discord-webhook in main
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-26 01:12:15 +01:00
SPP UserandClaude Haiku 5.5 9af83cb47c Discord-Webhook für Streamer-Meldungen mit wählbaren Ereignissen
Meldungen zu Veto, Match-Start, Map- und Serienende, Matchende und
Serverfehlern gehen an DISCORD_WEBHOOK_URL. Im Admin-Bereich wählt man,
welche Ereignisse gemeldet werden, und kann eine Testmeldung senden.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-26 01:12:15 +01:00
SPP UserandClaude Haiku 5.5 5a332655b2 Merge docs/readme-update in main
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-26 00:53:46 +01:00
SPP UserandClaude Haiku 5.5 c4bf4f19ae README: Match-Übersicht, Pausen und Turnierregeln dokumentiert
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-26 00:53:46 +01:00
SPP UserandClaude Haiku 5.5 2c0f86dccc Merge Rules in main
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-26 00:50:27 +01:00
SPP UserandClaude Haiku 5.5 91022e307c Merge feature/admin-pause in main
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-26 00:50:27 +01:00
SPP UserandClaude Haiku 5.5 fe251a13ef Turnierregeln für die CS2-Server-Konfiguration
Setzt Freezetime 20, Buytime 20, Startgeld 800, Rundenzeit 1:55 und
Halbzeit 1 fest und erlaubt Pausen mit sv_pausable 1. Die Werte
stammen aus FACEIT- und ESL-Richtwerten, nicht aus einer aktuellen
Regel des konkreten Turniers.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-26 00:48:26 +01:00
SPP UserandClaude Haiku 5.5 3e682d7fbc Admin: Pausieren und Fortsetzen des laufenden Matches
Neue Admin-Aktionen server/pause und server/resume senden
mp_pause_match bzw. mp_unpause_match an den CS2-Server. Nur bei
konfiguriertem Server verfügbar.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-26 00:48:25 +01:00
SPP UserandClaude Haiku 5.5 a6e7d7d287 Merge feature/organizer-stream-link in main
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-26 00:24:41 +01:00
SPP UserandClaude Haiku 5.5 d7677f3afe Organisator: Link zur Übersicht des eigenen Matches
Der Organisator bekommt einen Link auf /stream/match/<id>, die
Stream-Übersicht nur für dieses Match. Der Zuschauercode bleibt
außen vor. Ist STREAM_KEY gesetzt, wird der Schlüssel angehängt.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-26 00:24:41 +01:00
SPP UserandClaude Haiku 5.5 0341921655 Merge feature/stream-match-link in main
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-26 00:15:37 +01:00
SPP UserandClaude Haiku 5.5 fde3baafb5 Stream-Übersicht: Matches anklickbar, Einzelansicht pro Match
Karten und Listeneinträge der Stream-Übersicht führen zur Übersicht
des jeweiligen Matches (/stream/match/:matchId). Der Zuschauercode
bleibt geheim, daher wird nur die Match-ID verlinkt.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-26 00:15:37 +01:00
SPP UserandClaude Haiku 5.5 82239c8447 Merge feature/round-rollback in main
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-25 23:44:59 +01:00
SPP UserandClaude Haiku 5.5 b9dafbc6ab Rollback: Rundensicherung per Startparameter aktivieren
CS2 legt Rundensicherungen nur mit -allow_round_backup_files an (sonst
Antwort 'Round backup files are disabled'). Der Docker-Adapter setzt den
Parameter über CS2_ADDITIONAL_ARGS. Am echten Server geprüft: Sicherungen
für Runde 0 bis 2 vorhanden, Zurückspielen auf Runde 1 funktioniert.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-25 23:44:40 +01:00
SPP UserandClaude Haiku 5.5 a4218c47d1 Rollback: Runden aus CS2-Sicherungen zurückspielen (nur Admin)
Beim Konfigurieren aktiviert CS2 Mapveto die Rundensicherung des Servers
(mp_backup_round_auto, Präfix mit Match-ID). Im Admin-Modus lassen sich
die Sicherungen der laufenden Map auflisten und zurückspielen. Der
Live-Stand wird auf die Runden bis zur Sicherung gekürzt. Organisatoren
haben keinen Zugang zu den Rollback-Routen.

Sicherheit: Dateinamen werden gegen Match-ID und Muster geprüft, damit
keine fremden Dateien oder Befehle in die RCON-Zeile gelangen.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-25 23:24:49 +01:00
SPP UserandClaude Haiku 5.5 22996cc4e4 Merge feature/admin-mode in main
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-25 23:16:45 +01:00
SPP UserandClaude Haiku 5.5 330048acfc Admin-Modus: Verwaltung aller Matches unter /admin
Anmeldung mit ADMIN_PASSWORD (12 h, Sitzung nur im Speicher, Sperre nach
5 Fehlversuchen pro Minute). Alle Matches mit Codes, Servern, Spielern,
Veto und Verlauf. Aktionen: Veto starten bzw. Schritte für beide Teams,
Warmup beenden, nächste Map, Server stoppen/neu starten, abschließen,
abbrechen, Spieler entfernen, Codes erneuern, Match löschen.
Ohne ADMIN_PASSWORD ist der Admin-Modus komplett aus.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-25 23:16:44 +01:00
SPP UserandClaude Haiku 5.5 726974fe82 AMP und feste Host-Namen aus Doku und Tests entfernen
Der Docker-Host ist beliebig; Beispiele nutzen jetzt DOCKER-HOST-IP bzw.
den SSH-Alias docker-host statt eines Rechnernamens. Testfixture heißt
echte-logs-wingman.log.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-25 22:54:38 +01:00
SPP UserandClaude Haiku 5.5 413df03237 Stack unabhängig vom Host: deploy/compose.yml mit .env
CS2 Mapveto startet jetzt auf jedem Docker-Host (Linux) mit
'docker compose --env-file deploy/.env -f deploy/compose.yml up -d'.
Die Adresse kommt aus HOST_IP, nichts ist mehr fest eingetragen.
Anleitung für einen neuen Server: Image laden oder bauen, Spieldateien
kopieren oder neu laden, konfigurieren, starten.

Geprüft auf amp-cluster mit Demo-Adapter: Stack startet, Webseite
antwortet, Stack wieder entfernt, Spieldateien unverändert.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-25 22:46:51 +01:00
SPP UserandClaude Opus 5.5 d78f399612 CS2 Mapveto als ein Container (Dockerfile, compose.yml, Anleitung)
Ein Image mit Webseite, Backend, SQLite und Docker-Client; startet
Match-Container über /var/run/docker.sock auf demselben Host. Tests
laufen beim Bauen mit. Auf amp-cluster gebaut (301 MB) und geprüft.

Dazu: Testbefehl für Node 22 angepasst, beim Beenden werden ausstehende
Live-Updates verworfen und laufende Server-Aktionen abgewartet, damit nach
dem Schließen der Datenbank nichts mehr darauf zugreift.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 22:40:52 +01:00
SPP UserandClaude Opus 5.5 6ee2de5a46 Docker-Warteschlange: Update-Check per RCON statt nur per Logzeile erkennen
Die Zeile 'Starting CS2 Dedicated Server' rutscht nach dem Serverstart
schnell aus den letzten Logzeilen. Der nächste Match-Container wartete
dadurch bis zur Zeitgrenze (20 min). Jetzt gilt ein Container als fertig,
sobald er per RCON antwortet; die Logzeile bleibt als zweite Erkennung.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 22:16:57 +01:00
SPP UserandClaude Opus 5.5 7c74c6c4eb Docker-Adapter mit echtem Host getestet, zwei Fehler behoben
- CSTV belegt einen Spielerplatz: Container starten jetzt mit 6 bzw. 12
  Plätzen (Spieler + CSTV + Reserve), sonst kam der letzte Spieler nicht drauf
- 'Live-Daten empfangen' wird auch in der Aufwärmphase angezeigt, wenn
  der Server Logs liefert, die den Spielstand noch nicht ändern
- Anleitung: Serverliste, Plätze, Bots zum Testen

Getestet auf amp-cluster (Docker per SSH, LAN-Modus): Container-Start,
Veto, Konfiguration, Live-Runden, automatischer Mapwechsel, Entfernen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 22:09:43 +01:00
SPP UserandClaude Opus 5.5 59d6dc7177 Docker-Adapter: LAN-Modus ohne GSLT, Container starten nacheinander
Ohne CS2_GSLT starten Match-Container mit CS2_LAN=1. Neue Container
warten, bis der vorige seinen SteamCMD-Update-Check abgeschlossen hat,
damit nie zwei gleichzeitig das gemeinsame Volume aktualisieren.
Anleitung: Docker per SSH (DOCKER_HOST), eindeutige Passwort-Beispiele
mit Warnung vor Sonderzeichen (Startskript nutzt eval).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 21:54:30 +01:00
SPP UserandClaude Opus 5.5 8996294d7e Stream-Übersicht aller laufenden Vetos und Matches
/stream zeigt alle aktiven Matches als Karten (Veto mit Map-Board, Match
mit Live-Stand und Serie), dazu offene Lobbys und Ergebnisse. Raster passt
sich der Anzahl an, ab 7 Matches wird geblättert. Live per Socket, optional
mit STREAM_KEY geschützt, ohne Codes oder Verbindungsdaten.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 19:32:53 +01:00
SPP UserandClaude Opus 5.5 54bfe198d8 Aufwärmphase vor mp_warmup_end als beendet markieren
CS2 sendet Match_Start sofort nach mp_warmup_end, oft bevor die
RCON-Antwort da ist. Bei fehlgeschlagenem Befehl wird die Markierung
zurückgenommen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 19:23:53 +01:00
SPP UserandClaude Opus 5.5 43dc5b837a Live-Ergebnisse: Stream-Seite, Anzeige, automatischer Mapwechsel
- Stream-Übersicht unter /stream/<Zuschauercode> (1920x1080, Lobby, Veto, Spielstand)
- Live-Stand, Rundenverlauf und Serienstand auf Match-Seite und Übersicht
- Parser mit echten AMP-Logs geprüft: Game Over mit leerer Map-Gruppe,
  Match_Start vor der Aufwärmphase, veraltete Seitenzuordnung nach der Halbzeit
- Nach Map-Ende automatisch nächste Map bzw. Abschluss der entschiedenen Serie
- LOG_DEBUG zur Fehlersuche, README und .env.example ergänzt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 19:20:10 +01:00
SPP UserandClaude Opus 5.5 5fade769e7 Live-Ergebnisse aus CS2-Serverlogs (Backend)
Server bekommt beim Konfigurieren logaddress_add_http mit geheimem
Match-Schlüssel. Ein Parser wertet Rundenende, Seitenzuordnung, Matchstart
und Spielende aus; Stand, Rundenverlauf und Sieger werden pro Map gespeichert
und live verteilt. Der Demo-Adapter simuliert nach der Aufwärmphase einen
Matchverlauf über dieselben Logzeilen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 18:54:23 +01:00
75 changed files with 9227 additions and 212 deletions

No files matched your search

+11
View File
@@ -0,0 +1,11 @@
**/node_modules
client/dist
data
*.db
*.db-*
.env
.env.*
!.env.example
.git
.claude
npm-debug.log*
+28 -1
View File
@@ -21,6 +21,8 @@ TRUST_PROXY=false
SERVER_ADAPTER=demo
SERVER_IDLE_TIMEOUT_MINUTES=30
SERVER_MAX_LIFETIME_HOURS=8
# Offene Lobbys nach so vielen Stunden samt Server löschen (0 = nie)
LOBBY_MAX_HOURS=3
SERVER_STATUS_POLL_SECONDS=5
SERVER_REAPER_INTERVAL_SECONDS=60
@@ -39,7 +41,7 @@ DEMO_START_SECONDS=20
DEMO_FAIL_FIRST_CONFIGURE=false
# --- Docker-Adapter (SERVER_ADAPTER=docker) ---
# Pflicht: Game Server Login Token für App-ID 730, https://steamcommunity.com/dev/managegameservers
# Game Server Login Token für App-ID 730 (https://steamcommunity.com/dev/managegameservers); leer = LAN-Modus (sv_lan 1)
CS2_GSLT=
# Pflicht: Adresse, unter der Spieler den Docker-Host erreichen (IP oder DNS-Name)
CS2_PUBLIC_HOST=
@@ -52,9 +54,34 @@ CS2_PORT_RANGE_END=27115
# Gemeinsames Volume für die Spieldateien (~60 GB), damit nicht jedes Match neu herunterlädt
CS2_DOCKER_VOLUME=cs2-mapveto-gamefiles
CS2_DOCKER_EXTRA_ARGS=
# Optional: mehrere Docker-Hosts als JSON-Liste (eine Zeile). Leer = nur CS2_PUBLIC_HOST/DOCKER_HOST.
# Einrichtung pro Host: bash deploy/docker-host-einrichten.sh <ssh-ziel>
# CS2_DOCKER_HOSTS=[{"name":"host1","dockerHost":"ssh://cs2@192.168.178.111","publicHost":"192.168.178.111","portStart":27100,"portEnd":27139}]
CS2_START_TIMEOUT_MINUTES=20
# Spielports für den LAN-Reiter des Steam-Serverbrowsers (Steam sucht nur 27015–27020). Leer = aus.
CS2_LAN_PORTS=27015-27020
# CSTV-Port = Spielport + dieser Abstand (z. B. 27015 -> 27115)
CS2_TV_PORT_OFFSET=100
# Messer-Server für die Seitenwahl (im Admin-Bereich startbar)
KNIFE_MAP=de_mirage
KNIFE_WARMUP_SECONDS=90
KNIFE_MAX_SERVERS=4
# Anfang des Servernamens im Serverbrowser, danach folgen die Teamnamen
CS2_SERVERNAME_PREFIX=Maxlan
DOCKER_BIN=docker
# --- Static-Pool-Adapter (SERVER_ADAPTER=static) ---
# host:port:rconPasswort[:tvPort], mehrere durch Komma getrennt
CS2_STATIC_SERVERS=
# --- Live-Ergebnisse ---
# Adresse, unter der der CS2-Server dieses Backend erreicht (Standard: PUBLIC_URL), z. B. http://192.168.178.31:3000
LOG_RECEIVER_URL=
# true: empfangene Logzeilen im Terminal ausgeben (Fehlersuche)
LOG_DEBUG=false
# Sekunden nach Map-Ende bis zur nächsten Map bzw. zum Abschluss der Serie; -1 = nur manuell
SERIES_AUTO_ADVANCE_SECONDS=30
# Schlüssel für die Stream-Übersicht aller Matches (/stream?key=...); leer = offen
STREAM_KEY=
# Discord-Webhook für Meldungen (Veto, Match-Start, Map- und Serienende, Fehler). Leer = aus
DISCORD_WEBHOOK_URL=
+1
View File
@@ -9,3 +9,4 @@ data/
.env.local
npm-debug.log*
.claude/
dist/
+33
View File
@@ -0,0 +1,33 @@
# CS2 Mapveto als ein Container: Webseite, Backend, Datenbank (SQLite) und Docker-Client,
# mit dem die App pro Match eigene CS2-Server-Container auf demselben Docker-Host startet.
# ---------- Bauen ----------
FROM node:22-bookworm-slim AS build
WORKDIR /app
# Werkzeuge, falls better-sqlite3 kein fertiges Binärpaket findet
RUN apt-get update && apt-get install -y --no-install-recommends python3 make g++ \
&& rm -rf /var/lib/apt/lists/*
COPY package.json package-lock.json ./
COPY client/package.json client/
COPY server/package.json server/
RUN npm ci
COPY . .
RUN npm run build && npm test && npm prune --omit=dev
# ---------- Laufzeit ----------
FROM node:22-bookworm-slim
# Docker-Client (nur das Programm, kein Daemon): spricht über /var/run/docker.sock mit dem Host
COPY --from=docker:27-cli /usr/local/bin/docker /usr/local/bin/docker
WORKDIR /app
ENV NODE_ENV=production \
PORT=3000 \
DATABASE_FILE=/data/cs2-mapveto.db
COPY --from=build /app/package.json ./
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/server ./server
COPY --from=build /app/client/dist ./client/dist
VOLUME /data
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s \
CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||3000)+'/api/config').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
CMD ["node", "server/src/index.js"]
+259 -12
View File
@@ -14,6 +14,17 @@ Webanwendung zur Organisation von Counter-Strike-2-Matches: Match-Lobbys mit Tea
## Schnellstart
**Als Container (empfohlen für den Betrieb):** ein Container mit Webseite, Backend und Datenbank, der die
CS2-Server-Container auf demselben Docker-Host startet. Siehe [docs/container.md](docs/container.md):
```powershell
docker -H ssh://USER@DOCKER-HOST-IP build -t cs2-mapveto:latest .
```
…dann den Stack aus der Anleitung in Portainer anlegen.
**Zum Entwickeln:**
```bash
npm install
cp .env.example .env # optional, Standardwerte funktionieren für die Demo
@@ -64,14 +75,14 @@ Die Tests decken Veto-Engine und Ablaufvalidierung, RCON-Protokoll (gegen einen
6. **Server konfigurieren:** Nach dem Veto wird der bereits laufende Server automatisch eingerichtet: Spielmodus, erste Map, Teamnamen und Startseiten, Match-Einstellungen, Server- und CSTV-Passwort. Die restliche Map-Reihenfolge ist gespeichert.
- Spieler sehen danach den kopierbaren Konsolenbefehl `password …; connect host:port` und einen `steam://connect`-Link.
- Zuschauer sehen den CSTV-Zugang (eigener Port, eigenes Passwort, Verzögerung).
7. **Während der Serie:** Der Organisator kann die Aufwärmphase beenden (`mp_warmup_end`), die nächste Map laden, den Server stoppen und das Match abschließen oder abbrechen.
7. **Während der Serie:** Der Organisator kann die Aufwärmphase beenden (`mp_warmup_end`), das laufende Match pausieren und fortsetzen (`mp_pause_match`, `mp_unpause_match`), die nächste Map laden, den Server stoppen und das Match abschließen oder abbrechen.
8. **Fehlerfälle:** Startet der Server nicht, schlägt die Konfiguration fehl oder fällt der Server aus, zeigt die Seite den Grund. Der Organisator bekommt einen Knopf für den erneuten Versuch: „Server neu starten“ bzw. „Konfiguration wiederholen“.
### Rollen und Berechtigungen
| Rolle | Zugang über | Darf |
|---|---|---|
| Organisator | Token beim Erstellen | Veto starten, Spieler entfernen, Captain setzen, Codes erneuern, Server neu starten/stoppen/konfigurieren, nächste Map, Match beenden; sieht Einladungen, Spieler- und CSTV-Verbindung |
| Organisator | Token beim Erstellen | Veto starten, Spieler entfernen, Captain setzen, Codes erneuern, Server neu starten/stoppen/konfigurieren, pausieren/fortsetzen, nächste Map, Match beenden; sieht Einladungen, Spieler- und CSTV-Verbindung, die Match-Übersicht |
| Captain | Teamcode (erster Spieler oder ernannt) | alles wie Spieler, dazu Veto-Aktionen seines Teams und Captain-Übergabe |
| Spieler | Teamcode | bereit melden, Team verlassen; sieht die Spieler-Verbindungsdaten nach dem Veto |
| Zuschauer | Zuschauercode | alles live verfolgen; sieht nur den CSTV-Zugang, nicht das Spielerpasswort |
@@ -90,6 +101,15 @@ Alle Prüfungen passieren im Backend. Die Oberfläche blendet nur zusätzlich au
- **Server-Zugangsdaten:** RCON-Passwort, Container-Handle und GSLT liegen ausschließlich in der Datenbank bzw. den Umgebungsvariablen des Backends. Die Snapshot-Funktion gibt sie nie aus (per Test abgesichert). Server- und CSTV-Passwort sind als Beitrittsdaten nötig und werden nur an die jeweils berechtigte Rolle geliefert.
- **Befehls-Injection:** Teamnamen werden vor der Übergabe an die Serverkonsole von Anführungszeichen, `;` und Steuerzeichen bereinigt. Passwörter sind rein alphanumerisch.
### Chat im Match
Auf der Match-Seite gibt es einen Chat mit zwei Reitern über der Chatbox:
- **Match-Chat:** beide Teams und der Organisator.
- **Team-Chat:** nur die Spieler des eigenen Teams, z. B. für Absprachen im Veto. Der Organisator sieht ihn nur, wenn er selbst im Team mitspielt.
Zuschauer und Gäste sehen keinen Chat. Er gilt das ganze Match über, von der Lobby bis zum Ende. Danach ist er nur noch lesbar. Nachrichten haben höchstens 300 Zeichen, pro Person gehen höchstens 5 Nachrichten in 10 Sekunden. Ein roter Punkt am Reiter zeigt ungelesene Nachrichten.
### Wiederherstellung nach Neuladen oder Verbindungsabbruch
Der gesamte Zustand liegt in SQLite. Der Browser speichert pro Match nur seinen Zugangstoken (`localStorage`).
@@ -106,6 +126,7 @@ Alle `SERVER_REAPER_INTERVAL_SECONDS` prüft das Backend alle aktiven Server:
- **Lobby, Veto oder Server ohne Spielerzahl:** Ist niemand mit der Match-Seite verbunden und gab es länger als `SERVER_IDLE_TIMEOUT_MINUTES` keine Aktivität, wird der Server gestoppt.
- **Konfigurierter echter Server:** Gestoppt wird, wenn länger als die Frist keine Spieler auf dem Server waren. Die Spielerzahl kommt per RCON aus `status`.
- **Unabhängig davon:** Nach `SERVER_MAX_LIFETIME_HOURS` wird jeder Server gestoppt.
- **Alte Lobbys:** Eine Lobby, deren Veto nach `LOBBY_MAX_HOURS` (Standard 3 Stunden) seit dem Erstellen nicht gestartet wurde, wird samt Server gelöscht.
Ein gestoppter Server lässt sich vom Organisator neu starten.
@@ -137,13 +158,28 @@ Beim Start wird jeder Ablauf geprüft:
- genau eine Entscheidungsmap
- Seitenwahl nur direkt nach einem Pick oder der Entscheidungsmap
Ungültige oder fehlende Abläufe machen nur dieses Format unauswählbar (mit Begründung in der Oberfläche).
Passt kein hinterlegter Ablauf zur Map-Anzahl, erzeugt die Anwendung einen (siehe unten). Unauswählbar ist ein Format nur, wenn es zu wenige Maps gibt (mit Begründung in der Oberfläche).
### Maps im Admin-Bereich wählen
Unter Einstellungen → **Map-Pools** wählt der Admin pro Modus, welche Maps im Veto stehen (mindestens 2). Die Auswahl steht in der Datenbank und gilt für neue Matches. Laufende Matches behalten ihre Maps und ihren Ablauf. Maps mit `"active": false` in der Konfiguration stehen im Katalog, sind aber erst nach Auswahl aktiv.
**Ablauf passend zur Map-Anzahl:** Passt ein hinterlegter Ablauf zur Anzahl der gewählten Maps, gilt er. Sonst erzeugt die Anwendung einen Ablauf:
1. bis zu zwei Bans (X, dann Y),
2. die Picks der Serie im Wechsel, die Seite wählt jeweils das andere Team,
3. die restlichen Bans im Wechsel,
4. die letzte Map ist die Entscheidungsmap, ihre Seite wählt das Team, das nicht zuletzt dran war.
Jede weitere Map bedeutet also einen Ban mehr. BO3 braucht mindestens 3 Maps, BO5 mindestens 5. Der Admin-Bereich zeigt nach dem Speichern den Ablauf für jedes Format.
Beispiel Competitive mit allen 10 Maps, BO3: X bannt, Y bannt, X pickt (Y Seite), Y pickt (X Seite), X, Y, X, Y, X bannen → Entscheidungsmap, Y wählt Seite.
### Mitgelieferte Abläufe
**Competitive (7 Maps):** Ancient, Anubis, Dust II, Inferno, Mirage, Nuke, Train
**Competitive (Katalog mit 10 Maps):** Ancient, Anubis, Cache, Dust II, Inferno, Mirage, Nuke, Overpass, Train, Vertigo. Alle 10 sind aktiv. Die festen Abläufe unten gelten für die klassischen 7 Maps (Ancient, Anubis, Dust II, Inferno, Mirage, Nuke, Train), bei jeder anderen Auswahl erzeugt die Anwendung den Ablauf.
| Format | Ablauf |
| Format (7 Maps) | Ablauf |
|---|---|
| BO1 | X bannt, Y bannt, X bannt, Y bannt, X bannt, Y bannt → Entscheidungsmap, X wählt Seite |
| BO3 | X bannt, Y bannt, X pickt (Y wählt Seite), Y pickt (X wählt Seite), X bannt, Y bannt → Entscheidungsmap, X wählt Seite |
@@ -155,7 +191,7 @@ Ungültige oder fehlende Abläufe machen nur dieses Format unauswählbar (mit Be
|---|---|
| BO1 | X bannt, Y bannt, X bannt → Entscheidungsmap, Y wählt Seite |
| BO3 | X bannt, Y pickt (X Seite), X pickt (Y Seite) → Entscheidungsmap, X wählt Seite |
| BO5 | **nicht unterstützt:** Mit 4 Maps sind keine 5 Karten möglich. Mit einem Pool von mindestens 5 Maps und einem `bo5`-Ablauf wird es automatisch auswählbar. |
| BO5 | **nicht unterstützt:** Mit 4 Maps sind keine 5 Karten möglich. Mit mindestens 5 Maps wird es automatisch auswählbar. |
### Regel für Startseiten
@@ -168,7 +204,7 @@ Die Wahl wird als `mp_teamname_1` (Team mit CT-Start) und `mp_teamname_2` (T-Sta
- **Map-Bilder** liegen lokal unter `client/public/maps/<map>.webp` (960 px breit, je ca. 50–90 KB). `"image"` in `map-pools.json` verweist darauf. Neue oder fehlende Bilder lädt `npm run images` von [ghostcap-gaming/cs2-map-images](https://github.com/ghostcap-gaming/cs2-map-images), verkleinert sie und trägt den Pfad ein (`npm run images -- --force` lädt alle neu). Die Bilder stammen aus dem Spiel; die Rechte liegen bei Valve. Ohne Bild zeigt die Kachel ein Muster in der Map-Farbe.
- **Seiten-Embleme** für CT und T liegen unter `client/public/sides/ct.png` und `client/public/sides/t.png` (quadratisch, z. B. 256×256). Fehlt eine Datei, erscheint ein schlichtes Ersatzsymbol.
- **Logo und Farben:** Das MaxLAN-e.V.-Logo liegt freigestellt unter `client/public/brand/maxlan-logo.webp`. Die Farben stehen als Variablen am Anfang von `client/src/styles.css`: Logo-Blau `#344A99` für Team A und Hauptbuttons, Logo-Rot `#A72A1C` für Team B und den Abschluss der Kopfzeile, jeweils aufgehellt für die Lesbarkeit.
- **Logo und Farben:** Das Maxlan-e.V.-Logo liegt freigestellt unter `client/public/brand/maxlan-logo.webp`. Die Farben stehen als Variablen am Anfang von `client/src/styles.css`: Logo-Blau `#344A99` für Team A und Hauptbuttons, Logo-Rot `#A72A1C` für Team B und den Abschluss der Kopfzeile, jeweils aufgehellt für die Lesbarkeit.
- Bestehende Matches behalten die Map-Liste, mit der sie erstellt wurden. Neue Bilder erscheinen in neu erstellten Matches.
> Die mitgelieferten Map-Pools entsprechen dem Stand der Entwicklung. Valve ändert den aktiven Map-Pool regelmäßig. Map-IDs bitte gegen die eigene Serverinstallation prüfen. Eine Map, die nicht existiert, führt zu „Konfiguration fehlgeschlagen“ mit Meldung.
@@ -190,9 +226,22 @@ Die Serververwaltung ist hinter [`server/src/servers/adapter.js`](server/src/ser
- Wird alle `SERVER_STATUS_POLL_SECONDS` abgefragt, solange der Server startet, danach alle 30 s.
3. **`configure(handle, plan)`**: in zwei Phasen.
1. `game_type`, `game_mode`, `sv_password`, `tv_enable`, `tv_delay`, `tv_password`, dann `changelevel <map>`.
2. Warten, bis `status` die Map meldet (max. 2 Minuten), dann die Match-Einstellungen (`mp_teamname_1/2`, `mp_maxrounds`, `mp_overtime_enable`, `mp_match_can_clinch`, `mp_warmup_pausetimer 1`, `CS2_EXTRA_COMMANDS`).
2. Warten, bis `status` die Map meldet (max. 2 Minuten), dann die Match-Einstellungen (`mp_teamname_1/2`, `mp_maxrounds`, `mp_overtime_enable`, `mp_match_can_clinch`, `mp_warmup_pausetimer 1`, `CS2_EXTRA_COMMANDS`) und die Turnierregeln (siehe unten).
Die zweite Phase kommt erst nach dem Mapwechsel, weil die `gamemode_*.cfg` des Spiels diese Werte beim Laden der Map sonst überschreibt.
**Turnierregeln:** Jeder konfigurierte Server setzt außerdem diese Werte, die sich an FACEIT- und ESL-Richtwerten orientieren:
| Befehl | Wert | Zweck |
|---|---|---|
| `sv_pausable` | `1` | erlaubt Pausen (`mp_pause_match`) |
| `mp_halftime` | `1` | Seitenwechsel zur Halbzeit |
| `mp_freezetime` | `20` | Freezetime in Sekunden |
| `mp_buytime` | `20` | Kaufzeit in Sekunden |
| `mp_startmoney` | `800` | Startgeld |
| `mp_roundtime` | `1.92` | Rundenzeit in Minuten (1:55) |
Die Werte stammen nicht aus einer Regel eines konkreten Turniers. Vor einem echten Turnier bitte mit dem Regelwerk des Veranstalters abgleichen und in `server/src/servers/cs2Config.js` anpassen. Ob `sv_pausable` und die Pause-Befehle am echten Server wirken, ist noch nicht getestet.
4. **`stop(handle)`**: Server beenden bzw. freigeben.
### Option A: Docker (`SERVER_ADAPTER=docker`)
@@ -212,6 +261,47 @@ Voraussetzungen:
Container heißen `cs2mv-<matchId>` und tragen das Label `cs2-mapveto=1`. Stoppen bedeutet `docker rm -f`.
### Mehrere Docker-Hosts
Mit `CS2_DOCKER_HOSTS` verteilt die Anwendung Matches auf mehrere Docker-Hosts. Jedes neue Match läuft auf dem Host mit den meisten freien Ports. Der Host bleibt im Match gespeichert, Status, Konfiguration und Stopp gehen immer an denselben Host.
1. Jeden Host einrichten (SSH-Schlüssel, Docker, Volume, Image):
```bash
bash deploy/docker-host-einrichten.sh cs2@192.168.178.111
```
2. Die Hosts in der `.env` eintragen, als JSON-Liste in einer Zeile:
```
CS2_DOCKER_HOSTS=[{"name":"host1","dockerHost":"ssh://cs2@192.168.178.111","publicHost":"192.168.178.111","portStart":27100,"portEnd":27139},{"name":"host2","dockerHost":"ssh://cs2@192.168.178.112","publicHost":"192.168.178.112","portStart":27100,"portEnd":27139}]
```
Jeder Host braucht eigene Spieldateien (rund 60 bis 90 GB). Die Ports müssen auf jedem Host frei sein.
### Messer-Server für die Seitenwahl
Im Admin-Bereich (Einstellungen → Gameserver → Messer-Server) startet „Messer-Server starten“ einen Gameserver, der keinem Match gehört und dauerhaft läuft. Teams verbinden sich, spielen eine Messerrunde und gehen wieder.
- Modus wählbar: **Competitive 5v5** (12 Plätze, Maps aus dem Competitive-Pool) oder **Wingman 2v2** (6 Plätze, Maps aus dem Wingman-Pool).
- Offen, ohne Passwort, sichtbar im LAN-Reiter von Steam (`Maxlan | Messer 1 5v5 (Seitenwahl)`). Sie dienen als Ausweichmöglichkeit; Matches mit Messerrunde nutzen ihren eigenen Server (siehe unten).
- Nur Messer, kein Geld, eine Runde. Vorher läuft eine Aufwärmphase zum Verbinden (`KNIFE_WARMUP_SECONDS`, Standard 90). Nach der Runde startet das Spiel von selbst neu (`mp_match_end_restart`). Wird der Server leer, setzt die App ihn zusätzlich zurück.
- Fällt ein Messer-Server aus, startet die App ihn neu, höchstens fünfmal in Folge. Danach steht er auf „ausgefallen“ und lässt sich im Admin-Bereich neu starten.
- Knöpfe pro Server: „Runde starten“ (Aufwärmphase sofort beenden), „Zurücksetzen“, „Neu starten“ (neuer Container), „Entfernen“.
- Messer-Server belegen ihre Ports dauerhaft und nehmen zuerst LAN-Ports. Jeder Messer-Server ist also ein Platz weniger im LAN-Reiter für Matches.
- **Im Match:** Wählt der Organisator beim Erstellen „Wer beginnt das Veto?“ → **Messerrunde**, läuft die Messerrunde auf dem **eigenen Server des Matches**: Sobald er hochgefahren ist, richtet die App dort eine Messerrunde mit dem Passwort des Matches ein. Die Lobby zeigt den Spielern beider Teams die Verbindungsdaten („In CS2 verbinden“). Die Aufwärmphase läuft ohne Ende, bis der Organisator in der Lobby **Messerrunde starten** klickt; mit **Runde wiederholen** geht es zurück in die Aufwärmphase. Danach klickt der Organisator statt „Veto starten“ auf „Team A beginnt“ bzw. „Team B beginnt“. Nach dem Veto richtet die App denselben Server für das Match ein und nimmt die Messer-Einstellungen zurück, die Spieler bleiben einfach drauf. Ohne eigenen Server (z. B. Adapter `static` ohne freien Server) zeigt die Lobby wie bisher die freien Messer-Server. Im Admin-Bereich gibt es die Knöpfe „Team A/B beginnt“ auch in der Matchkarte.
- Weitere Einstellungen: `KNIFE_MAP` (Standard-Map, muss in einem Map-Pool sein), `KNIFE_MAX_SERVERS` (Standard 4).
### Server im Steam-Serverbrowser (LAN-Reiter)
Der LAN-Reiter des Steam-Serverbrowsers (Steam → Ansicht → Spielserver → LAN) fragt nur die Ports 27015 bis 27020 ab. Deshalb vergibt die App den Spielport zuerst aus `CS2_LAN_PORTS` (Standard `27015-27020`), danach aus dem normalen Portbereich. Der CSTV-Port liegt immer 100 über dem Spielport (Spiel 27015, CSTV 27115; Spiel 27100, CSTV 27200), der Abstand lässt sich mit `CS2_TV_PORT_OFFSET` ändern. Auf jedem Host müssen also auch die Ports 100 über dem Portbereich frei sein. So passen bis zu sechs Server pro Host in den LAN-Reiter. Weitere Server laufen im normalen Portbereich. Sie sind dann nicht im LAN-Reiter sichtbar, Spieler erreichen sie aber über den Verbindungsbefehl.
- Pro Host lassen sich die LAN-Ports in `CS2_DOCKER_HOSTS` mit `"lanPorts":"27016-27019"` einschränken, wenn dort schon etwas anderes auf einem dieser Ports läuft. `"lanPorts":""` schaltet es für einen Host ab. Im Admin-Bereich steht das in der Spalte „LAN-Ports (Steam)“.
- Ist ein Port trotzdem belegt, versucht der Start automatisch die nächsten freien Ports.
- Server auf LAN-Ports laufen im Netz des Hosts (`--network host`), weil die Port-Weiterleitung von Docker die Suchanfragen (Broadcasts) des Serverbrowsers nicht weitergibt. Die Firewall des Hosts muss die Ports deshalb selbst erlauben.
- Der Servername im Browser ist `Maxlan | Team A vs. Team B`. Den Anfang ändert `CS2_SERVERNAME_PREFIX`.
- Die Server sind mit Passwort geschützt und zeigen ein Schloss. Ohne `CS2_GSLT` laufen sie mit `sv_lan 1` und erscheinen nicht im Internet-Reiter.
### Option B: Vorhandene Server (`SERVER_ADAPTER=static`)
Für selbst betriebene, dauerhaft laufende CS2-Server:
@@ -269,10 +359,146 @@ Bekannte Werte: `game_type 0` + `game_mode 1` = Competitive, `game_mode 2` = Win
### Bekannte Grenzen von Vanilla-CS2
- Ohne Server-Plugin kann CS2 Spieler **nicht automatisch einem Team zuordnen**. Spieler wählen ihr Team im Spiel selbst. Teamnamen und Startseiten setzt die Anwendung. Für feste Teamzuordnung, Pausen und automatischen Mapwechsel eignen sich Plugins wie MatchZy oder Get5-Nachfolger. Sie lassen sich über einen eigenen Adapter anbinden; die SteamID64 der Spieler wird dafür bereits erfasst.
- Ergebnisse werden nicht ausgelesen. Der Organisator lädt die nächste Map manuell.
- Das Team eines Spielers ergibt sich aus der Seite, die er im Spiel wählt. Wechselt ein Spieler ins falsche Team, zählen seine Runden für das falsche Team.
### Live-Ergebnisse
Beim Konfigurieren setzt CS2 Mapveto auf dem Server `log on` und `logaddress_add_http "<LOG_RECEIVER_URL>/api/logs/<match>/<schlüssel>"`. Der CS2-Server schickt damit seine Logzeilen per HTTP an das Backend. Der Schlüssel ist pro Match und Serverstart zufällig, Zeilen mit falschem Schlüssel werden abgelehnt.
- **Was ausgewertet wird:**
- Matchstart nach der Aufwärmphase
- jedes Rundenende mit Sieger und Grund (Bombe, Entschärfung, Zeit, Ausschalten)
- die Seitenzuordnung der Teams
- das Spielende
- **Was angezeigt wird:**
- Live-Spielstand und Rundenverlauf pro Map
- Serienstand
- Ergebnisse in der Übersicht
- Stream-Seite
- **Nach dem Spielende:** Ist die Serie noch offen, lädt CS2 Mapveto nach `SERIES_AUTO_ADVANCE_SECONDS` (Standard 30) die nächste Map. Ist sie entschieden (z. B. 2:0 im Best of 3), wird das Match abgeschlossen und der Server freigegeben. Mit `-1` geht beides nur manuell.
- **Voraussetzung:** Der CS2-Server muss `LOG_RECEIVER_URL` erreichen, im LAN z. B. `http://192.168.178.31:3000`. Ob Daten ankommen, zeigt das Serverpanel des Organisators. Mit `LOG_DEBUG=true` gibt das Backend alle empfangenen Zeilen aus.
- **Geprüft:** mit echten Logzeilen eines CS2-Servers (Wingman), anonymisiert in `server/test/fixtures/wingman-anonym.log`.
Zwei Eigenheiten von CS2 berücksichtigt der Parser:
- CS2 sendet `Match_Start` schon beim Laden der Map. Gezählt wird erst, nachdem der Organisator „Aufwärmphase beenden“ geklickt hat.
- Direkt nach der Halbzeit nennt CS2 zu Rundenbeginn noch die alte Seitenzuordnung. Ausgewertet werden deshalb nur die Zeilen direkt nach einem Rundenende.
### Stream-Übersicht aller Matches
Unter `/stream` (in der Kopfzeile verlinkt) zeigt eine 1920×1080-Seite alle laufenden Vetos und Matches gleichzeitig, live aktualisiert:
- **Karten:** eine Karte pro Match.
- Im Veto: wer am Zug ist, Fortschritt, Map-Board.
- Im Match: aktuelle Map mit Live-Stand, CT/T-Seiten und Serienstand.
- **Raster:** passt sich der Anzahl an (1, 2, 4 oder 6 Karten). Bei mehr als 6 Matches blättert die Seite alle 12 Sekunden weiter.
- **Unten:** offene Lobbys und die Ergebnisse der letzten drei Stunden.
- **Datenschutz:** Codes und Verbindungsdaten erscheinen nicht.
- **Einzelnes Match:** Ein Klick auf eine Karte oder einen Eintrag öffnet die Übersicht nur dieses Matches unter `/stream/match/<matchId>`. Der Link enthält keinen Zuschauercode, der Schlüssel `?key=` wird übernommen.
- **Schlüssel:** Mit `STREAM_KEY` ist die Seite nur mit `/stream?key=<schlüssel>` erreichbar, sonst offen. `?bg=transparent` funktioniert ebenfalls. Der Organisator bekommt im Link „Match-Übersicht“ nicht den globalen Schlüssel, sondern einen eigenen, der nur sein Match zeigt.
### Beamer-Diashow
Unter `/beamer` (in der Kopfzeile verlinkt) läuft eine Diashow für den Beamer: ein laufendes Match pro Folie, groß und aus der Ferne lesbar, mit Teams, Serienstand, aktueller Map samt Rundenstand und Seiten sowie den Maps der Serie. Matches im Veto zeigen, wer gerade dran ist. Nach den Matches folgt eine Folie mit offenen Lobbys und Ergebnissen.
- **Weiterschalten:** alle 15 Sekunden, einstellbar mit `?sekunden=20` (5 bis 120). Ein roter Balken unten zeigt die Zeit bis zur nächsten Folie.
- **Bedienung:** Klick schaltet Vollbild um, `F` ebenso, `←`/`→` blättern, Leertaste hält an.
- **Schlüssel:** Mit `STREAM_KEY` als `/beamer?key=<schlüssel>` aufrufen, wie bei der Stream-Übersicht.
### Stream-Übersicht eines Matches
Unter `/stream/<Zuschauercode>` gibt es eine Vollbild-Übersicht für den Stream. Den Link findet der Organisator bei den Einladungen.
Für die Übersicht eines einzelnen Matches ohne Zuschauercode gibt es `/stream/match/<matchId>`. Der Organisator findet den Link unter „Match-Übersicht“ im Bereich Einladungen. Ist `STREAM_KEY` gesetzt, hängt der Link den Schlüssel an, damit Organisatoren die Seite öffnen können.
- **Format:** 1920×1080, skaliert auf jede Fenstergröße. In OBS als Browserquelle mit 1920×1080 einbinden. Mit `?bg=transparent` ist der Hintergrund durchsichtig.
- **Ablauf:** Die Seite wechselt automatisch zwischen Lobby (Aufstellungen), Map-Veto (live) und Match (Spielstand, Rundenverlauf, Map-Leiste).
- **Laufband:** zeigt das letzte für Zuschauer relevante Ereignis, keine technischen Servermeldungen.
- **Datenschutz:** Verbindungsdaten und Passwörter erscheinen dort nie.
### Discord-Meldungen für Streamer
Mit `DISCORD_WEBHOOK_URL` schickt die Anwendung kurze Meldungen in einen Discord-Kanal:
- Veto gestartet
- Match gestartet (Aufwärmphase beendet)
- Map beendet mit Ergebnis
- Serie entschieden
- Match abgeschlossen oder abgebrochen
- Serverfehler
Jede Meldung enthält einen Link auf die Match-Übersicht (`/stream/match/<id>`). Ist `STREAM_KEY` gesetzt, entfällt der Link, damit der Schlüssel nicht im Kanal landet. Ein Fehler beim Senden bremst den Matchablauf nicht. Er wird nur im Log vermerkt. Die Webhook-URL enthält ein Geheimnis und erscheint nie in Logs oder Oberflächen.
Webhook anlegen: Discord-Kanal → Einstellungen → Integrationen → Webhooks → Neuer Webhook, dann die URL kopieren.
Welche Ereignisse gemeldet werden, wählt der Admin im Admin-Bereich unter „Discord-Meldungen“. Die Auswahl wird in der Datenbank gespeichert. Die Webhook-URL bleibt in der Konfiguration, weil sie ein Geheimnis ist. Die Schaltfläche „Testmeldung senden“ prüft die Verbindung.
### Hinter einem Reverse-Proxy
Die Webseite lässt sich hinter einen Reverse-Proxy wie nginx, Caddy oder Traefik stellen, etwa um sie per HTTPS unter einer eigenen Adresse anzubieten.
- **Eigene (Sub-)Domain, kein Unterpfad.** Die Anwendung nutzt absolute Pfade (`/api`, `/socket.io`, `/stream`, `/admin`). Unter einem Unterpfad wie `example.de/veto/` funktioniert sie nicht. Nimm eine eigene Subdomain, z. B. `veto.example.de`.
- **WebSockets durchreichen.** Die Live-Updates laufen über Socket.IO. Der Proxy muss den `Upgrade`-Header weitergeben, sonst bleibt die Seite bei „Verbinde mit Lobby“ hängen.
- **`.env` anpassen:**
- `PUBLIC_URL=https://veto.example.de`, damit Einladungs- und Stream-Links stimmen
- `TRUST_PROXY=true`, damit die Begrenzung der Anmeldeversuche die echte Client-IP sieht und nicht die des Proxys
- **Nicht über den Proxy:**
- Die CS2-Spielverbindungen laufen per UDP direkt zum Docker-Host. Für Spieler aus dem Internet braucht es dort Portfreigaben und einen GSLT (`CS2_GSLT`).
- Die Logs der CS2-Server können weiter über das LAN kommen. `LOG_RECEIVER_URL` bleibt dann z. B. `http://192.168.178.31:3000`.
- **Vor dem Freigeben absichern:** Öffentlich erreichbar sind dann auch `/admin` und, ohne Schlüssel, die Stream-Übersicht. Setze ein starkes `ADMIN_PASSWORD` und einen `STREAM_KEY`.
Beispiel für nginx:
```nginx
server {
listen 443 ssl http2;
server_name veto.example.de;
ssl_certificate /etc/letsencrypt/live/veto.example.de/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/veto.example.de/privkey.pem;
location / {
proxy_pass http://192.168.178.31:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 1h;
}
}
```
Ein `502 Bad Gateway` vom Proxy heißt, dass er die Anwendung unter `proxy_pass` nicht erreicht: Sie läuft nicht, oder Adresse bzw. Port stimmen nicht.
---
## Admin-Bereich
Unter `/admin` mit dem Passwort aus `ADMIN_PASSWORD` (leer = Admin-Bereich aus). Die Anmeldung gilt 12 Stunden und nur, solange die Anwendung läuft. Nach einem Neustart der Anwendung muss man sich neu anmelden.
### Reiter „Matches“
- **Suche und Filter:** Suche nach Team, Spieler oder Match-ID. Filter Alle, Laufend, Fehler (Zahl rot, wenn es Fehler gibt), Abgeschlossen und Archiv. Der Filter bleibt im Browser gespeichert.
- **Matchkarte:** Teams, Modus, Format, Status, Live-Stand, Server- und CSTV-Adresse (`Server 192.168.178.45:27015 · CSTV …:27115`), Zeitpunkt der Server-Anforderung, Pausen-Abzeichen. Bei einem Serverfehler steht die Ursache rot in der Karte.
- **Aktionen:** Ansehen, Veto starten, Aufwärmphase beenden, Pausieren/Fortsetzen, Nächste Map, Server neu starten / wiederholen, Server stoppen, Match abschließen, Match abbrechen, Archivieren / Aus dem Archiv holen, Löschen. Destruktive Aktionen fragen nach.
- **Konsole:** Knopf „Konsole“ in der Matchkarte und bei jedem Messer-Server. Zeigt die Ausgabe des Containers live (`docker logs --follow`, die letzten 300 Zeilen und alles Neue), mit Filter, „Mitlaufen“, „Leeren“ und „Neu verbinden“. Passwörter des Servers sind ausgeblendet. Höchstens 8 Konsolen gleichzeitig.
- **Details:** Einladungslinks und Codes (erneuerbar), Verbindungsbefehle für Spieler und CSTV, Spielerliste mit „Entfernen“, Rollback der laufenden Map.
- **Archiv:** abgeschlossene oder abgebrochene Matches lassen sich archivieren. Nach `ARCHIVE_AFTER_DAYS` Tagen (Standard 3, 0 = nie) geschieht das automatisch. Archivierte Matches stehen nur unter dem Filter „Archiv“.
Startet man den Server eines laufenden Matches neu, lädt die Anwendung danach die aktuelle Map samt Passwort wieder. Ein frisch gestarteter Server hat die volle Leerlauf-Frist, bevor er automatisch heruntergefahren wird.
### Reiter „Einstellungen“
Die Einstellungen sind in eine Seitenleiste gegliedert: **Veto** (Map-Pools), **Gameserver** (Match-Regeln, CSTV, Zeiten, Messer-Server), **Server-Betrieb** (Docker-Hosts, Image- und Spieldateien-Update) und **Benachrichtigungen** (Discord). Am Handy stehen die Menüpunkte als Knöpfe über dem Inhalt. Der zuletzt gewählte Punkt bleibt im Browser gespeichert.
- **Messer-Server:** starten (Modus 5v5 oder 2v2 und Map wählen), Runde starten, zurücksetzen, neu starten, entfernen. Siehe „Messer-Server für die Seitenwahl“.
- **Map-Pools:** pro Modus wählen, welche Maps im Veto stehen. Der Veto-Ablauf passt sich der Anzahl an, siehe „Maps im Admin-Bereich wählen“.
- **Gameserver:** Runden Competitive und Wingman, Verlängerung, zusätzliche Konsolenbefehle, CSTV an/aus und Verzögerung, Zeit pro Veto-Schritt, Leerlauf-Frist, maximale Laufzeit, Startzeit (inklusive Download) und Archiv-Frist. Die Werte aus der `.env` sind die Vorgabe. Was hier geändert wird, steht in der Datenbank und gilt auch nach einem Neustart. Mit „live“ markierte Werte gehen beim Speichern sofort per RCON an alle laufenden Server. Befehle, die Passwörter ändern oder den Server beenden, sind gesperrt.
- **Docker-Hosts:** oben die Auslastung pro Host (belegte und freie Plätze, freier Speicher dort, wo das Gameserver-Volume liegt, Matches und Messer-Server darauf), darunter Hosts hinzufügen, entfernen und bearbeiten (Name, Docker-Ziel, Adresse, Portbereich, LAN-Ports, Volume). Die Liste ersetzt `CS2_DOCKER_HOSTS`. „Liste aus .env verwenden“ schaltet zurück. Hosts mit laufenden Servern lassen sich nicht entfernen und ihre Adresse nicht ändern.
- **Image aktualisieren:** lädt das CS2-Image auf allen Hosts gleichzeitig neu (per SSH direkt auf dem Host). Laufende Server laufen weiter.
- **Spieldateien aktualisieren:** Holt das neueste CS2-Update per SteamCMD in das Spieldateien-Volume jedes Hosts, in einem kurzlebigen Container (`cs2mv-update-<host>`). Das Image-Update allein bringt keine neuen Spieldateien. Der Knopf zeigt den Fortschritt („Update läuft 43 %“), darunter steht pro Host das Ergebnis, z. B. „bereits aktuell (Build 26334290)“. Hosts mit laufenden Servern (Matches oder Messer-Server) werden ausgelassen, außer man setzt den Haken „auch Hosts mit laufenden Servern“. Laufende Server nutzen die neuen Dateien erst nach einem Neustart.
- **Discord-Meldungen:** welche Ereignisse gemeldet werden, plus Testmeldung.
## Umgebungsvariablen
Alle Variablen mit Standardwerten stehen in [`.env.example`](.env.example). Die wichtigsten:
@@ -287,15 +513,36 @@ Alle Variablen mit Standardwerten stehen in [`.env.example`](.env.example). Die
| `SERVER_ADAPTER` | `demo` | `demo`, `docker` oder `static` |
| `SERVER_IDLE_TIMEOUT_MINUTES` | `30` | Frist bis zum automatischen Herunterfahren |
| `SERVER_MAX_LIFETIME_HOURS` | `8` | harte Obergrenze pro Server |
| `LOBBY_MAX_HOURS` | `3` | offene Lobbys nach so vielen Stunden samt Server löschen; `0` = nie |
| `ADMIN_PASSWORD` | leer | Passwort für den Admin-Bereich `/admin`; leer = Admin-Bereich aus |
| `VETO_STEP_SECONDS` | `30` | Zeit pro Veto-Schritt, danach wird automatisch gewählt; `0` = ohne Frist |
| `ARCHIVE_AFTER_DAYS` | `3` | abgeschlossene Matches nach so vielen Tagen archivieren; `0` = nie |
| `DOCKER_HOST` | leer | Docker-Ziel für einen einzelnen Host, z. B. `ssh://cs2docker` |
| `CS2_DOCKER_HOSTS` | leer | mehrere Docker-Hosts als JSON-Liste (siehe „Mehrere Docker-Hosts“) |
| `CS2_PORT_RANGE_START`, `CS2_PORT_RANGE_END` | `27015`, `27115` | Bereich der Spielports; CSTV liegt jeweils `CS2_TV_PORT_OFFSET` darüber |
| `CS2_LAN_PORTS` | `27015-27020` | Spielports für den LAN-Reiter von Steam, werden zuerst vergeben; leer = aus |
| `CS2_TV_PORT_OFFSET` | `100` | Abstand CSTV-Port zum Spielport (27015 → 27115) |
| `CS2_SERVERNAME_PREFIX` | `Maxlan` | Anfang des Servernamens im Serverbrowser |
| `CS2_START_TIMEOUT_MINUTES` | `20` | Startzeit eines Servers inklusive Download der Spieldateien |
| `KNIFE_MAP` | `de_mirage` | Standard-Map für Messer-Server |
| `KNIFE_WARMUP_SECONDS` | `90` | Zeit zum Verbinden, bevor die Messerrunde beginnt |
| `KNIFE_MAX_SERVERS` | `4` | höchstens so viele Messer-Server |
| `CS2_TV_DELAY` | `90` | CSTV-Verzögerung in Sekunden |
| `CS2_GSLT` | – | **Pflicht bei Docker:** Game Server Login Token |
| `CS2_GSLT` | leer | Docker: Game Server Login Token; leer = LAN-Modus (`sv_lan 1`) |
| `CS2_PUBLIC_HOST` | – | **Pflicht bei Docker:** öffentliche Adresse |
| `CS2_STATIC_SERVERS` | – | **Pflicht bei Static:** Serverliste |
| `DEMO_FAIL_FIRST_CONFIGURE` | `false` | Demo: Konfigurationsfehler simulieren |
| `DEMO_ROUND_SECONDS` | `4` | Demo: Sekunden pro simulierter Runde |
| `LOG_RECEIVER_URL` | `PUBLIC_URL` | Adresse, unter der der CS2-Server das Backend für Live-Logs erreicht |
| `LOG_DEBUG` | `false` | empfangene Logzeilen im Terminal ausgeben |
| `STREAM_KEY` | leer | Schlüssel für die Stream-Übersicht aller Matches (`/stream?key=…`); leer = offen |
| `DISCORD_WEBHOOK_URL` | leer | Discord-Webhook für Meldungen zu Veto, Match-Start, Map- und Serienende sowie Serverfehlern; leer = aus |
| `TRUST_PROXY` | `false` | `true`, wenn die Anwendung hinter einem Reverse-Proxy läuft (echte Client-IP für die Begrenzung der Anmeldeversuche) |
| `SERIES_AUTO_ADVANCE_SECONDS` | `30` | Wartezeit nach Map-Ende bis zur nächsten Map bzw. zum Abschluss; `-1` = manuell |
Benötigte Zugangsdaten für den Echtbetrieb:
- **Docker:** GSLT, Zugriff auf den Docker-Daemon, öffentliche IP bzw. Portfreigaben
- **Docker:** Zugriff auf den Docker-Daemon (lokal oder per `DOCKER_HOST=ssh://…`), Portfreigaben, für Internet-Betrieb ein GSLT (im LAN nicht nötig)
- **Static:** RCON-Passwörter der Server
RCON-Passwörter für Docker-Container erzeugt das Backend selbst.
@@ -333,6 +580,6 @@ Alle Aktionen laufen per `POST` mit `Authorization: Bearer <token>`. Der Token i
| `…/ready`, `…/leave` | Spieler |
| `…/veto/action` `{stepIndex, map?, side?}` | Captain am Zug |
| `…/captain` `{playerId}` | Organisator, Captain des Teams |
| `…/veto/start`, `…/kick`, `…/codes/regenerate`, `…/server/retry`, `…/server/stop`, `…/server/warmup-end`, `…/series/next`, `…/finish`, `…/cancel` | Organisator |
| `…/veto/start`, `…/kick`, `…/codes/regenerate`, `…/server/retry`, `…/server/stop`, `…/server/warmup-end`, `…/server/pause`, `…/server/resume`, `…/series/next`, `…/finish`, `…/cancel` | Organisator |
Socket.IO: `match:subscribe {matchId, token}` liefert `match:state` bei jeder Änderung; `overview:subscribe` liefert `overview`.
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#000000" />
<title>CS2 Mapveto – MaxLAN e.V.</title>
<title>CS2 Mapveto – Maxlan e.V.</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect width='32' height='32' rx='6' fill='%23000'/><path d='M8 6l16 20M24 6L8 26' stroke='%23a72a1c' stroke-width='5' stroke-linecap='round'/></svg>" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

+24 -2
View File
@@ -4,9 +4,27 @@ import { api } from './lib/api.js';
import Overview from './pages/Overview.jsx';
import CreateMatch from './pages/CreateMatch.jsx';
import MatchPage from './pages/MatchPage.jsx';
import StreamPage from './pages/StreamPage.jsx';
import StreamOverviewPage from './pages/StreamOverviewPage.jsx';
import BeamerPage from './pages/BeamerPage.jsx';
import AdminPage from './pages/AdminPage.jsx';
import { JoinPage, WatchPage } from './pages/AccessPages.jsx';
export default function App() {
return (
<Routes>
{/* Stream-Übersicht ohne Kopfzeile, als Vollbild bzw. OBS-Browserquelle */}
<Route path="/stream/:code" element={<StreamPage />} />
<Route path="/stream/match/:matchId" element={<StreamOverviewPage />} />
<Route path="/stream" element={<StreamOverviewPage />} />
{/* Diashow der laufenden Matches für den Beamer, ein Match pro Folie */}
<Route path="/beamer" element={<BeamerPage />} />
<Route path="*" element={<MainLayout />} />
</Routes>
);
}
function MainLayout() {
const [config, setConfig] = useState(null);
const [configError, setConfigError] = useState(null);
@@ -17,8 +35,8 @@ export default function App() {
return (
<div className="app">
<header className="topbar">
<Link to="/" className="brand" aria-label="MaxLAN e.V. CS2 Mapveto – zur Übersicht">
<img src="/brand/maxlan-logo.webp" alt="MaxLAN e.V." className="brand-logo" width="743" height="174" />
<Link to="/" className="brand" aria-label="Maxlan e.V. CS2 Mapveto – zur Übersicht">
<img src="/brand/maxlan-logo.webp" alt="Maxlan e.V." className="brand-logo" width="743" height="174" />
<span className="brand-app">CS2 Mapveto</span>
</Link>
{config?.server.simulated && (
@@ -28,6 +46,9 @@ export default function App() {
)}
<nav>
<Link to="/" className="nav-link">Übersicht</Link>
<a href="/stream" target="_blank" rel="noreferrer" className="nav-link">Stream-Übersicht</a>
<a href="/beamer" target="_blank" rel="noreferrer" className="nav-link">Beamer</a>
<Link to="/admin" className="nav-link">Admin</Link>
<Link to="/neu" className="btn btn-primary btn-sm">Match erstellen</Link>
</nav>
</header>
@@ -36,6 +57,7 @@ export default function App() {
<Routes>
<Route path="/" element={<Overview />} />
<Route path="/neu" element={<CreateMatch config={config} />} />
<Route path="/admin" element={<AdminPage />} />
<Route path="/m/:id" element={<MatchPage />} />
<Route path="/join/:code" element={<JoinPage config={config} />} />
<Route path="/watch/:code" element={<WatchPage />} />
+79
View File
@@ -0,0 +1,79 @@
/* ---------- Beamer-Diashow (1920×1080, skaliert): ein Match pro Folie, groß und aus der Ferne lesbar ---------- */
.bm-root {
position: relative; width: 100%; height: 100%; overflow: hidden; cursor: pointer;
display: grid; grid-template-rows: 104px 1fr 56px; padding: 0 72px;
color: var(--text); font-family: var(--body);
background:
radial-gradient(900px 600px at 0% 40%, rgba(52, 74, 153, .3), transparent 70%),
radial-gradient(900px 600px at 100% 40%, rgba(167, 42, 28, .28), transparent 70%),
#000;
}
.bm-root > :not(.bm-art) { position: relative; z-index: 1; }
.bm-art { position: absolute; inset: 0; opacity: .32; }
.bm-art::after { background: linear-gradient(180deg, rgba(0, 0, 0, .55), rgba(0, 0, 0, .92) 70%); }
.bm-main { display: grid; min-height: 0; animation: bm-in .6s ease both; }
@keyframes bm-in { from { opacity: 0; transform: translateY(18px); } }
.bm-slide { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 26px; min-height: 0; padding: 20px 0; }
.bm-meta { display: flex; align-items: center; gap: 22px; font: 600 32px var(--display); color: var(--muted); }
.bm-meta .so-badge { font-size: 30px; padding: 6px 18px; }
.bm-hero { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 56px; width: 100%; }
.bm-team { margin: 0; font: 700 128px/1 var(--display); letter-spacing: -.01em; overflow-wrap: anywhere; }
.bm-team.a { color: var(--team-a); text-align: right; }
.bm-team.b { color: var(--team-b); }
.bm-series { display: flex; flex-direction: column; align-items: center; font: 700 130px/1 var(--display); font-variant-numeric: tabular-nums; }
.bm-series span { display: flex; align-items: center; }
.bm-series i { font-style: normal; color: var(--muted); padding: 0 14px; }
.bm-series small { font: 600 26px var(--display); color: var(--muted); text-transform: uppercase; letter-spacing: .12em; }
.bm-vs { font-size: 90px; }
.bm-map { display: flex; align-items: baseline; gap: 26px; }
.bm-map strong { font: 700 64px var(--display); }
.bm-map span { font: 600 38px var(--display); color: var(--muted); }
.bm-score { display: flex; align-items: center; gap: 34px; font: 700 200px/1 var(--display); font-variant-numeric: tabular-nums; }
.bm-score i { font-style: normal; color: var(--muted); }
.bm-score b.a { color: var(--team-a); }
.bm-score b.b { color: var(--team-b); }
.bm-score b.won { text-shadow: 0 0 40px currentColor; }
.bm-maps { list-style: none; margin: 0; padding: 0; display: flex; gap: 18px; justify-content: center; }
.bm-maps li {
position: relative; overflow: hidden; width: 250px; height: 104px; border-radius: 8px; padding: 12px 16px;
display: flex; align-items: flex-end; justify-content: space-between; gap: 10px; border: 2px solid var(--line-strong);
}
.bm-maps li .map-art::after { background: linear-gradient(180deg, rgba(0, 0, 0, .2), rgba(0, 0, 0, .85)); }
.bm-maps li > span:not(.map-art), .bm-maps li b { position: relative; z-index: 1; font: 700 28px var(--display); text-shadow: 0 2px 8px #000; }
.bm-maps li.is-pending { filter: grayscale(.8) brightness(.6); }
.bm-maps li.is-current { border-color: var(--brand-red); box-shadow: 0 0 0 3px var(--brand-red); }
.bm-note { font: 600 44px var(--display); color: var(--muted); margin: 0; }
.bm-turn { display: flex; flex-direction: column; align-items: center; gap: 6px; margin: 0; font: 600 52px var(--display); }
.bm-turn .a { color: var(--team-a); }
.bm-turn .b { color: var(--team-b); }
.bm-turn small { font-size: 28px; color: var(--muted); }
.bm-vmaps { width: 100%; height: 330px; }
.bm-vmaps .so-vmap-name { font-size: 34px; }
.bm-summary { display: flex; flex-direction: column; justify-content: center; gap: 40px; min-height: 0; }
.bm-idle { margin: 0; text-align: center; font: 700 80px var(--display); }
.bm-columns { display: grid; grid-template-columns: 1fr 1fr; gap: 72px; }
.bm-columns h3 { margin: 0 0 18px; font: 700 40px var(--display); color: var(--muted); text-transform: uppercase; letter-spacing: .1em; border-bottom: 3px solid var(--brand-red); padding-bottom: 10px; }
.bm-columns ul { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 14px; }
.bm-columns li { display: flex; align-items: baseline; gap: 18px; font: 700 44px var(--display); }
.bm-columns li .a { color: var(--team-a); }
.bm-columns li .b { color: var(--team-b); }
.bm-columns li i { font-style: normal; color: var(--muted); font-size: 32px; }
.bm-columns li b { font-variant-numeric: tabular-nums; }
.bm-columns li .won { text-decoration: underline; text-underline-offset: 8px; }
.bm-columns li small { margin-left: auto; font: 600 28px var(--display); color: var(--muted); }
.bm-columns .so-none { font-size: 32px; }
.bm-bottom { position: relative; display: flex; align-items: center; justify-content: center; }
.bm-dots { display: flex; gap: 12px; }
.bm-dots span { width: 14px; height: 14px; border-radius: 50%; background: var(--line-strong); }
.bm-dots span.on { background: var(--brand-red); }
.bm-progress { position: absolute; left: 0; bottom: 0; height: 6px; width: 100%; background: var(--brand-red); transform-origin: left; animation: bm-progress linear both; }
@keyframes bm-progress { from { transform: scaleX(0); } to { transform: scaleX(1); } }
+107
View File
@@ -0,0 +1,107 @@
import { useEffect, useRef, useState } from 'react';
import { api } from '../lib/api.js';
const time = (ms) => new Date(ms).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
const SEEN_KEY = (matchId) => `cs2mv:chat-seen:${matchId}`;
function readSeen(matchId) {
try {
return JSON.parse(localStorage.getItem(SEEN_KEY(matchId))) ?? {};
} catch {
return {};
}
}
/** Chat im Match: Tabs für Match-Chat und Team-Chat über der Nachrichtenliste. */
export default function ChatBox({ chat, matchId, token, playerToken }) {
const [active, setActive] = useState(chat.channels[0].key);
const [text, setText] = useState('');
const [error, setError] = useState(null);
const [sending, setSending] = useState(false);
const [seen, setSeen] = useState(() => readSeen(matchId));
const listRef = useRef(null);
const channel = chat.channels.some((c) => c.key === active) ? active : chat.channels[0].key;
const messages = chat.messages[channel] ?? [];
const lastId = (key) => chat.messages[key]?.at(-1)?.id ?? 0;
// Aktiven Kanal als gelesen merken und ans Ende scrollen, wenn neue Nachrichten kommen
useEffect(() => {
const id = lastId(channel);
if (id && seen[channel] !== id) {
const next = { ...seen, [channel]: id };
setSeen(next);
try {
localStorage.setItem(SEEN_KEY(matchId), JSON.stringify(next));
} catch {
/* ignorieren */
}
}
const el = listRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, [channel, messages.length]); // eslint-disable-line react-hooks/exhaustive-deps
const send = async (e) => {
e.preventDefault();
if (!text.trim() || sending) return;
setSending(true);
setError(null);
try {
await api(`/matches/${matchId}/chat`, { method: 'POST', body: { channel, text }, token, playerToken });
setText('');
} catch (err) {
setError(err.message);
} finally {
setSending(false);
}
};
return (
<section className="panel chat-box" aria-label="Chat">
<div className="chat-tabs" role="tablist">
{chat.channels.map((c) => {
const unread = c.key !== channel && lastId(c.key) > (seen[c.key] ?? 0);
return (
<button key={c.key} role="tab" aria-selected={c.key === channel}
className={`chat-tab chat-tab-${c.key.toLowerCase()} ${c.key === channel ? 'is-active' : ''}`}
onClick={() => setActive(c.key)}>
{c.label}
{unread && <span className="chat-unread" aria-label="neue Nachrichten" />}
</button>
);
})}
</div>
<ol className="chat-list" ref={listRef} aria-live="polite">
{messages.length === 0 && (
<li className="chat-empty muted small">
{channel === 'match' ? 'Noch keine Nachricht. Hier schreiben beide Teams und der Organisator.' : 'Noch keine Nachricht. Diesen Chat sieht nur dein Team.'}
</li>
)}
{messages.map((m) => (
<li key={m.id} className={`chat-msg ${m.mine ? 'is-mine' : ''}`}>
<span className="chat-meta">
<strong className={m.team ? `team-${m.team.toLowerCase()}` : 'chat-org'}>{m.author}</strong>
{m.organizer && m.team && <span className="chat-role">Organisator</span>}
<time className="muted">{time(m.at)}</time>
</span>
<span className="chat-text">{m.text}</span>
</li>
))}
</ol>
{chat.canWrite ? (
<form className="chat-form" onSubmit={send}>
<input
value={text}
onChange={(e) => setText(e.target.value)}
maxLength={chat.maxLength}
placeholder={channel === 'match' ? 'Nachricht an alle im Match' : 'Nachricht an dein Team'}
aria-label="Nachricht"
/>
<button className="btn btn-primary" disabled={!text.trim() || sending}>Senden</button>
</form>
) : (
<p className="small muted chat-closed">Das Match ist beendet, der Chat ist nur noch lesbar.</p>
)}
{error && <p className="field-error">{error}</p>}
</section>
);
}
+88
View File
@@ -0,0 +1,88 @@
import { useCallback, useEffect, useRef, useState } from 'react';
const MAX_LINES = 2000;
/**
* Live-Konsole eines Containers im Admin-Bereich. Liest `GET /api/admin/logs/...` als Stream
* (fetch statt EventSource, weil die Anmeldung im Authorization-Kopf mitgeht).
*/
export default function ConsoleDialog({ title, path, token, onClose, note }) {
const [lines, setLines] = useState([]);
const [status, setStatus] = useState('verbinde');
const [follow, setFollow] = useState(true);
const [filter, setFilter] = useState('');
const [run, setRun] = useState(0);
const boxRef = useRef(null);
useEffect(() => {
const ctl = new AbortController();
let rest = '';
setLines([]);
setStatus('verbinde');
(async () => {
try {
const res = await fetch(`/api${path}?tail=300`, { headers: { authorization: `Bearer ${token}` }, signal: ctl.signal });
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error ?? `Fehler ${res.status}`);
}
setStatus('live');
const reader = res.body.getReader();
const decoder = new TextDecoder();
for (;;) {
const { value, done } = await reader.read();
if (done) break;
const parts = (rest + decoder.decode(value, { stream: true })).split('\n');
rest = parts.pop();
if (parts.length) setLines((cur) => [...cur, ...parts].slice(-MAX_LINES));
}
if (rest) setLines((cur) => [...cur, rest].slice(-MAX_LINES));
setStatus('beendet');
} catch (err) {
if (err.name !== 'AbortError') setStatus(`Fehler: ${err.message}`);
}
})();
return () => ctl.abort();
}, [path, token, run]);
// Mitlaufen: ans Ende scrollen, solange "Mitlaufen" an ist
useEffect(() => {
if (follow && boxRef.current) boxRef.current.scrollTop = boxRef.current.scrollHeight;
}, [lines, follow]);
const onKey = useCallback((e) => { if (e.key === 'Escape') onClose(); }, [onClose]);
useEffect(() => {
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [onKey]);
const q = filter.trim().toLowerCase();
const shown = q ? lines.filter((l) => l.toLowerCase().includes(q)) : lines;
return (
<div className="console-backdrop" onClick={onClose}>
<section className="console-dialog" role="dialog" aria-modal="true" aria-label={`Konsole ${title}`} onClick={(e) => e.stopPropagation()}>
<header className="console-head">
<strong>Konsole: {title}</strong>
<span className={`console-status ${status === 'live' ? 'is-live' : ''}`}>{status === 'live' ? 'live' : status}</span>
<button className="btn btn-sm" onClick={onClose}>Schließen</button>
</header>
<div className="console-tools">
<input type="search" value={filter} onChange={(e) => setFilter(e.target.value)} placeholder="Zeilen filtern" aria-label="Zeilen filtern" />
<label className="admin-check"><input type="checkbox" checked={follow} onChange={(e) => setFollow(e.target.checked)} /> Mitlaufen</label>
<button className="btn btn-sm" onClick={() => setLines([])}>Leeren</button>
<button className="btn btn-sm" onClick={() => setRun((n) => n + 1)}>Neu verbinden</button>
<span className="small muted">{shown.length} Zeilen</span>
</div>
<pre className="console-out" ref={boxRef} onScroll={(e) => {
const el = e.currentTarget;
const atEnd = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
if (atEnd !== follow) setFollow(atEnd);
}}>
{shown.length ? shown.join('\n') : status === 'verbinde' ? 'Verbinde …' : 'Noch keine Ausgabe.'}
</pre>
<p className="small muted console-note">{note ?? 'Passwörter sind ausgeblendet.'} Die Ansicht zeigt die letzten {MAX_LINES} Zeilen.</p>
</section>
</div>
);
}
+35 -15
View File
@@ -1,32 +1,52 @@
import CopyField from './CopyField.jsx';
function regenerate(act, target, label) {
if (!window.confirm(`Neuen Code für ${label} erzeugen? Der alte Link funktioniert dann nicht mehr. Bereits beigetretene Spieler bleiben im Team.`)) return;
act('codes/regenerate', { target }, `Neuer Code für ${label}`);
}
/** Einladungslink eines Teams, steht für den Organisator unten in der Team-Karte. */
export function TeamInvite({ state, side, act, busy }) {
const { codes, links } = state.organizer;
const open = !['finished', 'cancelled'].includes(state.status);
const name = state.teams[side].name;
return (
<div className={`invite team-invite team-${side}`}>
<div className="invite-head">
<strong>Einladung</strong>
<span className="invite-code">{codes[side]}</span>
{open && <button className="link-btn" disabled={!!busy} onClick={() => regenerate(act, side, name)}>Neuer Code</button>}
</div>
<CopyField value={links[side]} />
<p className="small muted">Führt nur in {name}. Nur an dieses Team weitergeben.</p>
</div>
);
}
export default function OrganizerPanel({ state, act, busy, matchId, token }) {
const { codes, links } = state.organizer;
const open = !['finished', 'cancelled'].includes(state.status);
const orgLink = `${window.location.origin}/m/${matchId}#org=${token}`;
const regen = (target, label) =>
window.confirm(`Neuen Code für ${label} erzeugen? Der alte Link funktioniert dann nicht mehr. Bereits beigetretene Spieler bleiben im Team.`) &&
act('codes/regenerate', { target }, `Neuer Code für ${label}`);
const label = 'Zuschauer (Stream, Kommentatoren)';
return (
<section className="panel organizer-panel">
<h2>Einladungen</h2>
<p className="small muted">Jeder Link führt nur in das jeweilige Team. Gib ihn ausschließlich an dieses Team weiter.</p>
{[
['A', state.teams.A.name, 'team-A'],
['B', state.teams.B.name, 'team-B'],
['spectator', 'Zuschauer (Stream, Kommentatoren)', 'spectator'],
].map(([key, label, cls]) => (
<div key={key} className={`invite ${cls}`}>
<p className="small muted">Die Einladungslinks der Teams stehen unten in der jeweiligen Team-Karte.</p>
<div className="invite spectator">
<div className="invite-head">
<strong>{label}</strong>
<span className="invite-code">{codes[key]}</span>
{open && <button className="link-btn" disabled={!!busy} onClick={() => regen(key, label)}>Neuer Code</button>}
<span className="invite-code">{codes.spectator}</span>
{open && <button className="link-btn" disabled={!!busy} onClick={() => regenerate(act, 'spectator', label)}>Neuer Code</button>}
</div>
<CopyField value={links.spectator} />
<div className="stream-link">
<CopyField label="Stream-Übersicht (als Browserquelle in OBS, 1920×1080)" value={links.stream} />
<a className="btn btn-sm" href={links.stream} target="_blank" rel="noreferrer">Stream-Ansicht öffnen</a>
<CopyField label="Match-Übersicht (nur dieses Match, ohne Zuschauercode)" value={links.overview} />
<a className="btn btn-sm" href={links.overview} target="_blank" rel="noreferrer">Match-Übersicht öffnen</a>
</div>
<CopyField value={links[key]} />
</div>
))}
<details className="org-link">
<summary>Organisator-Zugang auf einem anderen Gerät öffnen</summary>
<p className="small muted">Dieser Link gibt volle Kontrolle über das Match. Nicht weitergeben.</p>
+53
View File
@@ -0,0 +1,53 @@
// Rundenverlauf einer Map: ein Feld pro Runde in der Farbe des Siegerteams,
// mit Symbol für den Grund. Nach der Halbzeit (und jeder Verlängerungshälfte) ein Trenner.
export const REASON_LABEL = {
bomb: 'Bombe explodiert',
defuse: 'Bombe entschärft',
time: 'Zeit abgelaufen',
elimination: 'Alle ausgeschaltet',
surrender: 'Aufgegeben',
other: 'Runde gewonnen',
};
export function ReasonIcon({ reason, size = 12 }) {
const common = { width: size, height: size, viewBox: '0 0 16 16', 'aria-hidden': true, className: 'reason-icon' };
switch (reason) {
case 'bomb':
return (
<svg {...common}><circle cx="7" cy="9.5" r="5" fill="currentColor" /><path d="M10.5 5.5 13 3" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /><circle cx="13.6" cy="2.4" r="1.4" fill="currentColor" /></svg>
);
case 'defuse':
return (
<svg {...common}><path d="M3 13 9.5 6.5M6.5 3.5 12.5 9.5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" /><circle cx="11.5" cy="4.5" r="2.5" fill="none" stroke="currentColor" strokeWidth="1.8" /></svg>
);
case 'time':
return (
<svg {...common}><circle cx="8" cy="8" r="6" fill="none" stroke="currentColor" strokeWidth="1.8" /><path d="M8 4.5V8l2.5 1.8" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /></svg>
);
case 'elimination':
return (
<svg {...common}><circle cx="8" cy="8" r="5.5" fill="none" stroke="currentColor" strokeWidth="1.6" /><path d="M8 1v4M8 11v4M1 8h4M11 8h4" stroke="currentColor" strokeWidth="1.6" /></svg>
);
default:
return <svg {...common}><circle cx="8" cy="8" r="3" fill="currentColor" /></svg>;
}
}
export default function RoundStrip({ rounds = [], maxRounds = 24, teams, size = 'md' }) {
const half = maxRounds / 2;
const total = Math.max(rounds.length, maxRounds);
const cells = [];
for (let i = 1; i <= total; i++) {
const r = rounds.find((x) => x.number === i);
const breakBefore = i > 1 && (i - 1 === half || (i - 1 > maxRounds && (i - 1 - maxRounds) % 3 === 0));
cells.push(
<li key={i}
className={`round ${r ? `won-${r.winner}` : 'open'} ${breakBefore ? 'break' : ''}`}
title={r ? `Runde ${i}: ${teams[r.winner].name} (${REASON_LABEL[r.reason] ?? ''})` : `Runde ${i}`}>
{r ? <ReasonIcon reason={r.reason} /> : null}
</li>,
);
}
return <ol className={`round-strip round-strip-${size}`} aria-label="Rundenverlauf">{cells}</ol>;
}
+22 -2
View File
@@ -1,5 +1,6 @@
import MapImage from './MapImage.jsx';
import SideIcon from './SideIcon.jsx';
import RoundStrip from './RoundStrip.jsx';
const STATUS = { pending: 'Ausstehend', next: 'Wird eingerichtet', live: 'Aktuell', done: 'Gespielt' };
@@ -13,11 +14,26 @@ export default function SeriesList({ state }) {
const map = state.maps.find((m) => m.id === s.map);
const ct = s.ctTeam ? teams[s.ctTeam].name : null;
const t = s.ctTeam ? teams[s.ctTeam === 'A' ? 'B' : 'A'].name : null;
const isLive = s.live?.status === 'live' && s.status !== 'done';
const hasScore = s.live && (s.live.rounds.length > 0 || s.status === 'done');
return (
<li key={s.number} className={`series-item is-${s.status}`} style={{ '--map': map?.color ?? '#556' }}>
<li key={s.number} className={`series-item is-${s.status} ${isLive ? 'is-playing' : ''}`} style={{ '--map': map?.color ?? '#556' }}>
<MapImage map={map} className="series-art" />
<span className="series-no">Map {s.number}</span>
<strong className="series-map">{s.mapName}</strong>
<span className="series-status">
{isLive ? <span className="live-badge">Live</span> : STATUS[s.status]}
</span>
{hasScore && (
<span className="series-score" aria-label={`${teams.A.name} ${s.score.A} zu ${s.score.B} ${teams.B.name}`}>
<span className={`team-a ${s.winner === 'A' ? 'is-winner' : ''}`}>{s.score.A}</span>
<span className="colon">:</span>
<span className={`team-b ${s.winner === 'B' ? 'is-winner' : ''}`}>{s.score.B}</span>
{s.status === 'done' && (
<small>{s.winner ? `${teams[s.winner].name} gewinnt` : 'Unentschieden'}</small>
)}
</span>
)}
<span className="series-origin">
{s.pickedBy ? `Pick von ${teams[s.pickedBy].name}` : 'Entscheidungsmap'}
</span>
@@ -29,7 +45,11 @@ export default function SeriesList({ state }) {
</>
) : 'Seiten im Spiel festlegen'}
</span>
<span className="series-status">{STATUS[s.status]}</span>
{hasScore && (
<div className="series-rounds">
<RoundStrip rounds={s.live.rounds} maxRounds={state.maxRounds} teams={teams} size="sm" />
</div>
)}
</li>
);
})}
+31 -2
View File
@@ -10,6 +10,32 @@ const RETRY = {
};
const ACTIVE = ['starting', 'ready', 'configuring', 'configured', 'config_failed', 'failed'];
/** Zeigt dem Organisator, ob Live-Daten vom Server ankommen und ob die Map vorbei ist. */
function LiveDataStatus({ state, hasNext }) {
const current = state.series.find((s) => s.number === state.currentMapNumber);
if (!current) return null;
const updatedAt = current.live?.updatedAt;
if (current.status === 'done') {
const auto = state.autoAdvanceSeconds >= 0;
return (
<div className="alert alert-ok">
Map {current.number} ist beendet.{' '}
{auto
? (hasNext ? `Die nächste Map wird nach ${state.autoAdvanceSeconds} s automatisch geladen, sofern die Serie nicht entschieden ist.` : 'Das Match wird automatisch abgeschlossen.')
: (hasNext ? 'Lade jetzt die nächste Map.' : 'Das war die letzte Map – schließ das Match ab.')}
</div>
);
}
return (
<p className="small live-data">
<span className={`status-dot ${updatedAt ? 'tone-ok' : 'tone-muted'}`} />
{updatedAt
? `Live-Daten vom Server empfangen, zuletzt um ${new Date(updatedAt).toLocaleTimeString('de-DE')}.`
: 'Noch keine Live-Daten. Sie kommen, sobald die Aufwärmphase endet und der Server diese Webseite erreicht (LOG_RECEIVER_URL).'}
</p>
);
}
export default function ServerPanel({ state, isOrg, act, busy }) {
const { server } = state;
const label = serverLabel(server);
@@ -35,13 +61,14 @@ export default function ServerPanel({ state, isOrg, act, busy }) {
)}
{!server && <p className="muted small">Für dieses Match wurde noch kein Server angefordert.</p>}
{server && ['starting', 'ready'].includes(server.status) && state.status !== 'server_setup' && (
{server?.knife && <p className="muted small">Vor dem Veto läuft hier die Messerrunde, die Verbindungsdaten stehen in der Lobby. Nach dem Veto wird derselbe Server für das Match eingerichtet.</p>}
{server && !server.knife && ['starting', 'ready'].includes(server.status) && state.status !== 'server_setup' && (
<p className="muted small">
{sim ? 'Der Start wird während des Vetos simuliert.' : 'Der Server fährt während des Vetos hoch.'} Die Verbindungsdaten erscheinen nach dem Veto.
</p>
)}
{server?.connect && (
{server?.connect && !server.knife && (
<div className="connect">
<h3>{sim ? 'Spieler-Verbindung (Demo-Beispiel, nicht verbindbar)' : 'Verbindung für Spieler'}</h3>
<CopyField label="In die CS2-Konsole einfügen" value={server.connect.command} secret />
@@ -63,6 +90,8 @@ export default function ServerPanel({ state, isOrg, act, busy }) {
<p className="small muted">CSTV ist auf diesem Server deaktiviert.</p>
)}
{isOrg && server?.status === 'configured' && <LiveDataStatus state={state} hasNext={hasNext} />}
{isOrg && server && !closed && (
<div className="org-controls">
{canRetry && (
+2
View File
@@ -1,3 +1,4 @@
import { TeamInvite } from './OrganizerPanel.jsx';
export default function TeamCard({ team, side, state, viewer, isOrg, act, busy }) {
const isOwnTeam = viewer.team === side;
const inLobby = state.status === 'lobby';
@@ -59,6 +60,7 @@ export default function TeamCard({ team, side, state, viewer, isOrg, act, busy }
</div>
)}
{inLobby && !readiness.enough && <p className="small muted">Es fehlen noch {team.teamSize - readiness.count} Spieler.</p>}
{isOrg && state.organizer && <TeamInvite state={state} side={side} act={act} busy={busy} />}
</section>
);
}
+27
View File
@@ -10,6 +10,7 @@ export default function VetoBoard({ state, viewer, act, busy, compact = false })
const { current } = veto;
const [selected, setSelected] = useState(null);
useEffect(() => setSelected(null), [current?.index]);
const left = useCountdown(veto.remainingMs);
const mapsById = Object.fromEntries(state.maps.map((m) => [m.id, m]));
const yourTurn = !!current && viewer.isCaptain && viewer.team === current.team;
@@ -38,6 +39,7 @@ export default function VetoBoard({ state, viewer, act, busy, compact = false })
? 'Live-Ansicht – nur Captains können wählen.'
: 'Das andere Team ist am Zug.'}
</span>
{left !== null && <TimerBar left={left} totalMs={(veto.stepSeconds || 30) * 1000} />}
</div>
)}
{!current && veto.done && !compact && <div className="turn turn-done">Veto abgeschlossen</div>}
@@ -129,3 +131,28 @@ function statusText(m, teams) {
default: return 'Verfügbar';
}
}
/** Leiste für die verbleibende Zeit des Veto-Schritts. In den letzten 10 Sekunden wird sie rot. */
function TimerBar({ left, totalMs }) {
const seconds = Math.ceil(left / 1000);
const percent = Math.max(0, Math.min(100, (left / totalMs) * 100));
return (
<div className={`turn-timer ${left <= 10_000 ? 'is-low' : ''}`} role="timer" aria-label={`Noch ${seconds} Sekunden`}>
<div className="turn-bar"><span style={{ width: `${percent}%` }} /></div>
<span className="turn-seconds">{seconds} s</span>
</div>
);
}
/** Verbleibende Zeit des Veto-Schritts: der Server setzt sie bei jeder Änderung, danach zählt die Anzeige lokal herunter. */
function useCountdown(ms) {
const [left, setLeft] = useState(ms ?? null);
useEffect(() => {
setLeft(ms ?? null);
if (ms == null) return undefined;
const started = Date.now();
const t = setInterval(() => setLeft(Math.max(0, ms - (Date.now() - started))), 250);
return () => clearInterval(t);
}, [ms]);
return left;
}
File diff suppressed because it is too large. Load diff
+254
View File
@@ -0,0 +1,254 @@
import { useCallback, useEffect, useState } from 'react';
import { FORMAT_LABEL } from '../lib/labels.js';
import MapImage from '../components/MapImage.jsx';
import SideIcon from '../components/SideIcon.jsx';
import { ScaledStage, useClock } from './StreamPage.jsx';
import { useStreamOverview } from './StreamOverviewPage.jsx';
import '../stream.css';
import '../stream-overview.css';
import '../beamer.css';
const DEFAULT_SECONDS = 15;
/**
* Diashow der laufenden Matches für den Beamer: /beamer
* Ein Match pro Folie, schaltet automatisch weiter. Am Ende eine Folie mit Lobbys und Ergebnissen.
* Optional ?sekunden=20 (5 bis 120) und ?key=<STREAM_KEY>.
* Tasten: ← → blättern, Leertaste anhalten, F Vollbild. Klick schaltet Vollbild um.
*/
export default function BeamerPage() {
const params = new URLSearchParams(window.location.search);
const key = params.get('key') ?? '';
const seconds = Math.max(5, Math.min(120, Number.parseInt(params.get('sekunden'), 10) || DEFAULT_SECONDS));
const { data, error } = useStreamOverview(key);
const now = useClock();
const [index, setIndex] = useState(0);
const [paused, setPaused] = useState(false);
useEffect(() => {
document.documentElement.classList.add('stream-mode');
return () => document.documentElement.classList.remove('stream-mode');
}, []);
const slides = buildSlides(data);
const count = slides.length;
const current = slides[count ? index % count : 0];
// Folie über die ID merken, damit sich bei neuen Daten nicht die angezeigte Folie verschiebt
const currentId = current?.id;
const go = useCallback((step) => setIndex((i) => (count ? (i + step + count) % count : 0)), [count]);
useEffect(() => {
if (paused || count <= 1) return undefined;
const t = setTimeout(() => go(1), seconds * 1000);
return () => clearTimeout(t);
}, [index, paused, count, seconds, go, currentId]);
useEffect(() => {
const onKey = (e) => {
if (e.key === 'ArrowRight') go(1);
else if (e.key === 'ArrowLeft') go(-1);
else if (e.key === ' ') { e.preventDefault(); setPaused((p) => !p); } else if (e.key === 'f' || e.key === 'F') toggleFullscreen();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [go]);
if (error) {
return (
<ScaledStage>
<div className="st-message">
<img src="/brand/maxlan-logo.webp" alt="Maxlan e.V." />
<h1>Diashow nicht verfügbar</h1>
<p>{error}</p>
</div>
</ScaledStage>
);
}
const live = (data?.active ?? []).filter((m) => m.status === 'live').length;
return (
<ScaledStage>
<div className="bm-root" onClick={toggleFullscreen}>
{current?.type === 'match' && <Backdrop m={current.match} />}
<header className="st-top bm-top">
<img className="st-logo" src="/brand/maxlan-logo.webp" alt="Maxlan e.V." />
<div className="so-title">
<strong>Counter-Strike 2</strong>
{live > 0 && <span className="so-count so-count-live">{live} live</span>}
{count > 1 && <span className="so-count">{(index % count) + 1} / {count}</span>}
{paused && <span className="so-count">angehalten</span>}
</div>
<div className="st-clock"><time>{now.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}</time></div>
</header>
<main className="bm-main" key={currentId ?? 'leer'}>
{!data && <div className="so-empty">Lade Matches …</div>}
{current?.type === 'match' && <MatchSlide m={current.match} />}
{current?.type === 'summary' && <SummarySlide data={data} />}
</main>
<footer className="bm-bottom">
{count > 1 && (
<div className="bm-dots">
{slides.map((s, i) => <span key={s.id} className={i === index % count ? 'on' : ''} />)}
</div>
)}
{count > 1 && !paused && <div className="bm-progress" key={`${currentId}-${index}`} style={{ animationDuration: `${seconds}s` }} />}
</footer>
</div>
</ScaledStage>
);
}
/** Ein Match pro Folie, danach eine Übersicht mit Lobbys und Ergebnissen (wenn es welche gibt). */
function buildSlides(data) {
if (!data) return [];
const slides = data.active.map((m) => ({ id: m.id, type: 'match', match: m }));
if (data.upcoming.length || data.results.length || slides.length === 0) slides.push({ id: 'summary', type: 'summary' });
return slides;
}
function toggleFullscreen() {
if (document.fullscreenElement) document.exitFullscreen?.().catch(() => {});
else document.documentElement.requestFullscreen?.().catch(() => {});
}
function currentMap(m) {
return m.series.find((s) => s.number === m.currentMapNumber);
}
function Backdrop({ m }) {
const map = currentMap(m);
if (m.status === 'veto' || !map) return null;
return <MapImage map={map} className="bm-art" />;
}
function MatchSlide({ m }) {
const map = currentMap(m);
const isBo1 = m.format === 'bo1';
const playing = m.status === 'live' && map?.live?.status === 'live' && map.status !== 'done';
const badge = m.status === 'veto' ? 'Map-Veto' : m.status === 'server_setup' ? 'Server startet' : playing ? 'Live' : map?.status === 'done' ? 'Map beendet' : 'Aufwärmphase';
return (
<section className={`bm-slide status-${m.status}`}>
<div className="bm-meta">
<span>{m.modeLabel}, {FORMAT_LABEL[m.format]}</span>
<span className={`so-badge ${playing ? 'badge-live' : ''}`}>{badge}</span>
</div>
<div className="bm-hero">
<h1 className="bm-team a">{m.teams.A}</h1>
<div className="bm-series">
{!isBo1 && m.status !== 'veto' ? (
<>
<span>{m.seriesScore.A}<i>:</i>{m.seriesScore.B}</span>
<small>Maps</small>
</>
) : <i className="bm-vs">vs</i>}
</div>
<h1 className="bm-team b">{m.teams.B}</h1>
</div>
{m.status === 'veto' && <VetoPart m={m} />}
{m.status === 'server_setup' && map && <p className="bm-note">Server wird eingerichtet: Map {map.number}, {map.name}</p>}
{m.status === 'live' && map && <LivePart m={m} map={map} />}
</section>
);
}
function LivePart({ m, map }) {
const sides = map.live?.sides;
const sideOf = (t) => (sides ? (sides.CT === t ? 'CT' : 'T') : null);
const finished = map.status === 'done';
const rounds = map.live?.roundsPlayed ?? 0;
return (
<>
<div className="bm-map">
<strong>{map.name}</strong>
<span>{finished ? 'Endstand' : map.live?.status === 'live' ? `Runde ${rounds + 1}` : 'Aufwärmphase'}</span>
</div>
<div className="bm-score">
{sideOf('A') && <SideIcon side={sideOf('A')} size={72} />}
<b className={`a ${map.winner === 'A' ? 'won' : ''}`}>{map.score.A}</b>
<i>:</i>
<b className={`b ${map.winner === 'B' ? 'won' : ''}`}>{map.score.B}</b>
{sideOf('B') && <SideIcon side={sideOf('B')} size={72} />}
</div>
{m.series.length > 1 && (
<ol className="bm-maps">
{m.series.map((s) => (
<li key={s.number} className={`is-${s.status} ${s.number === map.number ? 'is-current' : ''}`} style={{ '--map': s.color ?? '#556' }}>
<MapImage map={s} />
<span>{s.name}</span>
{(s.status === 'done' || s.number === map.number) && <b>{s.score.A}:{s.score.B}</b>}
</li>
))}
</ol>
)}
</>
);
}
function VetoPart({ m }) {
const { veto, teams } = m;
const c = veto.current;
const action = c && ({ ban: 'bannt eine Map', pick: 'wählt eine Map', side: 'wählt die Seite' })[c.type];
return (
<>
<p className="bm-turn">
{c ? <><strong className={c.team === 'A' ? 'a' : 'b'}>{teams[c.team]}</strong> {action}</> : 'Veto abgeschlossen'}
<small>Schritt {Math.min(veto.stepsDone + 1, veto.stepsTotal)} von {veto.stepsTotal}</small>
</p>
<div className="so-vmaps bm-vmaps" style={{ '--count': veto.maps.length }}>
{veto.maps.map((x) => (
<div key={x.id} className={`so-vmap status-${x.status} ${x.by ? `by-${x.by}` : ''}`} style={{ '--map': x.color ?? '#556' }}>
<MapImage map={x} />
<span className="so-vmap-name">{x.name}</span>
</div>
))}
</div>
</>
);
}
function SummarySlide({ data }) {
const score = (m) => (m.format === 'bo1' && m.series[0] ? m.series[0].score : m.seriesScore);
return (
<section className="bm-summary">
{data.active.length === 0 && <h2 className="bm-idle">Gerade läuft kein Match</h2>}
<div className="bm-columns">
<div>
<h3>Gleich</h3>
{data.upcoming.length ? (
<ul>
{data.upcoming.map((m) => (
<li key={m.id}>
<span className="a">{m.teams.A}</span><i>vs</i><span className="b">{m.teams.B}</span>
<small>{FORMAT_LABEL[m.format]}{m.ready.A && m.ready.B ? ', bereit' : ''}</small>
</li>
))}
</ul>
) : <p className="so-none">Keine offenen Lobbys</p>}
</div>
<div>
<h3>Ergebnisse</h3>
{data.results.length ? (
<ul>
{data.results.map((m) => {
const s = score(m);
return (
<li key={m.id}>
<span className={`a ${s.A > s.B ? 'won' : ''}`}>{m.teams.A}</span>
<b>{s.A}:{s.B}</b>
<span className={`b ${s.B > s.A ? 'won' : ''}`}>{m.teams.B}</span>
</li>
);
})}
</ul>
) : <p className="so-none">Noch keine Ergebnisse</p>}
</div>
</div>
</section>
);
}
+1
View File
@@ -89,6 +89,7 @@ export default function CreateMatch({ config }) {
<option value="coin">Münzwurf beim Veto-Start</option>
<option value="A">{form.teamA || 'Team A'}</option>
<option value="B">{form.teamB || 'Team B'}</option>
<option value="knife">Messerrunde: Organisator legt danach fest, wer beginnt</option>
</select>
</label>
+134 -6
View File
@@ -12,6 +12,8 @@ import SeriesList from '../components/SeriesList.jsx';
import EventLog from '../components/EventLog.jsx';
import MapImage from '../components/MapImage.jsx';
import SelfJoin from '../components/SelfJoin.jsx';
import ChatBox from '../components/ChatBox.jsx';
import CopyField from '../components/CopyField.jsx';
export default function MatchPage() {
const { id } = useParams();
@@ -106,7 +108,13 @@ export default function MatchPage() {
<div className="versus">
<h1>
<span className="team-a">{state.teams.A.name}</span>
{state.series.length > 1 ? (
<span className="series-total" title="Serienstand (gewonnene Maps)">
{state.seriesScore.A}<span className="colon">:</span>{state.seriesScore.B}
</span>
) : (
<span className="vs">vs.</span>
)}
<span className="team-b">{state.teams.B.name}</span>
</h1>
</div>
@@ -121,7 +129,7 @@ export default function MatchPage() {
{viewer.role === 'public' && <PublicNotice />}
<div className={`match-grid ${state.status !== 'lobby' && viewer.role !== 'public' ? 'stage-wide' : ''}`}>
<div className={`match-grid ${state.status !== 'lobby' ? 'stage-wide' : ''}`}>
<TeamCard team={state.teams.A} side="A" {...ctx} />
<section className="stage">
<Stage {...ctx} />
@@ -131,6 +139,7 @@ export default function MatchPage() {
{viewer.role !== 'public' && (
<div className="match-lower">
{state.chat && <ChatBox chat={state.chat} matchId={id} token={token} playerToken={playerToken} />}
<ServerPanel {...ctx} />
{isOrg && <OrganizerPanel {...ctx} token={token} />}
<EventLog events={state.events} />
@@ -150,8 +159,8 @@ function describeViewer(viewer, teams) {
function Stage(ctx) {
const { state, isOrg, act, busy } = ctx;
if (state.viewer.role === 'public') {
return <div className="stage-note"><h2>{state.statusLabel}</h2><p className="muted">Details sehen nur Teilnehmer.</p></div>;
if (state.viewer.role === 'public' && state.status === 'lobby') {
return <div className="stage-note"><h2>{state.statusLabel}</h2><p className="muted">Das Veto und der Spielstand erscheinen hier, sobald das Match startet.</p></div>;
}
if (state.status === 'lobby') {
const { A, B } = state.teams;
@@ -164,7 +173,21 @@ function Stage(ctx) {
{missing.map((t) => <li key={t.key}><strong className={`team-${t.key.toLowerCase()}`}>{t.name}:</strong> {readinessGap(t, state.requireFullTeams)}</li>)}
</ul>
)}
{isOrg ? (
{state.vetoStart === 'knife' && (state.server?.knife ? <OwnKnifeServer state={state} isOrg={isOrg} act={act} busy={busy} /> : <KnifeRound state={state} />)}
{isOrg && state.vetoStart === 'knife' ? (
<div className="knife-decide">
<p>Wer hat die Messerrunde gewonnen? Dieses Team beginnt das Veto.</p>
<div className="row">
{['A', 'B'].map((t) => (
<button key={t} className={`btn btn-lg knife-team-${t.toLowerCase()}`} disabled={!state.canStartVeto || !!busy}
onClick={() => act('veto/start', { firstTeam: t }, `Veto gestartet – ${state.teams[t].name} beginnt`)}>
{state.teams[t].name} beginnt
</button>
))}
</div>
{!state.canStartVeto && <p className="small muted">Die Knöpfe werden aktiv, sobald beide Teams bereit sind.</p>}
</div>
) : isOrg ? (
<>
<button className="btn btn-primary btn-lg" disabled={!state.canStartVeto || !!busy}
onClick={() => act('veto/start', null, 'Veto gestartet')}>
@@ -172,6 +195,8 @@ function Stage(ctx) {
</button>
{!state.canStartVeto && <p className="small muted">Der Knopf wird aktiv, sobald beide Teams bereit sind.</p>}
</>
) : state.vetoStart === 'knife' ? (
<p className="small muted">Nach der Messerrunde legt der Organisator fest, welches Team das Veto beginnt.</p>
) : (
<p className="small muted">
{missing.length === 0
@@ -194,6 +219,109 @@ function Stage(ctx) {
}
/** Sagt konkret, was einem Team noch fehlt, damit es als bereit gilt. */
/**
* Seitenwahl per Messerrunde: freie Messer-Server vorschlagen, passende Teamgröße zuerst.
* Frei = niemand drauf (oder Spielerzahl unbekannt).
*/
/** Messerrunde auf dem eigenen Match-Server: derselbe Server wird nach dem Veto für das Match eingerichtet. */
function OwnKnifeServer({ state, isOrg, act, busy }) {
const { server } = state;
const { knife, connect } = server;
const live = knife.phase === 'live';
return (
<div className="knife-round">
<h3>Seitenwahl per Messerrunde</h3>
<p className="small">
Für euer Match läuft ein eigener Server. Beide Teams verbinden sich, spielen dort eine Runde nur mit Messer, danach legt der
Organisator fest, welches Team das Veto beginnt. Nach dem Veto geht es auf demselben Server weiter.
</p>
{knife.failed ? (
<>
<p className="small tone-error">{server.message}</p>
{isOrg ? (
<button className="btn btn-sm btn-primary" disabled={!!busy} onClick={() => act('server/retry', null, 'Messerrunde wird neu eingerichtet')}>
Messerrunde neu einrichten
</button>
) : <p className="small muted">Der Organisator kann die Messerrunde neu einrichten.</p>}
</>
) : !knife.ready ? (
<p className="small tone-pending">
<span className="status-dot tone-pending pulse" /> Der Server fährt hoch, die Verbindungsdaten erscheinen gleich hier.
{server.message && <span className="muted"> ({server.message})</span>}
</p>
) : (
<>
<p className={`small ${live ? 'tone-ok' : 'tone-ok'}`}>
<span className={`status-dot ${live ? 'tone-ok' : 'tone-ok'}`} />
{live ? ' Messerrunde läuft' : ' Aufwärmphase: verbinden und warten, bis der Organisator die Runde startet'}
{server.playerCount != null && <span className="muted"> · {server.playerCount} auf dem Server</span>}
</p>
{connect && (
<div className="knife-connect">
<CopyField label="In die CS2-Konsole einfügen" value={connect.command} secret />
<a className="btn btn-primary" href={connect.steamUrl}>In CS2 verbinden</a>
</div>
)}
{isOrg && (
<div className="row knife-actions">
{!live && <button className="btn btn-sm btn-primary" disabled={!!busy} onClick={() => act('server/knife-start', null, 'Messerrunde gestartet')}>Messerrunde starten</button>}
<button className="btn btn-sm" disabled={!!busy} onClick={() => act('server/knife-reset', null, 'Zurück in der Aufwärmphase')}>
{live ? 'Runde wiederholen (Aufwärmphase)' : 'Aufwärmphase neu starten'}
</button>
</div>
)}
</>
)}
</div>
);
}
function KnifeRound({ state }) {
const [servers, setServers] = useState(null);
useEffect(() => {
let alive = true;
const load = () => api('/knife-servers').then((res) => alive && setServers(res.servers)).catch(() => alive && setServers([]));
load();
const t = setInterval(load, 10_000);
return () => {
alive = false;
clearInterval(t);
};
}, []);
const free = (servers ?? []).filter((k) => !k.playerCount);
const matching = free.filter((k) => k.mode === state.mode);
const shown = matching.length ? matching : free;
return (
<div className="knife-round">
<h3>Seitenwahl per Messerrunde</h3>
<p className="small">
Beide Teams gehen zusammen auf einen freien Messer-Server und spielen eine Runde nur mit Messer. Danach legt der Organisator fest,
welches Team das Veto beginnt.
</p>
{servers === null ? (
<p className="small muted">Suche freie Messer-Server …</p>
) : shown.length === 0 ? (
<p className="small tone-pending">Gerade ist kein Messer-Server frei. Fragt die Turnierleitung, oder wartet, bis einer frei wird.</p>
) : (
<>
{!matching.length && <p className="small muted">Kein freier Server mit eurer Teamgröße, hier die anderen freien:</p>}
<ul className="knife-list">
{shown.map((k) => (
<li key={k.id}>
<strong>{k.name}</strong>
{k.modeShort && <span className="knife-mode">{k.modeShort}</span>}
<span className="muted small">{k.mapName}</span>
<code>{k.connect}</code>
<a className="btn btn-sm btn-primary" href={k.steamUrl}>Verbinden</a>
</li>
))}
</ul>
</>
)}
</div>
);
}
function readinessGap(team, requireFull) {
const r = team.readiness;
const gaps = [];
@@ -224,8 +352,8 @@ function MapPoolPreview({ maps }) {
function PublicNotice() {
return (
<div className="alert">
Du siehst diese Lobby ohne Zugang. Spieler treten über den Einladungslink ihres Teams bei,
Zuschauer über den Zuschauerlink. Beides bekommst du vom Organisator.
Du siehst dieses Match ohne Zugang. Veto, Serienstand und Ergebnisse sind öffentlich.
Teamdetails und Verbindungsdaten gibt es nur für Teilnehmer.
</div>
);
}
+190 -33
View File
@@ -4,48 +4,146 @@ import { api } from '../lib/api.js';
import { FORMAT_LABEL, serverLabel, timeAgo } from '../lib/labels.js';
import { getAccess } from '../lib/session.js';
import { useOverview } from '../lib/useMatch.js';
const GROUPS = [
{ key: 'open', title: 'Offen', empty: 'Keine offenen Lobbys. Erstelle ein Match, um zu starten.' },
{ key: 'running', title: 'Laufend', empty: 'Gerade läuft kein Match.' },
{ key: 'closed', title: 'Abgeschlossen', empty: 'Noch keine abgeschlossenen Matches.' },
];
import MapImage from '../components/MapImage.jsx';
export default function Overview() {
const { matches } = useOverview();
const mine = (matches ?? []).filter((m) => m.group !== 'closed' && getAccess(m.id));
const step = mine.length ? Math.max(...mine.map((m) => nextStep(m, getAccess(m.id)).step)) : 0;
const running = (matches ?? []).filter((m) => m.group === 'running');
const open = (matches ?? []).filter((m) => m.group === 'open');
const closed = (matches ?? []).filter((m) => m.group === 'closed');
return (
<div className="overview">
<section className="overview-head">
<div>
<h1>Match-Lobbys</h1>
<p className="lead">Teams treten mit ihrem Einladungscode bei, die Captains führen das Map-Veto, der Server wird automatisch eingerichtet.</p>
<div className="overview home">
<section className={`home-hero ${mine.length ? 'has-mine' : ''}`}>
<div className="home-intro">
<h1>Mit deinem Team ins Match</h1>
<p className="lead">
Den Teamlink postet der Organisator im Match-Chat auf maxlan.de oder in Discord. Öffne den Link deines Teams.
Hast du nur den Code, gib ihn hier ein.
</p>
<CodeTicket />
</div>
<CodeEntry />
{mine.length > 0 && <MyMatches matches={mine} />}
</section>
<Steps current={step} />
{matches === null ? (
<p className="muted">Lade Matches …</p>
) : (
<div className="groups">
{GROUPS.map((g) => {
const list = matches.filter((m) => m.group === g.key);
return (
<section key={g.key} className={`group group-${g.key}`}>
<h2>{g.title} <span className="count">{list.length}</span></h2>
{list.length === 0 ? <p className="muted small">{g.empty}</p> : (
<ul className="match-list">
{list.map((m) => <MatchRow key={m.id} match={m} />)}
</ul>
<>
<section className="group home-live">
<h2>Laufend <span className="count">{running.length}</span></h2>
{running.length === 0 ? (
<p className="muted small">Gerade läuft kein Match.</p>
) : (
<div className="run-grid">
{running.map((m) => <RunningCard key={m.id} match={m} />)}
</div>
)}
</section>
<div className="home-lists">
<section className="group">
<h2>Offene Lobbys <span className="count">{open.length}</span></h2>
{open.length === 0 ? (
<p className="muted small">Keine offene Lobby. Sobald der Organisator ein Match erstellt, steht es hier.</p>
) : (
<ul className="match-list">{open.map((m) => <MatchRow key={m.id} match={m} />)}</ul>
)}
</section>
<section className="group">
<h2>Ergebnisse <span className="count">{closed.length}</span></h2>
{closed.length === 0 ? (
<p className="muted small">Noch kein Match abgeschlossen.</p>
) : (
<ul className="match-list">{closed.slice(0, 12).map((m) => <MatchRow key={m.id} match={m} />)}</ul>
)}
</section>
);
})}
</div>
</>
)}
</div>
);
}
const STEPS = [
{ title: 'Teamlink öffnen', text: 'Link aus dem Match-Chat, Namen eingeben' },
{ title: 'Bereit klicken', text: 'In der Lobby auf „Ich bin bereit“' },
{ title: 'Map-Veto', text: 'Die Captains bannen und wählen' },
{ title: 'Auf den Server', text: 'Befehl kopieren oder im LAN-Reiter' },
];
/** Was als Nächstes zu tun ist, je nach Stand des Matches und Rolle. step = Schritt in STEPS (1–4). */
function nextStep(m, access) {
const organizer = access?.kind === 'organizer';
switch (m.status) {
case 'lobby':
return organizer
? { step: 2, text: 'Lobby offen: Veto starten, sobald beide Teams bereit sind', action: 'Zur Lobby' }
: { step: 2, text: 'Lobby offen: Klick auf „Ich bin bereit“', action: 'Zur Lobby' };
case 'veto':
return { step: 3, text: 'Das Map-Veto läuft', action: 'Zum Veto' };
case 'server_setup':
return { step: 4, text: 'Der Server wird eingerichtet, gleich geht es los', action: 'Zum Match' };
default:
return organizer
? { step: 4, text: 'Match läuft: Aufwärmphase beenden, wenn alle da sind', action: 'Zum Match' }
: { step: 4, text: 'Match läuft: Verbindung steht auf der Match-Seite', action: 'Zum Server' };
}
}
/** Matches, für die dieser Browser einen Zugang hat, mit dem nächsten Schritt. */
function MyMatches({ matches }) {
return (
<aside className="home-mine" aria-label="Deine Matches">
<h2>{matches.length === 1 ? 'Dein Match' : 'Deine Matches'}</h2>
<ul>
{matches.map((m) => {
const access = getAccess(m.id);
const next = nextStep(m, access);
return (
<li key={m.id}>
<Link to={`/m/${m.id}`} className="mine-card">
<span className="mine-teams">
<span className="team-a">{m.teamA}</span> <span className="vs">vs.</span> <span className="team-b">{m.teamB}</span>
</span>
<span className="mine-meta">
{m.modeLabel}, {FORMAT_LABEL[m.format]}
{access.kind === 'organizer' ? ', du bist Organisator' : access.kind === 'spectator' ? ', als Zuschauer' : ''}
</span>
<span className="mine-next">{next.text}</span>
<span className="btn btn-primary btn-sm mine-go">{next.action}</span>
</Link>
</li>
);
})}
</ul>
</aside>
);
}
/** Die vier Schritte vom Teamlink bis auf den Server. current = Schritt des eigenen Matches (0 = keins). */
function Steps({ current }) {
return (
<ol className="home-steps">
{STEPS.map((st, i) => {
const n = i + 1;
const state = current === 0 ? '' : n < current ? 'is-done' : n === current ? 'is-current' : '';
return (
<li key={st.title} className={state} aria-current={n === current ? 'step' : undefined}>
<span className="step-n">{n}</span>
<span className="step-title">{st.title}</span>
<span className="step-text">{st.text}</span>
</li>
);
})}
</ol>
);
}
function MatchRow({ match: m }) {
const access = getAccess(m.id);
const server = serverLabel(m.server);
@@ -54,10 +152,19 @@ function MatchRow({ match: m }) {
<Link to={`/m/${m.id}`} className="match-row">
<div className="match-row-teams">
<span className="team-a">{m.teamA}</span>
{m.seriesScore && m.format !== 'bo1' ? (
<span className="row-score" title="Serienstand">{m.seriesScore.A}:{m.seriesScore.B}</span>
) : (
<span className="vs">vs.</span>
)}
<span className="team-b">{m.teamB}</span>
</div>
<div className="match-row-meta">
{m.currentMap && (
<span className={m.currentMap.finished ? '' : 'row-live'}>
{m.currentMap.finished ? '' : <span className="live-badge">Live</span>} {m.currentMap.name} {m.currentMap.score.A}:{m.currentMap.score.B}
</span>
)}
<span>{m.modeLabel}</span>
<span>{FORMAT_LABEL[m.format]}</span>
{m.group === 'open' && <span>{m.players.A + m.players.B}/{m.players.teamSize * 2} Spieler</span>}
@@ -70,28 +177,78 @@ function MatchRow({ match: m }) {
);
}
function CodeEntry() {
/** Karte für ein laufendes Match: Map als Hintergrund, Stand groß, Serie und Status darunter. */
function RunningCard({ match: m }) {
const server = serverLabel(m.server);
const map = m.currentMap;
const live = map && !map.finished;
const bo1 = m.format === 'bo1';
return (
<Link to={`/m/${m.id}`} className={`run-card ${live ? 'is-live' : ''}`} style={{ '--map': map?.color ?? '#3a4150' }}>
{map?.image && <MapImage map={{ image: map.image, color: map.color, name: map.name }} className="run-art" />}
<div className="run-top">
<span className="run-mode">{m.modeLabel}, {FORMAT_LABEL[m.format]}</span>
{live ? <span className="live-badge run-live">Live</span> : <span className={`run-status tone-${server.tone}`}>{m.statusLabel}</span>}
</div>
<div className="run-teams">
<span className="run-team a">{m.teamA}</span>
<span className="run-score">
{map ? <>{map.score.A}<i>:</i>{map.score.B}</> : <i className="run-vs">vs.</i>}
</span>
<span className="run-team b">{m.teamB}</span>
</div>
<div className="run-foot">
<span>{map ? `Map ${map.number}: ${map.name}` : 'Veto läuft'}</span>
{!bo1 && m.seriesScore && <span className="run-series">Serie {m.seriesScore.A}:{m.seriesScore.B}</span>}
</div>
</Link>
);
}
/** Code in die Form ABCD-EFGH bringen (Großbuchstaben, Bindestrich nach vier Zeichen). */
function formatCode(raw) {
const clean = raw.toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, 8);
return clean.length > 4 ? `${clean.slice(0, 4)}-${clean.slice(4)}` : clean;
}
/** Code-Eingabe im Stil einer Eintrittskarte. */
function CodeTicket() {
const [code, setCode] = useState('');
const [error, setError] = useState(null);
const [busy, setBusy] = useState(false);
const navigate = useNavigate();
const complete = code.replace(/-/g, '').length === 8;
const submit = async (e) => {
e.preventDefault();
setError(null);
setBusy(true);
try {
const res = await api('/access', { method: 'POST', body: { code } });
navigate(res.access === 'spectator' ? `/watch/${code.trim()}` : `/join/${code.trim()}`);
navigate(res.access === 'spectator' ? `/watch/${code}` : `/join/${code}`);
} catch (err) {
setError(err.message);
setBusy(false);
}
};
return (
<form className="code-entry" onSubmit={submit}>
<label htmlFor="code">Einladungs- oder Zuschauercode</label>
<div className="row">
<input id="code" value={code} onChange={(e) => setCode(e.target.value)} placeholder="ABCD-EFGH" autoComplete="off" spellCheck="false" />
<button className="btn" disabled={code.replace(/[^a-z0-9]/gi, '').length < 8}>Beitreten</button>
</div>
{error && <p className="field-error">{error}</p>}
<form className="ticket" onSubmit={submit}>
<label className="ticket-stub" htmlFor="code">Code</label>
<input
id="code"
className="ticket-code"
value={code}
onChange={(e) => setCode(formatCode(e.target.value))}
placeholder="ABCD-EFGH"
autoComplete="off"
autoCapitalize="characters"
spellCheck="false"
inputMode="text"
aria-describedby={error ? 'code-error' : 'code-hint'}
/>
<button className="btn btn-primary ticket-go" disabled={!complete || busy}>{busy ? 'Prüfe …' : 'Beitreten'}</button>
{error
? <p id="code-error" className="field-error ticket-msg">{error}</p>
: <p id="code-hint" className="muted small ticket-msg">Teamcode oder Zuschauercode, acht Zeichen.</p>}
</form>
);
}
+258
View File
@@ -0,0 +1,258 @@
import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { api } from '../lib/api.js';
import { FORMAT_LABEL } from '../lib/labels.js';
import { getSocket } from '../lib/useMatch.js';
import MapImage from '../components/MapImage.jsx';
import SideIcon from '../components/SideIcon.jsx';
import { ScaledStage, useClock } from './StreamPage.jsx';
import '../stream.css';
import '../stream-overview.css';
const PER_PAGE = 6;
const PAGE_SECONDS = 12;
/**
* Stream-Übersicht aller laufenden Vetos und Matches: /stream
* Mit STREAM_KEY: /stream?key=<schlüssel>. Optional ?bg=transparent.
* Ein einzelnes Match: /stream/match/<matchId>, daraus führen die Karten der Übersicht.
*/
export default function StreamOverviewPage() {
const params = new URLSearchParams(window.location.search);
const key = params.get('key') ?? '';
const transparent = params.get('bg') === 'transparent';
const { matchId } = useParams();
const { data, error } = useStreamOverview(key);
const now = useClock();
const [page, setPage] = useState(0);
useEffect(() => {
document.documentElement.classList.toggle('stream-transparent', transparent);
document.documentElement.classList.add('stream-mode');
return () => document.documentElement.classList.remove('stream-mode', 'stream-transparent');
}, [transparent]);
const activeMatches = (data?.active ?? []).filter((m) => !matchId || m.id === matchId);
// Mehr Matches als auf eine Seite passen: automatisch weiterblättern
const pages = Math.max(1, Math.ceil(activeMatches.length / PER_PAGE));
useEffect(() => {
if (pages <= 1) { setPage(0); return undefined; }
const t = setInterval(() => setPage((p) => (p + 1) % pages), PAGE_SECONDS * 1000);
return () => clearInterval(t);
}, [pages]);
if (error) {
return (
<ScaledStage>
<div className="st-message">
<img src="/brand/maxlan-logo.webp" alt="Maxlan e.V." />
<h1>Stream-Übersicht nicht verfügbar</h1>
<p>{error}</p>
</div>
</ScaledStage>
);
}
const active = activeMatches;
const shown = active.slice(page * PER_PAGE, page * PER_PAGE + PER_PAGE);
const liveCount = active.filter((m) => m.status === 'live').length;
const vetoCount = active.filter((m) => m.status === 'veto').length;
const density = shown.length <= 1 ? 1 : shown.length <= 4 ? 2 : 3;
return (
<ScaledStage>
<div className="so-root">
<header className="st-top">
<img className="st-logo" src="/brand/maxlan-logo.webp" alt="Maxlan e.V." />
<div className="so-title">
<strong>Counter-Strike 2</strong>
{liveCount > 0 && <span className="so-count so-count-live">{liveCount} live</span>}
{vetoCount > 0 && <span className="so-count">{vetoCount} im Veto</span>}
{pages > 1 && <span className="so-count">Seite {page + 1} / {pages}</span>}
</div>
<div className="st-clock"><time>{now.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}</time></div>
</header>
<main className={`so-grid density-${density} count-${shown.length}`}>
{!data && <div className="so-empty">Lade Matches …</div>}
{data && shown.length === 0 && (
<div className="so-empty">
{matchId ? (
<>
<h2>Dieses Match läuft gerade nicht</h2>
<p>Es erscheint hier, sobald das Veto oder das Match beginnt.</p>
</>
) : (
<>
<h2>Gerade läuft kein Match</h2>
<p>{data.upcoming.length ? 'Die nächsten Lobbys stehen unten.' : 'Sobald ein Veto beginnt, erscheint es hier.'}</p>
</>
)}
</div>
)}
{shown.map((m) => (
<a key={m.id} className="so-link" href={matchHref(m.id, key)}>
<MatchCard m={m} />
</a>
))}
</main>
<footer className="so-bottom">
<section className="so-strip">
<h3>Gleich</h3>
{data?.upcoming.length ? (
<ul>
{data.upcoming.map((m) => (
<li key={m.id}>
<a className="so-link" href={matchHref(m.id, key)}>
<span className="a">{m.teams.A}</span> <span className="vs">vs</span> <span className="b">{m.teams.B}</span>
<small>{FORMAT_LABEL[m.format]}{m.ready.A && m.ready.B ? ', bereit' : ''}</small>
</a>
</li>
))}
</ul>
) : <p className="so-none">Keine offenen Lobbys</p>}
</section>
<section className="so-strip">
<h3>Ergebnisse</h3>
{data?.results.length ? (
<ul>
{data.results.map((m) => (
<li key={m.id}>
<a className="so-link" href={matchHref(m.id, key)}>
<span className={`a ${winner(m) === 'A' ? 'won' : ''}`}>{m.teams.A}</span>
<b>{resultScore(m)}</b>
<span className={`b ${winner(m) === 'B' ? 'won' : ''}`}>{m.teams.B}</span>
</a>
</li>
))}
</ul>
) : <p className="so-none">Noch keine Ergebnisse</p>}
</section>
</footer>
</div>
</ScaledStage>
);
}
/** Link zur Stream-Übersicht eines einzelnen Matches. Der Zuschauercode bleibt geheim, daher nur die Match-ID. */
function matchHref(id, key) {
return `/stream/match/${encodeURIComponent(id)}${key ? `?key=${encodeURIComponent(key)}` : ''}`;
}
export function useStreamOverview(key) {
const [data, setData] = useState(null);
const [error, setError] = useState(null);
useEffect(() => {
const socket = getSocket();
const subscribe = () => socket.emit('stream:subscribe', { key });
const onData = (d) => { setData(d); setError(null); };
const onError = (e) => setError(e.error);
socket.on('stream:overview', onData);
socket.on('stream:error', onError);
socket.on('connect', subscribe);
if (socket.connected) subscribe();
api(`/stream/overview?key=${encodeURIComponent(key)}`).then(onData).catch((e) => setError(e.message));
return () => {
socket.off('stream:overview', onData);
socket.off('stream:error', onError);
socket.off('connect', subscribe);
};
}, [key]);
return { data, error };
}
/** Bei BO1 zählt der Map-Stand, sonst der Serienstand. */
function resultScore(m) {
if (m.format === 'bo1' && m.series[0]) return `${m.series[0].score.A}:${m.series[0].score.B}`;
return `${m.seriesScore.A}:${m.seriesScore.B}`;
}
function winner(m) {
const [a, b] = resultScore(m).split(':').map(Number);
return a === b ? null : a > b ? 'A' : 'B';
}
function MatchCard({ m }) {
const current = m.series.find((s) => s.number === m.currentMapNumber);
const isBo1 = m.format === 'bo1';
const playing = m.status === 'live' && current?.live?.status === 'live' && current.status !== 'done';
const badge = m.status === 'veto' ? 'Veto' : m.status === 'server_setup' ? 'Server' : playing ? 'Live' : current?.status === 'done' ? 'Map beendet' : 'Aufwärmen';
return (
<article className={`so-card status-${m.status} ${playing ? 'is-playing' : ''}`} style={{ '--map': current?.color ?? '#556' }}>
{m.status !== 'veto' && current && <MapImage map={current} className="so-card-art" />}
<div className="so-card-head">
<span>{m.modeLabel}, {FORMAT_LABEL[m.format]}</span>
<span className={`so-badge badge-${badge === 'Live' ? 'live' : 'other'}`}>{badge}</span>
</div>
<div className="so-teams">
<span className="so-team a">{m.teams.A}</span>
<span className="so-series">
{!isBo1 && m.status !== 'veto' ? <>{m.seriesScore.A}<i>:</i>{m.seriesScore.B}</> : <i className="so-vs">vs</i>}
</span>
<span className="so-team b">{m.teams.B}</span>
</div>
{m.status === 'veto' && <VetoBody m={m} />}
{m.status === 'server_setup' && current && (
<div className="so-setup">Server wird eingerichtet: Map {current.number}, {current.name}</div>
)}
{m.status === 'live' && current && <LiveBody m={m} current={current} />}
</article>
);
}
function VetoBody({ m }) {
const { veto, teams } = m;
const c = veto.current;
const action = c && ({ ban: 'bannt', pick: 'wählt', side: 'wählt die Seite' })[c.type];
return (
<div className="so-veto">
<div className="so-turn">
{c ? <><strong className={c.team === 'A' ? 'a' : 'b'}>{teams[c.team]}</strong> {action}</> : 'Veto abgeschlossen'}
<small>Schritt {Math.min(veto.stepsDone + 1, veto.stepsTotal)} von {veto.stepsTotal}</small>
</div>
<div className="so-vmaps" style={{ '--count': veto.maps.length }}>
{veto.maps.map((x) => (
<div key={x.id} className={`so-vmap status-${x.status} ${x.by ? `by-${x.by}` : ''}`} style={{ '--map': x.color ?? '#556' }}>
<MapImage map={x} />
<span className="so-vmap-name">{x.name}</span>
</div>
))}
</div>
</div>
);
}
function LiveBody({ m, current }) {
const sides = current.live?.sides;
const sideOf = (t) => (sides ? (sides.CT === t ? 'CT' : 'T') : null);
const finished = current.status === 'done';
const rounds = current.live?.roundsPlayed ?? 0;
return (
<div className="so-live">
<div className="so-map">
<strong>{current.name}</strong>
<span>{finished ? 'Endstand' : current.live?.status === 'live' ? `Runde ${rounds + 1}` : 'Aufwärmphase'}</span>
</div>
<div className="so-score">
{sideOf('A') && <SideIcon side={sideOf('A')} size={44} />}
<b className={`a ${current.winner === 'A' ? 'won' : ''}`}>{current.score.A}</b>
<i>:</i>
<b className={`b ${current.winner === 'B' ? 'won' : ''}`}>{current.score.B}</b>
{sideOf('B') && <SideIcon side={sideOf('B')} size={44} />}
</div>
{m.series.length > 1 && (
<ol className="so-maps">
{m.series.map((s) => (
<li key={s.number} className={`is-${s.status} ${s.number === current.number ? 'is-current' : ''}`}>
<span>{s.name}</span>
{(s.status === 'done' || s.number === current.number) && <b>{s.score.A}:{s.score.B}</b>}
</li>
))}
</ol>
)}
</div>
);
}
+278
View File
@@ -0,0 +1,278 @@
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { useParams } from 'react-router-dom';
import { api } from '../lib/api.js';
import { FORMAT_LABEL } from '../lib/labels.js';
import { useMatch } from '../lib/useMatch.js';
import MapImage from '../components/MapImage.jsx';
import SideIcon from '../components/SideIcon.jsx';
import RoundStrip from '../components/RoundStrip.jsx';
import '../stream.css';
const STAGE_W = 1920;
const STAGE_H = 1080;
/**
* Vollbild-Übersicht für den Stream: /stream/<Zuschauercode>
* Optional ?bg=transparent für eine transparente OBS-Browserquelle.
*/
export default function StreamPage() {
const { code } = useParams();
const [matchId, setMatchId] = useState(null);
const [error, setError] = useState(null);
const transparent = new URLSearchParams(window.location.search).get('bg') === 'transparent';
useEffect(() => {
api('/access', { method: 'POST', body: { code } })
.then((info) => {
if (info.access !== 'spectator') throw new Error('Für die Stream-Ansicht wird der Zuschauercode benötigt.');
setMatchId(info.matchId);
})
.catch((e) => setError(e.message));
}, [code]);
useEffect(() => {
document.documentElement.classList.toggle('stream-transparent', transparent);
document.documentElement.classList.add('stream-mode');
return () => document.documentElement.classList.remove('stream-mode', 'stream-transparent');
}, [transparent]);
return (
<ScaledStage>
{error ? <StreamMessage title="Stream-Ansicht nicht verfügbar" text={error} />
: matchId ? <StreamContent matchId={matchId} code={code} />
: <StreamMessage title="Verbinde …" />}
</ScaledStage>
);
}
/** Feste 1920×1080-Bühne, auf die Fenstergröße skaliert. */
export function ScaledStage({ children }) {
const ref = useRef(null);
const [scale, setScale] = useState(1);
useLayoutEffect(() => {
const fit = () => setScale(Math.min(window.innerWidth / STAGE_W, window.innerHeight / STAGE_H));
fit();
window.addEventListener('resize', fit);
return () => window.removeEventListener('resize', fit);
}, []);
return (
<div className="stream-viewport">
<div ref={ref} className="stream-stage" style={{ width: STAGE_W, height: STAGE_H, transform: `translate(-50%, -50%) scale(${scale})` }}>
{children}
</div>
</div>
);
}
function StreamMessage({ title, text }) {
return (
<div className="st-message">
<img src="/brand/maxlan-logo.webp" alt="Maxlan e.V." />
<h1>{title}</h1>
{text && <p>{text}</p>}
</div>
);
}
export function useClock() {
const [now, setNow] = useState(() => new Date());
useEffect(() => {
const t = setInterval(() => setNow(new Date()), 1000);
return () => clearInterval(t);
}, []);
return now;
}
function StreamContent({ matchId, code }) {
const { state, connected } = useMatch(matchId, code);
const now = useClock();
if (!state) return <StreamMessage title="Lade Match …" />;
const { teams } = state;
const current = state.series.find((s) => s.number === state.currentMapNumber);
const playing = current?.live?.status === 'live' && current.status !== 'done';
const phase = state.status === 'lobby' ? 'lobby' : state.status === 'veto' ? 'veto' : 'match';
const showSeries = state.series.length > 1;
// Im Laufband nur, was Zuschauer interessiert – keine technischen Servermeldungen
const tickerEvent = state.events.find((e) => ['veto', 'score', 'series', 'join', 'captain', 'status'].includes(e.type));
return (
<div className={`st-root phase-${phase} ${phase !== 'lobby' && state.series.length > 0 ? 'has-maps' : 'no-maps'}`}>
<header className="st-top">
<img className="st-logo" src="/brand/maxlan-logo.webp" alt="Maxlan e.V." />
<div className="st-meta">
<span>Counter-Strike 2</span>
<span>{state.modeLabel}</span>
<span>{FORMAT_LABEL[state.format]}</span>
</div>
<div className="st-clock">
{playing && <span className="st-live">Live</span>}
{!connected && connected !== null && <span className="st-offline">Verbindung …</span>}
<time>{now.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}</time>
</div>
</header>
<section className="st-hero">
<div className="st-team st-team-a">
<h1>{teams.A.name}</h1>
</div>
<div className="st-center">
<span className="st-slash" aria-hidden="true" />
{showSeries && phase === 'match' ? (
<div className="st-series" aria-label="Serienstand">
<span className="a">{state.seriesScore.A}</span>
<span className="sep">–</span>
<span className="b">{state.seriesScore.B}</span>
<small>Maps</small>
</div>
) : (
<div className="st-vs">vs</div>
)}
</div>
<div className="st-team st-team-b">
<h1>{teams.B.name}</h1>
</div>
</section>
<section className="st-main">
{phase === 'lobby' && <LobbyScene state={state} />}
{phase === 'veto' && <VetoScene state={state} />}
{phase === 'match' && <MatchScene state={state} current={current} />}
</section>
{phase !== 'lobby' && state.series.length > 0 && (
<section className="st-maps">
{state.series.map((s) => <SeriesCard key={s.number} s={s} state={state} />)}
</section>
)}
<footer className="st-ticker">
<span className="st-ticker-label">{phaseLabel(state)}</span>
<span className="st-ticker-text" key={tickerEvent?.at}>{tickerEvent?.message ?? 'Warten auf die Teams'}</span>
</footer>
</div>
);
}
function phaseLabel(state) {
if (state.status === 'lobby') return 'Lobby';
if (state.status === 'veto') return 'Map-Veto';
if (state.status === 'finished') return 'Endstand';
if (state.status === 'cancelled') return 'Abgebrochen';
return 'Match';
}
function LobbyScene({ state }) {
return (
<div className="st-lobby">
{['A', 'B'].map((key) => {
const team = state.teams[key];
return (
<div key={key} className={`st-roster st-roster-${key}`}>
<h2>{team.readiness.ready ? 'Bereit' : `${team.readiness.count} / ${team.teamSize} Spieler`}</h2>
<ul>
{team.players.map((p) => (
<li key={p.id} className={p.ready ? 'is-ready' : ''}>
<span>{p.nickname}</span>
{p.isCaptain && <em>Captain</em>}
</li>
))}
</ul>
</div>
);
})}
<div className="st-lobby-note">Das Map-Veto beginnt gleich</div>
</div>
);
}
function VetoScene({ state }) {
const { veto, teams } = state;
const current = veto?.current;
const mapsById = Object.fromEntries(state.maps.map((m) => [m.id, m]));
const action = current && ({ ban: 'bannt', pick: 'wählt', side: 'wählt die Startseite auf' })[current.type];
return (
<div className="st-veto">
{current && (
<div className={`st-turn st-turn-${current.team}`}>
<strong>{teams[current.team].name}</strong> {action}
{current.type === 'side' ? ` ${mapsById[current.map]?.name}` : ' eine Map'}
</div>
)}
<div className="st-veto-grid" style={{ '--count': veto.maps.length }}>
{veto.maps.map((m) => (
<div key={m.id} className={`st-vmap status-${m.status} ${m.by ? `by-${m.by}` : ''} ${current?.map === m.id ? 'is-side' : ''}`}>
<MapImage map={mapsById[m.id]} />
<span className="st-vmap-name">{mapsById[m.id]?.name}</span>
<span className="st-vmap-status">
{m.status === 'banned' && `Bann ${teams[m.by].name}`}
{m.status === 'picked' && `Pick ${teams[m.by].name}`}
{m.status === 'decider' && 'Decider'}
</span>
</div>
))}
</div>
</div>
);
}
function MatchScene({ state, current }) {
const { teams } = state;
if (!current) return <div className="st-waiting">Server wird vorbereitet</div>;
const map = state.maps.find((m) => m.id === current.map);
const live = current.live;
const sides = live?.sides ?? (current.ctTeam ? { CT: current.ctTeam, T: current.ctTeam === 'A' ? 'B' : 'A' } : null);
const sideOf = (team) => (sides ? (sides.CT === team ? 'CT' : 'T') : null);
const finished = current.status === 'done';
const waiting = !live || live.status === 'warmup';
return (
<div className="st-board" style={{ '--map': map?.color ?? '#556' }}>
<MapImage map={map} className="st-board-art" />
<div className="st-board-head">
<span>Map {current.number}{state.series.length > 1 ? ` von ${state.series.length}` : ''}</span>
<strong>{current.mapName}</strong>
<span className="st-board-state">{finished ? 'Endstand' : waiting ? 'Aufwärmphase' : `Runde ${(live?.rounds.length ?? 0) + 1}`}</span>
</div>
<div className="st-score">
<div className={`st-score-team a ${current.winner === 'A' ? 'won' : ''}`}>
{sideOf('A') && <SideIcon side={sideOf('A')} size={64} />}
<span className="num">{current.score.A}</span>
</div>
<span className="st-score-colon">:</span>
<div className={`st-score-team b ${current.winner === 'B' ? 'won' : ''}`}>
<span className="num">{current.score.B}</span>
{sideOf('B') && <SideIcon side={sideOf('B')} size={64} />}
</div>
</div>
{finished && current.winner && <div className="st-winner">{teams[current.winner].name} gewinnt {current.mapName}</div>}
<div className="st-rounds">
<RoundStrip rounds={live?.rounds ?? []} maxRounds={state.maxRounds} teams={teams} size="lg" />
</div>
</div>
);
}
function SeriesCard({ s, state }) {
const map = state.maps.find((m) => m.id === s.map);
const isCurrent = s.number === state.currentMapNumber && state.status === 'live';
const playing = isCurrent && s.live?.status === 'live' && s.status !== 'done';
return (
<div className={`st-mapcard is-${s.status} ${playing ? 'is-playing' : ''}`} style={{ '--map': map?.color ?? '#556' }}>
<MapImage map={map} />
<span className="st-mapcard-no">Map {s.number}</span>
<strong>{s.mapName}</strong>
<span className="st-mapcard-foot">
{s.status === 'done' || playing ? (
<span className="st-mapcard-score">
<b className={s.winner === 'A' ? 'won a' : 'a'}>{s.score.A}</b>
<i>:</i>
<b className={s.winner === 'B' ? 'won b' : 'b'}>{s.score.B}</b>
</span>
) : (
<span className="st-mapcard-origin">{s.pickedBy ? `Pick ${state.teams[s.pickedBy].name}` : 'Decider'}</span>
)}
</span>
</div>
);
}
+122
View File
@@ -0,0 +1,122 @@
/* ---------- Stream-Übersicht aller Matches (1920×1080) ---------- */
.so-root {
width: 100%; height: 100%; overflow: hidden;
display: grid; grid-template-rows: 104px 1fr 190px; padding: 0 56px;
color: var(--text); font-family: var(--body);
background:
radial-gradient(900px 600px at 0% 20%, rgba(52, 74, 153, .25), transparent 70%),
radial-gradient(900px 600px at 100% 20%, rgba(167, 42, 28, .22), transparent 70%),
#000;
}
.stream-transparent .so-root { background: transparent; }
.so-title { display: flex; align-items: center; gap: 18px; font: 600 30px var(--display); }
.so-count { padding: 4px 14px; border-radius: 4px; background: var(--surface-2); color: var(--muted); font-size: 24px; }
.so-count-live { background: var(--brand-red); color: #fff; }
/* Raster je nach Anzahl */
.so-grid { display: grid; gap: 24px; padding: 24px 0; min-height: 0; }
.so-grid.count-1 { grid-template-columns: 1fr; }
.so-grid.count-2 { grid-template-columns: 1fr 1fr; }
.so-grid.count-3, .so-grid.count-4 { grid-template-columns: 1fr 1fr; grid-template-rows: 1fr 1fr; }
.so-grid.count-5, .so-grid.count-6 { grid-template-columns: repeat(3, 1fr); grid-template-rows: 1fr 1fr; }
.so-empty { grid-column: 1 / -1; display: grid; place-content: center; text-align: center; color: var(--muted); }
.so-empty h2 { font: 700 64px var(--display); color: var(--text); margin-bottom: 12px; }
.so-empty p { font-size: 28px; }
/* Karte */
.so-card {
position: relative; overflow: hidden; min-height: 0; border-radius: 10px;
display: grid; grid-template-rows: auto auto 1fr; gap: 10px; padding: 22px 28px;
background: rgba(17, 20, 30, .82); box-shadow: inset 0 0 0 2px rgba(255, 255, 255, .06);
}
.so-card.is-playing { box-shadow: inset 0 0 0 3px var(--brand-red), 0 0 40px rgba(167, 42, 28, .35); }
.so-card-art { opacity: .3; }
.so-card-art::after { background: linear-gradient(180deg, rgba(0, 0, 0, .4), rgba(0, 0, 0, .9)); }
.so-card > :not(.so-card-art) { position: relative; z-index: 1; }
.so-card-head { display: flex; justify-content: space-between; align-items: center; font: 600 22px var(--display); color: var(--muted); }
.so-badge { padding: 3px 12px; border-radius: 4px; background: var(--surface-2); color: var(--text); font-size: 20px; }
.so-badge.badge-live { background: var(--brand-red); color: #fff; }
.so-teams { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 24px; }
.so-team { font: 700 52px/1 var(--display); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.so-team.a { color: var(--team-a); text-align: right; }
.so-team.b { color: var(--team-b); }
.so-series { font: 700 56px var(--display); font-variant-numeric: tabular-nums; }
.so-series i { font-style: normal; color: var(--muted); margin: 0 .15em; }
.so-series .so-vs { font-size: 32px; color: var(--muted); margin: 0; }
/* Veto */
.so-veto { display: grid; grid-template-rows: auto 1fr; gap: 14px; min-height: 0; }
.so-turn { display: flex; align-items: baseline; justify-content: center; gap: 14px; font: 600 32px var(--display); }
.so-turn .a { color: var(--team-a); }
.so-turn .b { color: var(--team-b); }
.so-turn small { font: 500 20px var(--body); color: var(--muted); }
.so-vmaps { display: grid; grid-template-columns: repeat(var(--count), minmax(0, 1fr)); gap: 10px; min-height: 0; }
.so-vmap { --by: var(--line-strong); position: relative; overflow: hidden; border-radius: 6px; display: flex; align-items: flex-end; padding: 10px; min-height: 0; }
.so-vmap.by-A { --by: var(--team-a); }
.so-vmap.by-B { --by: var(--team-b); }
.so-vmap-name { position: relative; z-index: 2; font: 700 24px/1 var(--display); text-shadow: 0 2px 8px #000; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.so-vmap .map-art::after { background: linear-gradient(180deg, transparent 40%, rgba(0, 0, 0, .85)); }
.so-vmap.status-banned { filter: grayscale(.95) brightness(.45); }
.so-vmap.status-banned::after {
content: ''; position: absolute; inset: 0; z-index: 3;
background: linear-gradient(to top right, transparent calc(50% - 3px), var(--by) calc(50% - 3px), var(--by) calc(50% + 3px), transparent calc(50% + 3px));
}
.so-vmap.status-picked::before, .so-vmap.status-decider::before { content: ''; position: absolute; inset: 0; z-index: 3; border-radius: 6px; box-shadow: inset 0 0 0 4px var(--by); }
.so-vmap.status-decider { --by: #fff; }
/* Live */
.so-live { display: grid; grid-template-rows: auto 1fr auto; justify-items: center; align-items: center; min-height: 0; }
.so-map { display: flex; align-items: baseline; gap: 16px; }
.so-map strong { font: 700 40px var(--display); }
.so-map span { font: 600 24px var(--display); color: var(--pending); }
.so-score { display: flex; align-items: center; gap: 22px; }
.so-score b { font: 700 140px/0.9 var(--display); font-variant-numeric: tabular-nums; }
.so-score b.a { color: var(--team-a); }
.so-score b.b { color: var(--team-b); }
.so-score b.won { text-shadow: 0 0 30px currentColor; }
.so-score i { font: 700 70px var(--display); font-style: normal; color: var(--muted); }
.so-maps { list-style: none; display: flex; gap: 10px; padding: 0; margin: 0; }
.so-maps li { display: flex; gap: 10px; align-items: baseline; padding: 6px 14px; border-radius: 4px; background: rgba(0, 0, 0, .55); font: 600 22px var(--display); color: var(--muted); }
.so-maps li.is-current { color: var(--text); box-shadow: inset 0 -3px 0 var(--brand-red); }
.so-maps li b { color: var(--text); font-variant-numeric: tabular-nums; }
.so-setup { align-self: center; justify-self: center; font: 600 32px var(--display); color: var(--muted); }
/* Dichte: weniger Matches = größere Schrift */
.density-1 .so-team { font-size: 96px; }
.density-1 .so-series { font-size: 110px; }
.density-1 .so-score b { font-size: 260px; }
.density-1 .so-map strong { font-size: 60px; }
.density-1 .so-vmap-name { font-size: 40px; }
.density-1 .so-turn { font-size: 44px; }
.density-3 .so-team { font-size: 38px; }
.density-3 .so-series { font-size: 42px; }
.density-3 .so-score b { font-size: 96px; }
.density-3 .so-score .side-icon { width: 32px; height: 32px; }
.density-3 .so-map strong { font-size: 30px; }
.density-3 .so-vmap-name { font-size: 18px; }
.density-3 .so-turn { font-size: 26px; }
.density-3 .so-maps li { font-size: 18px; padding: 4px 10px; }
/* Unten: Lobbys und Ergebnisse */
.so-bottom { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; padding-bottom: 28px; min-height: 0; }
.so-strip { background: rgba(17, 20, 30, .82); border-radius: 10px; padding: 16px 24px; overflow: hidden; border-top: 4px solid var(--line-strong); }
.so-strip:last-child { border-top-color: var(--brand-red); }
.so-strip h3 { font: 700 26px var(--display); color: var(--muted); margin: 0 0 10px; }
.so-strip ul { list-style: none; padding: 0; margin: 0; display: grid; grid-template-columns: 1fr 1fr; gap: 6px 28px; }
.so-strip li { display: flex; align-items: baseline; gap: 10px; font: 600 26px var(--display); white-space: nowrap; overflow: hidden; }
.so-strip li .a { color: var(--team-a); }
.so-strip li .b { color: var(--team-b); }
.so-strip li .vs { color: var(--muted); font-size: 20px; }
.so-strip li small { font: 500 18px var(--body); color: var(--muted); }
.so-strip li b { font-variant-numeric: tabular-nums; }
.so-strip li .won { text-decoration: underline; text-underline-offset: 5px; }
.so-none { color: var(--muted); font-size: 22px; margin: 0; }
/* Klickbare Karten und Listeneinträge führen zur Übersicht des jeweiligen Matches */
.so-link { display: block; color: inherit; text-decoration: none; }
.so-strip li .so-link { display: flex; align-items: baseline; gap: 10px; color: inherit; text-decoration: none; }
.so-link:hover { filter: brightness(1.15); }
.so-link:focus-visible { outline: 3px solid var(--brand-red); outline-offset: 4px; }
+167
View File
@@ -0,0 +1,167 @@
/* ---------- Stream-Übersicht (1920×1080, skaliert) ---------- */
html.stream-mode, html.stream-mode body { background: #000; overflow: hidden; height: 100%; }
html.stream-mode.stream-transparent, html.stream-mode.stream-transparent body { background: transparent; }
.stream-viewport { position: fixed; inset: 0; overflow: hidden; }
.stream-stage { position: absolute; left: 50%; top: 50%; transform-origin: center center; }
.st-root {
position: relative; width: 100%; height: 100%; overflow: hidden;
display: grid; grid-template-rows: 104px 260px 1fr 190px 60px;
padding: 0 72px; color: var(--text); font-family: var(--body);
background:
radial-gradient(900px 600px at 0% 30%, rgba(52, 74, 153, .28), transparent 70%),
radial-gradient(900px 600px at 100% 30%, rgba(167, 42, 28, .26), transparent 70%),
#000;
}
.stream-transparent .st-root { background: transparent; }
/* Ohne Map-Leiste (Lobby, Veto) bekommt die Hauptfläche den Platz */
.st-root.no-maps { grid-template-rows: 104px 260px 1fr 60px; }
.phase-veto .st-veto-grid { align-items: stretch; }
.phase-veto .st-vmap { min-height: 420px; }
/* Kopfzeile */
.st-top { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; border-bottom: 3px solid var(--brand-red); }
.st-logo { height: 58px; width: auto; }
.st-meta { display: flex; gap: 28px; font: 600 26px var(--display); color: var(--muted); }
.st-meta span + span { padding-left: 28px; border-left: 2px solid var(--line-strong); }
.st-clock { justify-self: end; display: flex; align-items: center; gap: 22px; font: 600 30px var(--display); font-variant-numeric: tabular-nums; }
.st-live {
display: inline-flex; align-items: center; gap: 10px; padding: 4px 16px 4px 12px; border-radius: 4px;
background: var(--brand-red); color: #fff; font: 700 24px var(--display);
}
.st-live::before { content: ''; width: 12px; height: 12px; border-radius: 50%; background: #fff; animation: st-blink 1.4s ease-in-out infinite; }
.st-offline { color: var(--pending); font-size: 22px; }
@keyframes st-blink { 50% { opacity: .25; } }
/* Teams und Serienstand */
.st-hero { display: grid; grid-template-columns: 1fr 380px 1fr; align-items: center; }
.st-team h1 {
margin: 0; font: 700 108px/0.95 var(--display); letter-spacing: -.01em;
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; overflow-wrap: anywhere;
}
.st-team-a { text-align: right; }
.st-team-a h1 { color: var(--team-a); }
.st-team-b h1 { color: var(--team-b); }
.st-center { position: relative; display: grid; place-items: center; height: 100%; }
/* Das rote X aus dem Logo als diagonaler Schnitt */
.st-slash {
position: absolute; width: 34px; height: 230px; background: var(--brand-red); transform: rotate(18deg);
border-radius: 6px; box-shadow: 0 0 60px rgba(167, 42, 28, .55); opacity: .9;
}
.st-series { position: relative; display: grid; grid-template-columns: 1fr 60px 1fr; align-items: center; gap: 0 34px; text-shadow: 0 4px 24px #000; }
.st-series span { font: 700 150px/1 var(--display); font-variant-numeric: tabular-nums; }
.st-series .a { text-align: right; color: var(--team-a); }
.st-series .b { color: var(--team-b); }
/* Der rote Schrägstrich übernimmt die Trennung zwischen den Ziffern */
.st-series .sep { visibility: hidden; }
.st-series small { grid-column: 1 / -1; text-align: center; font: 600 24px var(--display); color: var(--muted); margin-top: -6px; }
.st-vs { position: relative; font: 700 84px var(--display); color: #fff; text-shadow: 0 4px 30px #000; }
.st-main { min-height: 0; padding: 18px 0 24px; }
/* Lobby */
.st-lobby { display: grid; grid-template-columns: 1fr 1fr; grid-template-rows: 1fr auto; gap: 24px 80px; height: 100%; }
.st-roster { padding: 30px 40px; background: rgba(17, 20, 30, .82); border-top: 6px solid var(--team-a); border-radius: 8px; }
.st-roster-B { border-top-color: var(--team-b); }
.st-roster h2 { font: 600 36px var(--display); color: var(--muted); margin-bottom: 18px; }
.st-roster ul { list-style: none; padding: 0; margin: 0; display: grid; gap: 12px; }
.st-roster li { display: flex; align-items: baseline; gap: 16px; font: 600 44px var(--display); color: var(--muted); }
.st-roster li.is-ready { color: var(--text); }
.st-roster li.is-ready::after { content: 'bereit'; font: 500 24px var(--body); color: var(--ok); }
.st-roster em { font: 500 22px var(--body); font-style: normal; color: var(--pending); }
.st-lobby-note { grid-column: 1 / -1; text-align: center; font: 600 34px var(--display); color: var(--muted); }
/* Veto */
.st-veto { display: grid; grid-template-rows: auto 1fr; gap: 26px; height: 100%; }
.st-turn {
justify-self: center; padding: 14px 40px; font: 600 40px var(--display); border-radius: 6px;
background: rgba(17, 20, 30, .85); border-left: 8px solid var(--team-a);
}
.st-turn-B { border-left-color: var(--team-b); }
.st-turn strong { color: var(--team-a); }
.st-turn-B strong { color: var(--team-b); }
.st-veto-grid { display: grid; grid-template-columns: repeat(var(--count), minmax(0, 1fr)); gap: 18px; align-items: stretch; }
.st-vmap { --by: var(--line-strong); position: relative; overflow: hidden; border-radius: 6px; display: flex; flex-direction: column; justify-content: flex-end; padding: 18px; min-height: 0; }
.st-vmap.by-A { --by: var(--team-a); }
.st-vmap.by-B { --by: var(--team-b); }
.st-vmap .map-art::after { background: linear-gradient(180deg, transparent 35%, rgba(0, 0, 0, .9)); }
.st-vmap-name, .st-vmap-status { position: relative; z-index: 2; }
.st-vmap-name { font: 700 46px/1 var(--display); text-shadow: 0 2px 12px #000; }
.st-vmap-status { font: 600 24px var(--display); color: var(--by); margin-top: 6px; min-height: 30px; }
.st-vmap.status-banned { filter: grayscale(.95) brightness(.5); }
.st-vmap.status-banned::after {
content: ''; position: absolute; inset: 0; z-index: 3;
background: linear-gradient(to top right, transparent calc(50% - 5px), var(--by) calc(50% - 5px), var(--by) calc(50% + 5px), transparent calc(50% + 5px));
}
.st-vmap.status-picked::before, .st-vmap.status-decider::before, .st-vmap.is-side::before {
content: ''; position: absolute; inset: 0; z-index: 3; border-radius: 6px; box-shadow: inset 0 0 0 6px var(--by);
}
.st-vmap.status-decider { --by: #fff; }
.st-vmap.is-side::before { animation: st-side 1.6s ease-in-out infinite; }
@keyframes st-side { 50% { box-shadow: inset 0 0 0 6px var(--pending); } }
/* Spielstand der aktuellen Map */
.st-board {
position: relative; overflow: hidden; height: 100%; border-radius: 10px;
display: grid; grid-template-rows: auto 1fr auto auto; justify-items: center; padding: 26px 48px 30px;
background: rgba(17, 20, 30, .7); box-shadow: inset 0 0 0 2px rgba(255, 255, 255, .06);
}
.st-board-art { opacity: .38; }
.st-board-art::after { background: linear-gradient(180deg, rgba(0, 0, 0, .35), rgba(0, 0, 0, .85)); }
.st-board > :not(.st-board-art) { position: relative; z-index: 1; }
.st-board-head { display: flex; align-items: baseline; gap: 26px; font: 600 30px var(--display); color: var(--muted); }
.st-board-head strong { font: 700 56px var(--display); color: var(--text); }
.st-board-state { color: var(--pending); }
.st-score { display: flex; align-items: center; gap: 40px; }
.st-score-team { display: flex; align-items: center; gap: 28px; }
.st-score .num { font: 700 230px/0.9 var(--display); font-variant-numeric: tabular-nums; min-width: 2ch; }
.st-score-team.a .num { color: var(--team-a); text-align: right; }
.st-score-team.b .num { color: var(--team-b); }
.st-score-team.won .num { text-shadow: 0 0 40px currentColor; }
.st-score-colon { font: 700 120px var(--display); color: var(--muted); }
.st-score .side-icon { filter: drop-shadow(0 4px 16px #000); }
.st-winner { font: 700 40px var(--display); color: var(--ok); margin-bottom: 8px; }
.st-rounds { width: 100%; display: flex; justify-content: center; }
.st-waiting { display: grid; place-items: center; height: 100%; font: 600 48px var(--display); color: var(--muted); }
/* Map-Leiste */
.st-maps { display: flex; gap: 18px; padding-bottom: 16px; }
.st-mapcard {
position: relative; flex: 1 1 0; min-width: 0; overflow: hidden; border-radius: 8px;
display: grid; align-content: end; padding: 14px 20px; border-bottom: 6px solid var(--map);
}
.st-mapcard .map-art::after { background: linear-gradient(180deg, rgba(0, 0, 0, .1), rgba(0, 0, 0, .9)); }
.st-mapcard > :not(.map-art) { position: relative; z-index: 1; }
.st-mapcard-no { font: 600 22px var(--display); color: var(--muted); }
.st-mapcard strong { font: 700 40px/1.05 var(--display); }
.st-mapcard-foot { font: 600 26px var(--display); color: var(--muted); min-height: 36px; }
.st-mapcard-score { display: inline-flex; gap: 10px; align-items: baseline; font-size: 38px; }
.st-mapcard-score b { font-weight: 700; color: var(--muted); }
.st-mapcard-score b.won.a { color: var(--team-a); }
.st-mapcard-score b.won.b { color: var(--team-b); }
.st-mapcard.is-playing .st-mapcard-score b.a { color: var(--team-a); }
.st-mapcard.is-playing .st-mapcard-score b.b { color: var(--team-b); }
.st-mapcard.is-pending, .st-mapcard.is-next { opacity: .65; }
.st-mapcard.is-playing { box-shadow: 0 0 0 4px var(--brand-red), 0 0 40px rgba(167, 42, 28, .5); }
/* Laufband */
.st-ticker { display: flex; align-items: stretch; margin: 0 -72px; background: #05060a; border-top: 2px solid var(--line); }
.st-ticker-label { display: flex; align-items: center; padding: 0 30px 0 72px; background: var(--brand-red); color: #fff; font: 700 26px var(--display); }
.st-ticker-text { display: flex; align-items: center; padding: 0 30px; font: 500 26px var(--body); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; animation: st-in .5s ease-out; }
@keyframes st-in { from { opacity: 0; transform: translateY(8px); } }
.st-message { display: grid; place-items: center; align-content: center; gap: 24px; height: 100%; text-align: center; background: #000; }
.st-message img { height: 90px; }
.st-message h1 { font: 700 64px var(--display); margin: 0; }
.st-message p { font-size: 28px; color: var(--muted); }
/* Große Rundenleiste */
.round-strip-lg { gap: 6px; }
.round-strip-lg .round { width: 50px; height: 50px; border-radius: 5px; }
.round-strip-lg .round.break { margin-left: 22px; }
.round-strip-lg .reason-icon { width: 24px; height: 24px; }
@media (prefers-reduced-motion: reduce) {
.st-live::before, .st-vmap.is-side::before, .st-ticker-text { animation: none; }
}
+337 -1
View File
@@ -1,6 +1,6 @@
/* ---------- Tokens ---------- */
:root {
/* MaxLAN-Farben aus dem Logo: Blau #344A99, Rot #A72A1C auf Schwarz */
/* Maxlan-Farben aus dem Logo: Blau #344A99, Rot #A72A1C auf Schwarz */
--brand-blue: #344a99;
--brand-red: #a72a1c;
@@ -443,3 +443,339 @@ input:focus, select:focus { border-color: var(--link); outline: none; }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation: none !important; transition: none !important; }
}
/* ---------- Live-Ergebnisse ---------- */
.live-badge {
display: inline-flex; align-items: center; gap: 6px; padding: 1px 8px 1px 6px; border-radius: 3px;
background: var(--brand-red); color: #fff; font: 700 .8125rem var(--display); vertical-align: middle;
}
.live-badge::before { content: ''; width: 7px; height: 7px; border-radius: 50%; background: #fff; animation: pulse 1.4s ease-in-out infinite; }
.series-total { font-variant-numeric: tabular-nums; color: var(--text); }
.series-total .colon, .series-score .colon { color: var(--muted); margin: 0 .12em; }
.row-score { font: 700 1.375rem var(--display); color: var(--text); font-variant-numeric: tabular-nums; }
.row-live { color: var(--text); }
.series-score {
grid-column: 3; grid-row: 2 / 4; align-self: center; justify-self: end; text-align: right;
font: 700 2.25rem/1 var(--display); font-variant-numeric: tabular-nums; text-shadow: 0 2px 10px rgba(0, 0, 0, .8);
}
.series-score small { display: block; font: 500 .8125rem var(--body); color: var(--muted); margin-top: 4px; }
.series-score .is-winner { text-decoration: underline; text-decoration-thickness: 3px; text-underline-offset: 6px; }
.series-rounds { grid-column: 1 / -1; margin-top: 10px; overflow-x: auto; }
.series-item.is-playing { box-shadow: inset 0 0 0 2px var(--brand-red); }
.round-strip { list-style: none; display: flex; gap: 3px; padding: 0; margin: 0; }
.round {
display: grid; place-items: center; flex: none; width: 22px; height: 22px; border-radius: 3px;
background: var(--surface-2); color: #fff; box-shadow: inset 0 0 0 1px var(--line);
}
.round.won-A { background: var(--team-a); box-shadow: none; }
.round.won-B { background: var(--team-b); box-shadow: none; }
.round.break { margin-left: 10px; }
.round-strip-sm .round { width: 18px; height: 18px; }
.round-strip-sm .reason-icon { width: 10px; height: 10px; }
.round-strip-sm .round.break { margin-left: 8px; }
.alert-ok { border-color: var(--ok); background: color-mix(in srgb, var(--ok) 9%, var(--surface)); }
.live-data { display: flex; align-items: center; gap: 8px; color: var(--muted); }
.stream-link { margin-top: 10px; display: grid; gap: 8px; justify-items: start; }
.stream-link .copy-field { width: 100%; }
@media (max-width: 640px) {
.series-score { grid-column: 2; grid-row: 2; font-size: 1.75rem; }
}
/* ---------- Admin ---------- */
.admin-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; margin-bottom: 20px; }
.admin-list { display: grid; gap: 14px; }
.admin-card { border-left: 4px solid var(--line-strong); }
.admin-card.status-live { border-left-color: var(--brand-red); }
.admin-card.status-veto { border-left-color: var(--pending); }
.admin-card-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; }
.admin-card-head strong { font: 700 1.5rem var(--display); }
.admin-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; align-items: center; }
.admin-details { margin-top: 16px; border-top: 1px solid var(--line); padding-top: 14px; display: grid; gap: 10px; }
.admin-details h3 { margin-top: 6px; }
.admin-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 20px; }
.admin-players { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
.admin-players ul { list-style: none; padding: 0; margin: 4px 0 0; }
.admin-players li { display: flex; gap: 10px; align-items: baseline; padding: 4px 0; border-bottom: 1px solid var(--line); }
.admin-players em { font-style: normal; color: var(--pending); font-size: .8125rem; }
.admin-log { list-style: none; padding: 0; margin: 0; max-height: 260px; overflow-y: auto; font-size: .875rem; }
.admin-log li { padding: 4px 0; border-bottom: 1px solid var(--line); }
.admin-log time { color: var(--muted); margin-right: 8px; }
.admin-login { margin-top: 40px; }
.danger { color: var(--error); }
@media (max-width: 640px) { .admin-players { grid-template-columns: 1fr; } }
/* ---------- Rollback (nur Admin) ---------- */
.rollback { border: 1px dashed var(--brand-red); border-radius: var(--radius); padding: 12px 14px; }
.rollback h3 { margin: 0 0 8px; display: flex; align-items: center; gap: 10px; }
.rollback select { min-width: 200px; }
.admin-badge { font: 600 .75rem var(--body); color: #fff; background: var(--brand-red); padding: 2px 8px; border-radius: 3px; }
.admin-settings { border: 1px solid var(--line); background: var(--surface); border-radius: var(--radius); padding: 16px 20px; margin-bottom: 20px; }
.admin-settings h2 { margin: 0 0 12px; font-size: 1.1rem; }
.admin-settings-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 8px 16px; margin-bottom: 12px; }
.admin-check { display: flex; align-items: center; gap: 8px; cursor: pointer; }
.admin-tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--line); margin-bottom: 20px; }
.admin-tab { background: none; border: 0; border-bottom: 2px solid transparent; color: var(--muted); padding: 10px 14px; font: inherit; cursor: pointer; }
.admin-tab:hover { color: var(--text); }
.admin-tab.is-active { color: var(--text); border-bottom-color: var(--primary); }
.turn-timer { grid-column: 1 / -1; display: flex; align-items: center; gap: 10px; margin-top: 8px; }
.turn-bar { flex: 1; height: 6px; border-radius: 3px; background: color-mix(in srgb, var(--team) 18%, transparent); overflow: hidden; }
.turn-bar span { display: block; height: 100%; background: var(--team); transition: width .25s linear; }
.turn-seconds { min-width: 3ch; text-align: right; color: var(--muted); font-size: .875rem; font-variant-numeric: tabular-nums; }
.turn-timer.is-low .turn-bar span { background: var(--error); }
.turn-timer.is-low .turn-seconds { color: var(--error); font-weight: 600; }
.admin-live { display: flex; gap: 10px; align-items: baseline; margin-top: 4px; font-size: .9rem; }
.admin-live strong { font-variant-numeric: tabular-nums; }
.admin-badge { display: inline-block; margin-top: 6px; padding: 2px 10px; border-radius: 999px; font-size: .8rem; font-weight: 600; }
.admin-badge.is-paused { background: color-mix(in srgb, var(--pending) 22%, transparent); color: var(--pending); border: 1px solid var(--pending); }
.btn-warn { border-color: var(--pending); color: var(--pending); }
/* Laufende Matches in der Übersicht */
.run-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 16px; }
.run-card { position: relative; display: flex; flex-direction: column; justify-content: space-between; gap: 10px; min-height: 190px; padding: 16px 18px; border-radius: var(--radius); border: 1px solid var(--line); background: var(--surface); color: var(--text); text-decoration: none; overflow: hidden; isolation: isolate; transition: transform .15s ease, border-color .15s ease; }
.run-card:hover { transform: translateY(-2px); border-color: var(--map); }
.run-card.is-live { box-shadow: inset 3px 0 0 var(--brand-red); }
.run-art { position: absolute; inset: 0; z-index: -1; opacity: .28; }
.run-art::after { background: linear-gradient(180deg, rgba(0,0,0,.2), rgba(0,0,0,.92)); }
.run-top { display: flex; justify-content: space-between; align-items: center; gap: 8px; font-size: .8rem; color: var(--muted); }
.run-live { animation: run-pulse 1.6s ease-in-out infinite; }
@keyframes run-pulse { 50% { opacity: .55; } }
.run-status { font-weight: 600; }
.run-teams { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 8px; text-align: center; }
.run-team { font: 700 1.25rem var(--display); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.run-team.a { color: var(--team-a); text-align: right; }
.run-team.b { color: var(--team-b); text-align: left; }
.run-score { font: 700 2.25rem var(--display); font-variant-numeric: tabular-nums; }
.run-score i { font-style: normal; color: var(--muted); padding: 0 4px; }
.run-vs { font-size: 1rem; color: var(--muted); font-style: normal; }
.run-foot { display: flex; justify-content: space-between; gap: 8px; font-size: .875rem; color: var(--muted); }
.run-series { font-variant-numeric: tabular-nums; }
.host-usage { margin-bottom: 20px; }
.host-usage h2 { font-size: 1.1rem; margin: 0 0 10px; }
.host-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 12px; }
.host-card { border: 1px solid var(--line); background: var(--surface); border-radius: var(--radius); padding: 12px 14px; }
.host-card.is-full { border-color: var(--pending); }
.host-head { display: flex; flex-wrap: wrap; gap: 4px 10px; align-items: baseline; margin-bottom: 8px; }
.host-count { margin-left: auto; font-variant-numeric: tabular-nums; font-size: .875rem; }
.host-bar { height: 6px; border-radius: 3px; background: var(--line); overflow: hidden; margin-bottom: 8px; }
.host-bar span { display: block; height: 100%; background: var(--primary); transition: width .3s ease; }
.host-card.is-full .host-bar span { background: var(--pending); }
.host-matches { list-style: none; margin: 0; padding: 0; font-size: .9rem; }
.host-matches li { padding: 3px 0; border-top: 1px solid var(--line); }
.host-disk { font-size: .85rem; color: var(--muted); margin: -2px 0 8px; font-variant-numeric: tabular-nums; }
.host-disk.is-low { color: var(--error); font-weight: 600; }
.admin-address { display: flex; flex-wrap: wrap; gap: 6px 16px; margin-top: 8px; font-size: .85rem; }
.admin-address code { font: 500 .85rem ui-monospace, SFMono-Regular, Consolas, monospace; padding: 1px 6px; border: 1px solid var(--line); border-radius: 4px; background: var(--surface); }
.admin-settings h3 { margin: 16px 0 8px; font-size: .95rem; }
.setting-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 12px 20px; }
.setting-item { display: flex; flex-direction: column; gap: 4px; }
.setting-field { display: flex; flex-direction: column; gap: 4px; font-size: .9rem; }
.setting-field input, .setting-field textarea { width: 100%; box-sizing: border-box; font: inherit; color: var(--text); background: var(--bg, transparent); border: 1px solid var(--line); border-radius: 4px; padding: 6px 8px; }
.setting-field textarea { resize: vertical; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: .85rem; }
.setting-live { display: inline-block; margin-right: 6px; padding: 0 6px; border-radius: 999px; font-size: .7rem; font-weight: 600; color: var(--primary); border: 1px solid var(--primary); }
.host-edit { display: flex; flex-direction: column; gap: 10px; margin-bottom: 12px; }
.host-edit-row { display: grid; grid-template-columns: 1fr 2fr 1.4fr .8fr .8fr 1.1fr 1.4fr auto; gap: 8px; align-items: end; padding-bottom: 10px; border-bottom: 1px solid var(--line); }
@media (max-width: 900px) {
.host-edit-row { grid-template-columns: 1fr 1fr; }
.host-col-dockerHost { grid-column: 1 / -1; }
}
.pull-results { list-style: none; margin: 8px 0 0; padding: 0; display: flex; flex-direction: column; gap: 4px; font-size: .9rem; }
.pull-results li { display: flex; flex-wrap: wrap; gap: 4px 10px; align-items: baseline; }
.pull-results code { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; }
.pull-running span { color: var(--pending); }
.pull-ok span { color: var(--ok, #22c55e); }
.pull-failed span { color: var(--error); }
.admin-toolbar { display: flex; flex-wrap: wrap; gap: 10px 16px; align-items: center; margin: 0 0 14px; }
.admin-search { flex: 1 1 240px; min-width: 0; font: inherit; color: var(--text); background: var(--surface); border: 1px solid var(--line); border-radius: 4px; padding: 7px 10px; }
.admin-filters { display: flex; flex-wrap: wrap; gap: 6px; }
.admin-filter { display: inline-flex; align-items: center; gap: 6px; font: inherit; font-size: .875rem; color: var(--muted); background: transparent; border: 1px solid var(--line); border-radius: 999px; padding: 4px 12px; cursor: pointer; }
.admin-filter:hover { color: var(--text); }
.admin-filter.is-active { color: var(--text); border-color: var(--primary); background: color-mix(in srgb, var(--primary) 16%, transparent); }
.admin-filter-count { font-variant-numeric: tabular-nums; font-size: .75rem; opacity: .8; }
.admin-filter.has-errors .admin-filter-count { color: var(--error); font-weight: 700; opacity: 1; }
.admin-error { margin: 8px 0 0; color: var(--error); }
.admin-badge.is-archived { background: transparent; color: var(--muted); border: 1px solid var(--line); }
.knife-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 12px; margin-bottom: 12px; }
.knife-card { border: 1px solid var(--line); border-radius: var(--radius); padding: 12px 14px; display: flex; flex-direction: column; gap: 6px; }
.knife-head { display: flex; flex-wrap: wrap; gap: 4px 10px; align-items: baseline; }
.knife-state { margin-left: auto; }
.knife-addr { font: 500 .9rem ui-monospace, SFMono-Regular, Consolas, monospace; align-self: flex-start; padding: 1px 6px; border: 1px solid var(--line); border-radius: 4px; }
.knife-new select { font: inherit; color: var(--text); background: var(--surface); border: 1px solid var(--line); border-radius: 4px; padding: 5px 8px; }
.knife-new { align-items: center; }
.knife-public { margin-bottom: 24px; }
.knife-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
.knife-list li { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 14px; padding: 10px 14px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); }
.knife-list code { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: .85rem; }
.knife-list .btn { margin-left: auto; }
.knife-mode { display: inline-block; margin: 0; padding: 1px 8px; border-radius: 999px; font-size: .75rem; font-weight: 700; color: var(--primary); border: 1px solid var(--primary); background: transparent; }
.pool-maps { display: grid; grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); gap: 8px; margin-bottom: 10px; }
.pool-map { position: relative; display: flex; flex-direction: column; justify-content: flex-end; min-height: 78px; isolation: isolate; border: 1px solid var(--line); border-radius: var(--radius); overflow: hidden; cursor: pointer; opacity: .45; transition: opacity .15s ease, border-color .15s ease; }
.pool-map.is-on { opacity: 1; border-color: var(--primary); }
.pool-map input { position: absolute; top: 6px; left: 6px; z-index: 2; }
.pool-map-name { position: relative; z-index: 1; padding: 5px 8px; font-size: .9rem; font-weight: 700; text-shadow: 0 1px 6px rgba(0, 0, 0, .7); }
.pool-flows { display: flex; flex-direction: column; gap: 6px; margin-bottom: 10px; }
.pool-flow { display: flex; flex-wrap: wrap; gap: 6px 10px; align-items: baseline; }
.pool-flow strong { min-width: 36px; font-size: .85rem; }
.flow-steps { display: flex; flex-wrap: wrap; gap: 4px; list-style: none; margin: 0; padding: 0; }
.flow-steps li { font-size: .75rem; padding: 1px 7px; border-radius: 999px; border: 1px solid var(--line); color: var(--muted); }
.flow-steps .flow-ban { color: var(--error); border-color: color-mix(in srgb, var(--error) 50%, transparent); }
.flow-steps .flow-pick { color: var(--ok); border-color: color-mix(in srgb, var(--ok) 50%, transparent); }
.flow-steps .flow-decider { color: var(--text); border-color: var(--primary); }
/* Admin-Einstellungen mit Seitenleiste */
.settings-layout { display: grid; grid-template-columns: 230px minmax(0, 1fr); gap: 20px; align-items: start; }
.settings-nav { position: sticky; top: 16px; display: flex; flex-direction: column; gap: 14px; }
.settings-nav-title { font-size: .72rem; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; color: var(--muted); margin: 0 0 4px 10px; }
.settings-nav-group { display: flex; flex-direction: column; gap: 2px; }
.settings-nav-item { display: flex; flex-direction: column; align-items: flex-start; gap: 1px; width: 100%; text-align: left; font: inherit; color: var(--text); background: transparent; border: 1px solid transparent; border-left: 3px solid transparent; border-radius: 4px; padding: 7px 10px; cursor: pointer; }
.settings-nav-item small { font-size: .75rem; color: var(--muted); }
.settings-nav-item:hover { background: var(--surface); }
.settings-nav-item.is-active { background: var(--surface); border-color: var(--line); border-left-color: var(--primary); }
.settings-content > .admin-settings { margin-bottom: 0; }
@media (max-width: 760px) {
.settings-layout { grid-template-columns: minmax(0, 1fr); gap: 12px; }
.settings-nav { position: static; flex-direction: row; flex-wrap: wrap; gap: 6px; }
.settings-nav-group { flex-direction: row; flex-wrap: wrap; gap: 6px; }
.settings-nav-title { display: none; }
.settings-nav-item { width: auto; white-space: nowrap; border: 1px solid var(--line); border-radius: 999px; padding: 5px 12px; }
.settings-nav-item small { display: none; }
.settings-nav-item.is-active { border-color: var(--primary); }
}
/* ---------- Startseite: Spieler zuerst ---------- */
.home-hero { display: grid; grid-template-columns: minmax(0, 1fr); gap: 32px; align-items: start; margin: 8px 0 28px; }
.home-hero.has-mine { grid-template-columns: minmax(0, 1.25fr) minmax(280px, 1fr); }
.home-intro h1 { font: 700 clamp(2.6rem, 6vw, 4.4rem)/.95 var(--display); letter-spacing: -.01em; margin: 0 0 14px; max-width: 14ch; }
.home-intro .lead { max-width: 58ch; margin: 0 0 22px; }
/* Code-Eingabe als Eintrittskarte: Abriss links, Code groß in der Mitte */
.ticket {
--notch: 11px;
display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; column-gap: 0;
max-width: 620px; background: var(--surface-2); border: 1px solid var(--line-strong);
clip-path: polygon(var(--chamfer) 0, 100% 0, 100% calc(100% - var(--chamfer)), calc(100% - var(--chamfer)) 100%, 0 100%, 0 var(--chamfer));
}
.ticket-stub {
align-self: stretch; display: flex; align-items: center; padding: 0 18px 0 22px;
font: 700 1.05rem var(--display); color: var(--muted); letter-spacing: .02em;
border-right: 2px dashed var(--line-strong); background: color-mix(in srgb, var(--brand-red) 14%, transparent);
}
.ticket-code {
min-width: 0; width: 100%; border: 0; background: transparent; color: var(--text); outline: none;
font: 700 clamp(1.9rem, 4.6vw, 2.75rem)/1 var(--display); letter-spacing: .14em; padding: 16px 18px;
}
.ticket-code::placeholder { color: color-mix(in srgb, var(--muted) 45%, transparent); }
.ticket:focus-within { border-color: var(--primary); box-shadow: inset 0 0 0 1px var(--primary); }
.ticket-go { margin: 0 14px 0 0; }
.ticket-msg { grid-column: 1 / -1; margin: 0; padding: 8px 22px 12px; border-top: 1px solid var(--line); }
/* Eigene Matches */
.home-mine h2 { font-size: 1.15rem; margin: 6px 0 10px; }
.home-mine ul { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
.mine-card {
display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 4px 14px; align-items: center;
padding: 14px 16px; text-decoration: none; color: var(--text);
background: var(--surface); border: 1px solid var(--line); border-left: 3px solid var(--primary); border-radius: var(--radius);
}
.mine-card:hover { border-color: var(--line-strong); border-left-color: var(--primary-hover); }
.mine-card:focus-visible { outline: 2px solid var(--link); outline-offset: 2px; }
.mine-teams { font: 700 1.35rem/1.1 var(--display); grid-column: 1; }
.mine-meta { grid-column: 1; font-size: .85rem; color: var(--muted); }
.mine-next { grid-column: 1; font-weight: 600; margin-top: 4px; }
.mine-go { grid-column: 2; grid-row: 1 / span 3; }
/* Vier Schritte */
.home-steps { list-style: none; margin: 0 0 36px; padding: 0; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); border-top: 1px solid var(--line); }
.home-steps li { position: relative; display: grid; grid-template-columns: auto 1fr; align-content: start; column-gap: 12px; row-gap: 2px; padding: 14px 16px 4px 0; }
.step-n {
grid-row: 1; align-self: center; width: 34px; height: 34px; display: grid; place-items: center;
font: 700 1.2rem var(--display); color: var(--muted); border: 1px solid var(--line-strong); border-radius: 50%;
}
.step-title { font-weight: 700; align-self: center; }
.step-text { grid-column: 2; font-size: .85rem; color: var(--muted); }
.home-steps li.is-done .step-n { color: var(--ok); border-color: color-mix(in srgb, var(--ok) 60%, transparent); }
.home-steps li.is-current::before { content: ''; position: absolute; top: -2px; left: 0; right: 16px; height: 3px; background: var(--primary); }
.home-steps li.is-current .step-n { color: #fff; background: var(--primary); border-color: var(--primary); }
.home-live { margin-bottom: 36px; }
.home-lists { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 32px; }
.home-lists .match-row-meta { flex-wrap: wrap; }
@media (max-width: 900px) {
.home-hero.has-mine { grid-template-columns: minmax(0, 1fr); }
.home-hero.has-mine .home-mine { order: -1; }
.home-steps { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.home-lists { grid-template-columns: minmax(0, 1fr); }
}
@media (max-width: 520px) {
.ticket { grid-template-columns: minmax(0, 1fr) auto; }
.ticket-stub { grid-column: 1 / -1; border-right: 0; border-bottom: 2px dashed var(--line-strong); padding: 8px 18px; }
.ticket-code { letter-spacing: .08em; padding: 12px 14px; }
.home-steps { grid-template-columns: minmax(0, 1fr); }
.home-steps li { padding: 10px 0 10px 12px; }
.home-steps li.is-current::before { top: 8px; bottom: 8px; left: 0; right: auto; width: 3px; height: auto; }
.mine-card { grid-template-columns: minmax(0, 1fr); }
.mine-go { grid-column: 1; grid-row: auto; justify-self: start; margin-top: 6px; }
}
/* Seitenwahl per Messerrunde in der Lobby */
.knife-round { margin: 14px 0; padding: 14px 16px; border: 1px solid var(--line); border-left: 3px solid var(--brand-red); border-radius: var(--radius); background: var(--surface-2); text-align: left; }
.knife-round h3 { margin: 0 0 6px; font-size: 1.05rem; }
.knife-round .knife-list { margin-top: 10px; }
.knife-decide { margin: 6px 0 4px; }
.knife-decide .row { display: flex; flex-wrap: wrap; gap: 10px; justify-content: center; }
.knife-team-a { border-color: var(--team-a); color: var(--team-a); }
.knife-team-b { border-color: var(--team-b); color: var(--team-b); }
/* Chat im Match */
.chat-box { display: flex; flex-direction: column; padding: 0; overflow: hidden; }
.chat-tabs { display: flex; border-bottom: 1px solid var(--line); }
.chat-tab { position: relative; flex: 1; font: 700 1rem var(--display); color: var(--muted); background: transparent; border: 0; border-bottom: 3px solid transparent; padding: 12px 14px 10px; cursor: pointer; }
.chat-tab:hover { color: var(--text); }
.chat-tab.is-active { color: var(--text); border-bottom-color: var(--primary); }
.chat-tab-a.is-active { border-bottom-color: var(--team-a); }
.chat-tab-b.is-active { border-bottom-color: var(--team-b); }
.chat-unread { display: inline-block; width: 8px; height: 8px; margin-left: 6px; border-radius: 50%; background: var(--brand-red); vertical-align: middle; }
.chat-list { list-style: none; margin: 0; padding: 10px 16px; height: 300px; overflow-y: auto; display: flex; flex-direction: column; gap: 8px; }
.chat-empty { margin: auto 0; text-align: center; }
.chat-msg { display: flex; flex-direction: column; gap: 1px; max-width: 92%; }
.chat-msg.is-mine { align-self: flex-end; text-align: right; }
.chat-meta { display: flex; gap: 6px; align-items: baseline; font-size: .8rem; }
.chat-msg.is-mine .chat-meta { justify-content: flex-end; }
.chat-org { color: var(--text); }
.chat-role { font-size: .7rem; padding: 0 6px; border: 1px solid var(--line-strong); border-radius: 999px; color: var(--muted); }
.chat-text { display: inline-block; padding: 6px 10px; border-radius: 10px; background: var(--surface-2); overflow-wrap: anywhere; }
.chat-msg.is-mine .chat-text { background: color-mix(in srgb, var(--primary) 30%, var(--surface-2)); }
.chat-form { display: flex; gap: 8px; padding: 10px 12px; border-top: 1px solid var(--line); }
.chat-form input { flex: 1; min-width: 0; }
.chat-closed { margin: 0; padding: 10px 16px; border-top: 1px solid var(--line); }
.chat-box .field-error { margin: 0 16px 10px; }
/* Live-Konsole der Container */
.console-backdrop { position: fixed; inset: 0; z-index: 50; display: grid; place-items: center; padding: 16px; background: rgba(var(--bg-rgb), .78); }
.console-dialog { display: flex; flex-direction: column; width: min(1100px, 100%); height: min(760px, 100%); background: var(--surface); border: 1px solid var(--line-strong); border-radius: var(--radius); overflow: hidden; }
.console-head { display: flex; align-items: center; gap: 12px; padding: 12px 16px; border-bottom: 1px solid var(--line); }
.console-head strong { font: 700 1.1rem var(--display); margin-right: auto; }
.console-status { font-size: .8rem; color: var(--muted); }
.console-status.is-live { color: var(--ok); }
.console-status.is-live::before { content: ''; display: inline-block; width: 7px; height: 7px; margin-right: 6px; border-radius: 50%; background: var(--ok); vertical-align: middle; }
.console-tools { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 14px; padding: 8px 16px; border-bottom: 1px solid var(--line); }
.console-tools input[type="search"] { flex: 1 1 200px; min-width: 0; }
.console-out { flex: 1; margin: 0; padding: 12px 16px; overflow: auto; background: #05060a; color: #cfd6e6; font: .8rem/1.45 ui-monospace, SFMono-Regular, Consolas, monospace; white-space: pre-wrap; overflow-wrap: anywhere; }
.console-note { margin: 0; padding: 6px 16px 10px; }
.host-log-btn { margin-left: 4px; }
@media (max-width: 600px) {
.console-backdrop { padding: 0; }
.console-dialog { height: 100%; border-radius: 0; }
}
.pull-skipped span { color: var(--muted); }
.files-bar { flex: 1 1 160px; height: 6px; border-radius: 3px; background: var(--line); overflow: hidden; }
.files-bar i { display: block; height: 100%; background: var(--primary); transition: width .4s ease; }
.knife-connect { display: flex; flex-wrap: wrap; align-items: end; gap: 8px 12px; margin-top: 10px; }
.knife-connect > :first-child { flex: 1 1 260px; }
.knife-actions { margin-top: 10px; gap: 8px; flex-wrap: wrap; }
.team-invite { margin-top: 12px; padding-right: 0; }
.team-invite .copy-row { flex-wrap: wrap; }
.team-invite .copy-row code { flex: 1 1 100%; font-size: .78rem; white-space: normal; word-break: break-all; overflow: visible; }
.team-invite p { margin: 6px 0 0; }
+31
View File
@@ -0,0 +1,31 @@
# Vorlage für deploy/.env – kopieren und anpassen:
# cp deploy/.env.example deploy/.env
# --- Pflicht ---
# IP oder Name des Docker-Hosts im LAN. Spieler verbinden sich dorthin.
HOST_IP=192.168.178.111
# --- Webseite ---
# Port der Webseite auf dem Host
WEB_PORT=3000
# Image: lokal gebaut oder mit "docker load" geladen (siehe docs/container.md)
CS2_MAPVETO_IMAGE=cs2-mapveto:latest
# --- Serververwaltung ---
# docker = pro Match ein CS2-Container (Standard)
# demo = nur Simulation, startet keine echten Server (zum Ausprobieren)
SERVER_ADAPTER=docker
# Game Server Login Token (App-ID 730). Leer = LAN-Modus (sv_lan 1).
CS2_GSLT=
CS2_DOCKER_IMAGE=joedwards32/cs2:latest
# Zwei Ports pro Match (Spiel TCP+UDP, CSTV UDP) – auf dem Host freigeben
CS2_PORT_RANGE_START=27100
CS2_PORT_RANGE_END=27139
SERVER_IDLE_TIMEOUT_MINUTES=30
# --- Matches und Stream ---
# Wenn gesetzt, braucht man dieses Passwort zum Anlegen von Matches
CREATE_MATCH_PASSWORD=
# Wenn gesetzt, braucht man /stream?key=… für die Stream-Übersicht
STREAM_KEY=
SERIES_AUTO_ADVANCE_SECONDS=30
+46
View File
@@ -0,0 +1,46 @@
# CS2 Mapveto – Start auf einem beliebigen Docker-Host (Linux, z. B. Portainer-Server, NAS, eigener Rechner).
#
# Voraussetzungen:
# 1. deploy/.env anlegen (Vorlage: deploy/.env.example), mindestens HOST_IP setzen
# 2. Image cs2-mapveto:latest auf dem Host (gebaut oder mit "docker load" geladen)
# 3. Volume cs2-mapveto-gamefiles mit den CS2-Spieldateien (siehe docs/container.md)
#
# Start: docker compose --env-file deploy/.env -f deploy/compose.yml up -d
# Stoppen: docker compose --env-file deploy/.env -f deploy/compose.yml down
name: cs2-mapveto
services:
cs2-mapveto:
image: ${CS2_MAPVETO_IMAGE:-cs2-mapveto:latest}
container_name: cs2-mapveto
restart: unless-stopped
ports:
- "${WEB_PORT:-3000}:3000"
environment:
PORT: "3000"
# Adresse des Docker-Hosts im LAN: Einladungslinks, Spieler-Verbindungen, Live-Logs
PUBLIC_URL: "http://${HOST_IP:?HOST_IP in deploy/.env eintragen}:${WEB_PORT:-3000}"
LOG_RECEIVER_URL: "http://${HOST_IP}:${WEB_PORT:-3000}"
DATABASE_FILE: /data/cs2-mapveto.db
SERVER_ADAPTER: ${SERVER_ADAPTER:-docker}
CS2_PUBLIC_HOST: ${HOST_IP}
CS2_RCON_HOST: ${HOST_IP}
CS2_GSLT: ${CS2_GSLT:-}
CS2_DOCKER_VOLUME: cs2-mapveto-gamefiles
CS2_DOCKER_IMAGE: ${CS2_DOCKER_IMAGE:-joedwards32/cs2:latest}
CS2_PORT_RANGE_START: ${CS2_PORT_RANGE_START:-27100}
CS2_PORT_RANGE_END: ${CS2_PORT_RANGE_END:-27139}
CREATE_MATCH_PASSWORD: ${CREATE_MATCH_PASSWORD:-}
STREAM_KEY: ${STREAM_KEY:-}
SERIES_AUTO_ADVANCE_SECONDS: ${SERIES_AUTO_ADVANCE_SECONDS:-30}
SERVER_IDLE_TIMEOUT_MINUTES: ${SERVER_IDLE_TIMEOUT_MINUTES:-30}
volumes:
# Datenbank (Matches, Ergebnisse) überdauert Updates
- cs2-mapveto-data:/data
# Zugriff auf Docker, um Match-Container zu starten und zu entfernen
- /var/run/docker.sock:/var/run/docker.sock
volumes:
cs2-mapveto-data:
name: cs2-mapveto-data
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env bash
# Richtet einen Docker-Host für CS2 Mapveto ein. Läuft vom eigenen Rechner aus per SSH.
#
# Aufruf: bash deploy/docker-host-einrichten.sh <ssh-ziel> [volume]
# Beispiel: bash deploy/docker-host-einrichten.sh cs2@192.168.178.111
#
# Voraussetzungen: SSH-Schlüssel hinterlegt (ohne Passwortabfrage), Docker auf dem Host installiert.
set -euo pipefail
TARGET="${1:?Aufruf: $0 <ssh-ziel> [volume]}"
VOLUME="${2:-cs2-mapveto-gamefiles}"
IMAGE="joedwards32/cs2:latest"
HOST_PART="${TARGET#*@}"
echo "→ Verbindung zu $TARGET prüfen"
ssh -o BatchMode=yes -o ConnectTimeout=10 "$TARGET" true
echo "→ Docker prüfen"
if ! ssh "$TARGET" "command -v docker >/dev/null 2>&1"; then
echo "Docker fehlt auf $HOST_PART. Installation: https://docs.docker.com/engine/install/" >&2
exit 1
fi
ssh "$TARGET" "docker info >/dev/null"
echo "→ Freier Platz für Docker (CS2 braucht rund 90 GB)"
ssh "$TARGET" "df -BG /var/lib/docker 2>/dev/null | tail -1 || df -BG / | tail -1"
echo "→ Volume $VOLUME anlegen (falls nicht vorhanden)"
ssh "$TARGET" "docker volume inspect $VOLUME >/dev/null 2>&1 || docker volume create $VOLUME >/dev/null"
echo "→ Image $IMAGE laden"
ssh "$TARGET" "docker pull $IMAGE"
cat <<EOF
Fertig. Diesen Eintrag in CS2_DOCKER_HOSTS (JSON-Liste in der .env) aufnehmen:
{"name":"$HOST_PART","dockerHost":"ssh://$TARGET","publicHost":"$HOST_PART","portStart":27100,"portEnd":27139}
Hinweise:
- name muss pro Host eindeutig sein.
- Frei sein müssen: 27015–27020 (LAN-Reiter von Steam), 27100–27139 und jeweils 100 darüber für CSTV
(27115–27120, 27200–27239). Pro Host sind bis zu 40 Matches möglich, 6 davon im LAN-Reiter.
- Der erste Server-Start lädt die CS2-Spieldateien (rund 60 bis 90 GB). Das dauert lange.
EOF
Binary file not shown.
Binary file not shown.
+172
View File
@@ -0,0 +1,172 @@
# CS2 Mapveto als Container betreiben
CS2 Mapveto läuft als **ein** Container: Webseite, Backend, Datenbank (SQLite) und ein Docker-Client.
Er steht auf demselben Docker-Host wie die CS2-Server und startet pro Match über `/var/run/docker.sock`
einen eigenen CS2-Container. Der Host kann jeder Linux-Rechner mit Docker sein.
```
Docker-Host (beliebig, Linux, Adresse z. B. 192.168.178.111)
├── cs2-mapveto ← diese Anwendung, Port 3000
├── cs2mv-<match> ← CS2-Server pro Match (startet/entfernt CS2 Mapveto)
├── Volume cs2-mapveto-gamefiles CS2-Spieldateien (~73 GB)
└── Volume cs2-mapveto-data Datenbank der Anwendung
```
> **Nur eine Instanz pro Docker-Host.** Läuft CS2 Mapveto zusätzlich anderswo mit demselben Host, streiten
> sich beide um Ports und Containernamen. Die andere Instanz vorher beenden.
---
## Was du brauchst
| | |
|---|---|
| Docker + Compose | `docker compose version` muss funktionieren |
| Speicher | ~80 GB frei für Spieldateien, Images und Updates |
| CPU / RAM | pro gleichzeitigem Match etwa 1–2 Kerne und 2–3 GB RAM |
| Netz | Ports TCP/UDP 27100–27139 (Spiel, RCON) und UDP 27101… (CSTV) sowie TCP 3000 im LAN freigeben |
| Dateien | das Image `cs2-mapveto` (siehe Schritt 1) und die CS2-Spieldateien (Schritt 2) |
---
## Schritt 1: Image auf den neuen Host bringen
Du hast zwei Wege.
### Weg A: Image-Datei kopieren (ohne Build, schnell)
Auf dem Rechner, auf dem das Image gebaut wurde, liegt die Datei `dist/cs2-mapveto-image.tar` (294 MB). Sie
auf den neuen Host kopieren und laden:
```bash
scp cs2-mapveto-image.tar USER@NEUERHOST:/tmp/
ssh USER@NEUERHOST "docker load -i /tmp/cs2-mapveto-image.tar && rm /tmp/cs2-mapveto-image.tar"
```
Prüfen mit `docker image ls cs2-mapveto`.
### Weg B: aus dem Repository bauen
Auf dem neuen Host ist Git nötig. Das Repository liegt auf dem Gitea (`http://192.168.178.45:3002/marcel/Cs2-Mapveto.git`):
```bash
git clone http://192.168.178.45:3002/marcel/Cs2-Mapveto.git
cd Cs2-Mapveto
docker build -t cs2-mapveto:latest .
```
Beim Bauen laufen alle Tests mit. Schlägt einer fehl, bricht der Build ab.
---
## Schritt 2: CS2-Spieldateien auf den neuen Host bringen
Das Volume `cs2-mapveto-gamefiles` muss vor dem ersten Match vorhanden und befüllt sein. Sonst lädt jeder Match-Start
die ~60 GB neu. Zwei Wege:
**a) Vom bisherigen Host kopieren** (im LAN, kein Internet nötig, dauert je nach Netz Stunden):
```bash
ssh ALTERHOST "docker run --rm -v cs2-mapveto-gamefiles:/from alpine tar -C /from -cf - ." \
| ssh NEUERHOST "docker volume create cs2-mapveto-gamefiles >/dev/null && docker run --rm -i -v cs2-mapveto-gamefiles:/to alpine tar -C /to -xf -"
```
**b) Neu laden** (braucht Internet, ~60 GB). Der Container lädt die Dateien und stoppt danach von selbst:
```bash
docker volume create cs2-mapveto-gamefiles
docker run -d --name cs2-updater -e STEAMAPPVALIDATE=0 -e CS2_RCONPW=updater1 -v cs2-mapveto-gamefiles:/home/steam/cs2-dedicated/ joedwards32/cs2:latest
docker logs -f cs2-updater # warten auf "Success! App '730' fully installed"
docker rm -f cs2-updater
```
Danach liegen unter `docker system df -v` rund 73 GB im Volume.
Das CS2-Image selbst (`joedwards32/cs2:latest`, ~3,7 GB) holt Docker beim ersten Match automatisch aus dem
Docker Hub. Ohne Internet muss es vorher ebenfalls geladen werden (`docker save`/`docker load` wie bei Schritt 1).
---
## Schritt 3: Konfiguration
Im Ordner `deploy/` die Vorlage kopieren und anpassen:
```bash
cp deploy/.env.example deploy/.env
```
Das Wichtigste:
| Variable | Bedeutung |
|---|---|
| `HOST_IP` | **Pflicht.** IP des neuen Hosts im LAN. Spieler verbinden sich dorthin, Einladungslinks zeigen darauf |
| `WEB_PORT` | Port der Webseite (Standard 3000) |
| `SERVER_ADAPTER` | `docker` für echte Server, `demo` nur zum Ausprobieren ohne CS2 |
| `CS2_GSLT` | leer = LAN-Modus (`sv_lan 1`), kein Token nötig |
| `CS2_PORT_RANGE_START` / `_END` | Ports für Match-Server, zwei pro Match |
| `CREATE_MATCH_PASSWORD` | Passwort zum Anlegen von Matches (empfohlen) |
| `STREAM_KEY` | Schlüssel für `/stream?key=…` (optional) |
Passwörter nur aus Buchstaben und Ziffern wählen.
---
## Schritt 4: Starten
```bash
docker compose --env-file deploy/.env -f deploy/compose.yml up -d
```
Prüfen:
```bash
docker compose --env-file deploy/.env -f deploy/compose.yml ps # Status "healthy" nach ~20 s
curl http://HOST_IP:3000/api/config # JSON mit "adapter": "docker"
```
Danach ist die Webseite unter `http://HOST_IP:3000` erreichbar, die Stream-Übersicht unter `/stream`.
Stoppen und neu starten:
```bash
docker compose --env-file deploy/.env -f deploy/compose.yml down
docker compose --env-file deploy/.env -f deploy/compose.yml up -d
```
Die Datenbank (Volume `cs2-mapveto-data`) bleibt dabei erhalten.
### Mit Portainer statt Kommandozeile
Portainer → Stacks → **Add stack** → Web editor: den Inhalt von `deploy/compose.yml` einfügen. Die Variablen aus
`deploy/.env` als *Environment variables* eintragen (mindestens `HOST_IP`). Danach **Deploy the stack**.
---
## Aktualisieren
1. Neues Image holen (Weg A oder B aus Schritt 1).
2. `docker compose … up -d` erneut ausführen. Compose ersetzt den Container, die Datenbank bleibt.
---
## Sicherheit
Der Container hat über `/var/run/docker.sock` volle Kontrolle über Docker auf dem Host. Das ist nötig, um
Match-Container zu starten, entspricht aber Root-Rechten auf dem Host. Deshalb:
- Port 3000 nur im LAN anbieten, nicht ins Internet weiterleiten.
- `CREATE_MATCH_PASSWORD` setzen, wenn nicht jeder im LAN Matches anlegen soll.
- `deploy/.env` enthält Passwörter. Nicht ins Repository einchecken (ist in `.gitignore`).
---
## Fehlersuche
| Symptom | Ursache / Lösung |
|---|---|
| `HOST_IP in deploy/.env eintragen` beim Start | `deploy/.env` fehlt oder `HOST_IP` ist leer |
| „Server wird angefordert“ bleibt stehen | Log des Containers prüfen: `docker logs cs2-mapveto` |
| `permission denied … docker.sock` im Log | Rootless-Docker oder anderer Socket-Pfad. Dann den Pfad im `volumes:`-Eintrag anpassen |
| Match-Server startet, Spieler finden ihn nicht | Ports 27100–27139 im Host-Netz freigeben; `HOST_IP` muss die LAN-Adresse des Hosts sein |
| Keine Live-Ergebnisse | `HOST_IP` falsch oder Port 3000 vom Match-Server aus nicht erreichbar |
| Ein Match-Start dauert sehr lange | Spieldateien fehlen im Volume und werden neu geladen (Schritt 2) |
+51 -12
View File
@@ -83,12 +83,16 @@ volumes:
5. Darunter bei **Environment variables** auf *+ Add an environment variable* klicken und anlegen:
| Name | Wert |
|---|---|
| `SRCDS_TOKEN` | dein GSLT |
| `CS2_RCONPW` | langes, zufälliges Passwort (nur Buchstaben und Ziffern) |
| `CS2_PW` | beliebiges Serverpasswort |
| `TV_PW` | Passwort für CSTV |
| Name | Beispielwert | Bedeutung |
|---|---|---|
| `SRCDS_TOKEN` | `A1B2C3D4E5F6…` | dein GSLT; leer lassen für LAN-Modus |
| `CS2_RCONPW` | `Kx7fQ2mPz9Lw4tNb` | RCON-Passwort, lang und zufällig |
| `CS2_PW` | `Lan2026` | Serverpasswort |
| `TV_PW` | `Cstv2026` | Passwort für CSTV |
> **Nur Buchstaben und Ziffern verwenden.** Das Startskript des Images setzt die Werte ungeschützt in einen
> Shell-Befehl ein. Leerzeichen, Klammern, Kommas, Anführungszeichen oder `$` führen dazu, dass der Server
> nicht startet (`syntax error near unexpected token`) und Docker ihn endlos neu startet.
So stehen die Zugangsdaten nicht im Stack-Text selbst.
@@ -208,19 +212,54 @@ CS2_STATIC_SERVERS=192.168.178.50:27015:PASSWORT:27020,192.168.178.50:27025:PASS
Statt dauerhaft laufender Server kann CS2 Mapveto pro Match einen eigenen Container starten und danach wieder entfernen (`SERVER_ADAPTER=docker`). Damit auch dabei nichts neu geladen wird, nutzt der Adapter das Volume **`cs2-mapveto-gamefiles`**. Das ist genau das Volume, das der Stack oben angelegt und befüllt hat.
### 1. Docker-Zugriff von CS2 Mapveto auf den Docker-Host
Läuft CS2 Mapveto auf einem anderen Rechner als Docker (z. B. Windows-PC → Docker-Host im LAN), steuert es Docker per SSH:
1. Auf dem Docker-Host den Benutzer in die Docker-Gruppe aufnehmen (einmalig, danach neu anmelden):
```bash
sudo usermod -aG docker server
```
2. Auf dem Rechner mit CS2 Mapveto einen SSH-Eintrag anlegen (Windows: `C:\Users\<Name>\.ssh\config`) und die Anmeldung per Schlüssel einrichten. `DOCKER-HOST-IP` und `USER` durch deine Werte ersetzen:
```
Host docker-host
HostName DOCKER-HOST-IP
User USER
```
3. Testen, **auf dem Rechner mit CS2 Mapveto**, nicht auf dem Docker-Host:
```powershell
docker -H ssh://docker-host ps
```
### 2. `.env` von CS2 Mapveto
```env
SERVER_ADAPTER=docker
CS2_GSLT=dein_gslt
CS2_PUBLIC_HOST=192.168.178.50
DOCKER_HOST=ssh://docker-host
# leer = LAN-Modus (sv_lan 1), sonst ein Game Server Login Token
CS2_GSLT=
# Adresse, unter der Spieler den Docker-Host erreichen
CS2_PUBLIC_HOST=192.168.178.111
# Adresse, unter der CS2 Mapveto RCON erreicht – bei getrennten Rechnern ebenfalls der Docker-Host
CS2_RCON_HOST=192.168.178.111
CS2_DOCKER_VOLUME=cs2-mapveto-gamefiles
CS2_DOCKER_IMAGE=joedwards32/cs2:latest
# zwei Ports pro Match (Spiel TCP+UDP, CSTV UDP), in der Firewall freigeben
CS2_PORT_RANGE_START=27100
CS2_PORT_RANGE_END=27139
# Container müssen CS2 Mapveto für die Live-Ergebnisse erreichen
LOG_RECEIVER_URL=http://192.168.178.31:3000
```
Wichtig dabei:
- **Den Stack-Server vorher stoppen**, damit nicht zwei Container gleichzeitig dasselbe Volume aktualisieren. Der Stack dient dann nur zum einmaligen Befüllen und für Updates.
- Für gleichzeitige Matches gilt dasselbe Problem: Alle Match-Container teilen sich das Volume und prüfen beim Start auf Updates. Das ist unkritisch, solange kein Valve-Update ansteht. Kurz nach einem CS2-Patch nur **ein** Match auf einmal starten.
- Das Backend braucht Zugriff auf den Docker-Daemon (es läuft auf demselben Host bzw. hat `/var/run/docker.sock`).
- **Den Stack-Server stoppen**, sobald die Spieldateien geladen sind. Der Stack dient nur zum einmaligen Befüllen und für Updates. Das Volume bleibt beim Stoppen und Löschen des Stacks erhalten.
- **Starts laufen nacheinander:** Jeder Match-Container prüft beim Start per SteamCMD auf Updates. Damit nie zwei Container gleichzeitig das Volume aktualisieren, startet CS2 Mapveto den nächsten erst, wenn der vorige seinen Update-Check abgeschlossen hat. Nach einem CS2-Patch dauert der erste Start dadurch länger, die übrigen warten kurz.
- **Ein GSLT gilt nur für einen laufenden Server.** Ohne Token (LAN-Modus) gibt es diese Grenze nicht.
- **Hardware:** Ein CS2-Server braucht etwa 1–2 CPU-Kerne und 2–3 GB RAM. 6 Kerne und 16 GB reichen für etwa 2–3 gleichzeitige Matches.
- **Plätze:** CSTV belegt selbst einen Spielerplatz. Der Adapter startet Container deshalb mit 6 (Wingman) bzw. 12 (5 gegen 5) Plätzen: alle Spieler, CSTV und eine Reserve. Bei festen Servern (Stack) `maxplayers` genauso hoch setzen, sonst kommt der letzte Spieler nicht drauf.
- **Serverliste:** Match-Container tauchen im LAN-Tab von CS2 nicht auf. Der Client sucht dort nur auf den Ports 27015–27020, und Broadcasts erreichen Docker-Container kaum. Spieler verbinden sich über den Befehl bzw. den Knopf „In CS2 verbinden“ auf der Match-Seite. Jedes Match hat ohnehin ein eigenes Passwort.
- **Bots zum Testen:** Ohne menschliche Spieler entfernt CS2 Bots zum Matchstart wieder (`bot_join_after_player 1`). Für Tests `CS2_EXTRA_COMMANDS=bot_join_after_player 0;bot_quota 4;bot_quota_mode fill` setzen.
Für eine LAN-Party mit festen Servern ist der **Static-Adapter mit Stack aus Schritt 1** die einfachere und robustere Wahl.
@@ -228,7 +267,7 @@ Für eine LAN-Party mit festen Servern ist der **Static-Adapter mit Stack aus Sc
## LAN-Betrieb ohne GSLT
Für reine LAN-Veranstaltungen kann `CS2_LAN=1` gesetzt werden (`sv_lan 1`). Der Server ist dann nur im lokalen Netz erreichbar und braucht kein Token. Spieler müssen im selben Netz sein. Für Zuschauer über das Internet oder externe Teams ist ein GSLT nötig.
Für reine LAN-Veranstaltungen kann `CS2_LAN=1` gesetzt werden (`sv_lan 1`). Beim Docker-Adapter genügt es, `CS2_GSLT` leer zu lassen. Der Server ist dann nur im lokalen Netz erreichbar und braucht kein Token. Spieler müssen im selben Netz sein. Für Zuschauer über das Internet oder externe Teams ist ein GSLT nötig.
---
+137 -17
View File
@@ -1,5 +1,5 @@
{
"$comment": "Map-Pools und Veto-Abläufe. Schritte: ban:X, pick:X, side:X, decider. X = erstes Team (startet das Veto), Y = zweites Team. 'side' folgt immer auf einen Pick bzw. auf 'decider' und bezieht sich auf diese Map. Ungültige Abläufe werden beim Start gemeldet und das Format ist dann nicht auswählbar. Optional pro Map: image = Bild-URL oder Pfad (z. B. /maps/de_dust2.jpg aus client/public). Standardbilder: github.com/ghostcap-gaming/cs2-map-images (Bilder aus dem Spiel, Rechte bei Valve).",
"$comment": "Map-Pools und Veto-Abläufe. Schritte: ban:X, pick:X, side:X, decider. X = erstes Team (startet das Veto), Y = zweites Team. 'side' folgt immer auf einen Pick bzw. auf 'decider' und bezieht sich auf diese Map. Welche Maps gespielt werden, wählt der Admin im Admin-Bereich (Einstellungen → Map-Pools); 'active': false = nur im Katalog. Die hier hinterlegten Abläufe gelten, wenn sie zur Anzahl der aktiven Maps passen, sonst erzeugt die Anwendung einen passenden Ablauf. Optional pro Map: image = Bild-URL oder Pfad (z. B. /maps/de_dust2.jpg aus client/public). Standardbilder: github.com/ghostcap-gaming/cs2-map-images (Bilder aus dem Spiel, Rechte bei Valve).",
"pools": {
"competitive": {
"label": "Competitive 5 gegen 5",
@@ -7,18 +7,104 @@
"gameType": 0,
"gameMode": 1,
"maps": [
{ "id": "de_ancient", "name": "Ancient", "color": "#3f8f5a", "image": "/maps/de_ancient.webp" },
{ "id": "de_anubis", "name": "Anubis", "color": "#c79a3d", "image": "/maps/de_anubis.webp" },
{ "id": "de_dust2", "name": "Dust II", "color": "#d3a45c", "image": "/maps/de_dust2.webp" },
{ "id": "de_inferno", "name": "Inferno", "color": "#c4573a", "image": "/maps/de_inferno.webp" },
{ "id": "de_mirage", "name": "Mirage", "color": "#b98a4a", "image": "/maps/de_mirage.webp" },
{ "id": "de_nuke", "name": "Nuke", "color": "#4a8fbf", "image": "/maps/de_nuke.webp" },
{ "id": "de_train", "name": "Train", "color": "#7a7f8a", "image": "/maps/de_train.webp" }
{
"id": "de_ancient",
"name": "Ancient",
"color": "#3f8f5a",
"image": "/maps/de_ancient.webp"
},
{
"id": "de_anubis",
"name": "Anubis",
"color": "#c79a3d",
"image": "/maps/de_anubis.webp"
},
{
"id": "de_cache",
"name": "Cache",
"color": "#6f8a4a",
"image": "/maps/de_cache.webp"
},
{
"id": "de_dust2",
"name": "Dust II",
"color": "#d3a45c",
"image": "/maps/de_dust2.webp"
},
{
"id": "de_inferno",
"name": "Inferno",
"color": "#c4573a",
"image": "/maps/de_inferno.webp"
},
{
"id": "de_mirage",
"name": "Mirage",
"color": "#b98a4a",
"image": "/maps/de_mirage.webp"
},
{
"id": "de_nuke",
"name": "Nuke",
"color": "#4a8fbf",
"image": "/maps/de_nuke.webp"
},
{
"id": "de_overpass",
"name": "Overpass",
"color": "#5f8f7a",
"image": "/maps/de_overpass.webp"
},
{
"id": "de_train",
"name": "Train",
"color": "#7a7f8a",
"image": "/maps/de_train.webp"
},
{
"id": "de_vertigo",
"name": "Vertigo",
"color": "#8a74b8",
"image": "/maps/de_vertigo.webp"
}
],
"flows": {
"bo1": ["ban:X", "ban:Y", "ban:X", "ban:Y", "ban:X", "ban:Y", "decider", "side:X"],
"bo3": ["ban:X", "ban:Y", "pick:X", "side:Y", "pick:Y", "side:X", "ban:X", "ban:Y", "decider", "side:X"],
"bo5": ["ban:X", "ban:Y", "pick:X", "side:Y", "pick:Y", "side:X", "pick:X", "side:Y", "pick:Y", "side:X", "decider", "side:Y"]
"bo1": [
"ban:X",
"ban:Y",
"ban:X",
"ban:Y",
"ban:X",
"ban:Y",
"decider",
"side:X"
],
"bo3": [
"ban:X",
"ban:Y",
"pick:X",
"side:Y",
"pick:Y",
"side:X",
"ban:X",
"ban:Y",
"decider",
"side:X"
],
"bo5": [
"ban:X",
"ban:Y",
"pick:X",
"side:Y",
"pick:Y",
"side:X",
"pick:X",
"side:Y",
"pick:Y",
"side:X",
"decider",
"side:Y"
]
}
},
"wingman": {
@@ -27,14 +113,48 @@
"gameType": 0,
"gameMode": 2,
"maps": [
{ "id": "de_inferno", "name": "Inferno", "color": "#c4573a", "image": "/maps/de_inferno.webp" },
{ "id": "de_nuke", "name": "Nuke", "color": "#4a8fbf", "image": "/maps/de_nuke.webp" },
{ "id": "de_overpass", "name": "Overpass", "color": "#5f8f7a", "image": "/maps/de_overpass.webp" },
{ "id": "de_vertigo", "name": "Vertigo", "color": "#8a74b8", "image": "/maps/de_vertigo.webp" }
{
"id": "de_inferno",
"name": "Inferno",
"color": "#c4573a",
"image": "/maps/de_inferno.webp"
},
{
"id": "de_nuke",
"name": "Nuke",
"color": "#4a8fbf",
"image": "/maps/de_nuke.webp"
},
{
"id": "de_overpass",
"name": "Overpass",
"color": "#5f8f7a",
"image": "/maps/de_overpass.webp"
},
{
"id": "de_vertigo",
"name": "Vertigo",
"color": "#8a74b8",
"image": "/maps/de_vertigo.webp"
}
],
"flows": {
"bo1": ["ban:X", "ban:Y", "ban:X", "decider", "side:Y"],
"bo3": ["ban:X", "pick:Y", "side:X", "pick:X", "side:Y", "decider", "side:X"]
"bo1": [
"ban:X",
"ban:Y",
"ban:X",
"decider",
"side:Y"
],
"bo3": [
"ban:X",
"pick:Y",
"side:X",
"pick:X",
"side:Y",
"decider",
"side:X"
]
}
}
}
+1 -1
View File
@@ -7,7 +7,7 @@
"scripts": {
"dev": "node --watch src/index.js",
"start": "node src/index.js",
"test": "node --test test/"
"test": "node --test"
},
"dependencies": {
"better-sqlite3": "^11.10.0",
+340
View File
@@ -0,0 +1,340 @@
import express from 'express';
import { formatPortList } from './config.js';
import { formatDockerEvent } from './servers/dockerAdapter.js';
import { HttpError } from './matchService.js';
import { createRateLimiter, randomToken, safeEqual } from './security.js';
const SESSION_HOURS = 12;
function bearer(req) {
const header = req.get('authorization') ?? '';
return header.startsWith('Bearer ') ? header.slice(7).trim() : null;
}
/**
* Admin-Modus unter /api/admin. Anmeldung mit ADMIN_PASSWORD, danach ein zufälliger Sitzungs-Token
* (nur im Speicher, verfällt nach 12 Stunden oder beim Neustart). Admin-Aktionen nutzen die bestehenden
* Organisator-Funktionen mit einem Admin-Zugang, die Berechtigungen bleiben also an einer Stelle.
*/
export function createAdminRouter({ service, manager, config, settings, knife, pools }) {
const router = express.Router();
const sessions = new Map(); // Token -> Ablaufzeit (ms)
const loginLimit = createRateLimiter({ windowMs: 60_000, max: 5 });
const wrap = (fn) => async (req, res, next) => {
try {
const result = await fn(req, res);
res.json(result ?? { ok: true });
} catch (err) {
next(err);
}
};
const requireEnabled = (req, res, next) => {
if (config.adminPassword) return next();
return next(new HttpError(404, 'Admin-Modus ist nicht eingerichtet (ADMIN_PASSWORD fehlt)', 'admin_disabled'));
};
const requireAdmin = (req, res, next) => {
const token = bearer(req);
const expires = token && sessions.get(token);
if (expires && expires > Date.now()) return next();
if (token) sessions.delete(token);
return next(new HttpError(401, 'Admin-Anmeldung erforderlich', 'unauthorized'));
};
// Admin-Zugang für eine Match-Aktion: wie ein Organisator, bei Bedarf als Captain eines Teams
const adminViewer = (matchId, extra = {}) => ({
matchId,
role: 'organizer',
isOrganizer: true,
isCaptain: false,
isSpectator: false,
playerId: null,
team: null,
...extra,
});
router.use(requireEnabled);
router.post('/login', (req, res, next) => {
if (!loginLimit(`admin:${req.ip}`)) {
return next(new HttpError(429, 'Zu viele Anmeldeversuche – bitte eine Minute warten', 'rate_limited'));
}
if (!safeEqual(String(req.body?.password ?? ''), config.adminPassword)) {
return next(new HttpError(401, 'Falsches Passwort', 'unauthorized'));
}
const token = randomToken();
const expiresAt = Date.now() + SESSION_HOURS * 3_600_000;
// Abgelaufene Anmeldungen entfernen, damit die Liste nicht wächst
for (const [t, until] of sessions) if (until < Date.now()) sessions.delete(t);
sessions.set(token, expiresAt);
return res.json({ token, expiresAt });
});
router.post('/logout', (req, res) => {
const token = bearer(req);
if (token) sessions.delete(token);
res.json({ ok: true });
});
router.get('/session', requireAdmin, (req, res) => res.json({ ok: true }));
// Discord-Meldungen: welche Ereignisse gemeldet werden
// Gameserver-Einstellungen: Vorgaben aus der .env, vom Admin überschreibbar
router.get('/server-settings', requireAdmin, wrap(() => ({ fields: settings.view() })));
router.put('/server-settings', requireAdmin, wrap(async (req) => {
const changed = settings.update(req.body?.values ?? {});
if (changed.includes('vetoStepSeconds')) service.startVetoTimer();
const live = changed.length ? await manager.applyLiveSettings(changed) : { updated: 0, failed: [] };
return { fields: settings.view(), changed, live };
}));
// Docker-Hosts: Liste wie CS2_DOCKER_HOSTS, gespeichert in der Datenbank
const hostView = () => ({
hosts: (manager.adapter.hosts ?? []).map((h) => ({
name: h.name,
dockerHost: h.dockerHost,
publicHost: h.publicHost,
rconHost: h.rconHost,
portStart: h.portRangeStart,
portEnd: h.portRangeEnd,
volume: h.volume,
lanPorts: formatPortList(h.lanPorts),
})),
overridden: settings.hostsOverridden(),
editable: !!manager.adapter.setHosts,
image: config.docker.image,
pull: manager.adapter.pullStatus?.() ?? null,
files: manager.adapter.filesStatus?.() ?? null,
});
router.get('/docker-hosts', requireAdmin, wrap(() => hostView()));
router.put('/docker-hosts', requireAdmin, wrap((req) => {
const list = req.body?.hosts;
const hosts = settings.parseHosts(list);
manager.setDockerHosts(hosts);
settings.saveHosts(list, hosts);
return hostView();
}));
router.delete('/docker-hosts', requireAdmin, wrap(() => {
manager.setDockerHosts(settings.defaultHosts);
settings.resetHosts();
return hostView();
}));
// Image auf allen Hosts neu laden. Läuft im Hintergrund, Stand über GET /docker-hosts.
// Spieldateien auf allen Hosts per SteamCMD aktualisieren. Hosts mit laufenden Servern werden ausgelassen,
// außer mit { force: true }. Läuft im Hintergrund, Stand über GET /docker-hosts.
router.post('/docker-hosts/update-files', requireAdmin, wrap((req) => {
if (!manager.adapter.updateGameFiles) throw new HttpError(409, 'Nur mit dem Docker-Adapter möglich', 'unsupported');
if (manager.adapter.filesStatus().running) throw new HttpError(409, 'Die Spieldateien werden bereits aktualisiert', 'busy');
const skip = new Map();
if (!req.body?.force) {
const byAddress = new Map(manager.adapter.hosts.map((h) => [h.publicHost, h.name]));
const busy = new Map();
for (const s of manager.activeServers()) {
const name = s.hostName ?? byAddress.get(s.host);
if (name) busy.set(name, (busy.get(name) ?? 0) + 1);
}
for (const k of knife.rows()) {
if (!k.handle) continue;
let name = byAddress.get(k.host);
try {
name = JSON.parse(k.handle).hostName ?? name;
} catch {
// kaputter Handle: Adresse reicht
}
if (name) busy.set(name, (busy.get(name) ?? 0) + 1);
}
for (const [name, n] of busy) skip.set(name, `ausgelassen: ${n} laufende${n === 1 ? 'r' : ''} Server`);
}
manager.adapter.updateGameFiles({ skip }).catch(() => {});
return hostView();
}));
router.post('/docker-hosts/pull', requireAdmin, wrap(() => {
if (!manager.adapter.pullImage) throw new HttpError(409, 'Nur mit dem Docker-Adapter möglich', 'unsupported');
if (manager.adapter.pullStatus().running) throw new HttpError(409, 'Das Image wird bereits aktualisiert', 'busy');
manager.adapter.pullImage().catch(() => {});
return hostView();
}));
// Map-Pools: welche Maps gespielt werden. Der Veto-Ablauf passt sich der Anzahl an.
const poolView = () => ({
pools: Object.values(pools).map((p) => ({
mode: p.mode,
label: p.label,
maps: p.allMaps.map((m) => ({ ...m, active: p.maps.some((a) => a.id === m.id) })),
formats: p.formats,
})),
});
router.get('/map-pools', requireAdmin, wrap(() => poolView()));
router.put('/map-pools', requireAdmin, wrap((req) => {
settings.saveMapSelection(pools, req.body?.mode, req.body?.maps);
return poolView();
}));
// Messer-Server für die Seitenwahl
router.get('/knife', requireAdmin, wrap(() => ({
servers: knife.list(),
modes: knife.modes(),
maxServers: config.knife.maxServers,
})));
router.post('/knife', requireAdmin, wrap((req) => knife.create({ map: req.body?.map, mode: req.body?.mode })));
router.post('/knife/:id/reset', requireAdmin, wrap((req) => knife.reset(req.params.id)));
router.post('/knife/:id/round', requireAdmin, wrap((req) => knife.startRound(req.params.id)));
router.post('/knife/:id/restart', requireAdmin, wrap((req) => knife.restart(req.params.id)));
router.delete('/knife/:id', requireAdmin, wrap((req) => knife.remove(req.params.id)));
router.get('/notifications', requireAdmin, wrap(() => service.notificationSettings()));
router.put('/notifications', requireAdmin, wrap((req) => service.setNotificationEvents(req.body?.events)));
router.post('/notifications/test', requireAdmin, wrap(async () => {
if (!service.notifier.enabled) throw new HttpError(409, 'Kein Discord-Webhook eingerichtet (DISCORD_WEBHOOK_URL)', 'not_configured');
const test = { embeds: [{ title: '🧪 Testmeldung', description: 'Die Verbindung zu CS2 Mapveto funktioniert.', color: 0x3b82f6 }] };
if (!(await service.notifier.send(test))) {
throw new HttpError(502, 'Discord hat die Meldung nicht angenommen (siehe Log)', 'send_failed');
}
return { ok: true };
}));
// Alle Matches mit allen Details (Codes, Verbindungsdaten, Spieler, Veto, Serie, Ereignisse)
// Konsolenausgabe eines Containers live (Match-Server oder Messer-Server). Passwörter werden ausgeblendet.
let openStreams = 0;
router.get('/logs/:kind/:id', requireAdmin, (req, res, next) => {
try {
const { kind, id } = req.params;
const row = kind === 'match' ? manager.row(id) : kind === 'knife' ? knife.row(id) : null;
if (!row) throw new HttpError(404, 'Server nicht gefunden', 'not_found');
if (!row.handle) throw new HttpError(409, 'Für diesen Server gibt es noch keinen Container', 'no_container');
if (!manager.adapter.streamLogs) throw new HttpError(501, 'Dieser Adapter hat keine Konsolenausgabe', 'unsupported');
if (openStreams >= 8) throw new HttpError(429, 'Zu viele offene Konsolen, erst eine schließen', 'rate_limited');
const tail = Math.max(0, Math.min(2000, Number.parseInt(req.query.tail, 10) || 200));
const secrets = [row.rcon_password, row.server_password, row.tv_password].filter((s) => s && s.length >= 4);
const mask = (text) => secrets.reduce((t, s) => t.split(s).join('••••'), text);
const proc = manager.adapter.streamLogs(JSON.parse(row.handle), { tail });
openStreams += 1;
res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no' });
// Zeilenweise ausgeben, damit ein Passwort nie an einer Paketgrenze zerrissen und so doch sichtbar wird
const pipe = (stream) => {
let rest = '';
stream.setEncoding?.('utf8');
stream.on('data', (chunk) => {
const lines = (rest + chunk).split('\n');
rest = lines.pop();
if (lines.length) res.write(mask(lines.join('\n')) + '\n');
});
stream.on('end', () => { if (rest) res.write(mask(rest) + '\n'); });
};
pipe(proc.stdout);
pipe(proc.stderr);
let done = false;
const finish = (note) => {
if (done) return;
done = true;
openStreams -= 1;
if (note && !res.writableEnded) res.end(`\n[${note}]\n`);
};
proc.on('close', () => finish('Ausgabe beendet'));
proc.on?.('error', (err) => finish(`Fehler: ${err.message}`));
req.on('close', () => {
proc.kill();
finish(null);
});
} catch (err) {
next(err);
}
});
// Live-Log eines Docker-Hosts: seine Ereignisse (Container gestartet, beendet, entfernt, Images geladen …). Nur lesen.
router.get('/host-log/:name', requireAdmin, (req, res, next) => {
try {
if (!manager.adapter.streamEvents) throw new HttpError(501, 'Nur mit dem Docker-Adapter möglich', 'unsupported');
if (openStreams >= 8) throw new HttpError(429, 'Zu viele offene Konsolen, erst eine schließen', 'rate_limited');
const since = Math.max(0, Math.min(24 * 60, Number.parseInt(req.query.since, 10) || 120));
const proc = manager.adapter.streamEvents(req.params.name, { sinceMinutes: since });
if (!proc) throw new HttpError(404, 'Docker-Host nicht gefunden', 'not_found');
openStreams += 1;
res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no' });
res.write(`Ereignisse von ${req.params.name} seit ${since} Minuten, neue erscheinen live.\n`);
let rest = '';
proc.stdout.setEncoding('utf8');
proc.stdout.on('data', (chunk) => {
const lines = (rest + chunk).split('\n');
rest = lines.pop();
const out = lines.map(formatDockerEvent).filter(Boolean);
if (out.length) res.write(out.join('\n') + '\n');
});
proc.stderr.setEncoding('utf8');
proc.stderr.on('data', (chunk) => res.write(`[docker] ${chunk}`));
let done = false;
const finish = (note) => {
if (done) return;
done = true;
openStreams -= 1;
if (note && !res.writableEnded) res.end(`\n[${note}]\n`);
};
proc.on('close', () => finish('Ausgabe beendet'));
proc.on('error', (err) => finish(`Fehler: ${err.message}`));
req.on('close', () => {
proc.kill();
finish(null);
});
} catch (err) {
next(err);
}
});
// Auslastung und Speicher der Docker-Hosts mit den Servern darauf
router.get('/hosts', requireAdmin, wrap(async () => ({ hosts: await manager.hostUsage() })));
// ?archived=1 liefert das Archiv, sonst alle nicht archivierten Matches
router.get('/matches', requireAdmin, wrap(async (req) => {
const archived = req.query.archived === '1';
const ids = service.db.prepare(archived
? 'SELECT id FROM matches WHERE archived_at IS NOT NULL ORDER BY archived_at DESC LIMIT 300'
: 'SELECT id FROM matches WHERE archived_at IS NULL ORDER BY created_at DESC LIMIT 150').all();
const archivedCount = service.db.prepare('SELECT COUNT(*) AS n FROM matches WHERE archived_at IS NOT NULL').get().n;
return {
matches: ids.map(({ id }) => service.snapshot(id, adminViewer(id))),
archivedCount,
};
}));
const action = (path, fn) => router.post(`/matches/:id/${path}`, requireAdmin, wrap((req) => {
service.getMatch(req.params.id);
return fn(adminViewer(req.params.id), req.body ?? {}, req);
}));
action('finish', (v) => service.close(v, 'finished'));
action('cancel', (v) => service.close(v, 'cancelled'));
action('server/stop', (v) => manager.stopByOrganizer(v));
action('server/retry', (v) => manager.retry(v));
action('server/warmup-end', (v) => manager.endWarmup(v));
action('archive', (v) => service.setArchived(v.matchId, true));
action('unarchive', (v) => service.setArchived(v.matchId, false));
action('server/pause', (v) => manager.setPaused(v, true));
action('server/resume', (v) => manager.setPaused(v, false));
action('series/next', (v) => manager.loadNextMap(v));
action('veto/start', (v, b) => service.startVeto(v, { firstTeam: b.firstTeam }));
action('veto/action', (v, b) => service.vetoAction(
adminViewer(v.matchId, { team: b.team, isCaptain: true, role: 'captain' }),
{ stepIndex: b.stepIndex, map: b.map, side: b.side },
));
action('kick', (v, b) => service.kick(v, b.playerId));
action('codes/regenerate', (v, b) => service.regenerateCode(v, b.target));
// Rollback: nur für Admins. Diese Routen gibt es für Organisatoren nicht.
router.get('/matches/:id/rollback-points', requireAdmin, wrap(async (req) => {
service.getMatch(req.params.id);
return { points: await manager.rollbackPoints(adminViewer(req.params.id)) };
}));
action('rollback', (v, b) => manager.rollback(v, b.file));
// Match endgültig löschen (Server wird vorher freigegeben)
router.delete('/matches/:id', requireAdmin, wrap(async (req) => {
await manager.deleteMatch(req.params.id);
return { ok: true };
}));
return router;
}
+24 -4
View File
@@ -5,22 +5,36 @@ import express from 'express';
import { REPO_ROOT } from './config.js';
import { openDatabase } from './db.js';
import { createApiRouter } from './http.js';
import { createDiscordNotifier } from './discord.js';
import { loadMapPools } from './mapPools.js';
import { MatchService } from './matchService.js';
import { attachRealtime } from './realtime.js';
import { createServerAdapter } from './servers/adapter.js';
import { ServerManager } from './servers/serverManager.js';
import { KnifeServers } from './servers/knifeServers.js';
import { ServerSettings } from './serverSettings.js';
/** Baut die komplette Anwendung zusammen (auch für Tests nutzbar). */
export function createApp(config, { adapter: adapterOverride, logger = console } = {}) {
const db = openDatabase(config.databaseFile);
// Vom Admin geänderte Gameserver-Einstellungen auf die Konfiguration legen, bevor der Adapter entsteht
const settings = new ServerSettings({ db, config });
settings.load();
const pools = loadMapPools(config.mapPoolFile);
const service = new MatchService({ db, pools, config });
// Im Admin-Bereich gewählte Maps (Einstellungen → Map-Pools)
settings.applyMapSelection(pools);
const notifier = createDiscordNotifier(config.discordWebhookUrl, { logger });
const service = new MatchService({ db, pools, config, notifier });
let manager;
const adapter = adapterOverride ?? createServerAdapter(config, { getUsedPorts: () => manager.usedPorts() });
const adapter = adapterOverride ?? createServerAdapter(config, {
getUsedPorts: (host) => (host ? manager.usedPortsOn(host) : manager.usedPorts()),
getStarting: (host, exclude) => manager.startingOn(host, exclude),
});
manager = new ServerManager({ db, service, adapter, config, logger });
manager.init();
const knife = new KnifeServers({ db, adapter, config, pools, logger });
service.startVetoTimer();
const app = express();
if (config.trustProxy) app.set('trust proxy', 1);
@@ -30,7 +44,7 @@ export function createApp(config, { adapter: adapterOverride, logger = console }
res.set('Referrer-Policy', 'no-referrer');
next();
});
app.use('/api', createApiRouter({ service, manager, config, pools, adapter }));
app.use('/api', createApiRouter({ service, manager, config, pools, adapter, settings, knife }));
// Gebautes Frontend ausliefern (npm run build), SPA-Fallback auf index.html.
const dist = path.join(REPO_ROOT, 'client', 'dist');
@@ -43,15 +57,21 @@ export function createApp(config, { adapter: adapterOverride, logger = console }
const io = attachRealtime(server, { service, config });
return {
app, server, io, db, service, manager, adapter,
app, server, io, db, service, manager, adapter, knife,
async start() {
await manager.resume();
manager.startLoops();
knife.startLoop();
await new Promise((resolve) => server.listen(config.port, resolve));
return server.address().port;
},
async close() {
manager.stopLoops();
knife.stopLoop();
service.stopVetoTimer();
// Laufende Server-Aktionen (Start, Konfiguration) kurz abwarten, bevor die Datenbank schließt
await manager.idle(5000);
io.disposeRealtime();
io.close();
await new Promise((resolve) => server.close(() => resolve()));
db.close();
+90
View File
@@ -22,6 +22,61 @@ function bool(name, fallback) {
return ['1', 'true', 'yes', 'ja', 'on'].includes(raw.toLowerCase());
}
/** Portliste aus Text wie "27015-27020" oder "27016,27018-27019". Leer = keine Ports. */
export function parsePortList(value, label = 'LAN-Ports') {
if (Array.isArray(value)) value = value.join(',');
const ports = [];
for (const part of String(value ?? '').split(',').map((p) => p.trim()).filter(Boolean)) {
const m = /^(\d+)(?:\s*-\s*(\d+))?$/.exec(part);
if (!m) throw new Error(`${label}: "${part}" ist kein Port oder Bereich`);
const from = Number(m[1]);
const to = Number(m[2] ?? m[1]);
if (from < 1024 || to > 65535 || to < from || to - from > 100) throw new Error(`${label}: Bereich ${part} ist ungültig`);
for (let p = from; p <= to; p += 1) if (!ports.includes(p)) ports.push(p);
}
return ports;
}
/** Portliste kompakt als Text, z. B. [27015, 27016, 27017] -> "27015-27017". */
export function formatPortList(ports = []) {
const sorted = [...ports].sort((a, b) => a - b);
const parts = [];
for (let i = 0; i < sorted.length; i += 1) {
let j = i;
while (j + 1 < sorted.length && sorted[j + 1] === sorted[j] + 1) j += 1;
parts.push(i === j ? String(sorted[i]) : `${sorted[i]}-${sorted[j]}`);
i = j;
}
return parts.join(',');
}
export function parseDockerHosts(raw) {
if (!raw) return null;
let list;
try {
list = JSON.parse(raw);
} catch {
throw new Error('CS2_DOCKER_HOSTS ist kein gültiges JSON');
}
if (!Array.isArray(list) || list.length === 0) throw new Error('CS2_DOCKER_HOSTS braucht mindestens einen Eintrag');
const names = new Set();
return list.map((h) => {
if (!h.name || !h.publicHost) throw new Error('Jeder Docker-Host braucht name und publicHost');
if (names.has(h.name)) throw new Error(`Docker-Host-Name "${h.name}" kommt doppelt vor`);
names.add(h.name);
return {
name: String(h.name),
dockerHost: h.dockerHost ?? null,
publicHost: String(h.publicHost),
rconHost: h.rconHost ?? h.publicHost,
portRangeStart: h.portStart == null ? undefined : Number(h.portStart),
portRangeEnd: h.portEnd == null ? undefined : Number(h.portEnd),
volume: h.volume ?? undefined,
lanPorts: h.lanPorts === undefined ? undefined : parsePortList(h.lanPorts, `LAN-Ports von ${h.name}`),
};
});
}
function str(name, fallback = '') {
const raw = process.env[name];
return raw === undefined || raw === '' ? fallback : raw;
@@ -31,17 +86,40 @@ export function loadConfig(overrides = {}) {
const config = {
port: int('PORT', 3000),
publicUrl: str('PUBLIC_URL', 'http://localhost:5173').replace(/\/$/, ''),
// Adresse, unter der der CS2-Server dieses Backend für Live-Logs erreicht (Standard: PUBLIC_URL)
logReceiverUrl: str('LOG_RECEIVER_URL', str('PUBLIC_URL', 'http://localhost:3000')).replace(/\/$/, ''),
logDebug: bool('LOG_DEBUG', false),
// Sekunden nach Map-Ende bis zur nächsten Map bzw. zum Abschluss der Serie; -1 = nur manuell
autoAdvanceSeconds: int('SERIES_AUTO_ADVANCE_SECONDS', 30),
databaseFile: path.resolve(REPO_ROOT, str('DATABASE_FILE', 'data/cs2-mapveto.db')),
mapPoolFile: path.resolve(REPO_ROOT, str('MAP_POOL_FILE', 'server/config/map-pools.json')),
createMatchPassword: str('CREATE_MATCH_PASSWORD'),
// Optionaler Schlüssel für die Stream-Übersicht aller Matches (/stream?key=…); leer = offen
streamKey: str('STREAM_KEY'),
discordWebhookUrl: str('DISCORD_WEBHOOK_URL'),
// Passwort für den Admin-Modus (/admin). Leer = Admin-Modus ist aus.
adminPassword: str('ADMIN_PASSWORD'),
requireFullTeams: bool('REQUIRE_FULL_TEAMS', false),
trustProxy: bool('TRUST_PROXY', false),
serverAdapter: str('SERVER_ADAPTER', 'demo'),
idleTimeoutMinutes: int('SERVER_IDLE_TIMEOUT_MINUTES', 30),
maxLifetimeHours: int('SERVER_MAX_LIFETIME_HOURS', 8),
// Offene Lobbys (Veto nie gestartet) werden nach so vielen Stunden gelöscht. 0 = nie
lobbyMaxHours: int('LOBBY_MAX_HOURS', 3),
statusPollSeconds: int('SERVER_STATUS_POLL_SECONDS', 5),
reaperIntervalSeconds: int('SERVER_REAPER_INTERVAL_SECONDS', 60),
// Frist pro Veto-Schritt in Sekunden, danach führt das Backend den Schritt automatisch aus. 0 = keine Frist
vetoStepSeconds: int('VETO_STEP_SECONDS', 30),
// Messer-Server für die Seitenwahl (im Admin-Bereich startbar)
knife: {
map: str('KNIFE_MAP', 'de_mirage'),
// Zeit zum Verbinden, bevor die Messerrunde beginnt
warmupSeconds: int('KNIFE_WARMUP_SECONDS', 90),
maxServers: int('KNIFE_MAX_SERVERS', 4),
},
// Abgeschlossene oder abgebrochene Matches nach so vielen Tagen automatisch archivieren. 0 = nie
archiveAfterDays: int('ARCHIVE_AFTER_DAYS', 3),
match: {
tvDelaySeconds: int('CS2_TV_DELAY', 90),
@@ -58,6 +136,8 @@ export function loadConfig(overrides = {}) {
demo: {
startSeconds: int('DEMO_START_SECONDS', 20),
failFirstConfigure: bool('DEMO_FAIL_FIRST_CONFIGURE', false),
// Sekunden pro simulierter Runde nach "Aufwärmphase beenden"
roundSeconds: int('DEMO_ROUND_SECONDS', 4),
},
docker: {
@@ -71,6 +151,16 @@ export function loadConfig(overrides = {}) {
volume: str('CS2_DOCKER_VOLUME', 'cs2-mapveto-gamefiles'),
extraArgs: str('CS2_DOCKER_EXTRA_ARGS'),
startTimeoutMinutes: int('CS2_START_TIMEOUT_MINUTES', 20),
// Spielports, die der Steam-Serverbrowser im LAN-Reiter absucht (Steam fragt nur 27015–27020 ab).
// Diese Ports werden zuerst vergeben, CSTV kommt dann aus dem normalen Portbereich. Leer = aus.
lanPorts: parsePortList(str('CS2_LAN_PORTS', '27015-27020'), 'CS2_LAN_PORTS'),
// Abstand des CSTV-Ports zum Spielport, z. B. Spiel 27015 -> CSTV 27115
tvPortOffset: int('CS2_TV_PORT_OFFSET', 100),
// Anfang des Servernamens im Serverbrowser, danach folgen die Teamnamen
serverNamePrefix: str('CS2_SERVERNAME_PREFIX', 'Maxlan'),
// Optional: mehrere Docker-Hosts als JSON-Liste, z. B.
// [{"name":"host1","dockerHost":"ssh://cs2@192.168.178.111","publicHost":"192.168.178.111","portStart":27100,"portEnd":27139}]
hosts: parseDockerHosts(str('CS2_DOCKER_HOSTS')),
},
staticPool: {
+65
View File
@@ -97,6 +97,45 @@ CREATE TABLE IF NOT EXISTS events (
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS events_match ON events(match_id, id);
-- Messer-Server für die Seitenwahl: laufen dauerhaft, gehören keinem Match
CREATE TABLE IF NOT EXISTS knife_servers (
id TEXT PRIMARY KEY,
number INTEGER NOT NULL,
name TEXT NOT NULL,
mode TEXT NOT NULL DEFAULT 'competitive',
map TEXT NOT NULL,
status TEXT NOT NULL,
message TEXT,
handle TEXT,
host TEXT,
port INTEGER,
tv_port INTEGER,
rcon_password TEXT NOT NULL,
player_count INTEGER,
restarts INTEGER NOT NULL DEFAULT 0,
started_at INTEGER,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
-- Chat im Match: channel = 'match' (beide Teams + Organisator) oder 'A' / 'B' (nur das eigene Team)
CREATE TABLE IF NOT EXISTS chat_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
match_id TEXT NOT NULL REFERENCES matches(id) ON DELETE CASCADE,
channel TEXT NOT NULL,
player_id TEXT,
author TEXT NOT NULL,
team TEXT,
organizer INTEGER NOT NULL DEFAULT 0,
text TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS chat_by_match ON chat_messages (match_id, channel, id);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
`;
export function openDatabase(file) {
@@ -105,5 +144,31 @@ export function openDatabase(file) {
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
db.exec(SCHEMA);
migrate(db);
return db;
}
/** Ergänzt Spalten, die in älteren Datenbanken noch fehlen. */
function migrate(db) {
const columns = (table) => new Set(db.prepare(`PRAGMA table_info(${table})`).all().map((c) => c.name));
const seriesColumns = columns('series_maps');
const add = [
['score_a', 'INTEGER NOT NULL DEFAULT 0'],
['score_b', 'INTEGER NOT NULL DEFAULT 0'],
['winner', 'TEXT'],
// Live-Zustand aus den Serverlogs (JSON, siehe liveScore.js)
['live', 'TEXT'],
['live_updated_at', 'INTEGER'],
];
for (const [name, type] of add) {
if (!seriesColumns.has(name)) db.exec(`ALTER TABLE series_maps ADD COLUMN ${name} ${type}`);
}
const serverColumns = columns('servers');
if (!serverColumns.has('paused')) db.exec('ALTER TABLE servers ADD COLUMN paused INTEGER NOT NULL DEFAULT 0');
// Seitenwahl per Messerrunde auf dem Match-Server: wann sie eingerichtet wurde und was gerade läuft
if (!serverColumns.has('knife_at')) db.exec('ALTER TABLE servers ADD COLUMN knife_at INTEGER');
if (!serverColumns.has('knife_phase')) db.exec('ALTER TABLE servers ADD COLUMN knife_phase TEXT');
// Abgeschlossene Matches lassen sich archivieren; sie verschwinden dann aus der normalen Admin-Liste
if (!columns('matches').has('archived_at')) db.exec('ALTER TABLE matches ADD COLUMN archived_at INTEGER');
if (!columns('knife_servers').has('mode')) db.exec("ALTER TABLE knife_servers ADD COLUMN mode TEXT NOT NULL DEFAULT 'competitive'");
}
+48
View File
@@ -0,0 +1,48 @@
// Meldungen an einen Discord-Webhook, z. B. für Streamer. Fehler werden nur protokolliert
// und bremsen den Matchablauf nie aus.
const WEBHOOK_PATTERN = /^https:\/\/(discord\.com|discordapp\.com)\/api\/webhooks\/\d+\/[\w-]+$/;
const TIMEOUT_MS = 5000;
const MAX_LENGTH = 1900;
/** Ereignisse, die gemeldet werden können. Alle sind standardmäßig aktiv. */
export const NOTIFY_EVENTS = [
{ key: 'veto_start', label: 'Veto gestartet' },
{ key: 'match_start', label: 'Match gestartet (Aufwärmphase beendet)' },
{ key: 'map_end', label: 'Map beendet (mit Ergebnis)' },
{ key: 'series_end', label: 'Serie entschieden' },
{ key: 'match_closed', label: 'Match abgeschlossen oder abgebrochen' },
{ key: 'server_error', label: 'Serverfehler' },
];
/**
* @param {string} url Webhook-URL aus DISCORD_WEBHOOK_URL, leer = keine Meldungen
* @param {object} options fetchImpl und logger, vor allem für Tests
*/
export function createDiscordNotifier(url, { fetchImpl = fetch, logger = console } = {}) {
if (!url) return { enabled: false, send: async () => false };
// Die URL enthält ein Geheimnis. Sie wird deshalb nie in Fehlermeldungen ausgegeben.
if (!WEBHOOK_PATTERN.test(url)) throw new Error('DISCORD_WEBHOOK_URL ist keine gültige Discord-Webhook-URL');
return {
enabled: true,
/** Liefert true, wenn Discord die Nachricht angenommen hat. */
async send(message) {
// Text oder vollständige Discord-Payload (z. B. mit embeds)
const payload = typeof message === 'string' ? { content: message.slice(0, MAX_LENGTH) } : message;
try {
const res = await fetchImpl(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
signal: AbortSignal.timeout(TIMEOUT_MS),
});
if (res.ok) return true;
logger.warn(`Discord-Webhook antwortet mit HTTP ${res.status}`);
} catch (err) {
logger.warn(`Discord-Meldung fehlgeschlagen: ${err.name}`);
}
return false;
},
};
}
+33 -2
View File
@@ -2,6 +2,7 @@ import express from 'express';
import { HttpError } from './matchService.js';
import { publicPools } from './mapPools.js';
import { createRateLimiter, safeEqual } from './security.js';
import { createAdminRouter } from './adminRoutes.js';
function bearer(req) {
const header = req.get('authorization') ?? '';
@@ -13,9 +14,25 @@ function accessTokens(req) {
return [bearer(req), req.get('x-player-token')?.trim() || null];
}
export function createApiRouter({ service, manager, config, pools, adapter }) {
export function createApiRouter({ service, manager, config, pools, adapter, settings, knife }) {
const router = express.Router();
// Live-Logs vom CS2-Server (logaddress_add_http). Vor dem JSON-Parser, weil CS2 Klartext sendet.
// Der Schlüssel in der URL ist pro Server und Match zufällig und nur im Backend bekannt.
router.post('/logs/:id/:key', express.text({ type: () => true, limit: '2mb' }), (req, res) => {
try {
const body = typeof req.body === 'string' ? req.body : '';
// LOG_DEBUG=true: empfangene Zeilen ausgeben, z. B. um das Logformat eines Servers zu prüfen
if (config.logDebug) console.log(`[logs ${req.params.id}] ${body.trim().replace(/\n/g, `\n[logs ${req.params.id}] `)}`);
service.ingestLogs(req.params.id, req.params.key, body);
res.status(200).end();
} catch (err) {
res.status(err.status ?? 500).end();
}
});
router.use(express.json({ limit: '32kb' }));
router.use('/admin', createAdminRouter({ service, manager, config, settings, knife, pools }));
const limiters = {
create: createRateLimiter({ windowMs: 60_000, max: 10 }),
@@ -54,6 +71,17 @@ export function createApiRouter({ service, manager, config, pools, adapter }) {
router.get('/matches', wrap(() => ({ matches: service.listMatches() })));
// Messer-Server für die Seitenwahl: offen, ohne Passwort, deshalb für alle sichtbar
router.get('/knife-servers', wrap(() => ({ servers: knife.list().filter((k) => k.status === 'running') })));
// Stream-Übersicht aller Matches; mit STREAM_KEY nur mit passendem ?key=
router.get('/stream/overview', wrap((req) => {
// Globaler STREAM_KEY: alle Matches; Match-Schlüssel (vom Organisator): nur dieses Match
const access = service.streamAccess(typeof req.query.key === 'string' ? req.query.key : '');
if (!access) throw new HttpError(403, 'Für die Stream-Übersicht wird der Stream-Schlüssel benötigt', 'forbidden');
return service.streamOverview({ matchId: access.matchId });
}));
router.post('/matches', limit('create'), wrap((req) => {
const { teamA, teamB, mode, format, vetoStart, password } = req.body ?? {};
if (config.createMatchPassword && !safeEqual(password ?? '', config.createMatchPassword)) {
@@ -85,15 +113,18 @@ export function createApiRouter({ service, manager, config, pools, adapter }) {
const action = (path, handler) => router.post(`/matches/:id/${path}`, limit('action'), wrap((req) => handler(viewerFor(req), req.body ?? {})));
action('ready', (v, b) => service.setReady(v, Boolean(b.ready)));
action('chat', (v, b) => service.postChat(v, { channel: b.channel, text: b.text }));
action('leave', (v) => service.leave(v));
action('kick', (v, b) => service.kick(v, b.playerId));
action('captain', (v, b) => service.setCaptain(v, b.playerId));
action('codes/regenerate', (v, b) => service.regenerateCode(v, b.target));
action('veto/start', (v) => service.startVeto(v));
action('veto/start', (v, b) => service.startVeto(v, { firstTeam: b.firstTeam }));
action('veto/action', (v, b) => service.vetoAction(v, b));
action('server/retry', (v) => manager.retry(v));
action('server/stop', (v) => manager.stopByOrganizer(v));
action('server/warmup-end', (v) => manager.endWarmup(v));
action('server/knife-start', (v) => manager.knifeAction(v, 'start'));
action('server/knife-reset', (v) => manager.knifeAction(v, 'reset'));
action('series/next', (v) => manager.loadNextMap(v));
action('finish', (v) => service.close(v, 'finished'));
action('cancel', (v) => service.close(v, 'cancelled'));
+173
View File
@@ -0,0 +1,173 @@
// Reine Auswertung von CS2-Serverlogs (logaddress_add_http) zu Live-Spielständen.
// Kein Datenbank- oder Netzwerkzugriff, damit sich die Logik mit Beispielzeilen testen lässt.
//
// Ausgewertete Zeilen (Präfix mit Datum/Uhrzeit wird ignoriert):
// World triggered "Match_Start" on "de_mirage"
// MatchStatus: Team playing "CT": MaxLAN Blau
// Team "TERRORIST" triggered "SFUI_Notice_Terrorists_Win" (CT "3") (T "5")
// World triggered "Warmup_Start"
// Game Over: competitive mg_active de_mirage score 13:9 after 35 min
// Game Over: scrimcomp2v2 de_inferno score 1:3 after 3 min (Wingman: leere Map-Gruppe, zwei Leerzeichen)
// Alle Muster gelten für die ganze Zeile nach dem Zeitstempel. Spielerzeilen (z. B. Chat:
// "Name<2><[U:1:1]><CT>" say "Game Over: …") beginnen mit einem Anführungszeichen und passen nie.
const RE = {
matchStart: /^World triggered "Match_Start" on "([^"]+)"$/,
warmupStart: /^World triggered "Warmup_Start"$/,
worldRoundEnd: /^World triggered "Round_End"$/,
teamPlaying: /^(?:MatchStatus: )?Team playing "(CT|TERRORIST)": (.+?)\s*$/,
roundEnd: /^Team "(CT|TERRORIST)" triggered "SFUI_Notice_(\w+)" \(CT "(\d+)"\) \(T "(\d+)"\)$/,
// Das Wort direkt vor "score" ist die Map – Modus und Map-Gruppe davor sind beliebig bzw. leer.
gameOver: /^Game Over: [\w ]*?(\S+)\s+score\s+(\d+):(\d+) after \d+ min$/,
};
// Zeitstempel am Zeilenanfang: "10/25/2026 - 18:07:52.396 - " (HTTP-Log) oder "L 10/25/2026 - 18:07:52: " (Datei)
const PREFIX = /^(?:L\s+)?\d{2}\/\d{2}\/\d{4}\s+-\s+\d{2}:\d{2}:\d{2}(?:\.\d+)?\s*[-:]\s*/;
/** Zeile ohne Zeitstempel, so wie sie die Muster oben erwarten. */
export function logPayload(line) {
return String(line).trim().replace(PREFIX, '');
}
// Gründe für ein Rundenende aus den SFUI-Hinweisen
const REASONS = {
Target_Bombed: 'bomb',
Bomb_Defused: 'defuse',
Target_Saved: 'time',
CTs_Win: 'elimination',
Terrorists_Win: 'elimination',
Terrorists_Surrender: 'surrender',
CTs_Surrender: 'surrender',
};
const other = (team) => (team === 'A' ? 'B' : 'A');
const normalizeName = (name) => String(name ?? '').trim().toLowerCase();
/**
* Neuer Live-Zustand einer Map. ctTeam = Team, das laut Veto auf CT startet.
* warmupEnded: erst danach zählt ein Match_Start (CS2 sendet ihn auch beim Laden der Map, vor der Aufwärmphase).
*/
export function initialLiveState({ map, ctTeam, warmupEnded = false }) {
const startCt = ctTeam ?? 'A';
return {
map,
status: 'warmup',
warmupEnded,
startCt,
sides: { CT: startCt, T: other(startCt) },
sidesFromLog: false,
score: { A: 0, B: 0 },
rounds: [],
winner: null,
pendingRound: null,
};
}
/**
* Wendet Logzeilen auf den Zustand an.
* @param {object} state Zustand aus initialLiveState bzw. vorherigem Aufruf
* @param {string[]} lines Logzeilen
* @param {object} ctx { teamNames: {A, B}, maxRounds, overtimeRounds }
* @returns {{ state, events }} events: [{ type: 'match_start'|'round'|'map_end', ... }]
*/
export function applyLogLines(state, lines, ctx) {
const s = structuredClone(state);
const events = [];
const names = { A: normalizeName(ctx.teamNames.A), B: normalizeName(ctx.teamNames.B) };
// Ein Rundenende wird erst abgeschlossen, wenn die Seitenzuordnung der gerade gespielten Runde feststeht:
// CS2 schickt sie direkt nach dem Rundenende ("MatchStatus: Team playing ..."). Die Zeilen zu Beginn der
// nächsten Runde sind nach der Halbzeit noch veraltet und werden deshalb ignoriert.
const finalize = () => {
const p = s.pendingRound;
if (!p) return;
s.pendingRound = null;
if (p.number <= s.rounds.length) return; // doppelt gesendete Zeile
const winner = s.sides[p.side];
s.rounds.push({ number: p.number, winner, side: p.side, reason: p.reason });
// Stand aus den Seitenwerten übernehmen – korrigiert sich bei verlorenen Zeilen selbst.
s.score = { [s.sides.CT]: p.sideScore.CT, [s.sides.T]: p.sideScore.T };
events.push({ type: 'round', number: p.number, winner, score: { ...s.score } });
switchSidesByRules(s, p.number, ctx);
};
const reset = (status) => {
Object.assign(s, { status, score: { A: 0, B: 0 }, rounds: [], winner: null, pendingRound: null });
};
for (const raw of lines) {
const line = logPayload(raw);
let m;
if ((m = RE.matchStart.exec(line))) {
// Echter Matchbeginn (nach Aufwärmphase oder mp_restartgame): Zählung beginnt neu.
// Nach Spielende startet CS2 von selbst neu – das Ergebnis bleibt dann stehen.
if (m[1] !== s.map || s.status === 'finished') continue;
reset(s.warmupEnded ? 'live' : 'warmup');
// Seitenzuordnung aus dem Log hat Vorrang vor der Annahme aus dem Veto.
if (!s.sidesFromLog) s.sides = { CT: s.startCt, T: other(s.startCt) };
if (s.warmupEnded) events.push({ type: 'match_start', map: s.map });
} else if (RE.warmupStart.test(line)) {
// Neue Aufwärmphase (Mapstart oder Neustart): bisherige Zählung verwerfen, Ergebnis einer beendeten Map bleibt.
if (s.status !== 'finished') reset('warmup');
} else if ((m = RE.teamPlaying.exec(line))) {
// Nur vor der ersten Runde und direkt nach einem Rundenende verlässlich.
if (!s.pendingRound && s.rounds.length > 0) continue;
const side = m[1] === 'CT' ? 'CT' : 'T';
const name = normalizeName(m[2]);
const team = name === names.A ? 'A' : name === names.B ? 'B' : null;
if (team) {
s.sides[side] = team;
s.sides[side === 'CT' ? 'T' : 'CT'] = other(team);
s.sidesFromLog = true;
}
} else if ((m = RE.roundEnd.exec(line))) {
if (s.status === 'finished') continue;
finalize();
if (s.status === 'warmup') s.status = 'live';
const sideScore = { CT: Number(m[3]), T: Number(m[4]) };
s.pendingRound = {
number: sideScore.CT + sideScore.T,
side: m[1] === 'CT' ? 'CT' : 'T',
reason: REASONS[m[2]] ?? 'other',
sideScore,
};
} else if (RE.worldRoundEnd.test(line)) {
finalize();
} else if ((m = RE.gameOver.exec(line))) {
if (m[1] !== s.map || s.status === 'finished') continue;
finalize();
s.status = 'finished';
s.winner = s.score.A === s.score.B ? null : s.score.A > s.score.B ? 'A' : 'B';
events.push({ type: 'map_end', winner: s.winner, score: { ...s.score } });
}
}
// Ende des Pakets: offenes Rundenende mit der bekannten Zuordnung abschließen, damit die Anzeige nicht hängt.
finalize();
return { state: s, events };
}
/**
* Seitenwechsel nach Regelwerk, damit die Anzeige auch während einer Runde stimmt:
* Halbzeit nach maxRounds/2, in der Verlängerung nach jeder halben Verlängerung.
* Die Zeilen nach dem nächsten Rundenende korrigieren die Zuordnung, falls ein Server anders wechselt.
*/
function switchSidesByRules(s, roundsPlayed, { maxRounds, overtimeRounds = 6 }) {
const half = maxRounds / 2;
const otHalf = overtimeRounds / 2;
const regulationSwitch = roundsPlayed === half;
const overtimeSwitch = roundsPlayed > maxRounds && (roundsPlayed - maxRounds) % otHalf === 0
&& ((roundsPlayed - maxRounds) / otHalf) % 2 === 1;
if (regulationSwitch || overtimeSwitch) s.sides = { CT: s.sides.T, T: s.sides.CT };
}
/** Teilt einen HTTP-Body in Logzeilen. */
export function splitLogBody(body) {
return String(body ?? '').split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
}
/** Serienstand aus den Ergebnissen der einzelnen Maps. */
export function seriesScore(maps) {
const score = { A: 0, B: 0 };
for (const m of maps) if (m.winner) score[m.winner] += 1;
return score;
}
+40 -16
View File
@@ -1,5 +1,5 @@
import fs from 'node:fs';
import { BEST_OF, validateFlow } from './veto.js';
import { BEST_OF, generateFlow, validateFlow } from './veto.js';
/**
* Lädt die Map-Pools und markiert jede Kombination aus Modus und Format als unterstützt oder nicht.
@@ -22,32 +22,56 @@ export function buildMapPools(raw) {
}
ids.add(map.id);
}
const formats = {};
for (const format of Object.keys(BEST_OF)) {
const flow = pool.flows?.[format];
if (!flow) {
formats[format] = { supported: false, reason: 'Kein Veto-Ablauf für dieses Format hinterlegt' };
continue;
}
const errors = validateFlow(flow, pool.maps.length, format);
formats[format] = errors.length
? { supported: false, reason: errors.join('; ') }
: { supported: true, flow };
if (errors.length) console.warn(`[map-pools] ${mode}/${format} deaktiviert: ${errors.join('; ')}`);
}
// "active": false = Map ist im Katalog, wird aber erst nach Auswahl im Admin-Bereich gespielt
const active = pool.maps.filter((m) => m.active !== false).map((m) => m.id);
pools[mode] = {
mode,
label: pool.label ?? mode,
teamSize: pool.teamSize,
gameType: pool.gameType,
gameMode: pool.gameMode,
maps: pool.maps,
formats,
allMaps: pool.maps.map(({ active: _ignored, ...m }) => m),
flows: pool.flows ?? {},
maps: [],
formats: {},
};
setActiveMaps(pools[mode], active, { warn: true });
}
return pools;
}
/**
* Veto-Abläufe für die aktiven Maps: Ein in der Konfiguration hinterlegter Ablauf gilt, wenn er zur Map-Anzahl passt,
* sonst erzeugt die Anwendung einen passenden Ablauf.
*/
export function formatsFor(pool, mapCount, { warn = false } = {}) {
const formats = {};
for (const format of Object.keys(BEST_OF)) {
const configured = pool.flows?.[format];
if (configured && validateFlow(configured, mapCount, format).length === 0) {
formats[format] = { supported: true, flow: configured, generated: false };
continue;
}
const flow = generateFlow(mapCount, format);
if (!flow) {
formats[format] = { supported: false, reason: `${format.toUpperCase()} braucht mindestens ${Math.max(BEST_OF[format], 2)} Maps` };
continue;
}
const errors = validateFlow(flow, mapCount, format);
formats[format] = errors.length ? { supported: false, reason: errors.join('; ') } : { supported: true, flow, generated: true };
if (errors.length && warn) console.warn(`[map-pools] ${pool.mode}/${format} deaktiviert: ${errors.join('; ')}`);
}
return formats;
}
/** Aktive Maps eines Pools setzen (Reihenfolge wie im Katalog) und die Formate neu berechnen. */
export function setActiveMaps(pool, ids, options) {
const wanted = new Set(ids);
pool.maps = pool.allMaps.filter((m) => wanted.has(m.id));
pool.formats = formatsFor(pool, pool.maps.length, options);
return pool;
}
export function publicPools(pools) {
return Object.values(pools).map((p) => ({
mode: p.mode,
+518 -21
View File
@@ -1,6 +1,11 @@
import { EventEmitter } from 'node:events';
import { BEST_OF, computeVetoState, resolveAction, VetoError } from './veto.js';
import crypto from 'node:crypto';
import { formatCode, hashToken, normalizeCode, randomCode, randomId, randomToken, safeEqual } from './security.js';
import { applyLogLines, initialLiveState, seriesScore, splitLogBody } from './liveScore.js';
import { trimLiveState } from './rounds.js';
import { sanitizeConsoleText } from './servers/cs2Config.js';
import { NOTIFY_EVENTS } from './discord.js';
export class HttpError extends Error {
constructor(status, message, code = 'error') {
@@ -37,12 +42,17 @@ function cleanSteamId(value) {
return raw;
}
const CHAT_MAX_LENGTH = 300;
const CHAT_HISTORY = 200;
export class MatchService extends EventEmitter {
constructor({ db, pools, config }) {
constructor({ db, pools, config, notifier = { enabled: false, send: async () => {} } }) {
super();
this.chatRate = new Map();
this.db = db;
this.pools = pools;
this.config = config;
this.notifier = notifier;
this.presence = () => new Set();
}
@@ -145,7 +155,8 @@ export class MatchService extends EventEmitter {
if (!formatInfo?.supported) {
throw new HttpError(400, `${String(format).toUpperCase()} wird für ${pool.label} nicht unterstützt`, 'unsupported_format');
}
if (!['coin', 'A', 'B'].includes(vetoStart)) throw new HttpError(400, 'Ungültige Veto-Startregel', 'invalid_input');
// knife = Seitenwahl per Messerrunde: der Organisator legt nach der Runde fest, wer das Veto beginnt
if (!['coin', 'A', 'B', 'knife'].includes(vetoStart)) throw new HttpError(400, 'Ungültige Veto-Startregel', 'invalid_input');
const nameA = cleanName(teamA, 'Name von Team A');
const nameB = cleanName(teamB, 'Name von Team B');
if (nameA.toLowerCase() === nameB.toLowerCase()) {
@@ -321,7 +332,11 @@ export class MatchService extends EventEmitter {
// ---------- Veto ----------
startVeto(viewer) {
/**
* Veto starten. Bei Seitenwahl per Messerrunde (veto_start = knife) gibt der Organisator mit
* `firstTeam` an, welches Team das Veto beginnt.
*/
startVeto(viewer, { firstTeam: chosen } = {}) {
this.require(viewer, 'organizer');
this.db.transaction(() => {
const match = this.getMatch(viewer.matchId);
@@ -330,16 +345,79 @@ export class MatchService extends EventEmitter {
if (!readiness.A.ready || !readiness.B.ready) {
throw new HttpError(409, 'Beide Teams müssen vollständig und bereit sein', 'teams_not_ready');
}
const firstTeam = match.first_team ?? (Math.random() < 0.5 ? 'A' : 'B');
const knife = match.veto_start === 'knife';
if (knife && !['A', 'B'].includes(chosen)) {
throw new HttpError(400, 'Nach der Messerrunde festlegen, welches Team das Veto beginnt', 'first_team_required');
}
const firstTeam = knife ? chosen : match.first_team ?? (Math.random() < 0.5 ? 'A' : 'B');
this.db
.prepare("UPDATE matches SET status = 'veto', first_team = ?, veto_started_at = ?, updated_at = ? WHERE id = ?")
.run(firstTeam, this.now(), this.now(), match.id);
const how = match.veto_start === 'coin' ? 'per Münzwurf ausgelost' : 'vom Organisator festgelegt';
const how = knife ? 'nach der Messerrunde vom Organisator festgelegt'
: match.veto_start === 'coin' ? 'per Münzwurf ausgelost' : 'vom Organisator festgelegt';
this.log(match.id, 'veto', `Veto gestartet – ${this.teamName(match, firstTeam)} beginnt (${how})`);
this.notify('veto_start', match.id, {
title: '🎮 Veto gestartet',
description: `**${this.matchTitle(match.id)}**`,
color: 0x3b82f6,
fields: [{ name: 'Beginnt', value: this.teamName(match, firstTeam), inline: true }],
});
})();
this.changed(viewer.matchId);
}
/** Ablaufzeitpunkt des aktuellen Veto-Schritts (ms), null wenn keine Frist gilt. */
vetoDeadline(match) {
if (!this.config.vetoStepSeconds || match.status !== 'veto') return null;
const actions = this.vetoActions(match.id);
const since = actions.length ? actions[actions.length - 1].created_at : match.veto_started_at;
return since == null ? null : since + this.config.vetoStepSeconds * 1000;
}
vetoRemainingMs(match) {
const deadline = this.vetoDeadline(match);
return deadline === null ? null : Math.max(0, deadline - this.now());
}
/** Führt den aktuellen Veto-Schritt für das Team am Zug aus: zufällige Map bzw. zufällige Seite. */
autoVetoStep(match) {
const state = this.vetoState(match);
if (state.done || !state.current) return;
const { type, team, index } = state.current;
const pick = (list) => list[Math.floor(Math.random() * list.length)];
const viewer = {
matchId: match.id, role: 'captain', isOrganizer: false, isCaptain: true, isSpectator: false, playerId: null, team,
};
const action = type === 'side'
? { stepIndex: index, side: pick(['CT', 'T']) }
: { stepIndex: index, map: pick(state.maps.filter((m) => m.status === 'available')).id };
this.log(match.id, 'veto', `Zeit abgelaufen – ${this.teamName(match, team)} wird automatisch vertreten`);
this.vetoAction(viewer, action);
}
/** Läuft die Frist eines Veto-Schritts ab, führt das Backend den Schritt aus. */
autoVetoTimeouts() {
for (const match of this.db.prepare("SELECT * FROM matches WHERE status = 'veto'").all()) {
const deadline = this.vetoDeadline(match);
if (deadline === null || this.now() < deadline) continue;
try {
this.autoVetoStep(match);
} catch (err) {
this.log(match.id, 'veto', `Automatischer Schritt fehlgeschlagen: ${err.message}`);
}
}
}
startVetoTimer() {
if (!this.config.vetoStepSeconds || this.vetoTimer) return;
this.vetoTimer = setInterval(() => this.autoVetoTimeouts(), 1000);
}
stopVetoTimer() {
clearInterval(this.vetoTimer);
this.vetoTimer = null;
}
vetoAction(viewer, { stepIndex, map, side }) {
this.require(viewer, 'captain');
let finished = false;
@@ -414,9 +492,14 @@ export class MatchService extends EventEmitter {
advanceSeries(viewer) {
this.require(viewer, 'organizer');
return this.advanceSeriesFor(viewer.matchId);
}
/** Nächste Map der Serie vormerken (Organisator oder automatisch nach Map-Ende). */
advanceSeriesFor(matchId) {
let nextNumber;
this.db.transaction(() => {
const match = this.getMatch(viewer.matchId);
const match = this.getMatch(matchId);
if (match.status !== 'live') throw new HttpError(409, 'Die nächste Map kann erst geladen werden, wenn das Match läuft', 'wrong_phase');
const maps = this.seriesMaps(match.id);
nextNumber = match.current_map_number + 1;
@@ -428,10 +511,131 @@ export class MatchService extends EventEmitter {
.run(nextNumber, this.now(), match.id);
this.log(match.id, 'series', `Map ${nextNumber} der Serie wird geladen`);
})();
this.changed(viewer.matchId);
this.changed(matchId);
return nextNumber;
}
// ---------- Live-Ergebnisse ----------
/** Startet die Zählung einer Map neu (beim Laden der Map auf dem Server). */
resetLiveScore(matchId, number) {
this.db
.prepare('UPDATE series_maps SET score_a = 0, score_b = 0, winner = NULL, live = NULL, live_updated_at = NULL WHERE match_id = ? AND number = ?')
.run(matchId, number);
}
/** Stand auf den Zeitpunkt nach einer Runde zurückstellen (nach Rollback). */
rewindLiveScore(matchId, roundNumber, file) {
const match = this.getMatch(matchId);
const row = this.seriesMaps(matchId).find((m) => m.number === match.current_map_number);
if (!row) return;
const live = row.live ? trimLiveState(JSON.parse(row.live), roundNumber) : null;
const score = live ? live.score : { A: 0, B: 0 };
this.db
.prepare("UPDATE series_maps SET score_a = ?, score_b = ?, winner = NULL, status = 'live', live = ?, live_updated_at = ? WHERE match_id = ? AND number = ?")
.run(score.A, score.B, live ? JSON.stringify(live) : null, this.now(), matchId, row.number);
this.log(matchId, 'rollback', `Map ${row.number} zurückgespielt auf Runde ${roundNumber} (${file})`);
this.changed(matchId);
}
/** Organisator hat die Aufwärmphase beendet: ab dem nächsten Match_Start wird gezählt. */
markWarmupEnded(matchId, ended = true) {
const match = this.getMatch(matchId);
const row = this.seriesMaps(matchId).find((m) => m.number === match.current_map_number);
if (!row) return;
const state = row.live ? JSON.parse(row.live) : initialLiveState({ map: row.map, ctTeam: row.ct_team });
state.warmupEnded = ended;
this.db
.prepare('UPDATE series_maps SET live = ? WHERE match_id = ? AND number = ?')
.run(JSON.stringify(state), matchId, row.number);
}
/** Logzeilen vom CS2-Server (logaddress_add_http). Der Schlüssel in der URL identifiziert den Server. */
ingestLogs(matchId, key, body) {
const server = this.serverRow(matchId);
if (!server || !safeEqual(String(key ?? ''), server.config_key)) {
throw new HttpError(403, 'Unbekannter Log-Schlüssel', 'forbidden');
}
// Erst zählen, wenn die Map eingerichtet ist. Während des Einrichtens kommen noch Zeilen vom vorigen
// Spiel auf dem Server (z. B. dem Ende der Messerrunde), die nicht zur neuen Map gehören.
if (server.status !== 'configured') return;
this.applyLiveLog(matchId, splitLogBody(body));
}
applyLiveLog(matchId, lines) {
if (!lines.length) return;
let ended = null;
this.db.transaction(() => {
const match = this.getMatch(matchId);
if (CLOSED_STATUSES.includes(match.status)) return;
const row = this.seriesMaps(matchId).find((m) => m.number === match.current_map_number);
if (!row) return;
const before = row.live ? JSON.parse(row.live) : initialLiveState({ map: row.map, ctTeam: row.ct_team });
const { state, events } = applyLogLines(before, lines, {
teamNames: { A: sanitizeConsoleText(match.team_a_name), B: sanitizeConsoleText(match.team_b_name) },
maxRounds: match.mode === 'wingman' ? this.config.match.maxRoundsWingman : this.config.match.maxRoundsCompetitive,
overtimeRounds: 6,
});
if (!events.length && JSON.stringify(before) === JSON.stringify(state)) {
// Nichts Relevantes dabei, aber der Server liefert: Empfang trotzdem vermerken (Anzeige "Live-Daten empfangen")
this.db.prepare('UPDATE series_maps SET live_updated_at = ? WHERE match_id = ? AND number = ?').run(this.now(), matchId, row.number);
return;
}
const finished = events.find((e) => e.type === 'map_end');
this.db
.prepare(
`UPDATE series_maps SET score_a = ?, score_b = ?, winner = ?, live = ?, live_updated_at = ?
${finished ? ", status = 'done'" : ''} WHERE match_id = ? AND number = ?`,
)
.run(state.score.A, state.score.B, state.winner, JSON.stringify(state), this.now(), matchId, row.number);
if (events.some((e) => e.type === 'match_start')) this.log(matchId, 'score', `Map ${row.number} (${this.mapName(match, row.map)}) live – Zählung beginnt`);
if (finished) {
ended = { row, state, match };
const winner = state.winner ? this.teamName(match, state.winner) : 'Unentschieden';
this.log(matchId, 'score',
`Map ${row.number} (${this.mapName(match, row.map)}) beendet: ${match.team_a_name} ${state.score.A}:${state.score.B} ${match.team_b_name} – Sieger: ${winner}`);
this.notify('map_end', matchId, {
title: '🗺️ Map beendet',
description: `**${this.matchTitle(matchId)}**`,
color: 0xf59e0b,
fields: [
{ name: `Map ${row.number}`, value: this.mapName(match, row.map), inline: true },
{ name: 'Stand', value: `${state.score.A} : ${state.score.B}`, inline: true },
{ name: 'Sieger', value: winner, inline: true },
],
});
}
})();
if (ended) {
const maps = this.seriesMaps(matchId);
const score = seriesScore(maps);
const needed = Math.floor(BEST_OF[ended.match.format] / 2) + 1;
const decided = score.A >= needed ? 'A' : score.B >= needed ? 'B' : null;
if (decided) this.log(matchId, 'score', `Serie entschieden: ${this.teamName(ended.match, decided)} gewinnt ${score[decided]}:${score[decided === 'A' ? 'B' : 'A']}`);
if (decided) {
const loser = decided === 'A' ? 'B' : 'A';
this.notify('series_end', matchId, {
title: '🏆 Serie entschieden',
description: `**${this.matchTitle(matchId)}**`,
color: 0x22c55e,
fields: [
{ name: 'Sieger', value: this.teamName(ended.match, decided), inline: true },
{ name: 'Serienstand', value: `${score[decided]} : ${score[loser]}`, inline: true },
],
});
}
const hasNext = maps.some((m) => m.number === ended.row.number + 1);
this.changed(matchId);
this.emit('mapEnded', { matchId, number: ended.row.number, decided, hasNext });
return;
}
this.changed(matchId);
}
mapName(match, mapId) {
return JSON.parse(match.maps).find((m) => m.id === mapId)?.name ?? mapId;
}
markCurrentMapLive(matchId) {
const match = this.getMatch(matchId);
this.db
@@ -439,14 +643,120 @@ export class MatchService extends EventEmitter {
.run(matchId, match.current_map_number);
}
// ---------- Chat ----------
/**
* Chat-Kanäle, die dieser Zugang sieht: der Match-Chat für Spieler beider Teams und den Organisator,
* dazu der Team-Chat des eigenen Teams. Zuschauer und Gäste sehen keinen Chat.
*/
chatChannels(match, viewer) {
const channels = [];
if (viewer.isOrganizer || viewer.playerId) channels.push({ key: 'match', label: 'Match-Chat' });
if (viewer.playerId && (viewer.team === 'A' || viewer.team === 'B')) {
channels.push({ key: viewer.team, label: `Team ${this.teamName(match, viewer.team)}` });
}
return channels;
}
chatView(match, viewer) {
const channels = this.chatChannels(match, viewer);
if (!channels.length) return null;
const stmt = this.db.prepare(`
SELECT * FROM (SELECT * FROM chat_messages WHERE match_id = ? AND channel = ? ORDER BY id DESC LIMIT ${CHAT_HISTORY})
ORDER BY id
`);
const messages = {};
for (const c of channels) {
messages[c.key] = stmt.all(match.id, c.key).map((m) => ({
id: m.id,
author: m.author,
team: m.team,
organizer: !!m.organizer,
text: m.text,
at: m.created_at,
mine: viewer.playerId ? m.player_id === viewer.playerId : !!m.organizer && !m.player_id,
}));
}
return { channels, messages, canWrite: !CLOSED_STATUSES.includes(match.status), maxLength: CHAT_MAX_LENGTH };
}
/** Nachricht in einen Kanal schreiben. Nach Matchende ist der Chat nur noch lesbar. */
postChat(viewer, { channel, text } = {}) {
const match = this.getMatch(viewer.matchId);
if (!this.chatChannels(match, viewer).some((c) => c.key === channel)) {
throw new HttpError(403, 'In diesem Chat darfst du nicht schreiben', 'forbidden');
}
if (CLOSED_STATUSES.includes(match.status)) throw new HttpError(409, 'Das Match ist beendet, der Chat ist nur noch lesbar', 'match_closed');
const clean = String(text ?? '').replace(/[\u0000-\u001f\u007f]/g, ' ').replace(/\s+/g, ' ').trim();
if (!clean) throw new HttpError(400, 'Die Nachricht ist leer', 'invalid_input');
if (clean.length > CHAT_MAX_LENGTH) throw new HttpError(400, `Höchstens ${CHAT_MAX_LENGTH} Zeichen`, 'invalid_input');
// Gegen Spam: höchstens 5 Nachrichten in 10 Sekunden pro Person
const who = `${match.id}:${viewer.playerId ?? 'org'}`;
const now = this.now();
const recent = (this.chatRate.get(who) ?? []).filter((t) => now - t < 10_000);
if (recent.length >= 5) throw new HttpError(429, 'Zu viele Nachrichten, kurz warten', 'rate_limited');
this.chatRate.set(who, [...recent, now]);
// Gelegentlich alte Einträge anderer Spieler entfernen
if (this.chatRate.size > 500) {
for (const [k, times] of this.chatRate) if (!times.some((t) => now - t < 10_000)) this.chatRate.delete(k);
}
const player = viewer.playerId ? this.db.prepare('SELECT nickname, team FROM players WHERE id = ?').get(viewer.playerId) : null;
this.db.prepare(`
INSERT INTO chat_messages (match_id, channel, player_id, author, team, organizer, text, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(match.id, channel, viewer.playerId ?? null, player?.nickname ?? 'Organisator', player?.team ?? null,
viewer.isOrganizer ? 1 : 0, clean, now);
this.changed(match.id);
}
/** Abgeschlossenes Match ins Archiv legen bzw. zurückholen. */
setArchived(matchId, archived) {
const match = this.getMatch(matchId);
if (archived && !CLOSED_STATUSES.includes(match.status)) {
throw new HttpError(409, 'Nur abgeschlossene oder abgebrochene Matches lassen sich archivieren', 'not_closed');
}
this.db.prepare('UPDATE matches SET archived_at = ? WHERE id = ?').run(archived ? this.now() : null, matchId);
this.log(matchId, 'status', archived ? 'Match archiviert (Admin)' : 'Match aus dem Archiv geholt (Admin)');
this.changed(matchId);
}
/** Abgeschlossene Matches nach `archiveAfterDays` Tagen automatisch archivieren. Liefert die Anzahl. */
archiveClosed(now = this.now()) {
const days = this.config.archiveAfterDays;
if (!days) return 0;
const cutoff = now - days * 86_400_000;
const ids = this.db.prepare(`
SELECT id FROM matches
WHERE archived_at IS NULL AND status IN ('finished', 'cancelled') AND COALESCE(finished_at, updated_at) < ?
`).all(cutoff).map((r) => r.id);
const stmt = this.db.prepare('UPDATE matches SET archived_at = ? WHERE id = ?');
for (const id of ids) {
stmt.run(now, id);
this.log(id, 'status', `Match automatisch archiviert (nach ${days} Tagen)`);
}
return ids.length;
}
close(viewer, status) {
this.require(viewer, 'organizer');
const match = this.getMatch(viewer.matchId);
this.closeMatch(viewer.matchId, status);
}
closeMatch(matchId, status, message) {
const match = this.getMatch(matchId);
if (CLOSED_STATUSES.includes(match.status)) throw new HttpError(409, 'Das Match ist bereits beendet', 'match_closed');
if (status === 'finished') {
this.db.prepare("UPDATE series_maps SET status = 'done' WHERE match_id = ? AND status IN ('live','next')").run(match.id);
}
this.log(match.id, 'status', status === 'finished' ? 'Match abgeschlossen' : 'Match abgebrochen');
this.log(match.id, 'status', message ?? (status === 'finished' ? 'Match abgeschlossen' : 'Match abgebrochen'));
const finished = status === 'finished';
this.notify('match_closed', match.id, {
title: finished ? '✅ Match abgeschlossen' : '⛔ Match abgebrochen',
description: `**${this.matchTitle(match.id)}**${message ? `\n${message}` : ''}`,
color: finished ? 0x6b7280 : 0x9ca3af,
});
this.setStatus(match.id, status);
this.emit('closed', match.id);
}
@@ -461,6 +771,47 @@ export class MatchService extends EventEmitter {
this.db.prepare('UPDATE matches SET last_activity_at = ? WHERE id = ?').run(this.now(), matchId);
}
/** Titel für Meldungen, z. B. „Team A vs. Team B (BO3)“. */
matchTitle(matchId) {
const m = this.getMatch(matchId);
return `${m.team_a_name} vs. ${m.team_b_name} (${m.format.toUpperCase()})`;
}
/**
* Meldung an den Discord-Webhook, falls konfiguriert. Ohne Stream-Schlüssel gehört die Übersicht
* des Matches dazu, mit Schlüssel entfällt der Link, damit der Schlüssel nicht in den Kanal gelangt.
*/
/** Discord-Meldungen: der Webhook muss eingerichtet und das Ereignis ausgewählt sein. */
notificationSettings() {
const row = this.db.prepare("SELECT value FROM settings WHERE key = 'discord_events'").get();
let stored = {};
try {
stored = row ? JSON.parse(row.value) : {};
} catch {
stored = {};
}
const events = Object.fromEntries(NOTIFY_EVENTS.map(({ key }) => [key, stored[key] !== false]));
return { enabled: this.notifier.enabled, events, catalog: NOTIFY_EVENTS };
}
setNotificationEvents(input) {
if (!input || typeof input !== 'object') throw new HttpError(400, 'Ungültige Auswahl der Meldungen', 'invalid');
const events = Object.fromEntries(NOTIFY_EVENTS.map(({ key }) => [key, input[key] === true]));
this.db
.prepare("INSERT INTO settings (key, value) VALUES ('discord_events', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value")
.run(JSON.stringify(events));
return this.notificationSettings();
}
/** Meldung als Discord-Embed. Ohne Stream-Schlüssel ist der Titel ein Link auf die Match-Übersicht. */
notify(type, matchId, embed) {
if (!this.notifier.enabled || !this.notificationSettings().events[type]) return;
const url = this.config.streamKey ? undefined : `${this.config.publicUrl}/stream/match/${matchId}`;
this.notifier.send({
embeds: [{ ...embed, url, timestamp: new Date(this.now()).toISOString(), footer: { text: 'CS2 Mapveto' } }],
});
}
log(matchId, type, message) {
this.db
.prepare('INSERT INTO events (match_id, type, message, created_at) VALUES (?, ?, ?, ?)')
@@ -472,6 +823,98 @@ export class MatchService extends EventEmitter {
this.emit('changed', matchId);
}
/**
* Übersicht aller aktiven Matches für die Stream-Seite: laufende Vetos und Matches mit Details,
* Lobbys und kürzlich beendete Matches knapp. Enthält nur, was Zuschauer sehen dürfen –
* keine Codes, keine Verbindungsdaten.
*/
/** Schlüssel für die Stream-Übersicht nur eines Matches, abgeleitet aus STREAM_KEY (ohne ihn preiszugeben). */
matchStreamKey(matchId) {
const mac = crypto.createHmac('sha256', this.config.streamKey).update(`stream:${matchId}`).digest('base64url').slice(0, 22);
return `${matchId}.${mac}`;
}
/**
* Prüft einen Stream-Schlüssel: { matchId: null } = alle Matches, { matchId } = nur dieses Match, null = kein Zugang.
* Ohne STREAM_KEY ist die Übersicht offen.
*/
streamAccess(key) {
if (!this.config.streamKey) return { matchId: null };
const value = String(key ?? '');
if (safeEqual(value, this.config.streamKey)) return { matchId: null };
const dot = value.lastIndexOf('.');
const matchId = dot > 0 ? value.slice(0, dot) : '';
if (matchId && safeEqual(value, this.matchStreamKey(matchId))) return { matchId };
return null;
}
streamOverview({ resultsWithinMs = 3 * 3_600_000, matchId = null } = {}) {
const rows = matchId
? this.db.prepare('SELECT * FROM matches WHERE id = ?').all(matchId)
: this.db.prepare('SELECT * FROM matches ORDER BY created_at DESC LIMIT 200').all();
const now = this.now();
const active = [];
const upcoming = [];
const results = [];
for (const m of rows) {
const pool = this.pools[m.mode];
const maps = JSON.parse(m.maps);
const mapInfo = (id) => {
const map = maps.find((x) => x.id === id);
return { id, name: map?.name ?? id, image: map?.image ?? null, color: map?.color ?? null };
};
const base = {
id: m.id,
teams: { A: m.team_a_name, B: m.team_b_name },
mode: m.mode,
modeLabel: pool?.label ?? m.mode,
format: m.format,
status: m.status,
statusLabel: MATCH_STATUS[m.status],
};
if (m.status === 'lobby') {
const readiness = this.teamReadiness(m);
upcoming.push({ ...base, ready: { A: readiness.A.ready, B: readiness.B.ready } });
continue;
}
const seriesRows = this.seriesMaps(m.id);
const series = seriesRows.map((s) => {
const live = s.live ? JSON.parse(s.live) : null;
return {
number: s.number,
...mapInfo(s.map),
pickedBy: s.picked_by,
status: s.status,
score: { A: s.score_a, B: s.score_b },
winner: s.winner,
live: live && { status: live.status, sides: live.sides, roundsPlayed: live.rounds.length },
};
});
const item = { ...base, seriesScore: seriesScore(seriesRows), currentMapNumber: m.current_map_number, series };
if (CLOSED_STATUSES.includes(m.status)) {
if (m.status === 'finished' && now - (m.finished_at ?? 0) < resultsWithinMs) results.push(item);
continue;
}
if (m.status === 'veto') {
const veto = this.vetoState(m);
item.veto = {
current: veto.current,
maps: veto.maps.map((x) => ({ ...mapInfo(x.id), status: x.status, by: x.by })),
stepsDone: veto.steps.filter((x) => x.done).length,
stepsTotal: veto.steps.length,
};
}
active.push(item);
}
// Laufende Matches zuerst, dann Vetos, dann Serverstart – jeweils älteste zuerst
const order = { live: 0, veto: 1, server_setup: 2 };
active.sort((a, b) => (order[a.status] - order[b.status]) || a.id.localeCompare(b.id));
return { active, upcoming: upcoming.slice(0, 8), results: results.slice(0, 8), updatedAt: now };
}
listMatches() {
const rows = this.db.prepare('SELECT * FROM matches ORDER BY created_at DESC LIMIT 200').all();
const counts = this.db.prepare('SELECT team, COUNT(*) AS n FROM players WHERE match_id = ? AND active = 1 GROUP BY team');
@@ -479,7 +922,20 @@ export class MatchService extends EventEmitter {
const byTeam = Object.fromEntries(counts.all(m.id).map((r) => [r.team, r.n]));
const server = this.serverRow(m.id);
const pool = this.pools[m.mode];
const maps = this.seriesMaps(m.id);
const current = maps.find((s) => s.number === m.current_map_number);
return {
seriesScore: maps.length ? seriesScore(maps) : null,
currentMap: current && (current.status === 'live' || current.status === 'done')
? {
number: current.number,
name: this.mapName(m, current.map),
image: JSON.parse(m.maps).find((x) => x.id === current.map)?.image ?? null,
color: JSON.parse(m.maps).find((x) => x.id === current.map)?.color ?? null,
score: { A: current.score_a, B: current.score_b },
finished: current.status === 'done',
}
: null,
id: m.id,
teamA: m.team_a_name,
teamB: m.team_b_name,
@@ -528,7 +984,10 @@ export class MatchService extends EventEmitter {
const veto = this.vetoState(match);
const actions = this.vetoActions(matchId);
const series = this.seriesMaps(matchId).map((s) => ({
const seriesRows = this.seriesMaps(matchId);
const series = seriesRows.map((s) => {
const live = s.live ? JSON.parse(s.live) : null;
return {
number: s.number,
map: s.map,
mapName: mapNames[s.map] ?? s.map,
@@ -536,7 +995,17 @@ export class MatchService extends EventEmitter {
sideChosenBy: s.side_chosen_by,
ctTeam: s.ct_team,
status: s.status,
}));
score: { A: s.score_a, B: s.score_b },
winner: s.winner,
// Auch ohne Spielstand (Aufwärmphase) mitschicken, sobald der Server Logs liefert
live: (live || s.live_updated_at) && {
status: live?.status ?? 'warmup',
sides: live?.sides ?? null,
rounds: live?.rounds ?? [],
updatedAt: s.live_updated_at,
},
};
});
const snapshot = {
id: match.id,
@@ -548,6 +1017,8 @@ export class MatchService extends EventEmitter {
status: match.status,
statusLabel: MATCH_STATUS[match.status],
createdAt: match.created_at,
finishedAt: match.finished_at ?? null,
archivedAt: match.archived_at ?? null,
updatedAt: match.updated_at,
vetoStart: match.veto_start,
currentMapNumber: match.current_map_number,
@@ -557,8 +1028,9 @@ export class MatchService extends EventEmitter {
},
canStartVeto: match.status === 'lobby' && readiness.A.ready && readiness.B.ready,
requireFullTeams: this.config.requireFullTeams,
autoAdvanceSeconds: this.config.autoAdvanceSeconds,
maps: pool.maps,
veto: publicView
veto: publicView && match.status === 'lobby'
? null
: {
started: match.status !== 'lobby',
@@ -566,6 +1038,8 @@ export class MatchService extends EventEmitter {
steps: veto.steps,
maps: veto.maps,
current: match.status === 'veto' ? veto.current : null,
remainingMs: this.vetoRemainingMs(match),
stepSeconds: this.config.vetoStepSeconds,
done: veto.done,
history: actions.map((a) => ({
index: a.idx,
@@ -578,7 +1052,10 @@ export class MatchService extends EventEmitter {
})),
},
series,
seriesScore: seriesScore(seriesRows),
maxRounds: match.mode === 'wingman' ? this.config.match.maxRoundsWingman : this.config.match.maxRoundsCompetitive,
server: this.serverView(match, viewer),
chat: this.chatView(match, viewer),
events: publicView
? []
: this.db
@@ -594,6 +1071,10 @@ export class MatchService extends EventEmitter {
A: `${base}/join/${formatCode(match.code_a)}`,
B: `${base}/join/${formatCode(match.code_b)}`,
spectator: `${base}/watch/${formatCode(match.code_spectator)}`,
// Vollbild-Übersicht für den Stream (OBS-Browserquelle), nutzt den Zuschauercode
stream: `${base}/stream/${formatCode(match.code_spectator)}`,
// Stream-Übersicht nur für dieses Match, ohne Zuschauercode (Schlüssel nur wenn gesetzt)
overview: `${base}/stream/match/${match.id}${this.config.streamKey ? `?key=${encodeURIComponent(this.matchStreamKey(match.id))}` : ''}`,
},
};
}
@@ -609,24 +1090,30 @@ export class MatchService extends EventEmitter {
status: row.status,
message: row.message,
playerCount: row.player_count,
paused: !!row.paused,
attempts: row.attempts,
updatedAt: row.updated_at,
idleTimeoutMinutes: this.config.idleTimeoutMinutes,
};
// Messerrunde auf dem Match-Server: Spieler bekommen die Verbindungsdaten schon in der Lobby
const knife = match.status === 'lobby' && row.knife_at && row.status === 'ready';
if (match.status === 'lobby' && match.veto_start === 'knife') {
// failed: alle automatischen Versuche fehlgeschlagen, der Organisator kann es von Hand wiederholen
const failed = !knife && row.status === 'ready' && String(row.message ?? '').startsWith('Messerrunde konnte nicht');
view.knife = { ready: !!knife, phase: knife ? row.knife_phase : null, failed };
}
if (viewer.isOrganizer) {
// Zeitpunkt der letzten Anforderung (neuer Start oder "Server neu starten / wiederholen")
view.requestedAt = row.started_at ?? null;
if (row.host) view.address = { host: row.host, port: row.port, tvPort: row.tv_port };
}
const configured = row.status === 'configured';
if (knife && viewer.role !== 'public' && (viewer.isOrganizer || viewer.playerId)) view.connect = this.connectInfo(row);
if (!configured || viewer.role === 'public') return view;
const isTeam = viewer.isOrganizer || !!viewer.playerId;
const isSpectatorView = viewer.isOrganizer || viewer.isSpectator;
if (isTeam) {
view.connect = {
host: row.host,
port: row.port,
password: row.server_password,
command: `password ${row.server_password}; connect ${row.host}:${row.port}`,
steamUrl: `steam://connect/${row.host}:${row.port}/${row.server_password}`,
};
}
if (isTeam) view.connect = this.connectInfo(row);
if (isSpectatorView && this.config.match.tvEnabled) {
view.gotv = {
host: row.host,
@@ -638,4 +1125,14 @@ export class MatchService extends EventEmitter {
}
return view;
}
connectInfo(row) {
return {
host: row.host,
port: row.port,
password: row.server_password,
command: `password ${row.server_password}; connect ${row.host}:${row.port}`,
steamUrl: `steam://connect/${row.host}:${row.port}/${row.server_password}`,
};
}
}
+66 -4
View File
@@ -1,4 +1,8 @@
import { Server } from 'socket.io';
// Abfragen pro Verbindung: kurzer Schwall erlaubt, danach 2 pro Sekunde
const SOCKET_BURST = 20;
const SOCKET_REFILL = 2;
const SOCKET_REFILL_MS = 1000;
/**
* Echtzeit-Kanal. Clients senden nie Aktionen über den Socket – alle Änderungen laufen über die
@@ -50,22 +54,66 @@ export function attachRealtime(httpServer, { service, config }) {
}, 50));
};
let streamTimer = null;
const broadcastStream = () => {
clearTimeout(streamTimer);
streamTimer = setTimeout(async () => {
io.to('stream-overview').emit('stream:overview', service.streamOverview());
// Zugänge mit Match-Schlüssel bekommen nur ihr Match
for (const s of await io.in('stream-match').fetchSockets()) {
s.emit('stream:overview', service.streamOverview({ matchId: s.data.streamMatch }));
}
}, 200);
};
service.on('changed', (matchId) => {
broadcastMatch(matchId);
broadcastOverview();
broadcastStream();
});
io.on('connection', (socket) => {
socket.on('overview:subscribe', () => {
// Jede Nachricht kommt ungeprüft vom Client: Fehler im Handler dürfen nie das Backend beenden,
// und Abfragen sind pro Verbindung begrenzt (jede baut einen vollständigen Zustand).
let budget = SOCKET_BURST;
const refill = setInterval(() => { budget = Math.min(SOCKET_BURST, budget + SOCKET_REFILL); }, SOCKET_REFILL_MS);
const on = (event, handler) => socket.on(event, (payload) => {
if (budget <= 0) {
socket.emit('rate_limited', { error: 'Zu viele Anfragen – bitte kurz warten' });
return;
}
budget -= 1;
try {
handler(payload && typeof payload === 'object' && !Array.isArray(payload) ? payload : {});
} catch (err) {
console.error(`[socket] ${event}: ${err.message}`);
}
});
on('overview:subscribe', () => {
socket.join('overview');
socket.emit('overview', { matches: service.listMatches() });
});
socket.on('match:subscribe', ({ matchId, token, playerToken } = {}) => {
if (typeof matchId !== 'string') return;
on('stream:subscribe', ({ key }) => {
const access = service.streamAccess(typeof key === 'string' ? key : '');
if (!access) {
socket.emit('stream:error', { error: 'Für die Stream-Übersicht wird der Stream-Schlüssel benötigt' });
return;
}
socket.leave('stream-overview');
socket.leave('stream-match');
// Mit einem Match-Schlüssel gibt es nur die Daten dieses einen Matches
socket.data.streamMatch = access.matchId ?? null;
socket.join(access.matchId ? 'stream-match' : 'stream-overview');
socket.emit('stream:overview', service.streamOverview({ matchId: access.matchId }));
});
on('match:subscribe', ({ matchId, token, playerToken }) => {
if (typeof matchId !== 'string' || matchId.length > 64) return;
for (const room of socket.rooms) if (room.startsWith('match:')) leave(room.slice(6));
const str = (v) => (typeof v === 'string' ? v : null);
socket.data = { matchId, token: str(token), playerToken: str(playerToken) };
socket.data = { ...socket.data, matchId, token: str(token), playerToken: str(playerToken) };
try {
service.getMatch(matchId);
} catch (err) {
@@ -90,9 +138,23 @@ export function attachRealtime(httpServer, { service, config }) {
};
socket.on('disconnect', () => {
clearInterval(refill);
try {
if (socket.data?.matchId) leave(socket.data.matchId);
} catch (err) {
console.error(`[socket] disconnect: ${err.message}`);
}
});
});
// Beim Herunterfahren: ausstehende Sammel-Updates verwerfen, damit nichts mehr auf die Datenbank zugreift
io.disposeRealtime = () => {
clearTimeout(overviewTimer);
clearTimeout(streamTimer);
for (const t of pending.values()) clearTimeout(t);
pending.clear();
service.removeAllListeners('changed');
};
return io;
}
+62
View File
@@ -0,0 +1,62 @@
// Rundensicherung und Zurückspielen (Rollback).
//
// CS2 speichert nach jeder Runde eine Sicherungsdatei des Spielzustands auf dem Server (mp_backup_round_auto).
// Diese Datei lässt sich später wieder laden (mp_backup_restore_load_file). Dieses Modul kennt die Namen,
// wertet die Liste aus und kürzt den gespeicherten Live-Stand passend zur Rückkehr.
// Präfix und Muster wie bei TMT2: der Präfix enthält die Match-ID, das Muster Map, Runde und Stand.
export const backupPrefix = (matchId) => `round_backup_${matchId}`;
export const BACKUP_PATTERN = '%prefix%_%date%_%time%_%map%_round%round%_score_%score1%_%score2%.txt';
// Endung: _<map>_round<N>_score_<a>_<b>.txt – die Map beginnt mit zwei Kleinbuchstaben und Unterstrich (de_, cs_, ar_ …)
const ROUND_RE = /_([a-z]{2}_[a-z0-9_]+?)_round(\d+)_score_(\d+)_(\d+)\.txt$/;
/** Ist der Dateiname eine Sicherung genau dieses Matches? Schützt vor Pfaden wie "../" und fremden Dateien. */
export function isValidBackupFile(file, matchId) {
if (typeof file !== 'string' || file.length > 200) return false;
if (!file.startsWith(backupPrefix(matchId))) return false;
return /^[A-Za-z0-9_.-]+\.txt$/.test(file) && !file.includes('..') && ROUND_RE.test(file);
}
/** Findet alle Sicherungsdateien dieses Matches in einer RCON-Antwort (Format der Antwort ist nicht festgelegt). */
export function parseBackupFiles(text, matchId) {
const prefix = backupPrefix(matchId);
const found = new Set();
const re = new RegExp(`${prefix}_[A-Za-z0-9_.-]+\\.txt`, 'g');
for (const m of String(text ?? '').matchAll(re)) found.add(m[0]);
return [...found].filter((f) => isValidBackupFile(f, matchId)).map((file) => describeBackup(file, matchId));
}
/**
* Zerlegt einen Dateinamen in Map, Rundennummer und Stand. Mit matchId wird vorher der Präfix entfernt,
* damit Teile der Match-ID nicht mit einem Map-Namen verwechselt werden.
*/
export function describeBackup(file, matchId = null) {
const rest = matchId && file.startsWith(backupPrefix(matchId)) ? file.slice(backupPrefix(matchId).length) : file;
const m = ROUND_RE.exec(rest);
if (!m) return { file, map: null, round: null, score: null };
return { file, map: m[1], round: Number(m[2]), score: { s1: Number(m[3]), s2: Number(m[4]) } };
}
/** Nur die Sicherungen der gerade gespielten Map, neueste Runde zuerst. */
export function pointsForMap(points, map) {
return points.filter((p) => p.map === map).sort((a, b) => b.round - a.round);
}
/**
* Kürzt den Live-Stand auf die Runden bis einschließlich roundNumber und rechnet den Stand neu.
* Danach zählt die Anzeige weiter, sobald CS2 wieder Rundenmeldungen schickt.
*/
export function trimLiveState(state, roundNumber) {
const rounds = (state.rounds ?? []).filter((r) => r.number <= roundNumber);
const score = { A: 0, B: 0 };
for (const r of rounds) if (r.winner) score[r.winner] += 1;
return {
...state,
status: 'live',
rounds,
score,
winner: null,
pendingRound: null,
};
}
+28 -6
View File
@@ -47,20 +47,42 @@ export function safeEqual(a, b) {
return ba.length === bb.length && crypto.timingSafeEqual(ba, bb);
}
/**
* Fehlermeldung ohne Zugangsdaten. Node schreibt bei einem fehlgeschlagenen Prozess die ganze Befehlszeile
* in die Meldung ("Command failed: docker run -e CS2_RCONPW=…"). Die Zeile fällt weg, übrig bleibt die
* Ausgabe des Programms; Werte von Variablen wie *_PW, *PASSWORD*, *TOKEN* werden zusätzlich geschwärzt,
* ebenso alle in `secrets` genannten Werte.
*/
export function safeErrorMessage(err, secrets = []) {
const stderr = String(err?.stderr ?? '').trim();
let text = stderr || String(err?.message ?? err ?? 'Unbekannter Fehler');
text = text.replace(/^Command failed:.*$/gm, '').trim() || 'Befehl fehlgeschlagen';
text = text.replace(/\b([A-Z0-9_]*(?:PW|PASS|PASSWORD|TOKEN|SECRET|KEY)[A-Z0-9_]*)=("[^"]*"|\S+)/gi, '$1=***');
for (const s of secrets) if (s && String(s).length >= 4) text = text.split(String(s)).join('***');
return text.slice(0, 500);
}
/** Einfacher In-Memory-Ratenbegrenzer (Fenster pro Schlüssel). */
export function createRateLimiter({ windowMs, max }) {
export function createRateLimiter({ windowMs, max, maxKeys = 10_000 }) {
const hits = new Map();
return function check(key) {
let lastSweep = Date.now();
function check(key) {
const now = Date.now();
// Abgelaufene Fenster regelmäßig entfernen, unabhängig davon, ob der Schlüssel neu ist
if (now - lastSweep > windowMs) {
for (const [k, v] of hits) if (now - v.start > windowMs) hits.delete(k);
lastSweep = now;
}
const entry = hits.get(key);
if (!entry || now - entry.start > windowMs) {
// Obergrenze für den Speicher: ältesten Eintrag verdrängen (Map hält die Einfügereihenfolge)
if (!entry && hits.size >= maxKeys) hits.delete(hits.keys().next().value);
hits.set(key, { start: now, count: 1 });
return true;
}
entry.count += 1;
if (hits.size > 10_000) {
for (const [k, v] of hits) if (now - v.start > windowMs) hits.delete(k);
}
return entry.count <= max;
};
}
check.size = () => hits.size;
return check;
}
+224
View File
@@ -0,0 +1,224 @@
// Gameserver-Einstellungen, die der Admin zur Laufzeit ändern kann. Die Werte aus der .env sind die
// Vorgabe; Änderungen landen in der Tabelle settings und überschreiben die Vorgabe auch nach einem Neustart.
import { HttpError } from './matchService.js';
import { parseDockerHosts } from './config.js';
import { setActiveMaps } from './mapPools.js';
const SETTINGS_KEY = 'server_settings';
const HOSTS_KEY = 'docker_hosts';
const MAPS_KEY = 'map_selection';
/**
* Änderbare Felder. `get`/`set` greifen auf das gemeinsame config-Objekt zu, das Match-Service,
* Server-Manager und Docker-Adapter lesen. `live` = wird an laufende Server geschickt.
*/
export const SERVER_SETTING_FIELDS = [
{ key: 'maxRoundsCompetitive', group: 'match', label: 'Runden Competitive (mp_maxrounds)', type: 'int', min: 2, max: 60, live: true,
get: (c) => c.match.maxRoundsCompetitive, set: (c, v) => { c.match.maxRoundsCompetitive = v; } },
{ key: 'maxRoundsWingman', group: 'match', label: 'Runden Wingman (mp_maxrounds)', type: 'int', min: 2, max: 60, live: true,
get: (c) => c.match.maxRoundsWingman, set: (c, v) => { c.match.maxRoundsWingman = v; } },
{ key: 'overtime', group: 'match', label: 'Verlängerung (mp_overtime_enable)', type: 'bool', live: true,
get: (c) => c.match.overtime, set: (c, v) => { c.match.overtime = v; } },
{ key: 'extraCommands', group: 'match', label: 'Zusätzliche Konsolenbefehle (durch ; getrennt)', type: 'commands', live: true,
get: (c) => c.match.extraCommands, set: (c, v) => { c.match.extraCommands = v; } },
{ key: 'tvEnabled', group: 'tv', label: 'CSTV aktiv (wirkt ab dem nächsten Mapwechsel)', type: 'bool', live: false,
get: (c) => c.match.tvEnabled, set: (c, v) => { c.match.tvEnabled = v; } },
{ key: 'tvDelaySeconds', group: 'tv', label: 'CSTV-Verzögerung in Sekunden (tv_delay)', type: 'int', min: 0, max: 300, live: true,
get: (c) => c.match.tvDelaySeconds, set: (c, v) => { c.match.tvDelaySeconds = v; } },
{ key: 'vetoStepSeconds', group: 'times', label: 'Zeit pro Veto-Schritt in Sekunden (0 = ohne Frist)', type: 'int', min: 0, max: 600, live: false,
get: (c) => c.vetoStepSeconds, set: (c, v) => { c.vetoStepSeconds = v; } },
{ key: 'idleTimeoutMinutes', group: 'times', label: 'Leerlauf bis zum Abschalten in Minuten', type: 'int', min: 1, max: 1440, live: false,
get: (c) => c.idleTimeoutMinutes, set: (c, v) => { c.idleTimeoutMinutes = v; } },
{ key: 'maxLifetimeHours', group: 'times', label: 'Maximale Laufzeit eines Servers in Stunden', type: 'int', min: 1, max: 72, live: false,
get: (c) => c.maxLifetimeHours, set: (c, v) => { c.maxLifetimeHours = v; } },
{ key: 'archiveAfterDays', group: 'times', label: 'Abgeschlossene Matches archivieren nach Tagen (0 = nie)', type: 'int', min: 0, max: 365, live: false,
get: (c) => c.archiveAfterDays, set: (c, v) => { c.archiveAfterDays = v; } },
{ key: 'startTimeoutMinutes', group: 'times', label: 'Startzeit eines Servers in Minuten (inkl. Download)', type: 'int', min: 5, max: 240, live: false,
get: (c) => c.docker.startTimeoutMinutes, set: (c, v) => { c.docker.startTimeoutMinutes = v; } },
];
const fieldFor = (key) => SERVER_SETTING_FIELDS.find((f) => f.key === key);
/** Konsolenbefehle aus Text: getrennt durch ; oder Zeilenumbruch. Befehle, die Zugänge ändern oder den Server beenden, sind gesperrt. */
export function parseCommands(value) {
const list = (Array.isArray(value) ? value : String(value ?? '').split(/[;\r\n]+/))
.map((c) => String(c).replace(/[\r\n;]/g, ' ').trim())
.filter(Boolean);
if (list.length > 30) throw new HttpError(400, 'Höchstens 30 zusätzliche Befehle', 'invalid');
for (const c of list) {
if (c.length > 120) throw new HttpError(400, `Befehl zu lang: ${c.slice(0, 30)} …`, 'invalid');
if (/^(rcon_password|sv_password|tv_password|rcon|quit|exit|_restart|logaddress_\w*)\b/i.test(c)) {
throw new HttpError(400, `Befehl nicht erlaubt: ${c.split(' ')[0]}`, 'invalid');
}
}
return list;
}
function parseValue(field, raw) {
if (field.type === 'bool') {
if (typeof raw !== 'boolean') throw new HttpError(400, `${field.label}: an oder aus erwartet`, 'invalid');
return raw;
}
if (field.type === 'int') {
const value = Number(raw);
if (raw === '' || raw === null || !Number.isInteger(value) || value < field.min || value > field.max) {
throw new HttpError(400, `${field.label}: ganze Zahl von ${field.min} bis ${field.max} erwartet`, 'invalid');
}
return value;
}
return parseCommands(raw);
}
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
export class ServerSettings {
constructor({ db, config }) {
this.db = db;
this.config = config;
// Vorgaben aus der .env merken, damit der Admin zurück auf die Vorgabe kann
this.defaults = Object.fromEntries(SERVER_SETTING_FIELDS.map((f) => [f.key, structuredClone(f.get(config))]));
this.defaultHosts = config.docker.hosts;
}
readJson(key) {
const row = this.db.prepare('SELECT value FROM settings WHERE key = ?').get(key);
if (!row) return null;
try {
return JSON.parse(row.value);
} catch {
return null;
}
}
writeJson(key, value) {
this.db.prepare('INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value')
.run(key, JSON.stringify(value));
}
/** Beim Start: gespeicherte Werte auf das config-Objekt legen (vor dem Anlegen des Docker-Adapters). */
load() {
const saved = this.readJson(SETTINGS_KEY) ?? {};
for (const field of SERVER_SETTING_FIELDS) {
if (!(field.key in saved)) continue;
try {
field.set(this.config, parseValue(field, saved[field.key]));
} catch {
// Ungültiger gespeicherter Wert: Vorgabe aus der .env behalten
}
}
const hosts = this.readJson(HOSTS_KEY);
if (hosts) {
try {
this.config.docker.hosts = parseDockerHosts(JSON.stringify(hosts));
} catch {
// Kaputte Host-Liste ignorieren, die .env gilt
}
}
}
view() {
const saved = this.readJson(SETTINGS_KEY) ?? {};
return SERVER_SETTING_FIELDS.map(({ key, group, label, type, min, max, live, get }) => ({
key, group, label, type, min, max, live,
value: get(this.config),
default: this.defaults[key],
overridden: key in saved,
}));
}
/** Übernimmt geänderte Werte. Liefert die Schlüssel, die sich wirklich geändert haben. */
update(input = {}) {
const parsed = Object.entries(input).map(([key, raw]) => {
const field = fieldFor(key);
if (!field) throw new HttpError(400, `Unbekannte Einstellung: ${key}`, 'invalid');
return [field, parseValue(field, raw)];
});
const saved = this.readJson(SETTINGS_KEY) ?? {};
const changed = [];
for (const [field, value] of parsed) {
if (!same(field.get(this.config), value)) changed.push(field.key);
field.set(this.config, value);
if (same(value, this.defaults[field.key])) delete saved[field.key];
else saved[field.key] = value;
}
this.writeJson(SETTINGS_KEY, saved);
return changed;
}
/** Prüft und speichert eine neue Host-Liste (Format wie CS2_DOCKER_HOSTS). */
parseHosts(list) {
if (!Array.isArray(list)) throw new HttpError(400, 'Host-Liste erwartet', 'invalid');
let hosts;
try {
hosts = parseDockerHosts(JSON.stringify(list));
} catch (err) {
throw new HttpError(400, err.message, 'invalid');
}
for (const h of hosts) {
if (h.dockerHost && !/^(ssh:\/\/[\w.@-]+(:\d+)?|tcp:\/\/[\w.-]+:\d+)$/.test(h.dockerHost)) {
throw new HttpError(400, `Ungültiges Docker-Ziel für ${h.name}: ${h.dockerHost}`, 'invalid');
}
if (!/^[\w.-]+$/.test(h.name)) throw new HttpError(400, `Ungültiger Name: ${h.name}`, 'invalid');
if (!/^[\w.-]+$/.test(h.publicHost) || !/^[\w.-]+$/.test(h.rconHost)) {
throw new HttpError(400, `Ungültige Adresse für ${h.name}`, 'invalid');
}
if (h.volume && !/^[\w.-]+$/.test(h.volume)) throw new HttpError(400, `Ungültiger Volume-Name für ${h.name}`, 'invalid');
const lan = h.lanPorts ?? this.config.docker.lanPorts ?? [];
const rangeStart = h.portRangeStart ?? this.config.docker.portRangeStart;
const rangeEnd = h.portRangeEnd ?? this.config.docker.portRangeEnd;
if (lan.some((p) => p >= rangeStart && p <= rangeEnd)) {
throw new HttpError(400, `LAN-Ports von ${h.name} dürfen nicht im normalen Portbereich liegen`, 'invalid');
}
const start = h.portRangeStart ?? this.config.docker.portRangeStart;
const end = h.portRangeEnd ?? this.config.docker.portRangeEnd;
if (!Number.isInteger(start) || !Number.isInteger(end) || start < 1024 || end > 65535 || end - start < 1) {
throw new HttpError(400, `Ungültiger Portbereich für ${h.name}`, 'invalid');
}
}
return hosts;
}
saveHosts(list, hosts) {
this.writeJson(HOSTS_KEY, list);
this.config.docker.hosts = hosts;
}
/** Entfernt die gespeicherte Host-Liste; danach gilt wieder die .env. */
resetHosts() {
this.db.prepare('DELETE FROM settings WHERE key = ?').run(HOSTS_KEY);
this.config.docker.hosts = this.defaultHosts;
return this.defaultHosts;
}
hostsOverridden() {
return this.readJson(HOSTS_KEY) != null;
}
/** Beim Start: im Admin-Bereich gewählte Maps auf die Pools legen. Unbekannte Maps werden ignoriert. */
applyMapSelection(pools) {
const saved = this.readJson(MAPS_KEY) ?? {};
for (const [mode, ids] of Object.entries(saved)) {
const pool = pools[mode];
if (!pool || !Array.isArray(ids)) continue;
const known = ids.filter((id) => pool.allMaps.some((m) => m.id === id));
if (known.length >= 2) setActiveMaps(pool, known);
}
}
/** Aktive Maps eines Modus setzen und speichern. Gilt für neue Matches, laufende behalten ihre Maps. */
saveMapSelection(pools, mode, ids) {
const pool = pools[mode];
if (!pool) throw new HttpError(400, `Unbekannter Modus: ${mode}`, 'invalid');
if (!Array.isArray(ids)) throw new HttpError(400, 'Map-Liste erwartet', 'invalid');
const unique = [...new Set(ids.map(String))];
const unknown = unique.filter((id) => !pool.allMaps.some((m) => m.id === id));
if (unknown.length) throw new HttpError(400, `Unbekannte Map: ${unknown.join(', ')}`, 'invalid');
if (unique.length < 2) throw new HttpError(400, 'Mindestens 2 Maps auswählen', 'invalid');
setActiveMaps(pool, unique);
const saved = this.readJson(MAPS_KEY) ?? {};
saved[mode] = pool.maps.map((m) => m.id);
this.writeJson(MAPS_KEY, saved);
return pool;
}
}
+2 -2
View File
@@ -29,7 +29,7 @@
*/
import { DemoServerAdapter } from './demoAdapter.js';
import { DockerServerAdapter } from './dockerAdapter.js';
import { DockerHostPool } from './dockerAdapter.js';
import { StaticPoolServerAdapter } from './staticPoolAdapter.js';
export function createServerAdapter(config, deps) {
@@ -37,7 +37,7 @@ export function createServerAdapter(config, deps) {
case 'demo':
return new DemoServerAdapter(config.demo);
case 'docker':
return new DockerServerAdapter(config.docker, deps);
return new DockerHostPool(config.docker, deps);
case 'static':
return new StaticPoolServerAdapter(config.staticPool, deps);
default:
+23 -1
View File
@@ -1,6 +1,8 @@
// Erzeugt die Konsolenbefehle, mit denen ein laufender CS2-Server für eine Map der Serie konfiguriert wird.
// Alle Werte werden so bereinigt, dass sie keine weiteren Befehle einschleusen können.
import { BACKUP_PATTERN } from '../rounds.js';
export function sanitizeConsoleText(value, maxLength = 32) {
return String(value ?? '')
.replace(/["';\\\r\n\t]/g, '')
@@ -23,7 +25,7 @@ export function sanitizeConsoleText(value, maxLength = 32) {
* @param {object} p.secrets { serverPassword, tvPassword }
* @param {object} p.settings config.match
*/
export function buildMapCommands({ match, pool, seriesMap, secrets, settings }) {
export function buildMapCommands({ match, pool, seriesMap, secrets, settings, logUrl, backupPrefix }) {
const isWingman = pool.mode === 'wingman';
const ctTeam = seriesMap.ctTeam ?? 'A';
const tTeam = ctTeam === 'A' ? 'B' : 'A';
@@ -36,6 +38,17 @@ export function buildMapCommands({ match, pool, seriesMap, secrets, settings })
`tv_enable ${settings.tvEnabled ? 1 : 0}`,
`tv_delay ${Number(settings.tvDelaySeconds)}`,
`tv_password "${secrets.tvPassword}"`,
// Live-Ergebnisse: Server schickt seine Logzeilen per HTTP an das Backend
...(logUrl && /^https?:\/\/[\w.:/-]+$/.test(logUrl)
? ['log on', 'logaddress_delall_http', `logaddress_add_http "${logUrl}"`]
: []),
// Rundensicherung: CS2 speichert nach jeder Runde den Spielzustand, damit sich Runden zurückspielen lassen
...(backupPrefix ? [
`mp_backup_round_file "${backupPrefix}"`,
`mp_backup_round_file_pattern "${BACKUP_PATTERN}"`,
'mp_backup_round_auto 1',
'mp_backup_restore_load_autopause 1',
] : []),
`changelevel ${seriesMap.map}`,
];
@@ -46,10 +59,19 @@ export function buildMapCommands({ match, pool, seriesMap, secrets, settings })
`mp_maxrounds ${isWingman ? settings.maxRoundsWingman : settings.maxRoundsCompetitive}`,
`mp_overtime_enable ${settings.overtime ? 1 : 0}`,
'mp_match_can_clinch 1',
// Keine Map-Abstimmung nach Spielende: die nächste Map legt das Veto fest
'mp_endmatch_votenextmap 0',
'mp_autoteambalance 0',
'mp_limitteams 0',
// Aufwärmphase läuft, bis der Organisator das Match startet (mp_warmup_end)
'mp_warmup_pausetimer 1',
// Turnierregeln (FACEIT/ESL-Richtwerte): Pausen erlauben, feste Rundenzeiten, Halbzeit
'sv_pausable 1',
'mp_halftime 1',
'mp_freezetime 20',
'mp_buytime 20',
'mp_startmoney 800',
'mp_roundtime 1.92',
...settings.extraCommands,
];
+25
View File
@@ -2,6 +2,9 @@
// Er simuliert nur Startdauer und Konfiguration, damit der Ablauf ohne Hosting getestet werden kann.
// Das Flag simulated=true sorgt dafür, dass das Frontend den Server ausdrücklich als Simulation kennzeichnet.
import { PassThrough } from 'node:stream';
import { backupPrefix, parseBackupFiles } from '../rounds.js';
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
export class DemoServerAdapter {
@@ -41,6 +44,28 @@ export class DemoServerAdapter {
handle.map = plan.map;
}
// Simulierte Sicherungen: eine Datei je Runde der aktuellen Map, damit die Oberfläche testbar ist
async listRoundBackups(handle, matchId, map = 'de_demo') {
const files = [1, 2, 3].map((r) => `${backupPrefix(matchId)}_01_01_${map}_round${r}_score_${r}_0.txt`);
return parseBackupFiles(files.join('\n'), matchId);
}
async loadRoundBackup() {}
/** Simulierte Konsolenausgabe, damit die Konsolen-Ansicht ohne echten Server testbar ist. */
streamLogs(handle, { intervalMs = 1000 } = {}) {
const stdout = new PassThrough();
const stderr = new PassThrough();
let n = 0;
stdout.write('Demo-Adapter: kein echter Container, die Ausgabe ist simuliert\n');
const timer = setInterval(() => {
n += 1;
stdout.write(`[Demo] Server läuft (${handle.map ?? 'de_demo'}), Zeile ${n}\n`);
}, intervalMs);
const proc = { stdout, stderr, kill() { clearInterval(timer); stdout.end(); stderr.end(); proc.onExit?.(0); }, on(evt, fn) { if (evt === 'close') proc.onExit = fn; return proc; } };
return proc;
}
async sendCommands(handle, commands) {
return commands.map(() => '(Demo – nicht ausgeführt)');
}
+70
View File
@@ -0,0 +1,70 @@
// DEMO: erzeugt einen zufälligen Matchverlauf als CS2-Logzeilen.
// Die Zeilen laufen durch denselben Parser wie echte Serverlogs, so lässt sich die
// Live-Anzeige ohne CS2-Server testen. Ergebnisse sind frei erfunden.
const REASONS = {
CT: ['CTs_Win', 'Bomb_Defused', 'Target_Saved'],
T: ['Terrorists_Win', 'Target_Bombed'],
};
const stamp = () => {
const d = new Date();
const p = (n, l = 2) => String(n).padStart(l, '0');
return `${p(d.getMonth() + 1)}/${p(d.getDate())}/${d.getFullYear()} - ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}.${p(d.getMilliseconds(), 3)} - `;
};
export class DemoMatchSimulator {
constructor({ random = Math.random } = {}) {
this.runs = new Map();
this.random = random;
}
start(matchId, { map, teamNames, ctTeam, maxRounds, roundMs, onLines }) {
this.stop(matchId);
const other = (t) => (t === 'A' ? 'B' : 'A');
const run = { sides: { CT: ctTeam, T: other(ctTeam) }, score: { A: 0, B: 0 }, played: 0 };
const toWin = maxRounds / 2 + 1;
// Leichte Stärke-Unterschiede, damit nicht jedes Demo-Match gleich aussieht
const strengthA = 0.4 + this.random() * 0.2;
const teamLines = () => [
`${stamp()}MatchStatus: Team playing "CT": ${teamNames[run.sides.CT]}`,
`${stamp()}MatchStatus: Team playing "TERRORIST": ${teamNames[run.sides.T]}`,
];
onLines([...teamLines(), `${stamp()}World triggered "Match_Start" on "${map}"`]);
run.timer = setInterval(() => {
const winner = this.random() < strengthA ? 'A' : 'B';
const side = run.sides.CT === winner ? 'CT' : 'T';
run.score[winner] += 1;
run.played += 1;
const notice = REASONS[side][Math.floor(this.random() * REASONS[side].length)];
// Reihenfolge wie bei einem echten CS2-Server: Rundenende, Seitenzuordnung der gespielten Runde, Round_End
const lines = [
`${stamp()}Team "${side === 'CT' ? 'CT' : 'TERRORIST'}" triggered "SFUI_Notice_${notice}" (CT "${run.score[run.sides.CT]}") (T "${run.score[run.sides.T]}")`,
...teamLines(),
`${stamp()}World triggered "Round_End"`,
];
if (run.score[winner] >= toWin) {
lines.push(`${stamp()}Game Over: competitive mg_active ${map} score ${run.score[run.sides.CT]}:${run.score[run.sides.T]} after 1 min`);
onLines(lines);
this.stop(matchId);
return;
}
// Halbzeit: Seiten wechseln, die nächsten Zeilen nennen die neue Zuordnung
if (run.played === maxRounds / 2) run.sides = { CT: run.sides.T, T: run.sides.CT };
onLines(lines);
}, roundMs);
this.runs.set(matchId, run);
}
stop(matchId) {
const run = this.runs.get(matchId);
if (run) clearInterval(run.timer);
this.runs.delete(matchId);
}
stopAll() {
for (const id of [...this.runs.keys()]) this.stop(id);
}
}
+583 -22
View File
@@ -1,55 +1,273 @@
import { execFile } from 'node:child_process';
import { execFile, spawn } from 'node:child_process';
import { promisify } from 'node:util';
import { configureViaRcon, rconStatus } from './rconConfigure.js';
import { configureViaRcon, listRoundBackups, loadRoundBackup, rconStatus } from './rconConfigure.js';
import { runRcon } from './rcon.js';
import { sanitizeConsoleText } from './cs2Config.js';
import { safeErrorMessage } from '../security.js';
const exec = promisify(execFile);
/**
* Plant Spiel- und CSTV-Port für einen neuen Server. Der CSTV-Port liegt immer `tvPortOffset` (Standard 100)
* über dem Spielport. Spielports kommen zuerst aus den LAN-Ports (damit der Server im LAN-Reiter des
* Steam-Serverbrowsers auftaucht), danach aus dem normalen Bereich. null = kein Platz.
*/
export function planPorts(host, used) {
const offset = host.tvPortOffset ?? 100;
const free = (p) => !used.has(p) && p <= 65535;
const candidates = [...(host.lanPorts ?? [])];
for (let p = host.portRangeStart; p <= host.portRangeEnd; p += 1) candidates.push(p);
const port = candidates.find((p) => free(p) && free(p + offset));
return port === undefined ? null : { port, tvPort: port + offset };
}
/** Wie viele Server passen noch auf einen Host, wenn bereits `used` belegt ist. */
export function countSlots(host, used) {
const taken = new Set(used);
let count = 0;
for (;;) {
const plan = planPorts(host, taken);
if (!plan) return count;
taken.add(plan.port);
taken.add(plan.tvPort);
count += 1;
}
}
// Ereignisse des Docker-Hosts, auf Deutsch. exec_* (Healthchecks fremder Container) und Mount-Rauschen fallen weg,
// weil sie nichts aussagen und Befehlszeilen mit Zugangsdaten enthalten können.
const EVENT_LABELS = {
container: {
create: 'angelegt', start: 'gestartet', restart: 'neu gestartet', stop: 'gestoppt', kill: 'abgebrochen',
die: 'beendet', destroy: 'entfernt', pause: 'angehalten', unpause: 'fortgesetzt', oom: 'kein Speicher mehr',
'health_status: healthy': 'gesund', 'health_status: unhealthy': 'ungesund',
},
image: { pull: 'geladen', delete: 'gelöscht', untag: 'Markierung entfernt', tag: 'markiert' },
volume: { create: 'angelegt', destroy: 'gelöscht' },
};
const EVENT_TYPES = { container: 'Container', image: 'Image', volume: 'Volume' };
/** Eine JSON-Zeile von `docker events` lesbar machen. null = Ereignis wird nicht angezeigt. */
export function formatDockerEvent(line) {
let e;
try {
e = JSON.parse(line);
} catch {
return null;
}
const label = EVENT_LABELS[e.Type]?.[e.Action];
if (!label) return null;
const attrs = e.Actor?.Attributes ?? {};
const time = new Date((e.time ?? 0) * 1000).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' });
const name = attrs.name ?? e.Actor?.ID?.slice(0, 12) ?? '';
const app = attrs['cs2-mapveto'] === '1' ? ' [Mapveto]' : '';
const extra = e.Action === 'die' && attrs.exitCode != null ? ` (Exit-Code ${attrs.exitCode})`
: e.Action === 'kill' && attrs.signal ? ` (Signal ${attrs.signal})`
: e.Type === 'container' && attrs.image ? ` (${attrs.image})` : '';
return `${time} ${EVENT_TYPES[e.Type]} ${name}${app} ${label}${extra}`;
}
/**
* Skript für den Update-Container: dasselbe SteamCMD-Update wie beim Serverstart des Images (entry.sh),
* nur ohne danach den Server zu starten. Vorher und nachher wird die Build-Nummer ausgegeben.
*/
export const GAMEFILES_UPDATE_SCRIPT = [
'f=/home/steam/cs2-dedicated/steamapps/appmanifest_730.acf',
'b() { grep -m1 \'"buildid"\' "$f" 2>/dev/null | tr -dc 0-9; }',
'echo "BUILD_BEFORE=$(b)"',
'bash /home/steam/steamcmd/steamcmd.sh +force_install_dir /home/steam/cs2-dedicated +@bClientTryRequestManifestWithoutCode 1'
+ ' +login anonymous +app_update 730 -beta public +quit',
'rc=$?',
'echo "BUILD_AFTER=$(b)"',
// StateFlags 4 = vollständig installiert; SteamCMD meldet auch bei Abbruch oft Exit-Code 0
'echo "STATE_AFTER=$(grep -m1 \'"StateFlags"\' "$f" 2>/dev/null | tr -dc 0-9)"',
'exit $rc',
].join('; ');
/** Fortschritt und Build-Nummern aus der Ausgabe des Update-Containers lesen. */
export function parseUpdateOutput(text, into = {}) {
for (const line of String(text).split(/\r?\n/)) {
const before = /^BUILD_BEFORE=(\d*)/.exec(line);
const after = /^BUILD_AFTER=(\d*)/.exec(line);
const state = /^STATE_AFTER=(\d*)/.exec(line);
const error = /(Error!.*|ERROR!.*)/.exec(line);
const progress = /progress:\s*([\d.]+)/.exec(line);
if (before) into.buildBefore = before[1] || null;
else if (after) into.buildAfter = after[1] || null;
else if (state) into.stateAfter = state[1] || null;
else if (error) into.error = error[1].trim().slice(0, 200);
else if (progress) into.progress = Number(progress[1]);
if (line.trim()) into.lastLine = line.trim().slice(0, 200);
}
return into;
}
/** Fehler von "docker run", wenn ein Port auf dem Host schon von etwas anderem belegt ist. */
const PORT_BUSY = /port is already allocated|address already in use/i;
const DISK_CACHE_MS = 60_000;
/** Liest Gesamt- und freien Speicher aus einer Zeile von `df -B1` (Spalten: Filesystem, 1B-blocks, Used, Available …). */
export function parseDf(output) {
const parts = String(output).trim().split(/\s+/);
const totalBytes = Number(parts[1]);
const freeBytes = Number(parts[3]);
if (!Number.isFinite(totalBytes) || !Number.isFinite(freeBytes)) return null;
return { totalBytes, freeBytes };
}
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
// Diese Zeile schreibt das Image, sobald SteamCMD fertig ist und der Spielserver startet.
const UPDATE_DONE_MARKER = 'Starting CS2 Dedicated Server';
/**
* Startet pro Match einen eigenen CS2-Container (Standard-Image: joedwards32/cs2, Nachfolger von cm2network/cs2).
* Benötigt Docker auf dem Backend-Host, ein Game Server Login Token (CS2_GSLT)
* und eine öffentlich erreichbare Adresse (CS2_PUBLIC_HOST).
* Benötigt Docker (lokal oder per DOCKER_HOST, z. B. ssh://…) und die Adresse, unter der Spieler
* den Docker-Host erreichen (CS2_PUBLIC_HOST).
*
* Ohne CS2_GSLT laufen die Server im LAN-Modus (sv_lan 1): nur aus dem lokalen Netz erreichbar.
*
* Pro Match werden zwei aufeinanderfolgende Ports belegt: Spielport (TCP+UDP, auch RCON)
* und CSTV-Port (UDP).
*
* Alle Container teilen sich das Volume mit den Spieldateien. Jeder prüft beim Start per SteamCMD
* auf Updates; damit nie zwei gleichzeitig schreiben, starten Container nacheinander: der nächste
* erst, wenn der vorige seinen Update-Check abgeschlossen hat.
*/
export class DockerServerAdapter {
constructor(options, { getUsedPorts }) {
constructor(options, { getUsedPorts, runDocker } = {}) {
this.name = 'docker';
this.simulated = false;
this.options = options;
this.getUsedPorts = getUsedPorts;
this.runDocker = runDocker; // nur für Tests
this.lanMode = !options.gslt;
this.startQueue = Promise.resolve();
// Starts, die gerade warten oder deren Container noch SteamCMD laufen lässt
this.pendingStarts = 0;
if (!options.host) throw new Error('SERVER_ADAPTER=docker benötigt CS2_PUBLIC_HOST');
if (!options.gslt) throw new Error('SERVER_ADAPTER=docker benötigt CS2_GSLT (Game Server Login Token)');
}
async docker(args, timeout = 60_000) {
const { stdout } = await exec(this.options.bin, args, { timeout, maxBuffer: 4 * 1024 * 1024 });
if (this.runDocker) return this.runDocker(args, this.options.name);
const env = this.options.dockerHost ? { ...process.env, DOCKER_HOST: this.options.dockerHost } : process.env;
try {
const { stdout } = await exec(this.options.bin, args, { timeout, maxBuffer: 4 * 1024 * 1024, env });
return stdout.trim();
} catch (err) {
// Die Befehlszeile enthält bei "docker run" die Passwörter: nie weiterreichen
const clean = new Error(safeErrorMessage(err));
clean.stderr = safeErrorMessage({ message: err.stderr ?? '' });
clean.code = err.code;
throw clean;
}
}
allocatePort() {
const used = this.getUsedPorts();
for (let p = this.options.portRangeStart; p + 1 <= this.options.portRangeEnd; p += 2) {
if (!used.has(p) && !used.has(p + 1)) return p;
}
throw new Error('Kein freier Port im Bereich CS2_PORT_RANGE_START–CS2_PORT_RANGE_END');
allocatePorts(blocked = new Set()) {
const used = new Set([...this.getUsedPorts(), ...blocked]);
const plan = planPorts(this.options, used);
if (!plan) throw new Error('Kein freier Port im Bereich CS2_PORT_RANGE_START–CS2_PORT_RANGE_END');
return plan;
}
async start({ matchId, mode, secrets }) {
const port = this.allocatePort();
const tvPort = port + 1;
/** Servername im Steam-Serverbrowser, z. B. "Maxlan | Team A vs. Team B". */
serverName(matchId, teamNames) {
const prefix = this.options.serverNamePrefix ? sanitizeConsoleText(this.options.serverNamePrefix, 24) : '';
if (!teamNames) return `${prefix} ${matchId}`.trim();
const title = `${sanitizeConsoleText(teamNames.A)} vs. ${sanitizeConsoleText(teamNames.B)}`;
return (prefix ? `${prefix} | ${title}` : title).slice(0, 63);
}
async start(args) {
// In die Warteschlange: erst starten, wenn der vorige Container seinen Update-Check hinter sich hat.
const previous = this.startQueue;
let releaseQueue;
this.startQueue = new Promise((resolve) => { releaseQueue = resolve; });
this.pendingStarts += 1;
const release = () => {
this.pendingStarts -= 1;
releaseQueue();
};
await previous;
let result;
try {
result = await this.launch(args);
} catch (err) {
release();
throw err;
}
this.waitForUpdateCheck(result.handle).finally(release);
return result;
}
/**
* Wartet, bis der Container SteamCMD abgeschlossen hat, beendet ist oder die Startzeit abläuft.
* Fertig ist er, sobald der Spielserver per RCON antwortet (RCON gibt es erst nach SteamCMD) oder
* die Startzeile im Log steht. Nur auf das Log zu schauen reicht nicht: Der Server schreibt danach
* so viel, dass die Zeile schnell aus den letzten Zeilen herausfällt.
*/
async waitForUpdateCheck(handle, { pollMs = 3000 } = {}) {
const deadline = Date.now() + this.options.startTimeoutMinutes * 60_000;
while (Date.now() < deadline) {
try {
const state = await this.docker(['inspect', '-f', '{{.State.Status}}', handle.container], 15_000);
if (state !== 'running' && state !== 'created') return;
if (await this.isReachable(handle)) return;
const logs = await this.docker(['logs', '--tail', '2000', handle.container], 15_000);
if (logs.includes(UPDATE_DONE_MARKER)) return;
} catch {
return; // Container weg – nicht ewig blockieren
}
await sleep(pollMs);
}
}
async isReachable(handle) {
try {
await rconStatus(this.target(handle));
return true;
} catch {
return false;
}
}
/**
* Im Host-Netz meldet Docker keinen Portkonflikt, CS2 startet dann einfach nicht. Deshalb vorher per SSH
* nachsehen, ob ein fremder Dienst den Port belegt. Ohne SSH-Ziel (oder in Tests) gilt der Port als frei.
*/
async hostPortsFree(ports) {
if (this.runDocker || !this.options.dockerHost?.startsWith('ssh://')) return true;
try {
const filter = ports.map((p) => `sport = :${Number(p)}`).join(' or ');
const { stdout } = await exec('ssh', [
'-o', 'BatchMode=yes', '-o', 'ConnectTimeout=5', this.options.dockerHost.slice('ssh://'.length),
`ss -Hlnut '( ${filter} )'`,
], { timeout: 10_000 });
return stdout.trim() === '';
} catch {
return true;
}
}
async launch({ matchId, mode, secrets, teamNames, serverName }, blocked = new Set()) {
const { port, tvPort } = this.allocatePorts(blocked);
const lan = (this.options.lanPorts ?? []).includes(port);
if (lan && blocked.size < 10 && !(await this.hostPortsFree([port, tvPort]))) {
return this.launch({ matchId, mode, secrets, teamNames, serverName }, new Set([...blocked, port, tvPort]));
}
const container = `cs2mv-${matchId.replace(/[^a-zA-Z0-9_-]/g, '')}`.toLowerCase();
const env = {
SRCDS_TOKEN: this.options.gslt,
CS2_SERVERNAME: `CS2 Mapveto ${matchId}`,
...(this.lanMode ? { CS2_LAN: '1' } : { SRCDS_TOKEN: this.options.gslt, CS2_LAN: '0' }),
CS2_SERVERNAME: serverName ? sanitizeConsoleText(serverName, 63) : this.serverName(matchId, teamNames),
CS2_PORT: String(port),
CS2_RCONPW: secrets.rconPassword,
CS2_PW: secrets.serverPassword,
CS2_MAXPLAYERS: mode === 'wingman' ? '4' : '10',
// Spielerplätze + 1 für CSTV (belegt selbst einen Platz) + 1 Reserve (Admin, Ersatz)
CS2_MAXPLAYERS: mode === 'wingman' ? '6' : '12',
CS2_GAMETYPE: '0',
CS2_GAMEMODE: mode === 'wingman' ? '2' : '1',
CS2_SERVER_HIBERNATE: '0',
// Ohne diesen Startparameter legt CS2 keine Rundensicherungen an (Rollback)
CS2_ADDITIONAL_ARGS: '-allow_round_backup_files',
TV_ENABLE: '1',
TV_PORT: String(tvPort),
TV_PW: secrets.tvPassword,
@@ -58,22 +276,42 @@ export class DockerServerAdapter {
};
const args = ['run', '-d', '--name', container, '--label', 'cs2-mapveto=1'];
for (const [k, v] of Object.entries(env)) args.push('-e', `${k}=${v}`);
if (lan) {
// Der LAN-Reiter von Steam sucht per Broadcast. Die Port-Weiterleitung von Docker reicht Broadcasts
// nicht an den Container weiter, deshalb laufen Server auf LAN-Ports direkt im Netz des Hosts.
args.push('--network', 'host');
} else {
args.push(
'-p', `${port}:${port}/tcp`,
'-p', `${port}:${port}/udp`,
'-p', `${tvPort}:${tvPort}/udp`,
'-v', `${this.options.volume}:/home/steam/cs2-dedicated`,
);
}
args.push('-v', `${this.options.volume}:/home/steam/cs2-dedicated`);
if (this.options.extraArgs) args.push(...this.options.extraArgs.split(' ').filter(Boolean));
args.push(this.options.image);
// Ein evtl. übrig gebliebener Container gleichen Namens (z. B. nach Retry) wird ersetzt.
await this.docker(['rm', '-f', container]).catch(() => {});
// Das erste "docker run" kann das Image laden und dauert dann länger.
try {
await this.docker(args, 15 * 60_000);
} catch (err) {
// Port von einem fremden Dienst belegt (die App kennt nur ihre eigenen Server): mit anderen Ports erneut versuchen
if (!PORT_BUSY.test(`${err.message} ${err.stderr ?? ''}`) || blocked.size >= 10) throw err;
await this.docker(['rm', '-f', container]).catch(() => {});
return this.launch({ matchId, mode, secrets, teamNames, serverName }, new Set([...blocked, port, tvPort]));
}
return {
handle: { container, rconHost: this.options.rconHost, port, startedAt: Date.now(), rconPassword: secrets.rconPassword },
handle: {
container,
hostName: this.options.name,
rconHost: this.options.rconHost,
port,
startedAt: Date.now(),
rconPassword: secrets.rconPassword,
},
host: this.options.host,
port,
tvPort,
@@ -104,7 +342,7 @@ export class DockerServerAdapter {
if (minutes >= this.options.startTimeoutMinutes) {
return { state: 'failed', message: `Server nach ${minutes} Minuten nicht per RCON erreichbar` };
}
return { state: 'starting', message: 'Container läuft, Spielserver lädt (Download/Update möglich)' };
return { state: 'starting', message: 'Container läuft, Spielserver lädt (Update-Prüfung oder Start)' };
}
}
@@ -112,11 +350,334 @@ export class DockerServerAdapter {
await configureViaRcon(this.target(handle), plan);
}
async listRoundBackups(handle, matchId) {
return listRoundBackups(this.target(handle), matchId);
}
async loadRoundBackup(handle, file) {
await loadRoundBackup(this.target(handle), file);
}
async sendCommands(handle, commands) {
return runRcon(this.target(handle), commands);
}
/** Ereignisse des Docker-Hosts live (`docker events`), ab `sinceMinutes` in der Vergangenheit. Nur lesen. */
streamEvents({ sinceMinutes = 120 } = {}) {
const env = this.options.dockerHost ? { ...process.env, DOCKER_HOST: this.options.dockerHost } : process.env;
return spawn(this.options.bin, ['events', '--since', `${Math.max(0, sinceMinutes)}m`, '--format', '{{json .}}'], { env, windowsHide: true });
}
/** Konsolenausgabe des Containers live: `docker logs --follow`. Liefert den Kindprozess (stdout/stderr, kill). */
streamLogs(handle, { tail = 200 } = {}) {
const env = this.options.dockerHost ? { ...process.env, DOCKER_HOST: this.options.dockerHost } : process.env;
return spawn(this.options.bin, ['logs', '--follow', '--tail', String(tail), handle.container], { env, windowsHide: true });
}
async stop(handle) {
await this.docker(['rm', '-f', handle.container]).catch(() => {});
}
}
/**
* Mehrere Docker-Hosts. Jedes Match läuft auf dem Host mit den meisten freien Port-Paaren,
* der Host steht im Handle und damit im Match. Start, Status, Konfiguration und Stopp gehen
* immer an denselben Host. Ohne CS2_DOCKER_HOSTS gibt es genau einen Host: den bisherigen.
*/
export class DockerHostPool {
constructor(options, { getUsedPorts, getStarting, runDocker } = {}) {
this.name = 'docker';
this.simulated = false;
this.depsGetUsedPorts = getUsedPorts;
this.depsGetStarting = getStarting;
this.runDocker = runDocker;
this.options = options;
this.diskCache = new Map();
this.diskInflight = new Map();
this.pull = { running: false, startedAt: null, finishedAt: null, results: [] };
this.files = { running: false, startedAt: null, finishedAt: null, results: [] };
this.setHosts(options.hosts);
}
/** Baut die Host-Liste neu auf (beim Start und wenn der Admin die Hosts ändert). */
setHosts(list) {
const options = this.options;
const hosts = list ?? [{
name: 'default',
dockerHost: process.env.DOCKER_HOST || null,
publicHost: options.host,
rconHost: options.rconHost,
portRangeStart: options.portRangeStart,
portRangeEnd: options.portRangeEnd,
volume: options.volume,
}];
// Fehlende Angaben kommen aus den allgemeinen Docker-Einstellungen
this.hosts = hosts.map((h) => {
const host = {
name: h.name,
dockerHost: h.dockerHost ?? null,
publicHost: h.publicHost ?? options.host,
rconHost: h.rconHost ?? options.rconHost,
portRangeStart: h.portRangeStart ?? options.portRangeStart,
portRangeEnd: h.portRangeEnd ?? options.portRangeEnd,
volume: h.volume ?? options.volume,
lanPorts: h.lanPorts ?? options.lanPorts ?? [],
tvPortOffset: options.tvPortOffset ?? 100,
};
const merged = { ...options, ...host, host: host.publicHost };
// Die Startzeit kann der Admin zur Laufzeit ändern, deshalb immer aus den gemeinsamen Einstellungen lesen
Object.defineProperty(merged, 'startTimeoutMinutes', { get: () => options.startTimeoutMinutes, enumerable: true });
return merged;
});
this.adapters = new Map(this.hosts.map((h) => [h.name, new DockerServerAdapter(h, {
getUsedPorts: () => (this.depsGetUsedPorts ? this.depsGetUsedPorts(h.publicHost) : new Set()),
runDocker: this.runDocker,
})]));
this.diskCache.clear();
}
/**
* Docker-Befehl direkt auf dem Host ausführen. Bei ssh:// per SSH, damit der Pull die Anmeldedaten des Hosts
* nutzt und nicht den Credential-Helper des Rechners, auf dem die App läuft.
*/
async hostDocker(name, args, timeout) {
const host = this.hosts.find((h) => h.name === name);
if (!this.runDocker && host?.dockerHost?.startsWith('ssh://')) {
const { stdout } = await exec('ssh', [
'-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10', host.dockerHost.slice('ssh://'.length),
['docker', ...args].map((a) => `'${a}'`).join(' '),
], { timeout, maxBuffer: 4 * 1024 * 1024 });
return stdout.trim();
}
return this.adapters.get(name).docker(args, timeout);
}
/** Lädt das CS2-Image auf allen Hosts gleichzeitig neu. Laufende Server bleiben unberührt. */
async pullImage() {
if (this.pull.running) throw new Error('Das Image wird bereits aktualisiert');
if (!/^[\w./:@-]+$/.test(this.options.image)) throw new Error('Ungültiger Image-Name');
const hosts = this.hosts.map((h) => h.name);
this.pull = {
running: true,
startedAt: Date.now(),
finishedAt: null,
image: this.options.image,
results: hosts.map((name) => ({ name, state: 'running', message: null })),
};
await Promise.all(hosts.map(async (name, i) => {
const result = this.pull.results[i];
try {
await this.hostDocker(name, ['pull', '--quiet', this.options.image], 20 * 60_000);
const digest = await this.hostDocker(name, ['image', 'inspect', this.options.image, '--format', '{{index .RepoDigests 0}}'], 30_000)
.catch(() => '');
Object.assign(result, { state: 'ok', message: digest.split('@')[1]?.slice(0, 19) ?? 'aktualisiert' });
} catch (err) {
Object.assign(result, { state: 'failed', message: String(err.stderr || err.message).trim().split('\n').pop().slice(0, 200) });
}
}));
this.pull.running = false;
this.pull.finishedAt = Date.now();
return this.pull;
}
pullStatus() {
return this.pull;
}
/**
* CS2-Spieldateien im Volume jedes Hosts per SteamCMD aktualisieren (eigener, kurzlebiger Container).
* Hosts in `skip` (z. B. mit laufenden Servern) werden ausgelassen, damit niemandem die Dateien unter
* einem laufenden Server ausgetauscht werden.
*/
async updateGameFiles({ skip = new Map() } = {}) {
if (this.files.running) throw new Error('Die Spieldateien werden bereits aktualisiert');
const hosts = this.hosts.map((h) => h.name);
this.files = {
running: true,
startedAt: Date.now(),
finishedAt: null,
results: hosts.map((name) => (skip.has(name)
? { name, state: 'skipped', message: skip.get(name) }
: { name, state: 'running', message: 'SteamCMD prüft auf Updates …', progress: null })),
};
await Promise.all(this.files.results.filter((r) => r.state === 'running').map((r) => this.runFileUpdate(r)));
this.files.running = false;
this.files.finishedAt = Date.now();
return this.files;
}
async runFileUpdate(result) {
const host = this.hosts.find((h) => h.name === result.name);
const args = [
'run', '--rm', '--name', `cs2mv-update-${host.name.replace(/[^a-zA-Z0-9_.-]/g, '')}`, '--label', 'cs2-mapveto-update=1',
'-v', `${host.volume}:/home/steam/cs2-dedicated`, '--entrypoint', 'bash', this.options.image, '-c', GAMEFILES_UPDATE_SCRIPT,
];
const info = {};
const finish = (exitOk, error) => {
const { buildBefore: from, buildAfter: to } = info;
// Nur "ok", wenn Steam die App als vollständig installiert meldet
const complete = info.stateAfter == null || info.stateAfter === '4';
const ok = exitOk && complete && !info.error;
if (!ok && exitOk && !error) {
error = info.error ?? `nicht vollständig installiert (StateFlags ${info.stateAfter}), bitte erneut starten`;
}
if (ok) {
Object.assign(result, {
state: 'ok',
progress: 100,
message: from && to && from === to ? `bereits aktuell (Build ${to})` : to ? `aktualisiert auf Build ${to}${from ? ` (vorher ${from})` : ''}` : 'aktualisiert',
});
} else {
Object.assign(result, { state: 'failed', message: String(error || info.lastLine || 'SteamCMD-Update fehlgeschlagen').slice(0, 200) });
}
};
if (this.runDocker) {
// Tests: Ausgabe kommt gesammelt zurück
try {
parseUpdateOutput(await this.runDocker(args, host.name), info);
finish(true);
} catch (err) {
finish(false, err.message);
}
return;
}
await new Promise((resolve) => {
const env = host.dockerHost ? { ...process.env, DOCKER_HOST: host.dockerHost } : process.env;
const proc = spawn(this.options.bin, args, { env, windowsHide: true });
const onData = (chunk) => {
parseUpdateOutput(chunk.toString('utf8'), info);
if (info.progress != null) Object.assign(result, { progress: info.progress, message: `Update läuft ${Math.floor(info.progress)} %` });
};
proc.stdout.on('data', onData);
proc.stderr.on('data', onData);
// Ein großes Update dauert, aber nicht ewig
const timer = setTimeout(() => proc.kill(), 90 * 60_000);
proc.on('error', (err) => { clearTimeout(timer); finish(false, err.message); resolve(); });
proc.on('close', (code) => { clearTimeout(timer); finish(code === 0, code === null ? 'abgebrochen (Zeitlimit)' : null); resolve(); });
});
}
filesStatus() {
return this.files;
}
/**
* Freier Speicher auf einem Host, per SSH: dort, wo das Gameserver-Volume liegt. Ergebnis wird eine Minute lang
* zwischengespeichert, weil die Admin-Seite alle paar Sekunden abfragt. Ohne ssh:// gibt es null.
*/
async diskUsage(name) {
const host = this.hosts.find((h) => h.name === name);
if (!host?.dockerHost?.startsWith('ssh://')) return null;
const cached = this.diskCache.get(name);
if (cached && Date.now() - cached.at < DISK_CACHE_MS) return cached.value;
if (!this.diskInflight.has(name)) {
this.diskInflight.set(name, (async () => {
let value = null;
try {
const { stdout } = await exec('ssh', [
'-o', 'BatchMode=yes', '-o', 'ConnectTimeout=5', host.dockerHost.slice('ssh://'.length),
`df -B1 "$(docker volume inspect ${host.volume} --format '{{with .Options}}{{.device}}{{end}}' 2>/dev/null | grep . || docker volume inspect ${host.volume} --format '{{.Mountpoint}}' 2>/dev/null || echo /var/lib/docker)" 2>/dev/null || df -B1 /var/lib/docker 2>/dev/null || df -B1 /`,
], { timeout: 10_000 });
value = parseDf(stdout.trim().split('\n').pop());
} catch {
value = null;
}
this.diskCache.set(name, { at: Date.now(), value });
this.diskInflight.delete(name);
return value;
})());
}
return this.diskInflight.get(name);
}
/** Wie viele Server noch auf einen Host passen. */
freeSlots(host) {
return countSlots(host, this.getUsedPorts(host.publicHost));
}
/** Auslastung pro Host: Anzahl Serverplätze insgesamt und belegt. */
hostStats() {
return this.hosts.map((h) => {
const total = countSlots(h, new Set());
return { name: h.name, publicHost: h.publicHost, total, used: total - this.freeSlots(h) };
});
}
getUsedPorts(publicHost) {
return this.depsGetUsedPorts?.(publicHost) ?? new Set();
}
/** Hosts, auf denen gerade die Spieldateien aktualisiert werden: dort startet kein neuer Server. */
updatingHosts() {
return new Set(this.files.running ? this.files.results.filter((r) => r.state === 'running').map((r) => r.name) : []);
}
/**
* Wie viele Server auf einem Host gerade hochfahren: Starts in der Warteschlange dieses Prozesses, und
* (nach einem Neustart der App) Server, die laut Datenbank noch starten. Ein Container lädt beim Start
* per SteamCMD, nach einem großen CS2-Update dauert das lange; weitere Starts dort müssten so lange warten.
*/
startingOn(host, exclude) {
const queued = this.adapters.get(host.name)?.pendingStarts ?? 0;
const stored = this.depsGetStarting?.(host.publicHost, exclude) ?? 0;
return Math.max(queued, stored);
}
pickHost(exclude) {
const updating = this.updatingHosts();
const best = this.hosts
.filter((h) => !updating.has(h.name))
.map((h) => ({ host: h, free: this.freeSlots(h), starting: this.startingOn(h, exclude) }))
.filter((x) => x.free > 0)
// Zuerst Hosts, auf denen nichts hochfährt, dann die mit den meisten freien Plätzen
.sort((a, b) => a.starting - b.starting || b.free - a.free)[0];
if (!best) throw new Error(updating.size ? 'Kein Docker-Host frei, auf den anderen werden gerade die Spieldateien aktualisiert' : 'Kein Docker-Host mit freien Ports');
return best.host;
}
async start(args) {
return this.adapters.get(this.pickHost(args?.matchId).name).start(args);
}
adapterFor(handle) {
const name = handle?.hostName ?? this.hosts[0].name;
const adapter = this.adapters.get(name);
if (adapter) return adapter;
// Server aus der Zeit vor der Host-Liste (Name "default") oder nach einer Umbenennung: über die Adresse finden
const byAddress = handle?.rconHost && this.hosts.find((h) => h.rconHost === handle.rconHost || h.publicHost === handle.rconHost);
if (byAddress) return this.adapters.get(byAddress.name);
throw new Error(`Docker-Host "${name}" ist nicht (mehr) konfiguriert`);
}
async status(handle) {
return this.adapterFor(handle).status(handle);
}
async configure(handle, plan) {
return this.adapterFor(handle).configure(handle, plan);
}
async listRoundBackups(handle, matchId) {
return this.adapterFor(handle).listRoundBackups(handle, matchId);
}
async loadRoundBackup(handle, file) {
return this.adapterFor(handle).loadRoundBackup(handle, file);
}
async sendCommands(handle, commands) {
return this.adapterFor(handle).sendCommands(handle, commands);
}
streamLogs(handle, options) {
return this.adapterFor(handle).streamLogs(handle, options);
}
/** Ereignisse eines Hosts nach Namen; null, wenn es den Host nicht gibt. */
streamEvents(name, options) {
return this.adapters.get(name)?.streamEvents(options) ?? null;
}
async stop(handle) {
return this.adapterFor(handle).stop(handle);
}
}
+354
View File
@@ -0,0 +1,354 @@
// Messer-Server für die Seitenwahl: Gameserver, die dauerhaft laufen und keinem Match gehören.
// Die Teams gehen drauf, spielen ihre Messerrunde und gehen wieder. Nach Spielende oder wenn der Server
// leer wird, setzt er sich von selbst zurück. Fällt ein Server aus, startet ihn die App neu.
import { HttpError } from '../matchService.js';
import { randomPassword, safeErrorMessage } from '../security.js';
const SLOW_POLL_MS = 10_000;
// Nach so vielen Ausfällen in Folge gibt die App auf, damit ein kaputter Host nicht endlos neu startet
const MAX_RESTARTS = 5;
/**
* Konsolenbefehle für eine Messerrunde: nur Messer, kein Geld, eine Runde, danach automatisch neues Spiel.
* `pool` bestimmt den Spielmodus (Competitive 5v5 oder Wingman 2v2), `warmupSeconds` die Zeit zum Verbinden.
*/
export function buildKnifeCommands({ map, pool = { gameType: 0, gameMode: 1 }, warmupSeconds = 90, password = '', endlessWarmup = false }) {
return {
map,
beforeMapChange: [
`game_type ${Number(pool.gameType)}`,
`game_mode ${Number(pool.gameMode)}`,
`sv_password "${password}"`,
// Auf dem Match-Server (mit Passwort) bleibt CSTV, wie es ist: das Match braucht es danach
...(password ? [] : ['tv_enable 0']),
`changelevel ${map}`,
],
afterMapChange: [
'bot_kick',
'bot_quota 0',
// Eine Runde entscheidet, danach startet das Spiel von selbst neu (für die nächsten Teams)
'mp_maxrounds 1',
'mp_match_can_clinch 1',
// Keine Map-Abstimmung nach Spielende: die nächste Map legt das Veto fest
'mp_endmatch_votenextmap 0',
'mp_match_end_restart 1',
'mp_match_restart_delay 10',
'mp_halftime 0',
'mp_overtime_enable 0',
// Nur Messer: keine Startwaffen, kein Geld, nichts zu kaufen
'mp_ct_default_primary ""',
'mp_t_default_primary ""',
'mp_ct_default_secondary ""',
'mp_t_default_secondary ""',
'mp_ct_default_grenades ""',
'mp_t_default_grenades ""',
'mp_give_player_c4 0',
'mp_startmoney 0',
'mp_maxmoney 0',
'mp_afterroundmoney 0',
'mp_buytime 0',
'mp_free_armor 1',
'mp_weapons_allow_map_placed 0',
'mp_death_drop_gun 0',
'mp_death_drop_grenade 0',
'mp_freezetime 3',
'mp_roundtime 2',
'mp_roundtime_defuse 2',
'mp_roundtime_hostage 2',
'mp_autoteambalance 0',
'mp_limitteams 0',
'mp_autokick 0',
// Aufwärmphase zum Verbinden, danach beginnt die Messerrunde. Endlos: bis der Organisator sie startet.
'mp_do_warmup_period 1',
`mp_warmuptime ${Math.max(10, Math.min(600, Number(warmupSeconds) || 90))}`,
`mp_warmup_pausetimer ${endlessWarmup ? 1 : 0}`,
'mp_warmup_start',
],
};
}
/**
* Setzt die Messer-Einstellungen auf CS2-Standard zurück, bevor auf demselben Server das Match eingerichtet wird.
* Was die Match-Konfiguration selbst setzt (Startgeld, Kaufzeit, Rundenzeit …), steht dort.
*/
export const KNIFE_UNDO_COMMANDS = [
'mp_ct_default_secondary weapon_hkp2000',
'mp_t_default_secondary weapon_glock',
'mp_ct_default_primary ""',
'mp_t_default_primary ""',
'mp_ct_default_grenades ""',
'mp_t_default_grenades ""',
'mp_give_player_c4 1',
'mp_maxmoney 16000',
'mp_afterroundmoney 0',
'mp_free_armor 0',
'mp_weapons_allow_map_placed 1',
'mp_death_drop_gun 1',
'mp_death_drop_grenade 2',
'mp_match_end_restart 0',
'mp_roundtime_defuse 1.92',
'mp_roundtime_hostage 1.92',
];
/** Befehle, mit denen ein laufender Messer-Server für die nächsten Teams zurückgesetzt wird. */
export const KNIFE_RESET_COMMANDS = ['mp_warmup_start'];
export class KnifeServers {
constructor({ db, adapter, config, pools = {}, logger = console }) {
this.db = db;
this.pools = pools;
this.adapter = adapter;
this.config = config;
this.logger = logger;
this.busy = new Set();
this.lastPoll = new Map();
this.timer = null;
}
rows() {
return this.db.prepare('SELECT * FROM knife_servers ORDER BY number').all();
}
row(id) {
return this.db.prepare('SELECT * FROM knife_servers WHERE id = ?').get(id);
}
update(id, fields) {
const keys = Object.keys(fields);
if (!keys.length) return;
this.db.prepare(`UPDATE knife_servers SET ${keys.map((k) => `${k} = @${k}`).join(', ')}, updated_at = @updated_at WHERE id = @id`)
.run({ ...fields, updated_at: Date.now(), id });
}
/** Ansicht für Admin und Startseite. Ohne RCON-Passwort und Handle. */
view(row) {
const pool = this.pools[row.mode];
const size = pool?.teamSize;
return {
id: row.id,
number: row.number,
name: row.name,
mode: row.mode,
modeLabel: pool?.label ?? row.mode,
modeShort: size ? `${size}v${size}` : null,
map: row.map,
mapName: pool?.maps.find((m) => m.id === row.map)?.name ?? row.map,
status: row.status,
message: row.message,
host: row.host,
port: row.port,
playerCount: row.player_count,
restarts: row.restarts,
startedAt: row.started_at,
connect: row.host && row.port ? `connect ${row.host}:${row.port}` : null,
steamUrl: row.host && row.port ? `steam://connect/${row.host}:${row.port}` : null,
};
}
list() {
return this.rows().map((r) => this.view(r));
}
/** Ports aller Messer-Server auf einem Host (für die Portvergabe zusammen mit den Match-Servern). */
usedPorts(host) {
const rows = host
? this.db.prepare('SELECT host, port, tv_port FROM knife_servers WHERE host = ? AND port IS NOT NULL').all(host)
: this.db.prepare('SELECT host, port, tv_port FROM knife_servers WHERE port IS NOT NULL').all();
return rows;
}
/** Modi für Messer-Server (5v5, 2v2) mit ihren Maps, aus den Map-Pools. */
modes() {
return Object.entries(this.pools).map(([id, pool]) => {
const maps = pool.maps.map((m) => ({ id: m.id, name: m.name }));
return {
id,
label: pool.label,
teamSize: pool.teamSize,
maps,
defaultMap: maps.some((m) => m.id === this.config.knife.map) ? this.config.knife.map : maps[0]?.id,
};
});
}
/** Neuen Messer-Server starten. Liefert die Ansicht des neuen Servers. */
async create({ map, mode = 'competitive' }) {
if (this.adapter.name === 'static') throw new HttpError(409, 'Mit festen Servern (static) gibt es keine Messer-Server', 'unsupported');
const info = this.modes().find((m) => m.id === mode);
if (!info) throw new HttpError(400, `Unbekannter Modus: ${mode}`, 'invalid');
const chosen = String(map || info.defaultMap);
if (!info.maps.some((m) => m.id === chosen)) throw new HttpError(400, `Map ${chosen} gibt es nicht für ${info.label}`, 'invalid');
const max = this.config.knife.maxServers;
const count = this.db.prepare('SELECT COUNT(*) AS n FROM knife_servers').get().n;
if (count >= max) throw new HttpError(409, `Höchstens ${max} Messer-Server`, 'limit');
const used = new Set(this.rows().map((r) => r.number));
let number = 1;
while (used.has(number)) number += 1;
const id = `knife${number}`;
const now = Date.now();
this.db.prepare(`
INSERT INTO knife_servers (id, number, name, mode, map, status, message, rcon_password, restarts, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, 'starting', 'Server wird gestartet', ?, 0, ?, ?)
`).run(id, number, `Messer ${number}`, mode, chosen, randomPassword(24), now, now);
await this.launch(id);
return this.view(this.row(id));
}
async launch(id) {
const row = this.row(id);
this.busy.add(id);
try {
const prefix = this.config.docker?.serverNamePrefix;
const size = this.pools[row.mode]?.teamSize;
const result = await this.adapter.start({
matchId: id,
// Wingman: 6 Plätze, Competitive: 12 Plätze (wie bei den Match-Servern)
mode: row.mode,
secrets: { rconPassword: row.rcon_password, serverPassword: '', tvPassword: randomPassword(12) },
serverName: `${prefix ? `${prefix} | ` : ''}${row.name} ${size ? `${size}v${size} ` : ''}(Seitenwahl)`,
});
// Während des Starts entfernt: Container gleich wieder abbauen
if (!this.row(id)) {
await this.adapter.stop(result.handle).catch(() => {});
return;
}
this.update(id, {
status: 'starting',
message: 'Container läuft, Spielserver lädt',
handle: JSON.stringify(result.handle),
host: result.host,
port: result.port,
tv_port: result.tvPort,
player_count: null,
started_at: Date.now(),
});
} catch (err) {
const message = safeErrorMessage(err, [row.rcon_password]);
if (this.row(id)) this.update(id, { status: 'failed', message: `Start fehlgeschlagen: ${message}` });
throw new HttpError(502, `Messer-Server konnte nicht gestartet werden: ${message}`, 'start_failed');
} finally {
this.busy.delete(id);
}
}
async configure(id) {
const row = this.row(id);
this.busy.add(id);
this.update(id, { status: 'configuring', message: `Lade ${row.map}` });
try {
await this.adapter.configure(JSON.parse(row.handle), buildKnifeCommands({ map: row.map, pool: this.pools[row.mode], warmupSeconds: this.config.knife.warmupSeconds }));
this.update(id, { status: 'running', message: 'Bereit für die Messerrunde', restarts: 0 });
} catch (err) {
this.update(id, { status: 'config_failed', message: `Konfiguration fehlgeschlagen: ${err.message}` });
} finally {
this.busy.delete(id);
}
}
async poll() {
for (const row of this.rows()) {
if (this.busy.has(row.id) || !row.handle) continue;
const last = this.lastPoll.get(row.id) ?? 0;
if (row.status === 'running' && Date.now() - last < SLOW_POLL_MS) continue;
this.lastPoll.set(row.id, Date.now());
await this.pollOne(row).catch((err) => this.logger.warn?.(`[messer] ${row.name}: ${err.message}`));
}
}
async pollOne(row) {
let status;
try {
status = await this.adapter.status(JSON.parse(row.handle));
} catch (err) {
status = { state: row.status === 'starting' ? 'starting' : 'running', message: err.message };
}
if (status.state === 'failed' || status.state === 'stopped') {
await this.recover(row, status.message);
return;
}
if (status.state === 'running' && row.status === 'starting') {
await this.configure(row.id);
return;
}
if (row.status === 'config_failed' && status.state === 'running') {
// Konfiguration noch einmal versuchen, der Server läuft ja
await this.configure(row.id);
return;
}
if (row.status !== 'running' || status.playerCount === undefined) return;
const players = status.playerCount;
if (players !== row.player_count) this.update(row.id, { player_count: players });
// Alle sind gegangen: Server für die nächsten Teams zurücksetzen
if (row.player_count > 0 && players === 0) await this.reset(row.id, 'Server leer – für die nächsten Teams zurückgesetzt');
}
/** Ausgefallenen Server neu starten, damit immer Messer-Server laufen. */
async recover(row, reason) {
// Während des Spieldateien-Updates nicht neu starten (zählt auch nicht als Fehlversuch)
if (this.adapter.filesStatus?.().running) {
if (row.message !== 'Wartet auf das Spieldateien-Update') this.update(row.id, { status: 'starting', message: 'Wartet auf das Spieldateien-Update' });
return;
}
if (row.restarts >= MAX_RESTARTS) {
if (row.status !== 'failed') this.update(row.id, { status: 'failed', message: `Ausgefallen, ${MAX_RESTARTS} Neustarts ohne Erfolg: ${reason ?? ''}`.trim() });
return;
}
this.logger.warn?.(`[messer] ${row.name} ausgefallen (${reason}), starte neu`);
this.update(row.id, { restarts: row.restarts + 1, status: 'starting', message: `Neustart nach Ausfall (${row.restarts + 1}/${MAX_RESTARTS})` });
await this.adapter.stop(JSON.parse(row.handle)).catch(() => {});
await this.launch(row.id).catch(() => {});
}
async reset(id, message = 'Spiel zurückgesetzt (Admin)') {
const row = this.requireRow(id);
if (row.status !== 'running') throw new HttpError(409, 'Der Server ist noch nicht bereit', 'wrong_state');
try {
await this.adapter.sendCommands(JSON.parse(row.handle), KNIFE_RESET_COMMANDS);
} catch (err) {
throw new HttpError(502, `Befehl konnte nicht gesendet werden: ${err.message}`, 'server_error');
}
this.update(id, { message });
}
async startRound(id) {
const row = this.requireRow(id);
if (row.status !== 'running') throw new HttpError(409, 'Der Server ist noch nicht bereit', 'wrong_state');
try {
await this.adapter.sendCommands(JSON.parse(row.handle), ['mp_warmup_end']);
} catch (err) {
throw new HttpError(502, `Befehl konnte nicht gesendet werden: ${err.message}`, 'server_error');
}
this.update(id, { message: 'Messerrunde läuft (Admin gestartet)' });
}
/** Server komplett neu starten (neuer Container), z. B. wenn er hängt. */
async restart(id) {
const row = this.requireRow(id);
if (this.busy.has(id)) throw new HttpError(409, 'Für diesen Server läuft bereits eine Aktion', 'busy');
if (row.handle) await this.adapter.stop(JSON.parse(row.handle)).catch(() => {});
this.update(id, { restarts: 0, status: 'starting', message: 'Neustart durch Admin' });
await this.launch(id);
}
async remove(id) {
const row = this.requireRow(id);
if (row.handle) await this.adapter.stop(JSON.parse(row.handle)).catch(() => {});
this.db.prepare('DELETE FROM knife_servers WHERE id = ?').run(id);
}
requireRow(id) {
const row = this.row(id);
if (!row) throw new HttpError(404, 'Messer-Server nicht gefunden', 'not_found');
return row;
}
startLoop() {
this.timer = setInterval(() => this.poll().catch((e) => this.logger.error(e)), this.config.statusPollSeconds * 1000);
}
stopLoop() {
clearInterval(this.timer);
this.timer = null;
}
}
+16 -5
View File
@@ -75,9 +75,17 @@ export class RconClient {
onData(chunk) {
this.buffer = Buffer.concat([this.buffer, chunk]);
const { packets, rest } = decodePackets(this.buffer);
this.buffer = rest;
for (const packet of packets) for (const listener of this.listeners) listener(packet);
let decoded;
try {
decoded = decodePackets(this.buffer);
} catch (err) {
// Kaputtes Paket: Verbindung trennen, laufende Befehle scheitern über "close" – nie das Backend beenden
this.buffer = Buffer.alloc(0);
this.socket?.destroy(new RconError(err.message));
return;
}
this.buffer = decoded.rest;
for (const packet of decoded.packets) for (const listener of this.listeners) listener(packet);
}
authenticate() {
@@ -105,15 +113,18 @@ export class RconClient {
const id = this.nextId++;
return new Promise((resolve, reject) => {
let body = '';
let answered = false;
let quietTimer = null;
const timer = setTimeout(() => finish(), this.timeoutMs);
// Ohne jede Antwort ist der Befehl nicht bestätigt: als Fehler melden statt als Erfolg
const timer = setTimeout(() => finish(answered ? null : new RconError(`keine Antwort auf "${cmd.split(' ')[0]}"`)), this.timeoutMs);
const listener = (packet) => {
if (packet.id !== id) return;
answered = true;
body += packet.body;
clearTimeout(quietTimer);
quietTimer = setTimeout(finish, quietMs);
};
const onClose = () => finish(body ? null : new RconError('RCON-Verbindung wurde geschlossen'));
const onClose = () => finish(answered ? null : new RconError('RCON-Verbindung wurde geschlossen'));
const finish = (err) => {
clearTimeout(timer);
clearTimeout(quietTimer);
+20 -2
View File
@@ -1,3 +1,4 @@
import { parseBackupFiles } from '../rounds.js';
import { parseStatus, runRcon } from './rcon.js';
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
@@ -10,8 +11,9 @@ export async function configureViaRcon(target, plan, { loadTimeoutMs = 120_000,
try {
await runRcon(target, plan.beforeMapChange);
} catch (err) {
// Beim changelevel trennt der Server die RCON-Verbindung teilweise – das ist kein Fehler.
if (!/geschlossen/.test(err.message)) throw new Error(`Befehle konnten nicht gesendet werden: ${err.message}`);
// Beim changelevel trennt der Server die RCON-Verbindung teilweise oder antwortet erst nach dem Laden –
// beides ist kein Fehler, ob die Map lädt, prüft die Schleife unten.
if (!/geschlossen|keine Antwort auf "changelevel"/.test(err.message)) throw new Error(`Befehle konnten nicht gesendet werden: ${err.message}`);
}
const deadline = Date.now() + loadTimeoutMs;
@@ -42,3 +44,19 @@ export async function rconStatus(target) {
const [output] = await runRcon(target, ['status'], { timeoutMs: 4000 });
return parseStatus(output);
}
/** Sicherungsdateien dieses Matches vom Server holen. */
export async function listRoundBackups(target, matchId) {
const [out] = await runRcon(target, ['mp_backup_restore_list_files 60'], { timeoutMs: 8000, quietMs: 500 });
return parseBackupFiles(out, matchId);
}
/** Sicherung laden. Der Dateiname muss vorher mit isValidBackupFile geprüft sein. */
export async function loadRoundBackup(target, file) {
// mp_backup_restore_load_autopause wirkt in CS2 nicht zuverlässig, deshalb pausieren wir ausdrücklich
await runRcon(target, [
'mp_backup_restore_load_autopause 1',
`mp_backup_restore_load_file "${file}"`,
'mp_pause_match',
], { timeoutMs: 15000, quietMs: 800 });
}
+420 -15
View File
@@ -1,6 +1,9 @@
import { buildMapCommands } from './cs2Config.js';
import { buildMapCommands, sanitizeConsoleText } from './cs2Config.js';
import { buildKnifeCommands, KNIFE_UNDO_COMMANDS } from './knifeServers.js';
import { DemoMatchSimulator } from './demoMatchSimulator.js';
import { backupPrefix, describeBackup, isValidBackupFile, pointsForMap } from '../rounds.js';
import { HttpError, CLOSED_STATUSES } from '../matchService.js';
import { randomPassword } from '../security.js';
import { randomPassword, safeErrorMessage } from '../security.js';
export const SERVER_STATUS = {
starting: 'Server startet',
@@ -15,6 +18,9 @@ export const SERVER_STATUS = {
const ACTIVE = ['starting', 'ready', 'configuring', 'configured', 'config_failed', 'failed'];
const SLOW_POLL_MS = 30_000;
// Messerrunde: so oft wird das Einrichten versucht, bevor der Organisator selbst neu starten muss
const KNIFE_RETRIES = 3;
const KNIFE_RETRY_DELAY_MS = 10_000;
/**
* Steuert den Lebenszyklus der Match-Server. Kennt nur die Adapter-Schnittstelle
@@ -24,6 +30,11 @@ export class ServerManager {
constructor({ db, service, adapter, config, logger = console }) {
Object.assign(this, { db, service, adapter, config, logger });
this.busy = new Set();
this.simulator = new DemoMatchSimulator();
this.autoAdvance = new Map();
// Fehlversuche beim Einrichten der Messerrunde je Match (für die automatische Wiederholung)
this.knifeAttempts = new Map();
this.knifeRetryDelayMs = KNIFE_RETRY_DELAY_MS;
this.lastPoll = new Map();
this.timers = [];
}
@@ -32,6 +43,37 @@ export class ServerManager {
this.service.on('created', (id) => this.provision(id).catch((err) => this.logger.error(err)));
this.service.on('vetoFinished', (id) => this.maybeConfigure(id));
this.service.on('closed', (id) => this.stop(id, 'Match beendet').catch((err) => this.logger.error(err)));
this.service.on('mapEnded', (e) => this.onMapEnded(e));
}
/**
* Nach Map-Ende: ist die Serie entschieden, wird das Match abgeschlossen, sonst die nächste Map geladen.
* Die Wartezeit lässt Spielern und Stream Zeit für den Endstand. Lädt der Organisator vorher selbst, entfällt es.
*/
onMapEnded({ matchId, number, decided, hasNext }) {
const seconds = this.config.autoAdvanceSeconds;
if (seconds < 0) return;
clearTimeout(this.autoAdvance.get(matchId));
const finish = Boolean(decided) || !hasNext;
this.service.log(matchId, 'series', finish
? `Serie entschieden – das Match wird in ${seconds} s automatisch abgeschlossen`
: `Map ${number + 1} wird in ${seconds} s automatisch geladen`);
this.service.changed(matchId);
this.autoAdvance.set(matchId, setTimeout(() => {
this.autoAdvance.delete(matchId);
const match = this.service.getMatch(matchId);
if (CLOSED_STATUSES.includes(match.status) || match.current_map_number !== number) return;
try {
if (finish) {
this.service.closeMatch(matchId, 'finished', 'Match automatisch abgeschlossen – Serie entschieden');
} else if (this.row(matchId)?.status === 'configured') {
this.service.advanceSeriesFor(matchId);
this.configure(matchId).catch((err) => this.logger.error(err));
}
} catch (err) {
this.logger.warn(`[serie] Automatischer Wechsel für ${matchId} fehlgeschlagen: ${err.message}`);
}
}, seconds * 1000));
}
/** Nach einem Neustart des Backends: unterbrochene Vorgänge aufräumen und fortsetzen. */
@@ -52,7 +94,65 @@ export class ServerManager {
this.timers.push(setInterval(() => this.reap().catch((e) => this.logger.error(e)), this.config.reaperIntervalSeconds * 1000));
}
/** Admin: Rücksprungpunkte der aktuellen Map, neueste Runde zuerst. */
async rollbackPoints(viewer) {
this.service.require(viewer, 'organizer');
const { match, seriesMap, handle } = this.rollbackContext(viewer.matchId);
const all = await this.adapter.listRoundBackups(handle, match.id, seriesMap.map);
return pointsForMap(all, seriesMap.map);
}
/** Admin: Match auf den Stand nach einer Runde zurückspielen. */
async rollback(viewer, file) {
this.service.require(viewer, 'organizer');
const { match, handle, seriesMap } = this.rollbackContext(viewer.matchId);
if (!isValidBackupFile(file, match.id)) throw new HttpError(400, 'Ungültige Sicherungsdatei', 'invalid_input');
const point = describeBackup(file, match.id);
if (point.map !== seriesMap.map) throw new HttpError(409, 'Die Sicherung gehört zu einer anderen Map', 'wrong_map');
if (this.busy.has(match.id)) throw new HttpError(409, 'Für diesen Server läuft bereits eine Aktion', 'busy');
this.busy.add(match.id);
try {
await this.adapter.loadRoundBackup(handle, file);
} catch (err) {
throw new HttpError(502, `Zurückspielen fehlgeschlagen: ${err.message}`, 'server_error');
} finally {
this.busy.delete(match.id);
}
this.service.rewindLiveScore(match.id, point.round, file);
}
rollbackContext(matchId) {
const match = this.service.getMatch(matchId);
const row = this.row(matchId);
if (!row?.handle || row.status !== 'configured' || match.status !== 'live') {
throw new HttpError(409, 'Zurückspielen ist nur bei einem laufenden Match mit bereitem Server möglich', 'wrong_state');
}
const seriesMap = this.service.seriesMaps(matchId).find((m) => m.number === match.current_map_number);
return { match, row, seriesMap, handle: JSON.parse(row.handle) };
}
/** Löscht ein Match samt Server-Datensatz. Ein laufender Server wird vorher freigegeben. */
async deleteMatch(matchId, reason = 'Match vom Admin gelöscht') {
this.service.getMatch(matchId);
clearTimeout(this.autoAdvance.get(matchId));
this.autoAdvance.delete(matchId);
this.simulator.stop(matchId);
this.knifeAttempts.delete(matchId);
await this.stop(matchId, reason);
this.db.prepare('DELETE FROM matches WHERE id = ?').run(matchId);
this.service.emit('changed', matchId);
}
/** Wartet, bis keine Server-Aktion mehr läuft (höchstens timeoutMs). */
async idle(timeoutMs = 5000) {
const deadline = Date.now() + timeoutMs;
while (this.busy.size > 0 && Date.now() < deadline) await new Promise((r) => setTimeout(r, 50));
}
stopLoops() {
this.simulator.stopAll();
for (const t of this.autoAdvance.values()) clearTimeout(t);
this.autoAdvance.clear();
this.timers.forEach(clearInterval);
this.timers = [];
}
@@ -68,9 +168,132 @@ export class ServerManager {
this.service.changed(matchId);
}
/** Auslastung der Docker-Hosts mit den Matches, die dort laufen. Leer, wenn der Adapter keine Hosts kennt. */
async hostUsage() {
if (!this.adapter.hostStats) return [];
const rows = this.db.prepare(`
SELECT s.host, s.status, m.id AS match_id, m.team_a_name, m.team_b_name
FROM servers s JOIN matches m ON m.id = s.match_id
WHERE s.status != 'stopped' AND s.host IS NOT NULL
`).all();
const knives = this.db.prepare('SELECT id, name, status, host FROM knife_servers WHERE host IS NOT NULL').all();
const stats = this.adapter.hostStats();
const disks = await Promise.all(stats.map((h) => this.adapter.diskUsage?.(h.name) ?? null));
return stats.map((h, i) => ({
...h,
disk: disks[i],
matches: [
...rows
.filter((r) => r.host === h.publicHost)
.map((r) => ({ id: r.match_id, title: `${r.team_a_name} vs. ${r.team_b_name}`, status: r.status })),
...knives
.filter((k) => k.host === h.publicHost)
.map((k) => ({ id: k.id, title: `${k.name} (Seitenwahl)`, status: k.status })),
],
}));
}
/** Server, die gerade laufen oder starten, mit dem Docker-Host aus ihrem Handle. */
activeServers() {
return this.db.prepare(`
SELECT s.match_id, s.status, s.host, s.handle, m.mode
FROM servers s JOIN matches m ON m.id = s.match_id
WHERE s.status != 'stopped' AND s.handle IS NOT NULL
`).all().map((r) => {
let handle = null;
try {
handle = JSON.parse(r.handle);
} catch {
// kaputter Handle: Server zählt trotzdem als aktiv
}
return { ...r, handle, hostName: handle?.hostName ?? null };
});
}
/**
* Übernimmt eine neue Liste von Docker-Hosts. Hosts mit laufenden Servern dürfen nicht wegfallen
* und ihre Adresse nicht ändern, sonst verliert die App den Kontakt zu diesen Servern.
*/
setDockerHosts(hosts) {
if (!this.adapter.setHosts) throw new HttpError(409, 'Nur mit dem Docker-Adapter möglich', 'unsupported');
const before = new Map(this.adapter.hosts.map((h) => [h.name, h]));
// Ohne Liste gilt der eine Host aus DOCKER_HOST/CS2_PUBLIC_HOST
const nextList = hosts ?? [{ name: 'default', publicHost: this.config.docker.host, dockerHost: process.env.DOCKER_HOST || null }];
for (const server of this.activeServers()) {
const name = server.hostName ?? this.adapter.hosts[0]?.name;
const old = before.get(name);
const next = nextList.find((h) => h.name === name);
if (!next) {
throw new HttpError(409, `Auf ${name} läuft noch ein Server. Erst stoppen, dann den Host entfernen.`, 'host_busy');
}
if (old && (next.publicHost !== old.publicHost || (next.dockerHost ?? null) !== (old.dockerHost ?? null))) {
throw new HttpError(409, `Auf ${name} läuft noch ein Server. Adresse und Docker-Ziel erst danach ändern.`, 'host_busy');
}
}
this.adapter.setHosts(hosts);
}
/**
* Schickt geänderte Match-Einstellungen an alle bereiten Server. Liefert, wie viele Server
* aktualisiert wurden und welche nicht erreichbar waren.
*/
async applyLiveSettings(changed) {
const keys = new Set(changed);
const rows = this.activeServers().filter((r) => r.status === 'configured');
if (!this.adapter.sendCommands || rows.length === 0) return { updated: 0, failed: [] };
const m = this.config.match;
const result = { updated: 0, failed: [] };
for (const row of rows) {
const wingman = row.mode === 'wingman';
const commands = [];
if (keys.has(wingman ? 'maxRoundsWingman' : 'maxRoundsCompetitive')) {
commands.push(`mp_maxrounds ${wingman ? m.maxRoundsWingman : m.maxRoundsCompetitive}`);
}
if (keys.has('overtime')) commands.push(`mp_overtime_enable ${m.overtime ? 1 : 0}`);
if (keys.has('tvDelaySeconds')) commands.push(`tv_delay ${m.tvDelaySeconds}`);
if (keys.has('extraCommands')) commands.push(...m.extraCommands);
if (commands.length === 0) continue;
try {
await this.adapter.sendCommands(row.handle, commands);
result.updated += 1;
this.service.log(row.match_id, 'server', `Einstellungen übernommen (Admin): ${commands.join('; ')}`);
} catch (err) {
result.failed.push(row.match_id);
this.logger.warn?.(`Einstellungen für ${row.match_id} nicht übernommen: ${err.message}`);
}
}
return result;
}
/** Server (Matches und Messer-Server), die auf einem Host gerade hochfahren. `exclude`: der, der gerade startet. */
startingOn(host, exclude = null) {
return this.db.prepare(`
SELECT (SELECT COUNT(*) FROM servers WHERE host = @host AND status = 'starting' AND handle IS NOT NULL AND match_id IS NOT @exclude)
+ (SELECT COUNT(*) FROM knife_servers WHERE host = @host AND status = 'starting' AND handle IS NOT NULL AND id IS NOT @exclude) AS n
`).get({ host, exclude }).n;
}
/** Ports, die auf einem bestimmten Docker-Host belegt sind. */
usedPortsOn(host) {
const used = new Set();
for (const r of this.db.prepare(`SELECT port, tv_port FROM servers WHERE host = ? AND status != 'stopped' AND port IS NOT NULL`).all(host)) {
used.add(r.port);
used.add(r.tv_port);
}
// Messer-Server belegen ihre Ports dauerhaft
for (const r of this.db.prepare('SELECT port, tv_port FROM knife_servers WHERE host = ? AND port IS NOT NULL').all(host)) {
used.add(r.port);
used.add(r.tv_port);
}
return used;
}
usedPorts() {
const used = new Set();
for (const r of this.db.prepare(`SELECT host, port, tv_port FROM servers WHERE status != 'stopped' AND port IS NOT NULL`).all()) {
for (const r of this.db.prepare(`
SELECT host, port, tv_port FROM servers WHERE status != 'stopped' AND port IS NOT NULL
UNION ALL SELECT host, port, tv_port FROM knife_servers WHERE port IS NOT NULL
`).all()) {
used.add(r.port);
used.add(r.tv_port);
used.add(`${r.host}:${r.port}`);
@@ -98,7 +321,7 @@ export class ServerManager {
VALUES (@match_id, @adapter, @simulated, 'starting', @message, NULL, NULL, NULL, NULL,
@rcon, @server, @tv, @key, @attempts, @now, @now)
ON CONFLICT(match_id) DO UPDATE SET adapter = excluded.adapter, simulated = excluded.simulated,
status = 'starting', message = excluded.message, handle = NULL, host = NULL, port = NULL, tv_port = NULL,
status = 'starting', message = excluded.message, handle = NULL, knife_at = NULL, knife_phase = NULL, host = NULL, port = NULL, tv_port = NULL,
rcon_password = excluded.rcon_password, server_password = excluded.server_password,
tv_password = excluded.tv_password, config_key = excluded.config_key, attempts = excluded.attempts,
started_at = excluded.started_at, ready_at = NULL, configured_at = NULL, stopped_at = NULL,
@@ -120,8 +343,22 @@ export class ServerManager {
this.service.log(matchId, 'server', this.adapter.simulated ? 'Demo-Server (Simulation) wird vorbereitet' : 'Server wird gestartet');
const match = this.service.getMatch(matchId);
const attempt = (previous?.attempts ?? 0) + 1;
try {
const result = await this.adapter.start({ matchId, mode: match.mode, secrets });
const result = await this.adapter.start({
matchId,
mode: match.mode,
secrets,
teamNames: { A: match.team_a_name, B: match.team_b_name },
});
// Während des (oft langen) Starts gestoppt, neu angefordert oder gelöscht: den neuen Container
// gleich wieder abbauen, sonst liefe er ohne Überwachung weiter
const current = this.row(matchId);
if (!current || current.status !== 'starting' || current.attempts !== attempt) {
this.logger.warn?.(`[server] ${matchId}: Start kam nach dem Stopp an, Container wird wieder entfernt`);
await this.adapter.stop(result.handle, { resetPassword: randomPassword(16) }).catch(() => {});
return;
}
this.update(matchId, {
handle: JSON.stringify(result.handle),
host: result.host,
@@ -130,8 +367,9 @@ export class ServerManager {
message: this.adapter.simulated ? 'Simulierter Start läuft' : 'Server fährt hoch',
});
} catch (err) {
this.update(matchId, { status: 'start_failed', message: err.message });
this.service.log(matchId, 'server', `Serverstart fehlgeschlagen: ${err.message}`);
const message = safeErrorMessage(err, Object.values(secrets));
if (this.row(matchId)?.status === 'starting') this.update(matchId, { status: 'start_failed', message });
if (this.row(matchId)) this.service.log(matchId, 'server', `Serverstart fehlgeschlagen: ${message}`);
}
} finally {
this.busy.delete(matchId);
@@ -173,6 +411,11 @@ export class ServerManager {
} else if (status.state === 'failed') {
Object.assign(fields, { status: current.status === 'starting' ? 'start_failed' : 'failed', message: status.message });
this.service.log(matchId, 'server', `Server-Fehler: ${status.message}`);
this.service.notify('server_error', matchId, {
title: '⚠️ Serverfehler',
description: `**${this.service.matchTitle(matchId)}**\n${status.message}`,
color: 0xdc2626,
});
} else if (status.state === 'stopped') {
Object.assign(fields, { status: 'stopped', stopped_at: Date.now(), message: status.message ?? 'Server wurde extern beendet' });
this.service.log(matchId, 'server', 'Server wurde außerhalb der Anwendung beendet');
@@ -189,11 +432,78 @@ export class ServerManager {
maybeConfigure(matchId) {
const match = this.service.getMatch(matchId);
const row = this.row(matchId);
if (match.status === 'server_setup' && row?.status === 'ready') {
// Seitenwahl per Messerrunde: derselbe Server dient vor dem Veto als Messer-Server
if (this.wantsKnife(match, row) && !row.knife_at) {
this.configureKnife(matchId).catch((err) => this.logger.error(err));
return;
}
// Auch im laufenden Match: nach einem Neustart des Servers die aktuelle Map samt Passwort wieder laden
if (['server_setup', 'live'].includes(match.status) && row?.status === 'ready') {
this.configure(matchId).catch((err) => this.logger.error(err));
}
}
/** Match in der Lobby mit Messerrunde und hochgefahrenem Server. */
wantsKnife(match, row) {
return match?.status === 'lobby' && match.veto_start === 'knife' && row?.status === 'ready';
}
/** Messerrunde auf dem Match-Server einrichten: Aufwärmphase ohne Ende, Passwort des Matches. */
async configureKnife(matchId) {
if (this.busy.has(matchId)) throw new HttpError(409, 'Für diesen Server läuft bereits eine Aktion', 'busy');
this.busy.add(matchId);
try {
const row = this.row(matchId);
const match = this.service.getMatch(matchId);
const pool = this.service.poolFor(match);
const map = pool.maps.some((m) => m.id === this.config.knife?.map) ? this.config.knife.map : pool.maps[0].id;
this.update(matchId, { message: 'Messerrunde wird eingerichtet' });
try {
await this.adapter.configure(JSON.parse(row.handle), buildKnifeCommands({ map, pool, password: row.server_password, endlessWarmup: true }));
} catch (err) {
// Das erste Laden einer Map dauert nach einer frischen Installation oft länger: von selbst noch einmal versuchen
const attempt = (this.knifeAttempts.get(matchId) ?? 0) + 1;
this.knifeAttempts.set(matchId, attempt);
const again = attempt < KNIFE_RETRIES;
this.update(matchId, {
knife_at: null,
knife_phase: null,
message: again
? `Messerrunde wird erneut eingerichtet (Versuch ${attempt + 1} von ${KNIFE_RETRIES}): ${err.message}`
: `Messerrunde konnte nicht eingerichtet werden: ${err.message}`,
});
this.service.log(matchId, 'server', `Messerrunde nicht eingerichtet: ${err.message}`);
if (again) this.timers.push(setTimeout(() => this.maybeConfigure(matchId), this.knifeRetryDelayMs));
return;
}
this.knifeAttempts.delete(matchId);
this.update(matchId, { knife_at: Date.now(), knife_phase: 'warmup', message: 'Messerrunde: Aufwärmphase, bis der Organisator startet' });
this.service.log(matchId, 'server', 'Server für die Messerrunde bereit (Aufwärmphase)');
} finally {
this.busy.delete(matchId);
}
// Ist das Veto inzwischen durch, gleich das Match einrichten (der Aufruf dafür kam, während wir beschäftigt waren)
if (this.service.getMatch(matchId).status !== 'lobby') this.maybeConfigure(matchId);
}
/** Organisator: Messerrunde starten (Aufwärmphase beenden) oder wiederholen (zurück in die Aufwärmphase). */
async knifeAction(viewer, what) {
this.service.require(viewer, 'organizer');
const match = this.service.getMatch(viewer.matchId);
const row = this.row(viewer.matchId);
if (match.status !== 'lobby' || !row?.knife_at) throw new HttpError(409, 'Der Server ist noch nicht für die Messerrunde bereit', 'wrong_state');
const start = what === 'start';
try {
await this.adapter.sendCommands(JSON.parse(row.handle), start ? ['mp_warmup_pausetimer 0', 'mp_warmup_end'] : ['mp_warmup_pausetimer 1', 'mp_warmup_start']);
} catch (err) {
throw new HttpError(502, `Befehl konnte nicht gesendet werden: ${err.message}`, 'server_error');
}
this.update(viewer.matchId, start
? { knife_phase: 'live', message: 'Messerrunde läuft' }
: { knife_phase: 'warmup', message: 'Messerrunde: Aufwärmphase, bis der Organisator startet' });
this.service.log(viewer.matchId, 'server', start ? 'Messerrunde gestartet' : 'Messerrunde zurück in die Aufwärmphase');
}
async configure(matchId) {
const row = this.row(matchId);
if (!row?.handle) throw new HttpError(409, 'Es gibt noch keinen gestarteten Server', 'no_server');
@@ -211,7 +521,8 @@ export class ServerManager {
if (!seriesMap) throw new HttpError(409, 'Das Veto ist noch nicht abgeschlossen', 'veto_pending');
const mapName = pool.maps.find((m) => m.id === seriesMap.map)?.name ?? seriesMap.map;
this.update(matchId, { status: 'configuring', message: `Lade Map ${seriesMap.number}: ${mapName}` });
// Ein Mapwechsel beendet die Pause, CS2 lädt die Map neu
this.update(matchId, { status: 'configuring', paused: 0, message: `Lade Map ${seriesMap.number}: ${mapName}` });
this.service.log(matchId, 'server', `Konfiguration für Map ${seriesMap.number} (${mapName}) gestartet`);
const plan = buildMapCommands({
@@ -220,17 +531,31 @@ export class ServerManager {
seriesMap: { map: seriesMap.map, ctTeam: seriesMap.ct_team },
secrets: { serverPassword: row.server_password, tvPassword: row.tv_password },
settings: this.config.match,
logUrl: `${this.config.logReceiverUrl}/api/logs/${matchId}/${row.config_key}`,
backupPrefix: backupPrefix(matchId),
});
this.simulator.stop(matchId);
this.service.resetLiveScore(matchId, seriesMap.number);
// Lief vorher die Messerrunde auf diesem Server, deren Einstellungen zurücknehmen
if (row.knife_at) {
// Eine noch laufende Messerrunde zuerst anhalten: sonst endet sie (mp_maxrounds 1) mitten im Einrichten,
// und ihr Ergebnis würde als Ende der ersten Map gezählt
plan.beforeMapChange = ['mp_warmup_pausetimer 1', 'mp_warmup_start', ...plan.beforeMapChange];
plan.afterMapChange = [...KNIFE_UNDO_COMMANDS, ...plan.afterMapChange];
}
try {
await this.adapter.configure(JSON.parse(row.handle), { map: seriesMap.map, ...plan });
} catch (err) {
this.update(matchId, { status: 'config_failed', message: err.message });
this.service.log(matchId, 'server', `Konfiguration fehlgeschlagen: ${err.message}`);
const message = safeErrorMessage(err, [row.rcon_password, row.server_password, row.tv_password]);
this.update(matchId, { status: 'config_failed', message });
this.service.log(matchId, 'server', `Konfiguration fehlgeschlagen: ${message}`);
return;
}
this.update(matchId, {
status: 'configured',
configured_at: Date.now(),
knife_at: null,
knife_phase: null,
message: this.adapter.simulated
? `Simuliert: ${mapName} wäre jetzt geladen – kein echter Server`
: `${mapName} geladen – Aufwärmphase läuft`,
@@ -259,7 +584,13 @@ export class ServerManager {
await this.provision(matchId);
return;
}
if (row.status === 'config_failed' || (row.status === 'ready' && match.status === 'server_setup')) {
if (this.wantsKnife(match, row)) {
this.knifeAttempts.delete(matchId);
this.service.log(matchId, 'server', 'Organisator richtet die Messerrunde neu ein');
this.configureKnife(matchId).catch((err) => this.logger.error(err));
return;
}
if (row.status === 'config_failed' || (row.status === 'ready' && ['server_setup', 'live'].includes(match.status))) {
this.service.log(matchId, 'server', 'Organisator wiederholt die Konfiguration');
this.configure(matchId).catch((err) => this.logger.error(err));
return;
@@ -271,6 +602,8 @@ export class ServerManager {
this.service.require(viewer, 'organizer');
const row = this.row(viewer.matchId);
if (row?.status !== 'configured') throw new HttpError(409, 'Der Server ist nicht bereit für die nächste Map', 'wrong_state');
clearTimeout(this.autoAdvance.get(viewer.matchId));
this.autoAdvance.delete(viewer.matchId);
this.service.advanceSeries(viewer);
this.configure(viewer.matchId).catch((err) => this.logger.error(err));
}
@@ -280,15 +613,68 @@ export class ServerManager {
const row = this.row(viewer.matchId);
if (row?.status !== 'configured') throw new HttpError(409, 'Der Server ist noch nicht konfiguriert', 'wrong_state');
if (!this.adapter.sendCommands) throw new HttpError(501, 'Dieser Adapter unterstützt keine Befehle', 'unsupported');
// Vor dem Befehl markieren: CS2 schickt Match_Start sofort nach mp_warmup_end, oft bevor RCON antwortet.
this.service.markWarmupEnded(viewer.matchId, true);
try {
await this.adapter.sendCommands(JSON.parse(row.handle), ['mp_warmup_pausetimer 0', 'mp_warmup_end']);
} catch (err) {
this.service.markWarmupEnded(viewer.matchId, false);
throw new HttpError(502, `Befehl konnte nicht gesendet werden: ${err.message}`, 'server_error');
}
this.service.log(viewer.matchId, 'server', this.adapter.simulated ? 'Aufwärmphase beendet (simuliert)' : 'Aufwärmphase beendet – Match startet');
// CSTV-Zugang für Zuschauer, gleiche Form wie im Zuschauerbereich der Webseite
const fields = [];
if (this.config.match.tvEnabled && row.tv_port) {
fields.push({ name: '📺 CSTV', value: `\`password ${row.tv_password}; connect ${row.host}:${row.tv_port}\`` });
}
this.service.notify('match_start', viewer.matchId, {
title: '🔴 Match läuft',
description: `**${this.service.matchTitle(viewer.matchId)}**`,
color: 0xef4444,
fields,
});
if (this.adapter.simulated) this.startDemoSimulation(viewer.matchId);
this.service.changed(viewer.matchId);
}
/** Admin: pausiert oder setzt das laufende Match auf dem Server fort (mp_pause_match / mp_unpause_match). */
async setPaused(viewer, paused) {
this.service.require(viewer, 'organizer');
const row = this.row(viewer.matchId);
if (row?.status !== 'configured') throw new HttpError(409, 'Der Server ist nicht bereit', 'wrong_state');
if (!this.adapter.sendCommands) throw new HttpError(501, 'Dieser Adapter unterstützt keine Befehle', 'unsupported');
try {
await this.adapter.sendCommands(JSON.parse(row.handle), [paused ? 'mp_pause_match' : 'mp_unpause_match']);
} catch (err) {
throw new HttpError(502, `Befehl konnte nicht gesendet werden: ${err.message}`, 'server_error');
}
this.update(viewer.matchId, { paused: paused ? 1 : 0 });
this.service.log(viewer.matchId, 'server', paused ? 'Match pausiert (Admin)' : 'Match fortgesetzt (Admin)');
}
/** Demo-Modus: simuliert einen Matchverlauf, indem echte CS2-Logzeilen erzeugt und ausgewertet werden. */
startDemoSimulation(matchId) {
const match = this.service.getMatch(matchId);
const seriesMap = this.service.seriesMaps(matchId).find((m) => m.number === match.current_map_number);
if (!seriesMap) return;
this.service.log(matchId, 'server', 'Demo: simulierter Spielverlauf startet (keine echten Ergebnisse)');
this.simulator.start(matchId, {
map: seriesMap.map,
teamNames: { A: sanitizeConsoleText(match.team_a_name), B: sanitizeConsoleText(match.team_b_name) },
ctTeam: seriesMap.ct_team ?? 'A',
maxRounds: match.mode === 'wingman' ? this.config.match.maxRoundsWingman : this.config.match.maxRoundsCompetitive,
roundMs: this.config.demo.roundSeconds * 1000,
onLines: (lines) => {
try {
this.service.applyLiveLog(matchId, lines);
} catch (err) {
this.logger.warn(`[demo] Simulation für ${matchId}: ${err.message}`);
this.simulator.stop(matchId);
}
},
});
}
async stopByOrganizer(viewer) {
this.service.require(viewer, 'organizer');
await this.stop(viewer.matchId, 'Vom Organisator gestoppt');
@@ -303,16 +689,32 @@ export class ServerManager {
}
async stop(matchId, reason) {
this.simulator.stop(matchId);
const row = this.row(matchId);
if (!row || row.status === 'stopped') return;
if (row.handle) await this.safeStop(row);
this.update(matchId, { status: 'stopped', stopped_at: Date.now(), message: reason, player_count: null });
this.update(matchId, { status: 'stopped', stopped_at: Date.now(), message: reason, player_count: null, paused: 0 });
this.service.log(matchId, 'server', `Server gestoppt: ${reason}`);
}
// ---------- Automatisches Herunterfahren ----------
/** Lobbys, die länger als LOBBY_MAX_HOURS offen sind (Veto nie gestartet), samt Server löschen. */
async deleteStaleLobbies(now = Date.now()) {
const hours = this.config.lobbyMaxHours;
if (!hours) return 0;
const stale = this.db.prepare("SELECT id FROM matches WHERE status = 'lobby' AND created_at < ?").all(now - hours * 3_600_000);
for (const { id } of stale) {
if (this.busy.has(id)) continue;
this.logger.log?.(`[aufräumen] Lobby ${id} gelöscht (länger als ${hours} h offen)`);
await this.deleteMatch(id, `Lobby länger als ${hours} h offen`).catch((err) => this.logger.error(err));
}
return stale.length;
}
async reap(now = Date.now()) {
this.service.archiveClosed(now);
await this.deleteStaleLobbies(now);
const timeoutMs = this.config.idleTimeoutMinutes * 60_000;
const rows = this.db.prepare(`SELECT * FROM servers WHERE status IN (${ACTIVE.map(() => '?').join(',')})`).all(...ACTIVE);
for (const row of rows) {
@@ -327,11 +729,14 @@ export class ServerManager {
continue;
}
const webActive = this.service.presence(row.match_id).size > 0;
let lastUse = match.last_activity_at;
// Ein (neu) gestarteter Server zählt als Nutzung, sonst stoppt ein Neustart nach langer Pause sofort wieder
let lastUse = Math.max(match.last_activity_at, row.started_at ?? 0);
// Spieler auf dem Server während der Messerrunde: in Benutzung
if (row.knife_at && row.player_count > 0) continue;
if (row.status === 'configured' && row.player_count !== null) {
// Echter Server mit bekannter Spielerzahl: entscheidend ist, ob jemand auf dem Server ist.
if (row.player_count > 0) continue;
lastUse = Math.max(row.configured_at ?? 0, row.last_player_seen_at ?? 0);
lastUse = Math.max(row.configured_at ?? 0, row.last_player_seen_at ?? 0, row.started_at ?? 0);
} else if (webActive) {
continue;
}
+9 -1
View File
@@ -1,4 +1,4 @@
import { configureViaRcon, rconStatus } from './rconConfigure.js';
import { configureViaRcon, listRoundBackups, loadRoundBackup, rconStatus } from './rconConfigure.js';
import { runRcon } from './rcon.js';
/**
@@ -45,6 +45,14 @@ export class StaticPoolServerAdapter {
await configureViaRcon(this.target(handle), plan);
}
async listRoundBackups(handle, matchId) {
return listRoundBackups(this.target(handle), matchId);
}
async loadRoundBackup(handle, file) {
await loadRoundBackup(this.target(handle), file);
}
async sendCommands(handle, commands) {
return runRcon(this.target(handle), commands);
}
+29
View File
@@ -54,6 +54,35 @@ export function validateFlow(flow, mapCount, format) {
return errors;
}
/**
* Erzeugt einen Veto-Ablauf für eine beliebige Anzahl Maps. Bans und Picks wechseln sich zwischen den Teams ab:
* erst bis zu zwei Bans, dann die Picks (die Seite wählt jeweils das andere Team), dann die restlichen Bans.
* Die letzte Map ist die Entscheidungsmap, ihre Seite wählt das Team, das nicht zuletzt dran war.
* Liefert null, wenn es für das Format zu wenige Maps gibt.
*/
export function generateFlow(mapCount, format) {
const bestOf = BEST_OF[format];
if (!bestOf || mapCount < Math.max(bestOf, 2)) return null;
const picks = bestOf - 1;
const bans = mapCount - bestOf;
const before = Math.min(2, bans);
const steps = [];
let actor = 'X';
const next = () => {
const current = actor;
actor = actor === 'X' ? 'Y' : 'X';
return current;
};
for (let i = 0; i < before; i += 1) steps.push(`ban:${next()}`);
for (let i = 0; i < picks; i += 1) {
const picker = next();
steps.push(`pick:${picker}`, `side:${picker === 'X' ? 'Y' : 'X'}`);
}
for (let i = before; i < bans; i += 1) steps.push(`ban:${next()}`);
steps.push('decider', `side:${actor}`);
return steps;
}
function teamFor(actor, firstTeam) {
const second = firstTeam === 'A' ? 'B' : 'A';
return actor === 'X' ? firstTeam : second;
+562
View File
@@ -107,6 +107,9 @@ test('kompletter Ablauf: Lobby, Berechtigungen, Veto, Server-Konfiguration', asy
const orgView = (await call('GET', `/matches/${id}`, { token: org })).body;
assert.equal(orgView.viewer.role, 'organizer');
const { codes } = orgView.organizer;
// Match-Übersicht nur für dieses Match, ohne Zuschauercode
assert.match(orgView.organizer.links.overview, new RegExp(`/stream/match/${id}$`));
assert.ok(!orgView.organizer.links.overview.includes(codes.spectator));
// Zuschauer- und fremde Codes erlauben keinen Teambeitritt
assert.equal((await call('POST', `/matches/${id}/join`, { body: { code: codes.spectator, nickname: 'spy' } })).status, 403);
@@ -253,6 +256,211 @@ test('Organisator spielt im selben Browser selbst mit (zwei Zugänge)', async ()
}
});
test('Live-Ergebnisse aus Serverlogs und Demo-Simulation', async () => {
const { app, call, close } = await setup({ demo: { startSeconds: 0, failFirstConfigure: false, roundSeconds: 0 } });
try {
const { id, org, tokens } = await readyWingmanMatch(call, 'bo3');
await call('POST', `/matches/${id}/veto/start`, { token: org });
await playVeto(call, id, { A: tokens.a1, B: tokens.b1 });
await app.manager.poll();
await waitFor(() => app.service.getMatch(id).status === 'live', { label: 'live' });
const { config_key: key } = app.service.serverRow(id);
const map1 = app.service.seriesMaps(id)[0];
const base = `http://127.0.0.1:${app.server.address().port}/api/logs/${id}`;
const post = (k, text) => fetch(`${base}/${k}`, { method: 'POST', headers: { 'content-type': 'text/plain' }, body: text });
assert.equal((await post('falsch', 'x')).status, 403);
const ct = map1.ct_team === 'A' ? 'Alpha' : 'Bravo';
const t = map1.ct_team === 'A' ? 'Bravo' : 'Alpha';
const res = await post(key, [
`L 10/25/2026 - 18:00:00: MatchStatus: Team playing "CT": ${ct}`,
`L 10/25/2026 - 18:00:00: MatchStatus: Team playing "TERRORIST": ${t}`,
`L 10/25/2026 - 18:00:01: World triggered "Match_Start" on "${map1.map}"`,
'L 10/25/2026 - 18:01:00: Team "CT" triggered "SFUI_Notice_CTs_Win" (CT "1") (T "0")',
'L 10/25/2026 - 18:02:00: Team "TERRORIST" triggered "SFUI_Notice_Target_Bombed" (CT "1") (T "1")',
].join('\n'));
assert.equal(res.status, 200);
const snap = (await call('GET', `/matches/${id}`, { token: org })).body;
const ctTeam = map1.ct_team;
assert.equal(snap.series[0].score[ctTeam], 1);
assert.equal(snap.series[0].live.rounds.length, 2);
assert.equal(snap.series[0].live.rounds[1].reason, 'bomb');
await post(key, `Game Over: wingman mg_active ${map1.map} score 1:1 after 3 min`);
// Gleichstand beendet die Map ohne Sieger; Serienstand bleibt 0:0
const after = (await call('GET', `/matches/${id}`, { token: org })).body;
assert.equal(after.series[0].status, 'done');
assert.deepEqual(after.seriesScore, { A: 0, B: 0 });
// Map 2 laden und den Demo-Verlauf bis zum Ende laufen lassen
await call('POST', `/matches/${id}/series/next`, { token: org });
await waitFor(() => app.service.serverRow(id).status === 'configured' && app.service.getMatch(id).current_map_number === 2, { label: 'Map 2' });
assert.equal((await call('POST', `/matches/${id}/server/warmup-end`, { token: org })).status, 200);
await waitFor(() => app.service.seriesMaps(id)[1].status === 'done', { label: 'Demo-Map beendet', timeoutMs: 15000 });
const final = (await call('GET', `/matches/${id}`, { token: org })).body;
const winner = final.series[1].winner;
assert.ok(['A', 'B'].includes(winner));
assert.equal(final.series[1].score[winner], 9, 'Wingman: 9 Runden zum Sieg');
assert.equal(final.seriesScore[winner], 1);
const list = (await call('GET', '/matches')).body.matches.find((m) => m.id === id);
assert.equal(list.seriesScore[winner], 1);
} finally {
await close();
}
});
test('Nach Map-Ende lädt die nächste Map automatisch, entschiedene Serie wird abgeschlossen', async () => {
const { app, call, close } = await setup({ autoAdvanceSeconds: 0 });
try {
const { id, org, tokens } = await readyWingmanMatch(call, 'bo3');
await call('POST', `/matches/${id}/veto/start`, { token: org });
await playVeto(call, id, { A: tokens.a1, B: tokens.b1 });
await app.manager.poll();
await waitFor(() => app.service.getMatch(id).status === 'live', { label: 'live' });
const key = app.service.serverRow(id).config_key;
const winMapForAlpha = async (map) => {
const body = [
'Team playing "CT": Alpha',
'Team playing "TERRORIST": Bravo',
'Team "CT" triggered "SFUI_Notice_CTs_Win" (CT "1") (T "0")',
'World triggered "Round_End"',
`Game Over: scrimcomp2v2 ${map} score 1:0 after 1 min`,
].join('\n');
const res = await fetch(`http://127.0.0.1:${app.server.address().port}/api/logs/${id}/${key}`, { method: 'POST', body });
assert.equal(res.status, 200);
};
await winMapForAlpha(app.service.seriesMaps(id)[0].map);
// Serie 1:0, nicht entschieden -> Map 2 wird automatisch geladen
await waitFor(() => app.service.getMatch(id).current_map_number === 2 && app.service.serverRow(id).status === 'configured', { label: 'Map 2 automatisch' });
assert.equal(app.service.getMatch(id).status, 'live');
await winMapForAlpha(app.service.seriesMaps(id)[1].map);
// 2:0 im Best of 3 -> Match wird automatisch abgeschlossen, Map 3 nicht gespielt
await waitFor(() => app.service.getMatch(id).status === 'finished', { label: 'Serie abgeschlossen' });
await waitFor(() => app.service.serverRow(id).status === 'stopped', { label: 'Server freigegeben' });
const snap = (await call('GET', `/matches/${id}`, { token: org })).body;
assert.deepEqual(snap.seriesScore, { A: 2, B: 0 });
assert.equal(snap.series[2].winner, null);
} finally {
await close();
}
});
test('Stream-Übersicht aller Matches: Inhalte, keine Geheimnisse, optionaler Schlüssel', async () => {
const { app, call, close } = await setup({ streamKey: 'geheim123' });
try {
const lobby = (await call('POST', '/matches', { body: { teamA: 'Lobby A', teamB: 'Lobby B', mode: 'wingman', format: 'bo1' } })).body;
const { id, org, tokens } = await readyWingmanMatch(call, 'bo3');
await call('POST', `/matches/${id}/veto/start`, { token: org });
await call('POST', `/matches/${id}/veto/action`, { token: tokens.a1, body: { stepIndex: 0, map: 'de_inferno' } });
assert.equal((await call('GET', '/stream/overview')).status, 403);
assert.equal((await call('GET', '/stream/overview?key=falsch')).status, 403);
const res = await call('GET', '/stream/overview?key=geheim123');
assert.equal(res.status, 200);
const { active, upcoming } = res.body;
assert.equal(active.length, 1);
assert.equal(active[0].status, 'veto');
assert.equal(active[0].veto.maps.find((m) => m.id === 'de_inferno').status, 'banned');
assert.equal(active[0].veto.current.team, 'B');
assert.ok(upcoming.some((m) => m.id === lobby.id));
const json = JSON.stringify(res.body);
const row = app.service.getMatch(id);
for (const secret of [row.code_a, row.code_b, row.code_spectator, app.service.serverRow(id).rcon_password, app.service.serverRow(id).server_password]) {
assert.ok(!json.includes(secret), 'keine Codes oder Passwörter in der Stream-Übersicht');
}
} finally {
await close();
}
});
test('Admin-Modus: Anmeldung, Verwaltung aller Matches, Sperre ohne Anmeldung', async () => {
const { app, call, close } = await setup({ adminPassword: 'Admin123' });
try {
const { id } = (await call('POST', '/matches', {
body: { teamA: 'Alpha', teamB: 'Bravo', mode: 'wingman', format: 'bo1' },
})).body;
const adminListed = (await call('GET', '/admin/matches')).body; // ohne Anmeldung
assert.equal(adminListed.error, 'Admin-Anmeldung erforderlich');
assert.equal((await call('POST', '/admin/login', { body: { password: 'falsch' } })).status, 401);
const login = (await call('POST', '/admin/login', { body: { password: 'Admin123' } })).body;
const admin = { token: login.token };
assert.ok(login.token);
const list = (await call('GET', '/admin/matches', admin)).body.matches;
const mine = list.find((m) => m.id === id);
assert.ok(mine, 'alle Matches sichtbar, nicht nur die eigenen');
assert.ok(mine.organizer.codes.A, 'Codes sichtbar');
// Ein Match abbrechen wie ein Admin
assert.equal((await call('POST', `/admin/matches/${id}/cancel`, { ...admin, body: {} })).status, 200);
assert.equal((await call('POST', `/admin/matches/${id}/server/pause`, { ...admin, body: {} })).status, 409, 'ohne Server keine Pause');
assert.equal(app.service.getMatch(id).status, 'cancelled');
// Endgültig löschen
assert.equal((await call('DELETE', `/admin/matches/${id}`, admin)).status, 200);
assert.equal((await call('GET', `/matches/${id}`)).status, 404);
// Abmelden: Token ist danach ungültig
await call('POST', '/admin/logout', admin);
assert.equal((await call('GET', '/admin/session', admin)).status, 401);
} finally {
await close();
}
});
test('Rollback: nur mit Admin-Anmeldung, nur für die laufende Map, Dateiname wird geprüft', async () => {
const { app, call, close } = await setup({ adminPassword: 'Admin123', demo: { startSeconds: 0, failFirstConfigure: false, roundSeconds: 600 } });
try {
const { id, org, tokens } = await readyWingmanMatch(call, 'bo3');
await call('POST', `/matches/${id}/veto/start`, { token: org });
await playVeto(call, id, { A: tokens.a1, B: tokens.b1 });
await app.manager.poll();
await waitFor(() => app.service.getMatch(id).status === 'live', { label: 'live' });
await call('POST', `/matches/${id}/server/warmup-end`, { token: org });
// Organisator sieht keine Rollback-Funktion
assert.equal((await call('GET', `/admin/matches/${id}/rollback-points`, { token: org })).status, 401);
assert.equal((await call('POST', `/matches/${id}/rollback`, { token: org, body: { file: 'x.txt' } })).status, 404);
const login = (await call('POST', '/admin/login', { body: { password: 'Admin123' } })).body;
const admin = { token: login.token };
const points = (await call('GET', `/admin/matches/${id}/rollback-points`, admin)).body.points;
assert.equal(points.length, 3, 'Demo liefert drei Sicherungen der aktuellen Map');
const map = app.service.seriesMaps(id)[0].map;
assert.ok(points.every((p) => p.map === map));
// Dateien, die nicht zu diesem Match gehören, werden abgelehnt
assert.equal((await call('POST', `/admin/matches/${id}/rollback`, { ...admin, body: { file: '../../x.txt' } })).status, 400);
const ok = await call('POST', `/admin/matches/${id}/rollback`, { ...admin, body: { file: points[0].file } });
assert.equal(ok.status, 200);
const log = app.service.db.prepare("SELECT message FROM events WHERE match_id = ? AND type = 'rollback'").all(id);
assert.equal(log.length, 1);
assert.match(log[0].message, /zurückgespielt/);
} finally {
await close();
}
});
test('Admin-Modus ohne ADMIN_PASSWORD ist komplett aus', async () => {
const { call, close } = await setup({ adminPassword: '' });
try {
const res = await call('POST', '/admin/login', { body: { password: 'x' } });
assert.equal(res.status, 404);
assert.equal(res.body.code, 'admin_disabled');
} finally {
await close();
}
});
test('Spieler verlassen die Lobby, Captain geht über', async () => {
const { call, close } = await setup();
try {
@@ -269,3 +477,357 @@ test('Spieler verlassen die Lobby, Captain geht über', async () => {
await close();
}
});
test('Neustart im laufenden Match: Map wird wieder geladen, Server läuft die volle Frist', async () => {
const { app, call, close } = await setup({ idleTimeoutMinutes: 30 });
try {
const { id, org, tokens } = await readyWingmanMatch(call, 'bo1');
await call('POST', `/matches/${id}/veto/start`, { token: org });
await playVeto(call, id, { A: tokens.a1, B: tokens.b1 });
await app.manager.poll();
await waitFor(() => app.service.getMatch(id).status === 'live', { label: 'live' });
// Lange nichts los, dann stoppt der Server
const hourAgo = Date.now() - 60 * 60_000;
app.db.prepare('UPDATE matches SET last_activity_at = ? WHERE id = ?').run(hourAgo, id);
await call('POST', `/matches/${id}/server/stop`, { token: org });
await waitFor(() => app.service.serverRow(id).status === 'stopped', { label: 'gestoppt' });
// Neustart durch den Organisator: Server fährt hoch und lädt die Map wieder (Match ist weiter "live")
const res = await call('POST', `/matches/${id}/server/retry`, { token: org });
assert.equal(res.status, 200, JSON.stringify(res.body));
await waitFor(async () => { await app.manager.poll(); return app.service.serverRow(id).status === 'configured'; }, { label: 'neu konfiguriert' });
assert.equal(app.service.getMatch(id).status, 'live');
// Die Abschaltung darf den frisch gestarteten Server nicht sofort wieder stoppen
await app.manager.reap();
assert.equal(app.service.serverRow(id).status, 'configured');
// Nach Ablauf der Frist ab dem Neustart dagegen schon
await app.manager.reap(Date.now() + 31 * 60_000);
assert.equal(app.service.serverRow(id).status, 'stopped');
} finally {
await close();
}
});
test('Archiv: nur abgeschlossene Matches, eigene Liste, automatisch nach Frist', async () => {
const { app, call, close } = await setup({ adminPassword: 'admin-geheim', archiveAfterDays: 3 });
try {
const token = (await call('POST', '/admin/login', { body: { password: 'admin-geheim' } })).body.token;
const mk = async (a, b) => (await call('POST', '/matches', { body: { teamA: a, teamB: b, mode: 'wingman', format: 'bo1' } })).body;
const open = await mk('Offen A', 'Offen B');
const done = await mk('Fertig A', 'Fertig B');
const old = await mk('Alt A', 'Alt B');
// Laufende Matches lassen sich nicht archivieren
assert.equal((await call('POST', `/admin/matches/${open.id}/archive`, { token })).status, 409);
await call('POST', `/admin/matches/${done.id}/finish`, { token });
assert.equal((await call('POST', `/admin/matches/${done.id}/archive`, { token })).status, 200);
let list = (await call('GET', '/admin/matches', { token })).body;
assert.ok(!list.matches.some((m) => m.id === done.id), 'archiviertes Match fehlt in der normalen Liste');
assert.equal(list.archivedCount, 1);
const archive = (await call('GET', '/admin/matches?archived=1', { token })).body;
assert.deepEqual(archive.matches.map((m) => m.id), [done.id]);
assert.ok(archive.matches[0].archivedAt > 0);
// Zurückholen
await call('POST', `/admin/matches/${done.id}/unarchive`, { token });
list = (await call('GET', '/admin/matches', { token })).body;
assert.ok(list.matches.some((m) => m.id === done.id));
// Automatisch: abgebrochenes Match, älter als 3 Tage
await call('POST', `/admin/matches/${old.id}/cancel`, { token });
assert.equal(app.service.archiveClosed(Date.now() + 2 * 86_400_000), 0, 'nach 2 Tagen noch nicht');
// Hier geht es nur ums Archiv: das Löschen alter Lobbys (eigener Test) bleibt aus
app.manager.config.lobbyMaxHours = 0;
await app.manager.reap(Date.now() + 4 * 86_400_000);
assert.ok(app.service.getMatch(old.id).archived_at > 0, 'nach 4 Tagen archiviert');
assert.ok(app.service.getMatch(done.id).archived_at > 0, 'zurückgeholtes Match wird nach Ablauf der Frist wieder archiviert');
assert.equal(app.service.getMatch(open.id).archived_at, null, 'offene Matches bleiben');
} finally {
await close();
}
});
test('Messer-Server: nur Admin startet, Startseite sieht laufende, Ports zählen bei der Vergabe mit', async () => {
const { app, call, close } = await setup({ adminPassword: 'admin-geheim' });
try {
assert.equal((await call('POST', '/admin/knife', { body: {} })).status, 401);
const token = (await call('POST', '/admin/login', { body: { password: 'admin-geheim' } })).body.token;
const info = (await call('GET', '/admin/knife', { token })).body;
assert.deepEqual(info.modes.map((m) => m.id).sort(), ['competitive', 'wingman']);
const created = await call('POST', '/admin/knife', { token, body: { mode: 'competitive', map: 'de_inferno' } });
assert.equal(created.status, 200, JSON.stringify(created.body));
// Noch nicht bereit: Startseite zeigt ihn nicht
assert.equal((await call('GET', '/knife-servers')).body.servers.length, 0);
await app.knife.poll();
await waitFor(() => app.knife.row(created.body.id).status === 'running', { label: 'Messer-Server bereit' });
const pub = (await call('GET', '/knife-servers')).body.servers;
assert.equal(pub.length, 1);
assert.equal(pub[0].map, 'de_inferno');
assert.ok(!JSON.stringify(pub).includes(app.knife.row(created.body.id).rcon_password));
// Ports des Messer-Servers sind für Matches belegt
const row = app.knife.row(created.body.id);
assert.ok(app.manager.usedPortsOn(row.host).has(row.port));
assert.equal((await call('DELETE', `/admin/knife/${created.body.id}`, { token })).status, 200);
assert.equal(app.knife.list().length, 0);
} finally {
await close();
}
});
test('Map-Pools im Admin: Auswahl gilt für neue Matches, Veto passt sich an, laufende Matches behalten ihre Maps', async () => {
const { app, call, close } = await setup({ adminPassword: 'admin-geheim' });
try {
const token = (await call('POST', '/admin/login', { body: { password: 'admin-geheim' } })).body.token;
const before = (await call('POST', '/matches', { body: { teamA: 'Vorher A', teamB: 'Vorher B', mode: 'competitive', format: 'bo3' } })).body;
const view = (await call('GET', '/admin/map-pools', { token })).body;
const comp = view.pools.find((p) => p.mode === 'competitive');
assert.ok(['de_overpass', 'de_vertigo', 'de_cache'].every((id) => comp.maps.some((m) => m.id === id && m.active)));
// Nur 5 Maps: BO5 geht noch, BO3 hat dann 2 Bans
const five = ['de_cache', 'de_dust2', 'de_inferno', 'de_mirage', 'de_nuke'];
const saved = await call('PUT', '/admin/map-pools', { token, body: { mode: 'competitive', maps: five } });
assert.equal(saved.status, 200, JSON.stringify(saved.body));
const pool = saved.body.pools.find((p) => p.mode === 'competitive');
assert.equal(pool.maps.filter((m) => m.active).length, 5);
assert.deepEqual(pool.formats.bo3.flow, ['ban:X', 'ban:Y', 'pick:X', 'side:Y', 'pick:Y', 'side:X', 'decider', 'side:X']);
// Neue Matches bekommen die neue Auswahl, das alte behält seine 10 Maps
const after = (await call('POST', '/matches', { body: { teamA: 'Nachher A', teamB: 'Nachher B', mode: 'competitive', format: 'bo3' } })).body;
assert.equal(JSON.parse(app.service.getMatch(after.id).maps).length, 5);
assert.equal(JSON.parse(app.service.getMatch(before.id).maps).length, 10);
// Öffentliche Konfiguration (Match erstellen) zeigt die neuen Formate
const cfg = (await call('GET', '/config')).body;
assert.equal(cfg.pools.find((p) => p.mode === 'competitive').maps.length, 5);
// Ungültig: zu wenige oder unbekannte Maps
assert.equal((await call('PUT', '/admin/map-pools', { token, body: { mode: 'competitive', maps: ['de_cache'] } })).status, 400);
assert.equal((await call('PUT', '/admin/map-pools', { token, body: { mode: 'competitive', maps: ['de_cache', 'de_foo'] } })).status, 400);
assert.equal((await call('PUT', '/admin/map-pools', { body: { mode: 'competitive', maps: five } })).status, 401);
} finally {
await close();
}
});
test('Seitenwahl per Messerrunde: Organisator legt fest, wer das Veto beginnt', async () => {
const { app, call, close } = await setup();
try {
const created = await call('POST', '/matches', { body: { teamA: 'Alpha', teamB: 'Bravo', mode: 'wingman', format: 'bo3', vetoStart: 'knife' } });
assert.equal(created.status, 200, JSON.stringify(created.body));
const { id, organizerToken: org } = created.body;
const snap = (await call('GET', `/matches/${id}`, { token: org })).body;
assert.equal(snap.vetoStart, 'knife');
const codes = snap.organizer.codes;
for (const [name, team] of [['a1', 'A'], ['a2', 'A'], ['b1', 'B'], ['b2', 'B']]) {
const r = await call('POST', `/matches/${id}/join`, { body: { code: codes[team], nickname: name } });
await call('POST', `/matches/${id}/ready`, { token: r.body.playerToken, body: { ready: true } });
}
// Ohne Angabe, wer beginnt, startet das Veto nicht
const missing = await call('POST', `/matches/${id}/veto/start`, { token: org });
assert.equal(missing.status, 400);
assert.equal(missing.body.code, 'first_team_required');
assert.equal((await call('POST', `/matches/${id}/veto/start`, { token: org, body: { firstTeam: 'X' } })).status, 400);
const ok = await call('POST', `/matches/${id}/veto/start`, { token: org, body: { firstTeam: 'B' } });
assert.equal(ok.status, 200, JSON.stringify(ok.body));
const after = (await call('GET', `/matches/${id}`, { token: org })).body;
assert.equal(after.status, 'veto');
assert.equal(after.veto.current.team, 'B', 'das gewählte Team beginnt');
assert.ok(app.service.db.prepare("SELECT message FROM events WHERE match_id = ? AND message LIKE '%Messerrunde%'").get(id));
} finally {
await close();
}
});
test('Ohne Messerrunde bleibt firstTeam wirkungslos', async () => {
const { call, close } = await setup();
try {
const { id, org, tokens } = await readyWingmanMatch(call, 'bo1');
await call('POST', `/matches/${id}/veto/start`, { token: org, body: { firstTeam: 'B' } });
const snap = (await call('GET', `/matches/${id}`, { token: tokens.a1 })).body;
assert.equal(snap.veto.current.team, 'A', 'feste Startregel A gilt');
} finally {
await close();
}
});
test('Chat: Match-Chat für beide Teams und Organisator, Team-Chat nur fürs eigene Team, Zuschauer sehen nichts', async () => {
const { call, close } = await setup();
try {
const { id, org, codes, tokens } = await readyWingmanMatch(call, 'bo1');
const spectator = codes.spectator;
const post = (token, channel, text) => call('POST', `/matches/${id}/chat`, { token, body: { channel, text } });
assert.equal((await post(tokens.a1, 'match', 'Hallo zusammen')).status, 200);
assert.equal((await post(tokens.a1, 'A', 'Wir bannen Nuke')).status, 200);
assert.equal((await post(org, 'match', 'Veto startet gleich')).status, 200);
// Fremder Team-Chat und Zuschauer: verboten
assert.equal((await post(tokens.b1, 'A', 'Spioniere')).status, 403);
assert.equal((await post(org, 'A', 'Organisator im Team-Chat')).status, 403);
assert.equal((await post(spectator, 'match', 'Zuschauer')).status, 403);
assert.equal((await post(tokens.a1, 'match', ' ')).status, 400);
assert.equal((await post(tokens.a1, 'match', 'x'.repeat(301))).status, 400);
const a = (await call('GET', `/matches/${id}`, { token: tokens.a2 })).body.chat;
assert.deepEqual(a.channels.map((c) => c.key), ['match', 'A']);
assert.deepEqual(a.messages.match.map((m) => m.text), ['Hallo zusammen', 'Veto startet gleich']);
assert.equal(a.messages.match[1].organizer, true);
assert.deepEqual(a.messages.A.map((m) => m.text), ['Wir bannen Nuke']);
const b = (await call('GET', `/matches/${id}`, { token: tokens.b1 })).body.chat;
assert.deepEqual(b.channels.map((c) => c.key), ['match', 'B']);
assert.ok(!JSON.stringify(b).includes('Wir bannen Nuke'), 'Team B sieht den Team-Chat von A nicht');
assert.deepEqual((await call('GET', `/matches/${id}`, { token: org })).body.chat.channels.map((c) => c.key), ['match']);
assert.equal((await call('GET', `/matches/${id}`, { token: spectator })).body.chat, null);
assert.equal((await call('GET', `/matches/${id}`)).body.chat, null);
// Spam-Schutz und nach Matchende nur lesbar
for (let i = 0; i < 5; i++) await post(tokens.b1, 'match', `Nachricht ${i}`);
assert.equal((await post(tokens.b1, 'match', 'eine zu viel')).status, 429);
await call('POST', `/matches/${id}/finish`, { token: org });
assert.equal((await post(tokens.a1, 'match', 'Nach dem Ende')).status, 409);
const closed = (await call('GET', `/matches/${id}`, { token: tokens.a1 })).body.chat;
assert.equal(closed.canWrite, false);
assert.equal(closed.messages.match.length, 7);
} finally {
await close();
}
});
test('Konsole: nur für Admins, Ausgabe kommt live, unbekannte Server 404', async () => {
const { app, call, close } = await setup({ adminPassword: 'admin-geheim' });
try {
const token = (await call('POST', '/admin/login', { body: { password: 'admin-geheim' } })).body.token;
const m = (await call('POST', '/matches', { body: { teamA: 'Alpha', teamB: 'Bravo', mode: 'wingman', format: 'bo1' } })).body;
await waitFor(() => app.service.serverRow(m.id)?.handle, { label: 'Server gestartet' });
const base = `http://127.0.0.1:${app.server.address().port}/api/admin/logs`;
assert.equal((await fetch(`${base}/match/${m.id}`)).status, 401);
assert.equal((await fetch(`${base}/match/gibtsnicht`, { headers: { authorization: `Bearer ${token}` } })).status, 404);
assert.equal((await fetch(`${base}/sonstwas/${m.id}`, { headers: { authorization: `Bearer ${token}` } })).status, 404);
const ctl = new AbortController();
const res = await fetch(`${base}/match/${m.id}`, { headers: { authorization: `Bearer ${token}` }, signal: ctl.signal });
assert.equal(res.status, 200);
const reader = res.body.getReader();
const { value } = await reader.read();
assert.match(new TextDecoder().decode(value), /Demo-Adapter/);
ctl.abort();
} finally {
await close();
}
});
test('Messerrunde auf dem eigenen Match-Server: Verbindungsdaten in der Lobby, danach derselbe Server fürs Match', async () => {
const { app, call, close } = await setup();
try {
const created = await call('POST', '/matches', { body: { teamA: 'Alpha', teamB: 'Bravo', mode: 'wingman', format: 'bo1', vetoStart: 'knife' } });
const { id, organizerToken: org } = created.body;
const codes = (await call('GET', `/matches/${id}`, { token: org })).body.organizer.codes;
const tokens = {};
for (const [name, team] of [['a1', 'A'], ['a2', 'A'], ['b1', 'B'], ['b2', 'B']]) {
tokens[name] = (await call('POST', `/matches/${id}/join`, { body: { code: codes[team], nickname: name } })).body.playerToken;
}
// Vor dem Start: Aktionen gehen noch nicht
assert.equal((await call('POST', `/matches/${id}/server/knife-start`, { token: org })).status, 409);
await app.manager.poll();
await waitFor(() => app.manager.row(id).knife_phase === 'warmup', { label: 'Messerrunde eingerichtet' });
const player = (await call('GET', `/matches/${id}`, { token: tokens.b1 })).body;
assert.deepEqual(player.server.knife, { ready: true, phase: 'warmup', failed: false });
assert.equal(player.server.connect.password, app.manager.row(id).server_password, 'Spieler bekommen das Passwort des Matches');
const pub = (await call('GET', `/matches/${id}`)).body;
assert.equal(pub.server?.connect, undefined, 'öffentlich keine Verbindungsdaten');
// Nur der Organisator startet die Runde
assert.equal((await call('POST', `/matches/${id}/server/knife-start`, { token: tokens.a1 })).status, 403);
assert.equal((await call('POST', `/matches/${id}/server/knife-start`, { token: org })).status, 200);
assert.equal(app.manager.row(id).knife_phase, 'live');
assert.equal((await call('POST', `/matches/${id}/server/knife-reset`, { token: org })).status, 200);
assert.equal(app.manager.row(id).knife_phase, 'warmup');
// Nach dem Veto wird derselbe Server fürs Match eingerichtet, die Messer-Einstellungen werden zurückgenommen
const handle = app.manager.row(id).handle;
const plans = [];
const configure = app.adapter.configure.bind(app.adapter);
app.adapter.configure = async (h, plan) => { plans.push(plan); return configure(h, plan); };
for (const t of Object.values(tokens)) await call('POST', `/matches/${id}/ready`, { token: t, body: { ready: true } });
assert.equal((await call('POST', `/matches/${id}/veto/start`, { token: org, body: { firstTeam: 'A' } })).status, 200);
const snap = (await call('GET', `/matches/${id}`, { token: org })).body;
const capA = snap.teams.A.players.find((p) => p.isCaptain)?.nickname ?? 'a1';
const capB = snap.teams.B.players.find((p) => p.isCaptain)?.nickname ?? 'b1';
await playVeto(call, id, { A: tokens[capA], B: tokens[capB] });
// Das Ende der Messerrunde kommt während des Einrichtens an: darf nicht als Ende von Map 1 zählen
await waitFor(() => app.manager.row(id).status === 'configuring', { label: 'Map 1 wird eingerichtet' });
const firstMap = app.service.seriesMaps(id)[0].map;
const knifeEnd = ['Team "CT" triggered "SFUI_Notice_Target_Saved" (CT "1") (T "0")', 'World triggered "Round_End"',
`Game Over: scrimcomp2v2 ${firstMap} score 1:0 after 2 min`].join('\n');
const key = app.manager.row(id).config_key;
assert.equal((await fetch(`http://127.0.0.1:${app.server.address().port}/api/logs/${id}/${key}`, { method: 'POST', body: knifeEnd })).status, 200);
await waitFor(() => app.manager.row(id).status === 'configured', { label: 'Match eingerichtet' });
const map1 = app.service.seriesMaps(id)[0];
assert.notEqual(map1.status, 'done', 'Messerrunde zählt nicht als Map-Ende');
assert.equal(map1.score_a, 0);
assert.equal(app.service.getMatch(id).current_map_number, 1);
assert.deepEqual(plans.at(-1).beforeMapChange.slice(0, 2), ['mp_warmup_pausetimer 1', 'mp_warmup_start'], 'laufende Messerrunde wird zuerst angehalten');
const row = app.manager.row(id);
assert.equal(row.handle, handle, 'derselbe Server');
assert.equal(row.knife_at, null);
assert.ok(plans.at(-1).afterMapChange.includes('mp_give_player_c4 1'));
assert.equal((await call('GET', `/matches/${id}`, { token: tokens.b1 })).body.server.knife, undefined);
} finally {
await close();
}
});
test('Messerrunde: Einrichten wird bei Fehlern von selbst wiederholt, danach kann der Organisator neu starten', async () => {
const { app, call, close } = await setup();
try {
app.manager.knifeRetryDelayMs = 0;
const configure = app.adapter.configure.bind(app.adapter);
let failures = 2;
app.adapter.configure = async (h, plan) => {
if (failures > 0) { failures -= 1; throw new Error('Map de_mirage wurde nicht rechtzeitig geladen'); }
return configure(h, plan);
};
const created = await call('POST', '/matches', { body: { teamA: 'Alpha', teamB: 'Bravo', mode: 'wingman', format: 'bo1', vetoStart: 'knife' } });
const { id, organizerToken: org } = created.body;
await app.manager.poll();
// Zwei Fehlschläge, der dritte Versuch klappt ohne Zutun
await waitFor(() => app.manager.row(id).knife_phase === 'warmup', { label: 'Messerrunde nach Wiederholung eingerichtet' });
// Scheitern alle Versuche, bekommt der Organisator den Knopf
failures = 99;
app.manager.update(id, { knife_at: null, knife_phase: null });
app.manager.knifeAttempts.delete(id);
app.manager.maybeConfigure(id);
await waitFor(async () => (await call('GET', `/matches/${id}`, { token: org })).body.server.knife.failed, { label: 'als fehlgeschlagen gemeldet' });
failures = 0;
assert.equal((await call('POST', `/matches/${id}/server/retry`, { token: org })).status, 200);
await waitFor(() => app.manager.row(id).knife_phase === 'warmup', { label: 'von Hand neu eingerichtet' });
} finally {
await close();
}
});
test('Offene Lobbys werden nach LOBBY_MAX_HOURS samt Server gelöscht, laufende Matches nicht', async () => {
const { app, call, close } = await setup();
try {
const make = async () => (await call('POST', '/matches', { body: { teamA: 'Alpha', teamB: 'Bravo', mode: 'wingman', format: 'bo1', vetoStart: 'A' } })).body.id;
const old = await make();
const fresh = await make();
const { id: running, org } = await readyWingmanMatch(call, 'bo1');
assert.equal((await call('POST', `/matches/${running}/veto/start`, { token: org })).status, 200);
const ago = (h) => Date.now() - h * 3_600_000;
app.service.db.prepare('UPDATE matches SET created_at = ? WHERE id IN (?, ?)').run(ago(4), old, running);
app.service.db.prepare('UPDATE matches SET created_at = ? WHERE id = ?').run(ago(2), fresh);
await app.manager.reap();
const left = app.service.db.prepare('SELECT id FROM matches').all().map((r) => r.id);
assert.ok(!left.includes(old), 'alte Lobby gelöscht');
assert.ok(left.includes(fresh), 'Lobby unter 3 h bleibt');
assert.ok(left.includes(running), 'Match im Veto bleibt');
assert.equal(app.service.db.prepare('SELECT COUNT(*) AS n FROM servers WHERE match_id = ?').get(old).n, 0, 'Server-Datensatz weg');
assert.equal((await call('GET', `/matches/${old}`)).status, 404);
} finally {
await close();
}
});
+46
View File
@@ -0,0 +1,46 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createDiscordNotifier } from '../src/discord.js';
const URL_OK = 'https://discord.com/api/webhooks/123456/geheim_TOKEN-1';
test('Ohne URL ist der Notifier aus und tut nichts', async () => {
let called = false;
const n = createDiscordNotifier('', { fetchImpl: async () => { called = true; } });
assert.equal(n.enabled, false);
await n.send('Test');
assert.equal(called, false);
});
test('Ungültige URL wird beim Start abgelehnt, ohne die URL zu zeigen', () => {
assert.throws(() => createDiscordNotifier('https://example.com/geheim'), (err) => {
assert.match(err.message, /keine gültige Discord-Webhook-URL/);
assert.ok(!err.message.includes('geheim'));
return true;
});
});
test('Nachricht wird als JSON an den Webhook geschickt, überlange Texte gekürzt', async () => {
const sent = [];
const n = createDiscordNotifier(URL_OK, {
fetchImpl: async (url, init) => { sent.push({ url, init }); return { ok: true, status: 204 }; },
});
await n.send('Hallo');
await n.send('x'.repeat(5000));
assert.equal(sent[0].url, URL_OK);
assert.equal(sent[0].init.method, 'POST');
assert.equal(JSON.parse(sent[0].init.body).content, 'Hallo');
assert.equal(JSON.parse(sent[1].init.body).content.length, 1900);
});
test('Fehler des Webhooks brechen den Ablauf nicht ab und werden protokolliert ohne URL', async () => {
const warnings = [];
const logger = { warn: (m) => warnings.push(m) };
const failing = createDiscordNotifier(URL_OK, { logger, fetchImpl: async () => { throw new TypeError('fetch failed'); } });
await failing.send('Test');
const bad = createDiscordNotifier(URL_OK, { logger, fetchImpl: async () => ({ ok: false, status: 404 }) });
await bad.send('Test');
assert.equal(warnings.length, 2);
assert.match(warnings[1], /HTTP 404/);
assert.ok(warnings.every((w) => !w.includes('TOKEN')));
});
+99
View File
@@ -0,0 +1,99 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { DockerServerAdapter } from '../src/servers/dockerAdapter.js';
const options = {
bin: 'docker', image: 'joedwards32/cs2:latest', host: '192.168.178.111', rconHost: '192.168.178.111',
portRangeStart: 27100, portRangeEnd: 27139, gslt: '', volume: 'cs2-mapveto-gamefiles', extraArgs: '', startTimeoutMinutes: 1,
};
const secrets = { rconPassword: 'Rcon123', serverPassword: 'Pw123', tvPassword: 'Tv123' };
/** Simuliertes Docker: merkt sich Aufrufe; Container gelten als "Update fertig", sobald freigegeben. */
function fakeDocker() {
const calls = [];
const updated = new Set();
const run = async (args) => {
calls.push(args);
const [cmd] = args;
const name = args.at(-1);
if (cmd === 'inspect') return 'running';
if (cmd === 'logs') return updated.has(name) ? 'Success! App \'730\' already up to date.\nStarting CS2 Dedicated Server' : 'Checking for available updates...';
return '';
};
return { run, calls, finishUpdate: (name) => updated.add(name) };
}
const envOf = (args) => Object.fromEntries(args.flatMap((a, i) => (args[i - 1] === '-e' ? [a.split(/=(.*)/s).slice(0, 2)] : [])));
test('Ohne GSLT startet der Container im LAN-Modus, mit GSLT mit Token', async () => {
const lan = fakeDocker();
const a = new DockerServerAdapter(options, { getUsedPorts: () => new Set(), runDocker: lan.run });
a.isReachable = async () => false;
const r = await a.start({ matchId: 'abc_DEF-1', mode: 'wingman', secrets });
lan.finishUpdate(r.handle.container);
const runArgs = lan.calls.find((c) => c[0] === 'run');
const env = envOf(runArgs);
assert.equal(env.CS2_LAN, '1');
assert.equal(env.SRCDS_TOKEN, undefined);
assert.equal(env.CS2_GAMEMODE, '2');
assert.equal(env.CS2_MAXPLAYERS, '6', '4 Spieler + CSTV + Reserve');
assert.equal(env.CS2_ADDITIONAL_ARGS, '-allow_round_backup_files', 'Rundensicherung für Rollback');
assert.equal(env.CS2_PORT, '27100');
assert.equal(env.TV_PORT, '27200');
assert.ok(runArgs.includes('cs2-mapveto-gamefiles:/home/steam/cs2-dedicated'));
assert.equal(r.host, '192.168.178.111');
const tok = fakeDocker();
const b = new DockerServerAdapter({ ...options, gslt: 'TOKEN1' }, { getUsedPorts: () => new Set(), runDocker: tok.run });
b.isReachable = async () => false;
const r2 = await b.start({ matchId: 'x', mode: 'competitive', secrets });
tok.finishUpdate(r2.handle.container);
const env2 = envOf(tok.calls.find((c) => c[0] === 'run'));
assert.equal(env2.SRCDS_TOKEN, 'TOKEN1');
assert.equal(env2.CS2_LAN, '0');
});
test('Zweiter Container startet erst, wenn der erste seinen Update-Check abgeschlossen hat', async () => {
const d = fakeDocker();
const used = new Set();
const a = new DockerServerAdapter(options, { getUsedPorts: () => used, runDocker: d.run });
// Wartezeit zwischen Prüfungen für den Test verkürzen
const original = a.waitForUpdateCheck.bind(a);
a.waitForUpdateCheck = (handle) => original(handle, { pollMs: 10 });
a.isReachable = async () => false;
const first = await a.start({ matchId: 'm1', mode: 'competitive', secrets });
used.add(first.port).add(first.tvPort);
let secondDone = false;
const second = a.start({ matchId: 'm2', mode: 'competitive', secrets }).then((r) => { secondDone = true; return r; });
await new Promise((r) => setTimeout(r, 60));
assert.equal(secondDone, false, 'zweiter Start wartet noch');
assert.equal(d.calls.filter((c) => c[0] === 'run').length, 1);
d.finishUpdate(first.handle.container);
const r2 = await second;
assert.equal(d.calls.filter((c) => c[0] === 'run').length, 2);
assert.equal(r2.port, 27101, 'zweiter Container bekommt den nächsten Port');
d.finishUpdate(r2.handle.container);
});
test('Warteschlange gibt frei, sobald der Server per RCON antwortet – auch ohne Startzeile im Log', async () => {
const d = fakeDocker();
const used = new Set();
const a = new DockerServerAdapter(options, { getUsedPorts: () => used, runDocker: d.run });
const original = a.waitForUpdateCheck.bind(a);
a.waitForUpdateCheck = (handle) => original(handle, { pollMs: 10 });
const reachable = new Set();
a.isReachable = async (handle) => reachable.has(handle.container);
const first = await a.start({ matchId: 'r1', mode: 'wingman', secrets });
used.add(first.port).add(first.tvPort);
const second = a.start({ matchId: 'r2', mode: 'wingman', secrets });
await new Promise((r) => setTimeout(r, 50));
assert.equal(d.calls.filter((c) => c[0] === 'run').length, 1, 'wartet noch');
reachable.add(first.handle.container); // Log ohne Startzeile, aber RCON antwortet
const r2 = await second;
assert.equal(d.calls.filter((c) => c[0] === 'run').length, 2);
reachable.add(r2.handle.container);
});
+19
View File
@@ -0,0 +1,19 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { formatDockerEvent } from '../src/servers/dockerAdapter.js';
const ev = (o) => JSON.stringify({ time: 1793549403, ...o });
test('Docker-Ereignisse werden lesbar, App-Container markiert', () => {
const start = formatDockerEvent(ev({ Type: 'container', Action: 'start', Actor: { ID: 'abc', Attributes: { name: 'cs2mv-knife1', image: 'joedwards32/cs2:latest', 'cs2-mapveto': '1' } } }));
assert.match(start, /Container cs2mv-knife1 \[Mapveto\] gestartet \(joedwards32\/cs2:latest\)$/);
const die = formatDockerEvent(ev({ Type: 'container', Action: 'die', Actor: { ID: 'abc', Attributes: { name: 'gitea', exitCode: '137' } } }));
assert.match(die, /Container gitea beendet \(Exit-Code 137\)$/);
assert.match(formatDockerEvent(ev({ Type: 'image', Action: 'pull', Actor: { ID: 'joedwards32/cs2:latest', Attributes: { name: 'joedwards32/cs2' } } })), /Image joedwards32\/cs2 geladen/);
});
test('Healthchecks, Mounts und kaputte Zeilen fallen weg', () => {
assert.equal(formatDockerEvent(ev({ Type: 'container', Action: 'exec_start: /bin/sh -c mysql --password=geheim', Actor: { Attributes: {} } })), null);
assert.equal(formatDockerEvent(ev({ Type: 'volume', Action: 'mount', Actor: { Attributes: {} } })), null);
assert.equal(formatDockerEvent('kein json'), null);
});
+135
View File
@@ -0,0 +1,135 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { DockerHostPool } from '../src/servers/dockerAdapter.js';
const secrets = { rconPassword: 'Rcon123', serverPassword: 'Pw123', tvPassword: 'Tv123' };
const hostA = { name: 'a', publicHost: '10.0.0.1', portRangeStart: 27100, portRangeEnd: 27103 }; // 2 Paare
const hostB = { name: 'b', publicHost: '10.0.0.2', portRangeStart: 27100, portRangeEnd: 27139 }; // 20 Paare
function options(hosts) {
return {
bin: 'docker', image: 'joedwards32/cs2:latest', host: '10.0.0.9', rconHost: '10.0.0.9',
portRangeStart: 27015, portRangeEnd: 27115, gslt: '', volume: 'cs2-mapveto-gamefiles', extraArgs: '',
startTimeoutMinutes: 1, hosts,
};
}
/** Docker-Aufrufe merken, Container gelten sofort als fertig. */
function fakeDocker() {
const calls = [];
const run = async (args) => {
calls.push(args);
if (args[0] === 'inspect') return 'running';
if (args[0] === 'logs') return 'Starting CS2 Dedicated Server';
return '';
};
return { run, calls };
}
/** Nur die Konfiguration der Hosts, ohne auf echte Container zu warten. */
function pool(hosts, used = {}) {
const docker = fakeDocker();
const p = new DockerHostPool(options(hosts), {
getUsedPorts: (publicHost) => new Set(used[publicHost] ?? []),
runDocker: docker.run,
});
for (const a of p.adapters.values()) a.waitForUpdateCheck = async () => {};
return { pool: p, calls: docker.calls };
}
test('Ohne CS2_DOCKER_HOSTS gibt es genau einen Host, wie bisher', () => {
const { pool: p } = pool(null);
assert.equal(p.hosts.length, 1);
assert.equal(p.hosts[0].publicHost, '10.0.0.9');
});
test('Ein Match landet auf dem Host mit den meisten freien Ports', async () => {
const { pool: p } = pool([hostA, hostB], { '10.0.0.1': [27100] }); // A hat ein Paar belegt, B ist frei
const r = await p.start({ matchId: 'm1', mode: 'competitive', secrets });
assert.equal(r.handle.hostName, 'b');
assert.equal(r.host, '10.0.0.2');
});
test('Ein voller Host wird übersprungen', async () => {
const fullA = Array.from({ length: 4 }, (_, i) => 27100 + i); // alle Ports von A belegt
const { pool: p } = pool([hostA, hostB], { '10.0.0.1': fullA });
const r = await p.start({ matchId: 'm2', mode: 'wingman', secrets });
assert.equal(r.handle.hostName, 'b');
});
test('Sind alle Hosts voll, gibt es einen klaren Fehler', async () => {
const fullA = Array.from({ length: 4 }, (_, i) => 27100 + i);
const fullB = Array.from({ length: 40 }, (_, i) => 27100 + i);
const { pool: p } = pool([hostA, hostB], { '10.0.0.1': fullA, '10.0.0.2': fullB });
await assert.rejects(p.start({ matchId: 'm3', mode: 'wingman', secrets }), /Kein Docker-Host mit freien Ports/);
});
test('Status, Stopp und Befehle gehen an den Host des Matches', async () => {
const { pool: p, calls } = pool([hostA, hostB], { '10.0.0.1': [27100] });
const r = await p.start({ matchId: 'm4', mode: 'competitive', secrets });
calls.length = 0;
await p.stop(r.handle);
assert.deepEqual(calls.at(-1)[0], 'rm');
assert.equal(calls.at(-1).at(-1), r.handle.container);
// Ein Handle von einem Host, der weder per Name noch per Adresse bekannt ist, wird abgelehnt
await assert.rejects(p.status({ ...r.handle, hostName: 'gelöscht', rconHost: '10.9.9.9' }), /nicht \(mehr\) konfiguriert/);
});
test('Alte Handles ohne Host-Namen laufen auf dem ersten Host', () => {
const { pool: p } = pool([hostA, hostB]);
assert.equal(p.adapterFor({ container: 'x' }), p.adapters.get('a'));
});
test('Auslastung pro Host: Serverplätze insgesamt und belegt', () => {
const { pool: p } = pool([hostA, hostB], { '10.0.0.1': [27100] });
assert.deepEqual(p.hostStats(), [
{ name: 'a', publicHost: '10.0.0.1', total: 4, used: 1 },
{ name: 'b', publicHost: '10.0.0.2', total: 40, used: 0 },
]);
});
test('df-Ausgabe wird in Gesamt- und freien Speicher zerlegt', async () => {
const { parseDf } = await import('../src/servers/dockerAdapter.js');
assert.deepEqual(parseDf('/dev/sda1 1000000000000 400000000000 600000000000 40% /var/lib/docker'), {
totalBytes: 1000000000000,
freeBytes: 600000000000,
});
assert.equal(parseDf('kaputt'), null);
});
test('Alter Handle ohne passenden Host-Namen wird über die Adresse dem richtigen Host zugeordnet', async () => {
const docker = fakeDocker();
const p = new DockerHostPool(options([
{ ...hostA, rconHost: '10.0.0.1' },
{ ...hostB, rconHost: '10.0.0.2' },
]), { runDocker: docker.run });
await p.stop({ container: 'cs2mv-alt', hostName: 'default', rconHost: '10.0.0.2', port: 27100 });
assert.deepEqual(docker.calls.at(-1), ['rm', '-f', 'cs2mv-alt']);
assert.equal(p.adapterFor({ hostName: 'default', rconHost: '10.0.0.2' }).options.name, 'b');
assert.throws(() => p.adapterFor({ hostName: 'weg', rconHost: '10.9.9.9' }), /nicht \(mehr\) konfiguriert/);
});
test('Neue Server meiden Hosts, auf denen gerade ein Server hochfährt (SteamCMD)', async () => {
const starting = { '10.0.0.2': 1 };
const p = new DockerHostPool(options([hostA, hostB]), {
getUsedPorts: () => new Set(),
getStarting: (publicHost, exclude) => (exclude === 'self' ? 0 : starting[publicHost] ?? 0),
runDocker: fakeDocker().run,
});
// b hat mehr Plätze, aber dort lädt gerade ein Container: a gewinnt
assert.equal(p.pickHost().name, 'a');
// Der hochfahrende Server selbst zählt nicht gegen seinen eigenen Neustart
assert.equal(p.pickHost('self').name, 'b');
// Warteschlange im Prozess zählt genauso
starting['10.0.0.2'] = 0;
p.adapters.get('b').pendingStarts = 1;
assert.equal(p.pickHost().name, 'a');
// Starts zählen hoch und nach dem Update-Check wieder herunter
const a = p.adapters.get('a');
let done;
a.waitForUpdateCheck = () => new Promise((r) => { done = r; });
await a.start({ matchId: 'm1', mode: 'competitive', secrets });
assert.equal(a.pendingStarts, 1);
done();
await new Promise((r) => setImmediate(r));
assert.equal(a.pendingStarts, 0);
});
+84
View File
@@ -0,0 +1,84 @@
10/25/2026 - 18:06:44.021 - rcon from "192.168.178.31:54420": command "status"
10/25/2026 - 18:06:48.883 - "Spieler1<0><[U:1:1000001]><>" entered the game
10/25/2026 - 18:07:05.771 - "Spieler1<0><[U:1:1000001]>" switched from team <Unassigned> to <CT>
10/25/2026 - 18:07:18.570 - "Spieler1<0><[U:1:1000001]><CT>" [1331 132 138] killed "Sarge<10><BOT><TERRORIST>" [1410 322 128] with "usp_silencer" (headshot)
10/25/2026 - 18:07:52.100 - rcon from "192.168.178.31:61554": command "mp_warmup_pausetimer 0"
10/25/2026 - 18:07:52.381 - rcon from "192.168.178.31:61554": command "mp_warmup_end"
10/25/2026 - 18:07:52.396 - Starting Freeze period
10/25/2026 - 18:07:52.396 - JSON_BEGIN{
10/25/2026 - 18:07:52.396 - "name": "round_stats",
10/25/2026 - 18:07:52.396 - "round_number" : "1",
10/25/2026 - 18:07:52.396 - "map" : "de_inferno",
10/25/2026 - 18:07:52.396 - }}JSON_END
10/25/2026 - 18:07:52.396 - World triggered "Match_Start" on "de_inferno"
10/25/2026 - 18:07:52.396 - Team playing "CT": MaxLAN Blau
10/25/2026 - 18:07:52.396 - Team playing "TERRORIST": MaxLAN Rot
10/25/2026 - 18:07:52.396 - "Darryl<12><BOT><TERRORIST>" triggered "Got_The_Bomb"
10/25/2026 - 18:07:52.396 - MatchStatus: Team playing "CT": MaxLAN Blau
10/25/2026 - 18:07:52.396 - MatchStatus: Team playing "TERRORIST": MaxLAN Rot
10/25/2026 - 18:07:52.396 - MatchStatus: Score: 0:0 on map "de_inferno" RoundsPlayed: 0
10/25/2026 - 18:08:01.869 - World triggered "Round_Start"
10/25/2026 - 18:08:24.463 - "Darryl<12><BOT><TERRORIST>" triggered "Planted_The_Bomb" at bombsite A
10/25/2026 - 18:08:30.541 - "Darryl<12><BOT><TERRORIST>" [2059 398 160] killed "Maximus<11><BOT><CT>" [2356 255 126] with "glock"
10/25/2026 - 18:08:30.541 - Team "TERRORIST" triggered "SFUI_Notice_Terrorists_Win" (CT "0") (T "1")
10/25/2026 - 18:08:30.541 - Team "CT" scored "0" with "3" players
10/25/2026 - 18:08:30.541 - Team "TERRORIST" scored "1" with "2" players
10/25/2026 - 18:08:30.541 - MatchStatus: Team playing "CT": MaxLAN Blau
10/25/2026 - 18:08:30.541 - MatchStatus: Team playing "TERRORIST": MaxLAN Rot
10/25/2026 - 18:08:30.541 - MatchStatus: Score: 0:1 on map "de_inferno" RoundsPlayed: 1
10/25/2026 - 18:08:30.541 - World triggered "Round_End"
10/25/2026 - 18:08:33.541 - Starting Freeze period
10/25/2026 - 18:08:33.541 - MatchStatus: Team playing "CT": MaxLAN Blau
10/25/2026 - 18:08:33.541 - MatchStatus: Team playing "TERRORIST": MaxLAN Rot
10/25/2026 - 18:08:33.541 - MatchStatus: Score: 0:1 on map "de_inferno" RoundsPlayed: 1
10/25/2026 - 18:08:36.543 - World triggered "Round_Start"
10/25/2026 - 18:09:08.839 - "Spieler1<0><[U:1:1000001]><CT>" [2254 1009 159] killed "Sarge<10><BOT><TERRORIST>" [2287 352 132] with "famas" (headshot)
10/25/2026 - 18:09:08.839 - Team "CT" triggered "SFUI_Notice_CTs_Win" (CT "1") (T "1")
10/25/2026 - 18:09:08.839 - Team "CT" scored "1" with "3" players
10/25/2026 - 18:09:08.839 - Team "TERRORIST" scored "1" with "3" players
10/25/2026 - 18:09:08.839 - MatchStatus: Team playing "CT": MaxLAN Blau
10/25/2026 - 18:09:08.839 - MatchStatus: Team playing "TERRORIST": MaxLAN Rot
10/25/2026 - 18:09:08.839 - MatchStatus: Score: 1:1 on map "de_inferno" RoundsPlayed: 2
10/25/2026 - 18:09:08.839 - World triggered "Round_End"
10/25/2026 - 18:09:17.339 - "Spieler1<0><[U:1:1000001]>" switched from team <CT> to <TERRORIST>
10/25/2026 - 18:09:17.339 - "Sarge<10><BOT>" switched from team <TERRORIST> to <CT>
10/25/2026 - 18:09:17.339 - Starting Freeze period
10/25/2026 - 18:09:17.339 - "Spieler1<0><[U:1:1000001]><TERRORIST>" triggered "Got_The_Bomb"
10/25/2026 - 18:09:17.339 - MatchStatus: Team playing "CT": MaxLAN Blau
10/25/2026 - 18:09:17.339 - MatchStatus: Team playing "TERRORIST": MaxLAN Rot
10/25/2026 - 18:09:17.339 - MatchStatus: Score: 1:1 on map "de_inferno" RoundsPlayed: 2
10/25/2026 - 18:09:26.841 - World triggered "Round_Start"
10/25/2026 - 18:09:35.935 - "Spieler1<0><[U:1:1000001]><TERRORIST>" [1167 474 111] killed "Darryl<12><BOT><CT>" [1326 435 129] with "deagle" (headshot)
10/25/2026 - 18:09:35.935 - Team "TERRORIST" triggered "SFUI_Notice_Terrorists_Win" (CT "1") (T "2")
10/25/2026 - 18:09:35.935 - Team "CT" scored "1" with "3" players
10/25/2026 - 18:09:35.935 - Team "TERRORIST" scored "2" with "4" players
10/25/2026 - 18:09:35.935 - MatchStatus: Team playing "CT": MaxLAN Rot
10/25/2026 - 18:09:35.935 - MatchStatus: Team playing "TERRORIST": MaxLAN Blau
10/25/2026 - 18:09:35.935 - MatchStatus: Score: 1:2 on map "de_inferno" RoundsPlayed: 3
10/25/2026 - 18:09:35.935 - World triggered "Round_End"
10/25/2026 - 18:09:38.935 - Starting Freeze period
10/25/2026 - 18:09:38.935 - MatchStatus: Team playing "CT": MaxLAN Rot
10/25/2026 - 18:09:38.935 - MatchStatus: Team playing "TERRORIST": MaxLAN Blau
10/25/2026 - 18:09:38.935 - MatchStatus: Score: 1:2 on map "de_inferno" RoundsPlayed: 3
10/25/2026 - 18:09:41.936 - World triggered "Round_Start"
10/25/2026 - 18:09:59.311 - "Spieler1<0><[U:1:1000001]><TERRORIST>" [1364 124 132] killed "Sarge<10><BOT><CT>" [1746 -51 135] with "ak47"
10/25/2026 - 18:09:59.311 - Team "TERRORIST" triggered "SFUI_Notice_Terrorists_Win" (CT "1") (T "3")
10/25/2026 - 18:09:59.311 - Team "CT" scored "1" with "5" players
10/25/2026 - 18:09:59.311 - Team "TERRORIST" scored "3" with "2" players
10/25/2026 - 18:09:59.311 - MatchStatus: Team playing "CT": MaxLAN Rot
10/25/2026 - 18:09:59.311 - MatchStatus: Team playing "TERRORIST": MaxLAN Blau
10/25/2026 - 18:09:59.311 - MatchStatus: Score: 1:3 on map "de_inferno" RoundsPlayed: 4
10/25/2026 - 18:09:59.311 - World triggered "Round_End"
10/25/2026 - 18:09:59.311 - ACCOLADE, FINAL: {killreward}, Spieler1<0>, VALUE: 1800.000000, POS: 1, SCORE: 70.000000
10/25/2026 - 18:09:59.311 - MatchStatus: Team playing "CT": MaxLAN Rot
10/25/2026 - 18:09:59.311 - MatchStatus: Team playing "TERRORIST": MaxLAN Blau
10/25/2026 - 18:09:59.311 - MatchStatus: Score: 1:3 on map "de_inferno" RoundsPlayed: 4
10/25/2026 - 18:09:59.311 - Game Over: scrimcomp2v2 de_inferno score 1:3 after 3 min
10/25/2026 - 18:10:24.326 - World triggered "Match_Start" on "de_inferno"
10/25/2026 - 18:10:24.326 - Team playing "CT": MaxLAN Rot
10/25/2026 - 18:10:24.326 - Team playing "TERRORIST": MaxLAN Blau
10/25/2026 - 18:10:24.326 - MatchStatus: Score: 0:0 on map "de_inferno" RoundsPlayed: -1
10/25/2026 - 18:10:26.336 - World triggered "Warmup_Start"
10/25/2026 - 18:10:31.304 - World triggered "Game_Commencing"
10/25/2026 - 18:10:31.304 - World triggered "Match_Start" on "de_inferno"
10/25/2026 - 18:10:33.293 - World triggered "Warmup_Start"
+65
View File
@@ -0,0 +1,65 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { DockerHostPool, GAMEFILES_UPDATE_SCRIPT, parseUpdateOutput } from '../src/servers/dockerAdapter.js';
const options = (runDocker) => new DockerHostPool({
bin: 'docker', image: 'joedwards32/cs2:latest', host: '10.0.0.9', rconHost: '10.0.0.9', portRangeStart: 27100, portRangeEnd: 27139,
volume: 'cs2-mapveto-gamefiles', startTimeoutMinutes: 1, lanPorts: [],
hosts: [{ name: 'a', publicHost: '10.0.0.1' }, { name: 'b', publicHost: '10.0.0.2', volume: 'andere-dateien' }],
}, { runDocker });
test('Ausgabe von SteamCMD: Fortschritt und Build-Nummern', () => {
const info = parseUpdateOutput('BUILD_BEFORE=100\n Update state (0x61) downloading, progress: 42.50 (1 / 2)\n');
assert.equal(info.buildBefore, '100');
assert.equal(info.progress, 42.5);
parseUpdateOutput('Success! App \'730\' fully installed.\nBUILD_AFTER=105\n', info);
assert.equal(info.buildAfter, '105');
assert.match(GAMEFILES_UPDATE_SCRIPT, /\+app_update 730 -beta public \+quit/);
});
test('Spieldateien-Update: eigener Container je Host, Hosts mit Servern ausgelassen', async () => {
const calls = [];
const pool = options(async (args, host) => {
calls.push({ args, host });
return 'BUILD_BEFORE=100\nBUILD_AFTER=105\n';
});
const res = await pool.updateGameFiles({ skip: new Map([['b', 'ausgelassen: 1 laufender Server']]) });
assert.equal(calls.length, 1);
assert.equal(calls[0].host, 'a');
const args = calls[0].args;
assert.deepEqual(args.slice(0, 2), ['run', '--rm']);
assert.ok(args.includes('cs2-mapveto-gamefiles:/home/steam/cs2-dedicated'));
assert.equal(args[args.indexOf('--entrypoint') + 1], 'bash');
assert.ok(!args.includes('cs2-mapveto=1'), 'Update-Container zählen nicht als Gameserver');
assert.deepEqual(res.results.map((r) => [r.name, r.state]), [['a', 'ok'], ['b', 'skipped']]);
assert.match(res.results[0].message, /Build 105 \(vorher 100\)/);
assert.equal(res.running, false);
});
test('Bereits aktuell und Fehler werden gemeldet', async () => {
const same = options(async () => 'BUILD_BEFORE=105\nBUILD_AFTER=105\n');
assert.match((await same.updateGameFiles()).results[0].message, /bereits aktuell \(Build 105\)/);
const broken = options(async () => { throw new Error('SteamCMD failed (rc 8)'); });
const r = (await broken.updateGameFiles()).results;
assert.deepEqual(r.map((x) => x.state), ['failed', 'failed']);
assert.match(r[0].message, /rc 8/);
});
test('Während des Updates startet auf dem Host kein neuer Server', () => {
const pool = options(async () => '');
pool.files = { running: true, results: [{ name: 'a', state: 'running' }, { name: 'b', state: 'ok' }] };
assert.equal(pool.pickHost().name, 'b');
pool.files.results[1].state = 'running';
assert.throws(() => pool.pickHost(), /Spieldateien/);
});
test('Unvollständige Installation oder SteamCMD-Fehler gelten als fehlgeschlagen, auch mit Exit-Code 0', async () => {
const partial = options(async () => 'BUILD_BEFORE=0\nBUILD_AFTER=0\nSTATE_AFTER=1026\n');
const [a] = (await partial.updateGameFiles()).results;
assert.equal(a.state, 'failed');
assert.match(a.message, /StateFlags 1026/);
const err = options(async () => "Error! App '730' state is 0x202 after update job.\nBUILD_BEFORE=1\nBUILD_AFTER=1\nSTATE_AFTER=4\n");
assert.match((await err.updateGameFiles()).results[0].message, /Error! App '730'/);
const fine = options(async () => 'BUILD_BEFORE=1\nBUILD_AFTER=2\nSTATE_AFTER=4\n');
assert.equal((await fine.updateGameFiles()).results[0].state, 'ok');
});
+224
View File
@@ -0,0 +1,224 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import net from 'node:net';
import { io as connect } from 'socket.io-client';
import { loadConfig } from '../src/config.js';
import { createApp } from '../src/app.js';
import { DemoServerAdapter } from '../src/servers/demoAdapter.js';
import { applyLogLines, initialLiveState } from '../src/liveScore.js';
import { RconClient } from '../src/servers/rcon.js';
import { createRateLimiter, safeErrorMessage } from '../src/security.js';
// Absicherungen aus der Prüfung: jede Stelle mit dem Fehler, der vorher möglich war.
const quiet = { log() {}, warn() {}, error() {} };
async function start(overrides = {}, adapter) {
const config = loadConfig({
databaseFile: ':memory:', serverAdapter: 'demo', createMatchPassword: '',
demo: { startSeconds: 0, failFirstConfigure: false }, ...overrides,
});
const app = createApp(config, { logger: quiet, adapter });
await new Promise((resolve) => app.server.listen(0, '127.0.0.1', resolve));
const port = app.server.address().port;
const call = async (method, path, { token, body } = {}) => {
const res = await fetch(`http://127.0.0.1:${port}/api${path}`, {
method,
headers: { 'content-type': 'application/json', ...(token ? { authorization: `Bearer ${token}` } : {}) },
body: body ? JSON.stringify(body) : undefined,
});
return { status: res.status, body: await res.json() };
};
return { app, port, call, close: () => app.close() };
}
const newMatch = (call) => call('POST', '/matches', { body: { teamA: 'Alpha', teamB: 'Bravo', mode: 'wingman', format: 'bo1', vetoStart: 'A' } });
test('Kaputte Socket-Nachrichten beenden das Backend nicht', async () => {
const { port, call, close } = await start();
const socket = connect(`http://127.0.0.1:${port}`, { transports: ['websocket'], reconnection: false });
try {
await new Promise((resolve, reject) => { socket.on('connect', resolve); socket.on('connect_error', reject); });
for (const payload of [null, 5, 'x', [], { matchId: { $ne: 1 } }]) {
socket.emit('stream:subscribe', payload);
socket.emit('match:subscribe', payload);
socket.emit('overview:subscribe', payload);
}
await new Promise((r) => setTimeout(r, 200));
assert.equal((await call('GET', '/config')).status, 200, 'Backend antwortet noch');
} finally {
socket.close();
await close();
}
});
test('Zu viele Socket-Abfragen werden gebremst', async () => {
const { port, close } = await start();
const socket = connect(`http://127.0.0.1:${port}`, { transports: ['websocket'], reconnection: false });
try {
await new Promise((resolve) => socket.on('connect', resolve));
let limited = 0;
socket.on('rate_limited', () => { limited += 1; });
for (let i = 0; i < 60; i++) socket.emit('overview:subscribe');
await new Promise((r) => setTimeout(r, 300));
assert.ok(limited > 0, 'ab einer Grenze werden Abfragen abgewiesen');
} finally {
socket.close();
await close();
}
});
test('Fehlgeschlagener Serverstart veröffentlicht keine Passwörter', async () => {
class Failing extends DemoServerAdapter {
async start({ secrets }) {
const err = new Error(`Command failed: docker run -e CS2_RCONPW=${secrets.rconPassword} -e CS2_PW=${secrets.serverPassword} joedwards32/cs2`);
err.stderr = '';
throw err;
}
}
const { app, call, close } = await start({}, new Failing({ startSeconds: 0 }));
try {
const { id } = (await newMatch(call)).body;
await app.manager.idle();
const row = app.manager.row(id);
assert.equal(row.status, 'start_failed');
const pub = JSON.stringify((await call('GET', `/matches/${id}`)).body);
for (const secret of [row.rcon_password, row.server_password]) {
assert.ok(!pub.includes(secret), 'Passwort nicht in der öffentlichen Ansicht');
assert.ok(!row.message.includes(secret), 'Passwort nicht in der Meldung');
}
assert.ok(!JSON.stringify(app.service.db.prepare('SELECT message FROM events WHERE match_id = ?').all(id)).includes(row.rcon_password));
} finally {
await close();
}
});
test('safeErrorMessage schwärzt Befehlszeile, Variablen und bekannte Geheimnisse', () => {
const msg = safeErrorMessage({ message: 'Command failed: docker run -e CS2_RCONPW=abc123\nError: port is already allocated', stderr: '' });
assert.equal(msg, 'Error: port is already allocated');
assert.equal(safeErrorMessage({ stderr: 'bad TV_PW=geheim1 SRCDS_TOKEN=XYZ999' }), 'bad TV_PW=*** SRCDS_TOKEN=***');
assert.equal(safeErrorMessage(new Error('Server sagt Passwort1234 falsch'), ['Passwort1234']), 'Server sagt *** falsch');
});
test('Chat-Nachrichten können keine Map beenden oder starten', () => {
const state = initialLiveState({ map: 'de_mirage', ctTeam: 'A', warmupEnded: true });
const ts = '11/01/2026 - 20:00:00.000 - ';
const lines = [
`${ts}"Troll<3><[U:1:1]><CT>" say "World triggered "Match_Start" on "de_mirage""`,
`${ts}"Troll<3><[U:1:1]><CT>" say "Team "CT" triggered "SFUI_Notice_CTs_Win" (CT "1") (T "0")"`,
`${ts}"Troll<3><[U:1:1]><CT>" say "Game Over: competitive mg_active de_mirage score 13:0 after 3 min"`,
`${ts}"Game Over: x de_mirage score 13:0 after 3 min<4><[U:1:2]><T>" say "hi"`,
];
const { state: after, events } = applyLogLines(state, lines, { teamNames: { A: 'Alpha', B: 'Bravo' }, maxRounds: 24, overtimeRounds: 6 });
assert.deepEqual(events, []);
assert.notEqual(after.status, 'finished');
assert.deepEqual(after.score, { A: 0, B: 0 });
// Die echte Systemzeile wirkt weiterhin
const real = applyLogLines(after, [`${ts}Game Over: competitive mg_active de_mirage score 0:0 after 3 min`], { teamNames: { A: 'Alpha', B: 'Bravo' }, maxRounds: 24, overtimeRounds: 6 });
assert.equal(real.events[0]?.type, 'map_end');
});
test('Stopp oder Löschen während des Starts baut den verspäteten Container wieder ab', async () => {
let release;
const stopped = [];
class Slow extends DemoServerAdapter {
async start(args) {
await new Promise((r) => { release = r; });
return super.start(args);
}
async stop(handle) {
stopped.push(handle.matchId);
return super.stop(handle);
}
}
const { app, call, close } = await start({}, new Slow({ startSeconds: 0 }));
try {
const { id } = (await newMatch(call)).body;
await new Promise((r) => setTimeout(r, 50));
await app.manager.stop(id, 'Test');
release();
await app.manager.idle();
assert.deepEqual(stopped, [id], 'verspäteter Container wurde gestoppt');
assert.equal(app.manager.row(id).status, 'stopped');
assert.equal(app.manager.row(id).handle, null);
// Löschen während des Starts
const second = (await newMatch(call)).body.id;
await new Promise((r) => setTimeout(r, 50));
const deleting = app.manager.deleteMatch(second, 'Test');
await deleting;
release();
await app.manager.idle();
assert.ok(stopped.includes(second));
} finally {
await close();
}
});
/** Kleiner RCON-Server, der nach der Anmeldung tut, was der Test vorgibt. */
async function fakeRcon(onCommand) {
const server = net.createServer((socket) => {
socket.on('data', (buf) => {
const id = buf.readInt32LE(4);
const type = buf.readInt32LE(8);
if (type === 3) {
const ok = Buffer.alloc(14);
ok.writeInt32LE(10, 0); ok.writeInt32LE(id, 4); ok.writeInt32LE(2, 8);
socket.write(ok);
} else onCommand(socket, id);
});
});
await new Promise((r) => server.listen(0, '127.0.0.1', r));
return { port: server.address().port, close: () => server.close() };
}
test('RCON: kaputte Pakete trennen nur die Verbindung, fehlende Antworten sind ein Fehler', async () => {
const bad = await fakeRcon((socket) => {
const junk = Buffer.alloc(8);
junk.writeInt32LE(5, 0); // Paketgröße unter dem Minimum
socket.write(junk);
});
const silent = await fakeRcon(() => {});
try {
const a = await new RconClient({ host: '127.0.0.1', port: bad.port, password: 'x', timeoutMs: 1000 }).connect();
await assert.rejects(a.command('status'), /geschlossen|keine Antwort/);
a.close();
const b = await new RconClient({ host: '127.0.0.1', port: silent.port, password: 'x', timeoutMs: 300 }).connect();
await assert.rejects(b.command('mp_warmup_end'), /keine Antwort auf "mp_warmup_end"/);
b.close();
} finally {
bad.close();
silent.close();
}
});
test('Organisatoren bekommen nur einen Stream-Schlüssel für ihr Match, nicht den globalen', async () => {
const { call, close } = await start({ streamKey: 'GlobalerSchluessel42' });
try {
const one = (await newMatch(call)).body;
const two = (await newMatch(call)).body;
const view = (await call('GET', `/matches/${one.id}`, { token: one.organizerToken })).body;
const link = view.organizer.links.overview;
assert.ok(!link.includes('GlobalerSchluessel42'), 'globaler Schlüssel nicht im Link');
const key = new URL(link).searchParams.get('key');
const scoped = (await call('GET', `/stream/overview?key=${encodeURIComponent(key)}`)).body;
const ids = [...scoped.active, ...scoped.upcoming, ...scoped.results].map((m) => m.id);
assert.deepEqual(ids, [one.id], 'nur das eigene Match');
assert.ok(!ids.includes(two.id));
// Gefälschter Schlüssel für ein anderes Match geht nicht
assert.equal((await call('GET', `/stream/overview?key=${encodeURIComponent(`${two.id}.${key.split('.').pop()}`)}`)).status, 403);
// Der globale Schlüssel zeigt weiter alles
const all = (await call('GET', '/stream/overview?key=GlobalerSchluessel42')).body;
assert.equal(all.upcoming.length, 2);
} finally {
await close();
}
});
test('Rate-Limiter hält seinen Speicher begrenzt', () => {
const check = createRateLimiter({ windowMs: 60_000, max: 5, maxKeys: 100 });
for (let i = 0; i < 1000; i++) check(`ip-${i}`);
assert.ok(check.size() <= 100);
for (let i = 0; i < 5; i++) assert.equal(check('x'), true);
assert.equal(check('x'), false);
});
+125
View File
@@ -0,0 +1,125 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { loadConfig } from '../src/config.js';
import { openDatabase } from '../src/db.js';
import { buildKnifeCommands, KnifeServers } from '../src/servers/knifeServers.js';
const pools = {
competitive: { label: 'Competitive 5 gegen 5', gameType: 0, gameMode: 1, teamSize: 5, maps: [{ id: 'de_mirage', name: 'Mirage' }, { id: 'de_inferno', name: 'Inferno' }] },
wingman: { label: 'Wingman 2 gegen 2', gameType: 0, gameMode: 2, teamSize: 2, maps: [{ id: 'de_vertigo', name: 'Vertigo' }] },
};
const quiet = { log() {}, warn() {}, error() {} };
/** Adapter, dessen Zustand der Test steuert. */
function fakeAdapter() {
const a = {
name: 'docker',
starts: [],
configures: [],
commands: [],
stops: 0,
state: 'starting',
players: null,
async start(args) {
a.starts.push(args);
return { handle: { container: `cs2mv-${args.matchId}`, n: a.starts.length }, host: '10.0.0.5', port: 27015 + a.starts.length - 1, tvPort: 27115 };
},
async status() { return { state: a.state, playerCount: a.players }; },
async configure(handle, plan) { a.configures.push(plan); },
async sendCommands(handle, cmds) { a.commands.push(...cmds); },
async stop() { a.stops += 1; },
};
return a;
}
function setup() {
const config = loadConfig({ knife: { map: 'de_mirage', warmupSeconds: 60, maxServers: 2 }, docker: { serverNamePrefix: 'MaxLAN' } });
const adapter = fakeAdapter();
const knife = new KnifeServers({ db: openDatabase(':memory:'), adapter, config, pools, logger: quiet });
return { knife, adapter };
}
const poll = async (knife) => { knife.lastPoll.clear(); await knife.poll(); };
test('Messerrunde: nur Messer, eine Runde, danach automatisch neues Spiel', () => {
const plan = buildKnifeCommands({ map: 'de_inferno', warmupSeconds: 45 });
assert.equal(plan.beforeMapChange.at(-1), 'changelevel de_inferno');
assert.ok(plan.beforeMapChange.includes('sv_password ""'), 'offen, ohne Passwort');
for (const c of ['mp_maxrounds 1', 'mp_match_end_restart 1', 'mp_ct_default_secondary ""', 'mp_startmoney 0', 'mp_warmuptime 45']) {
assert.ok(plan.afterMapChange.includes(c), `${c} fehlt`);
}
});
test('Start, Konfiguration und Ansicht ohne Geheimnisse', async () => {
const { knife, adapter } = setup();
const created = await knife.create({});
assert.equal(created.name, 'Messer 1');
assert.equal(created.status, 'starting');
assert.equal(adapter.starts[0].serverName, 'MaxLAN | Messer 1 5v5 (Seitenwahl)');
assert.equal(adapter.starts[0].mode, 'competitive');
assert.equal(adapter.starts[0].secrets.serverPassword, '');
adapter.state = 'running';
await poll(knife);
assert.equal(knife.row('knife1').status, 'running');
assert.equal(adapter.configures[0].map, 'de_mirage');
const [view] = knife.list();
assert.equal(view.connect, 'connect 10.0.0.5:27015');
assert.ok(!JSON.stringify(view).includes(knife.row('knife1').rcon_password), 'kein RCON-Passwort in der Ansicht');
});
test('Wingman 2v2: eigener Spielmodus, Maps nur aus dem Wingman-Pool', async () => {
const { knife, adapter } = setup();
const k = await knife.create({ mode: 'wingman' });
assert.equal(k.modeShort, '2v2');
assert.equal(k.map, 'de_vertigo', 'Standard-Map gibt es im Wingman-Pool nicht, also die erste');
assert.equal(adapter.starts[0].mode, 'wingman');
assert.equal(adapter.starts[0].serverName, 'MaxLAN | Messer 1 2v2 (Seitenwahl)');
adapter.state = 'running';
await poll(knife);
assert.ok(adapter.configures[0].beforeMapChange.includes('game_mode 2'));
await assert.rejects(knife.create({ mode: 'wingman', map: 'de_mirage' }), /gibt es nicht für Wingman/);
await assert.rejects(knife.create({ mode: 'deathmatch' }), /Unbekannter Modus/);
});
test('Grenzen: nur Maps aus den Pools, höchstens maxServers', async () => {
const { knife } = setup();
await assert.rejects(knife.create({ map: 'de_unbekannt' }), /gibt es nicht/);
await knife.create({ map: 'de_inferno' });
await knife.create({});
await assert.rejects(knife.create({}), /Höchstens 2/);
// Freie Nummer wird wiederverwendet
await knife.remove('knife1');
assert.equal((await knife.create({})).name, 'Messer 1');
});
test('Teams gehen runter: Server setzt sich für die nächsten zurück', async () => {
const { knife, adapter } = setup();
await knife.create({});
adapter.state = 'running';
await poll(knife);
adapter.players = 4;
await poll(knife);
assert.equal(adapter.commands.length, 0);
adapter.players = 0;
await poll(knife);
assert.deepEqual(adapter.commands, ['mp_warmup_start']);
assert.match(knife.row('knife1').message, /zurückgesetzt/);
});
test('Ausfall: Server startet neu, nach zu vielen Ausfällen gibt die App auf', async () => {
const { knife, adapter } = setup();
await knife.create({});
adapter.state = 'failed';
await poll(knife);
assert.equal(adapter.starts.length, 2, 'nach Ausfall neu gestartet');
assert.equal(knife.row('knife1').restarts, 1);
for (let i = 0; i < 6; i++) await poll(knife);
assert.equal(adapter.starts.length, 6, 'höchstens 5 Neustarts');
assert.equal(knife.row('knife1').status, 'failed');
// Neustart durch den Admin setzt den Zähler zurück
adapter.state = 'starting';
await knife.restart('knife1');
assert.equal(knife.row('knife1').restarts, 0);
assert.equal(adapter.starts.length, 7);
});
+95
View File
@@ -0,0 +1,95 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { formatPortList, parsePortList } from '../src/config.js';
import { countSlots, DockerHostPool, planPorts } from '../src/servers/dockerAdapter.js';
const host = { portRangeStart: 27100, portRangeEnd: 27105, lanPorts: [27015, 27016] };
const secrets = { rconPassword: 'Rcon123', serverPassword: 'Pw123', tvPassword: 'Tv123' };
test('Portlisten werden gelesen und kompakt geschrieben', () => {
assert.deepEqual(parsePortList('27015-27017, 27020'), [27015, 27016, 27017, 27020]);
assert.deepEqual(parsePortList(''), []);
assert.throws(() => parsePortList('abc'), /kein Port/);
assert.throws(() => parsePortList('80-90'), /ungültig/);
assert.equal(formatPortList([27020, 27015, 27016, 27017]), '27015-27017,27020');
});
test('Spielport kommt zuerst aus den LAN-Ports, CSTV liegt immer 100 darüber', () => {
assert.deepEqual(planPorts(host, new Set()), { port: 27015, tvPort: 27115 });
assert.deepEqual(planPorts(host, new Set([27015, 27115])), { port: 27016, tvPort: 27116 });
// LAN-Ports voll: weiter im normalen Bereich, CSTV ebenfalls +100
assert.deepEqual(planPorts(host, new Set([27015, 27016, 27115, 27116])), { port: 27100, tvPort: 27200 });
// Spielport frei, aber sein CSTV-Port belegt: nächster Spielport
assert.deepEqual(planPorts({ ...host, lanPorts: [] }, new Set([27200])), { port: 27101, tvPort: 27201 });
assert.equal(planPorts({ ...host, lanPorts: [] }, new Set([27100, 27101, 27102, 27103, 27104, 27105])), null);
});
test('Abstand zwischen Spiel- und CSTV-Port ist einstellbar', () => {
assert.deepEqual(planPorts({ ...host, tvPortOffset: 5 }, new Set()), { port: 27015, tvPort: 27020 });
});
test('Plätze pro Host: 2 LAN-Ports plus 6 Spielports im normalen Bereich', () => {
assert.equal(countSlots(host, new Set()), 8);
assert.equal(countSlots({ ...host, lanPorts: [] }, new Set()), 6);
});
function fakePool({ busy = [] } = {}) {
const calls = [];
const runDocker = async (args) => {
calls.push(args);
if (args[0] === 'run') {
const ports = args.filter((a, i) => args[i - 1] === '-p').map((a) => Number(a.split(':')[0]));
const portEnv = args.find((a) => a.startsWith('CS2_PORT='));
if (args.includes('--network') && portEnv) ports.push(Number(portEnv.split('=')[1]));
if (ports.some((p) => busy.includes(p))) {
const err = new Error('Command failed');
err.stderr = 'Bind for 0.0.0.0:27015 failed: port is already allocated';
throw err;
}
}
if (args[0] === 'inspect') return 'running';
return '';
};
const p = new DockerHostPool({
bin: 'docker', image: 'joedwards32/cs2:latest', host: '10.0.0.9', rconHost: '10.0.0.9',
portRangeStart: 27100, portRangeEnd: 27139, gslt: '', volume: 'v', extraArgs: '',
startTimeoutMinutes: 1, lanPorts: [27015, 27016], serverNamePrefix: 'MaxLAN',
hosts: [{ name: 'a', publicHost: '10.0.0.1' }],
}, { runDocker });
for (const a of p.adapters.values()) a.waitForUpdateCheck = async () => {};
return { pool: p, calls };
}
test('Servername zeigt die Teams, Spielport ist ein LAN-Port', async () => {
const { pool: p, calls } = fakePool();
const r = await p.start({ matchId: 'm1', mode: 'wingman', secrets, teamNames: { A: 'Rote "Füchse"', B: 'Blau' } });
assert.equal(r.port, 27015);
assert.equal(r.tvPort, 27115);
const run = calls.find((c) => c[0] === 'run');
assert.ok(run.includes('CS2_SERVERNAME=MaxLAN | Rote Füchse vs. Blau'));
// Auf LAN-Ports im Host-Netz, damit Broadcasts des Serverbrowsers ankommen; keine Port-Weiterleitung
assert.equal(run[run.indexOf('--network') + 1], 'host');
assert.ok(!run.includes('-p'));
});
test('Server außerhalb der LAN-Ports laufen weiter mit Port-Weiterleitung', async () => {
const { pool: p, calls } = fakePool();
await p.start({ matchId: 'x1', mode: 'wingman', secrets, teamNames: { A: 'A', B: 'B' } });
await p.start({ matchId: 'x2', mode: 'wingman', secrets, teamNames: { A: 'A', B: 'B' } });
calls.length = 0;
// Die App kennt ihre Ports aus der Datenbank; hier sind beide LAN-Ports belegt
const adapter = p.adapters.get('a');
adapter.getUsedPorts = () => new Set([27015, 27016, 27115, 27116]);
await adapter.launch({ matchId: 'x3', mode: 'wingman', secrets });
const run = calls.find((c) => c[0] === 'run');
assert.ok(!run.includes('--network'));
assert.ok(run.includes('27100:27100/udp'));
assert.ok(run.includes('27200:27200/udp'));
});
test('Ist ein LAN-Port von einem fremden Dienst belegt, nimmt der Start den nächsten', async () => {
const { pool: p, calls } = fakePool({ busy: [27015] });
const r = await p.start({ matchId: 'm2', mode: 'competitive', secrets, teamNames: { A: 'A', B: 'B' } });
assert.equal(r.port, 27016);
assert.equal(calls.filter((c) => c[0] === 'run').length, 2);
});
+134
View File
@@ -0,0 +1,134 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { applyLogLines, initialLiveState, seriesScore, splitLogBody } from '../src/liveScore.js';
const here = path.dirname(fileURLToPath(import.meta.url));
const ctx = { teamNames: { A: 'MaxLAN Blau', B: 'MaxLAN Rot' }, maxRounds: 24, overtimeRounds: 6 };
const ts = '10/25/2026 - 18:00:00.000 - ';
function round(winnerSide, ct, t, notice) {
const n = notice ?? (winnerSide === 'CT' ? 'CTs_Win' : 'Terrorists_Win');
const side = winnerSide === 'CT' ? 'CT' : 'TERRORIST';
return `${ts}Team "${side}" triggered "SFUI_Notice_${n}" (CT "${ct}") (T "${t}")`;
}
test('Logzeilen ergeben Spielstand, Rundenverlauf und Gründe', () => {
let state = initialLiveState({ map: 'de_mirage', ctTeam: 'A', warmupEnded: true });
const lines = [
`${ts}World triggered "Match_Start" on "de_mirage"`,
round('T', 0, 1, 'Target_Bombed'),
round('CT', 1, 1, 'Bomb_Defused'),
round('CT', 2, 1, 'Target_Saved'),
];
const { state: next, events } = applyLogLines(state, lines, ctx);
state = next;
assert.equal(state.status, 'live');
assert.deepEqual(state.score, { A: 2, B: 1 });
assert.deepEqual(state.rounds.map((r) => [r.winner, r.reason]), [['B', 'bomb'], ['A', 'defuse'], ['A', 'time']]);
assert.equal(events.filter((e) => e.type === 'round').length, 3);
});
test('Doppelt gesendete Zeilen werden nur einmal gezählt', () => {
const state = initialLiveState({ map: 'de_nuke', ctTeam: 'A', warmupEnded: true });
const line = round('CT', 1, 0);
const { state: s } = applyLogLines(state, [line, line, line], ctx);
assert.equal(s.rounds.length, 1);
assert.deepEqual(s.score, { A: 1, B: 0 });
});
test('Seitenzuordnung nach dem Rundenende hat Vorrang, veraltete Zeilen zu Rundenbeginn zählen nicht', () => {
let s = initialLiveState({ map: 'de_nuke', ctTeam: 'B', warmupEnded: true });
const playing = (ct, t) => [`${ts}MatchStatus: Team playing "CT": ${ct}`, `${ts}MatchStatus: Team playing "TERRORIST": ${t}`];
({ state: s } = applyLogLines(s, [
// Veto sagt Rot auf CT, tatsächlich steht Blau auf CT – das Log gewinnt
`${ts}World triggered "Match_Start" on "de_nuke"`,
...playing('MaxLAN Blau', 'MaxLAN Rot'),
round('CT', 1, 0),
...playing('MaxLAN Blau', 'MaxLAN Rot'),
`${ts}World triggered "Round_End"`,
// veraltete Zeile zu Beginn der nächsten Runde (darf nichts ändern)
...playing('MaxLAN Rot', 'MaxLAN Blau'),
round('T', 1, 1),
...playing('MaxLAN Blau', 'MaxLAN Rot'),
`${ts}World triggered "Round_End"`,
], ctx));
assert.deepEqual(s.score, { A: 1, B: 1 });
assert.deepEqual(s.rounds.map((r) => r.winner), ['A', 'B']);
});
test('Echte, anonymisierte Logzeilen eines CS2-Servers (Wingman, 4 Runden, Halbzeit nach 2)', () => {
const lines = fs.readFileSync(path.join(here, 'fixtures', 'wingman-anonym.log'), 'utf8').split(/\r?\n/).filter(Boolean);
const warmupEnd = lines.findIndex((l) => l.includes('command "mp_warmup_end"'));
const ctx = { teamNames: { A: 'MaxLAN Blau', B: 'MaxLAN Rot' }, maxRounds: 4, overtimeRounds: 6 };
const check = (s) => {
assert.equal(s.status, 'finished');
assert.equal(s.winner, 'A');
assert.deepEqual(s.score, { A: 3, B: 1 });
assert.deepEqual(s.rounds.map((r) => r.winner), ['B', 'A', 'A', 'A']);
assert.ok(s.rounds.every((r) => r.reason === 'elimination'));
};
// a) wie vom Server in Paketen geschickt
let s = initialLiveState({ map: 'de_inferno', ctTeam: 'A' });
({ state: s } = applyLogLines(s, lines.slice(0, warmupEnd), ctx));
assert.equal(s.status, 'warmup');
s.warmupEnded = true; // Organisator hat "Aufwärmphase beenden" geklickt
({ state: s } = applyLogLines(s, lines.slice(warmupEnd), ctx));
check(s);
// b) jede Zeile einzeln (ungünstigste Paketgrenzen)
let t = initialLiveState({ map: 'de_inferno', ctTeam: 'A', warmupEnded: true });
for (const line of lines.slice(warmupEnd)) ({ state: t } = applyLogLines(t, [line], ctx));
check(t);
});
test('Ohne "Team playing": Halbzeitwechsel nach Regelwerk', () => {
let s = initialLiveState({ map: 'de_inferno', ctTeam: 'B', warmupEnded: true });
const small = { ...ctx, maxRounds: 4 };
({ state: s } = applyLogLines(s, [
`${ts}World triggered "Match_Start" on "de_inferno"`,
round('CT', 1, 0), // B gewinnt als CT
round('CT', 2, 0), // B gewinnt als CT -> Halbzeit, B ist jetzt T
round('T', 0, 3), // B gewinnt als T: CT-Seite (A) hat 0, T-Seite (B) hat 3
], small));
assert.deepEqual(s.score, { A: 0, B: 3 });
assert.deepEqual(s.rounds.map((r) => r.winner), ['B', 'B', 'B']);
});
test('Game Over beendet die Map, Aufwärmrunden vor Match_Start zählen nicht', () => {
let s = initialLiveState({ map: 'de_dust2', ctTeam: 'A', warmupEnded: true });
({ state: s } = applyLogLines(s, [
round('T', 0, 1), // Aufwärm-/Messerrunde
`${ts}World triggered "Match_Start" on "de_dust2"`,
round('CT', 1, 0),
`${ts}Game Over: competitive mg_active de_dust2 score 1:0 after 2 min`,
round('T', 1, 1), // nach Spielende ignoriert
], ctx));
assert.equal(s.status, 'finished');
assert.equal(s.winner, 'A');
assert.deepEqual(s.score, { A: 1, B: 0 });
});
test('Zeilen einer anderen Map werden ignoriert, Serienstand wird gezählt', () => {
const s = initialLiveState({ map: 'de_dust2', ctTeam: 'A', warmupEnded: true });
const { state } = applyLogLines(s, [`${ts}Game Over: competitive mg_active de_nuke score 13:2 after 30 min`], ctx);
assert.equal(state.status, 'warmup');
assert.deepEqual(seriesScore([{ winner: 'A' }, { winner: 'B' }, { winner: 'A' }, { winner: null }]), { A: 2, B: 1 });
assert.deepEqual(splitLogBody('a\r\n\r\nb\n'), ['a', 'b']);
});
test('Match_Start vor dem Ende der Aufwärmphase startet keine Zählung', () => {
let s = initialLiveState({ map: 'de_inferno', ctTeam: 'A' });
const start = `${ts}World triggered "Match_Start" on "de_inferno"`;
({ state: s } = applyLogLines(s, [start], ctx));
assert.equal(s.status, 'warmup');
s.warmupEnded = true;
const { state, events } = applyLogLines(s, [start], ctx);
assert.equal(state.status, 'live');
assert.equal(events[0].type, 'match_start');
});
+18
View File
@@ -64,4 +64,22 @@ test('Konsolentexte können keine Befehle einschleusen', () => {
assert.ok(afterMapChange.includes('mp_teamname_1 "Bravo"'));
assert.ok(afterMapChange.includes('mp_teamname_2 "Alphaquit"'));
assert.ok(afterMapChange.includes('mp_maxrounds 16'));
assert.ok(afterMapChange.includes('sv_pausable 1'), 'Pausen-Knopf braucht sv_pausable');
assert.ok(afterMapChange.includes('mp_freezetime 20'));
});
test('Log-Weiterleitung wird vor dem Mapwechsel gesetzt, unsichere URLs nicht', () => {
const base = {
match: { team_a_name: 'A', team_b_name: 'B' },
pool: { mode: 'competitive', gameType: 0, gameMode: 1 },
seriesMap: { map: 'de_nuke', ctTeam: 'A' },
secrets: { serverPassword: 'abc', tvPassword: 'def' },
settings: { tvEnabled: true, tvDelaySeconds: 90, maxRoundsWingman: 16, maxRoundsCompetitive: 24, overtime: true, extraCommands: [] },
};
const ok = buildMapCommands({ ...base, logUrl: 'http://192.168.178.31:3000/api/logs/abc_D-1/KEY123' }).beforeMapChange;
assert.ok(ok.includes('logaddress_add_http "http://192.168.178.31:3000/api/logs/abc_D-1/KEY123"'));
assert.ok(buildMapCommands({ ...base, logUrl: 'http://x/api/logs/a/b' }).afterMapChange.includes('mp_endmatch_votenextmap 0'), 'keine Map-Abstimmung nach dem Match');
assert.ok(ok.indexOf('log on') < ok.indexOf('changelevel de_nuke'));
const bad = buildMapCommands({ ...base, logUrl: 'http://x"; quit' }).beforeMapChange;
assert.ok(!bad.some((c) => c.startsWith('logaddress')));
});
+54
View File
@@ -0,0 +1,54 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { backupPrefix, describeBackup, isValidBackupFile, parseBackupFiles, pointsForMap, trimLiveState } from '../src/rounds.js';
const id = 'Ab_9-xY';
const file = (map, round, s1, s2) => `${backupPrefix(id)}_2026-10-25_18-30-00_${map}_round${round}_score_${s1}_${s2}.txt`;
test('Gültige Sicherungsdateien nur für dieses Match', () => {
assert.equal(isValidBackupFile(file('de_inferno', 3, 2, 1), id), true);
assert.equal(isValidBackupFile(file('de_inferno', 3, 2, 1), 'anderes'), false, 'fremdes Match');
assert.equal(isValidBackupFile(`${backupPrefix(id)}_../../etc/passwd.txt`, id), false, 'Pfad');
assert.equal(isValidBackupFile(`${backupPrefix(id)}_x;quit_round1_score_0_0.txt`, id), false, 'Befehl');
assert.equal(isValidBackupFile(`${backupPrefix(id)}_de_inferno.txt`, id), false, 'ohne Runde');
assert.equal(isValidBackupFile(undefined, id), false);
});
test('Liste aus einer RCON-Antwort auslesen, fremde Dateien ignorieren', () => {
const text = [
'Backups:',
file('de_inferno', 1, 1, 0),
file('de_inferno', 2, 1, 1),
'round_backup_fremd_2026_de_nuke_round4_score_3_1.txt',
file('de_nuke', 1, 1, 0),
].join('\n');
const found = parseBackupFiles(text, id);
assert.equal(found.length, 3);
assert.deepEqual(found[0], { file: file('de_inferno', 1, 1, 0), map: 'de_inferno', round: 1, score: { s1: 1, s2: 0 } });
});
test('Nur die Map der laufenden Serie, neueste Runde zuerst', () => {
const all = parseBackupFiles([file('de_inferno', 1, 1, 0), file('de_inferno', 3, 2, 1), file('de_nuke', 2, 1, 1)].join('\n'), id);
const points = pointsForMap(all, 'de_inferno');
assert.deepEqual(points.map((p) => p.round), [3, 1]);
});
test('Stand nach Rollback: Runden bis zur Sicherung, Punkte neu gerechnet', () => {
const state = {
map: 'de_inferno', status: 'live', warmupEnded: true, score: { A: 3, B: 2 }, winner: 'A', pendingRound: null,
rounds: [
{ number: 1, winner: 'A' }, { number: 2, winner: 'B' }, { number: 3, winner: 'A' },
{ number: 4, winner: 'B' }, { number: 5, winner: 'A' },
],
};
const back = trimLiveState(state, 3);
assert.deepEqual(back.rounds.map((r) => r.number), [1, 2, 3]);
assert.deepEqual(back.score, { A: 2, B: 1 });
assert.equal(back.winner, null);
assert.equal(back.status, 'live');
assert.equal(back.warmupEnded, true, 'Zustand bleibt erhalten');
});
test('Beschreibung eines Dateinamens', () => {
assert.deepEqual(describeBackup('nichts.txt'), { file: 'nichts.txt', map: null, round: null, score: null });
});
+61
View File
@@ -0,0 +1,61 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { loadConfig } from '../src/config.js';
import { openDatabase } from '../src/db.js';
import { ServerSettings } from '../src/serverSettings.js';
import { DockerHostPool } from '../src/servers/dockerAdapter.js';
function setup() {
const config = loadConfig({ match: { maxRoundsCompetitive: 24, extraCommands: [] }, vetoStepSeconds: 30 });
const db = openDatabase(':memory:');
return { config, db, settings: new ServerSettings({ db, config }) };
}
test('Geänderte Einstellungen landen in der Konfiguration und überstehen einen Neustart', () => {
const { config, db, settings } = setup();
const changed = settings.update({ maxRoundsCompetitive: 30, extraCommands: 'mp_freezetime 15; sv_cheats 0', vetoStepSeconds: 30 });
assert.deepEqual(changed, ['maxRoundsCompetitive', 'extraCommands']);
assert.equal(config.match.maxRoundsCompetitive, 30);
assert.deepEqual(config.match.extraCommands, ['mp_freezetime 15', 'sv_cheats 0']);
// Neustart: neue Konfiguration aus der .env, gespeicherte Werte werden wieder darübergelegt
const fresh = loadConfig({ match: { maxRoundsCompetitive: 24, extraCommands: [] } });
new ServerSettings({ db, config: fresh }).load();
assert.equal(fresh.match.maxRoundsCompetitive, 30);
assert.deepEqual(fresh.match.extraCommands, ['mp_freezetime 15', 'sv_cheats 0']);
});
test('Ungültige Werte und gesperrte Befehle werden abgelehnt', () => {
const { config, settings } = setup();
assert.throws(() => settings.update({ maxRoundsCompetitive: 500 }), /ganze Zahl von 2 bis 60/);
assert.throws(() => settings.update({ overtime: 'ja' }), /an oder aus/);
assert.throws(() => settings.update({ extraCommands: 'rcon_password neu' }), /nicht erlaubt/);
assert.throws(() => settings.update({ unbekannt: 1 }), /Unbekannte Einstellung/);
// Nichts davon wurde übernommen
assert.equal(config.match.maxRoundsCompetitive, 24);
});
test('Zurück auf die Vorgabe entfernt die gespeicherte Abweichung', () => {
const { settings } = setup();
settings.update({ maxRoundsCompetitive: 30 });
assert.equal(settings.view().find((f) => f.key === 'maxRoundsCompetitive').overridden, true);
settings.update({ maxRoundsCompetitive: 24 });
assert.equal(settings.view().find((f) => f.key === 'maxRoundsCompetitive').overridden, false);
});
test('Docker-Hosts: Prüfung der Liste und Übernahme in den Pool, Startzeit wirkt sofort', () => {
const { config, settings } = setup();
assert.throws(() => settings.parseHosts([{ name: 'a', publicHost: '1.2.3.4', dockerHost: 'ssh://x; rm -rf /' }]), /Ungültiges Docker-Ziel/);
assert.throws(() => settings.parseHosts([{ name: 'a', publicHost: '1.2.3.4', portStart: 80, portEnd: 90 }]), /Portbereich/);
const hosts = settings.parseHosts([
{ name: 'a', publicHost: '10.0.0.1', dockerHost: 'ssh://server@10.0.0.1', portStart: 27100, portEnd: 27139 },
{ name: 'b', publicHost: '10.0.0.2', dockerHost: 'ssh://b', portStart: 27100, portEnd: 27119 },
]);
const pool = new DockerHostPool({ ...config.docker, startTimeoutMinutes: 20, lanPorts: [], hosts: null });
pool.setHosts(hosts);
assert.deepEqual(pool.hostStats().map((h) => [h.name, h.total]), [['a', 40], ['b', 20]]);
pool.options.startTimeoutMinutes = 45;
assert.equal(pool.adapters.get('b').options.startTimeoutMinutes, 45);
});
+43 -6
View File
@@ -2,11 +2,14 @@ import { test } from 'node:test';
import assert from 'node:assert/strict';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { computeVetoState, resolveAction, validateFlow } from '../src/veto.js';
import { buildMapPools, loadMapPools } from '../src/mapPools.js';
import { computeVetoState, generateFlow, resolveAction, validateFlow } from '../src/veto.js';
import { buildMapPools, loadMapPools, setActiveMaps } from '../src/mapPools.js';
const here = path.dirname(fileURLToPath(import.meta.url));
const pools = loadMapPools(path.join(here, '..', 'config', 'map-pools.json'));
// Die klassischen 7 Competitive-Maps: dafür gilt der fest hinterlegte Ablauf
const SEVEN = ['de_ancient', 'de_anubis', 'de_dust2', 'de_inferno', 'de_mirage', 'de_nuke', 'de_train'];
const competitive7 = () => setActiveMaps(structuredClone(pools.competitive), SEVEN);
test('alle ausgelieferten Abläufe sind gültig, Wingman-BO5 ist nicht unterstützt', () => {
assert.equal(pools.competitive.formats.bo1.supported, true);
@@ -37,8 +40,42 @@ test('ungültiger Ablauf in der Konfiguration deaktiviert nur dieses Format', ()
},
});
assert.equal(custom.mini.formats.bo1.supported, true);
assert.equal(custom.mini.formats.bo3.supported, false);
assert.match(custom.mini.formats.bo5.reason, /Kein Veto-Ablauf/);
assert.equal(custom.mini.formats.bo1.generated, false, 'passender fester Ablauf wird genommen');
// Ungültiger fester Ablauf: die Anwendung erzeugt einen passenden
assert.equal(custom.mini.formats.bo3.supported, true);
assert.equal(custom.mini.formats.bo3.generated, true);
assert.match(custom.mini.formats.bo5.reason, /mindestens 5 Maps/);
});
test('Erzeugte Abläufe sind für jede Map-Anzahl gültig', () => {
for (let n = 2; n <= 12; n += 1) {
for (const format of ['bo1', 'bo3', 'bo5']) {
const flow = generateFlow(n, format);
const need = { bo1: 2, bo3: 3, bo5: 5 }[format];
if (n < need) {
assert.equal(flow, null, `${format} mit ${n} Maps`);
continue;
}
assert.deepEqual(validateFlow(flow, n, format), [], `${format} mit ${n} Maps: ${flow.join(' ')}`);
}
}
// Bans und Picks wechseln sich ab, die Seite der Entscheidungsmap wählt das Team, das nicht zuletzt dran war
assert.deepEqual(generateFlow(5, 'bo3'), ['ban:X', 'ban:Y', 'pick:X', 'side:Y', 'pick:Y', 'side:X', 'decider', 'side:X']);
assert.deepEqual(generateFlow(3, 'bo1'), ['ban:X', 'ban:Y', 'decider', 'side:X']);
});
test('Aktive Maps wählen: Formate passen sich an, Reihenfolge bleibt wie im Katalog', () => {
const pool = structuredClone(pools.competitive);
setActiveMaps(pool, ['de_vertigo', 'de_cache', 'de_mirage', 'de_unbekannt']);
assert.deepEqual(pool.maps.map((m) => m.id), ['de_cache', 'de_mirage', 'de_vertigo']);
assert.equal(pool.formats.bo1.supported, true);
assert.equal(pool.formats.bo3.supported, true);
assert.equal(pool.formats.bo5.supported, false);
// 10 Maps (alle): kein fester Ablauf passt, es wird erzeugt
assert.equal(pools.competitive.maps.length, 10);
assert.equal(pools.competitive.formats.bo3.generated, true);
// 7 klassische Maps: fester Ablauf aus der Konfiguration
assert.equal(competitive7().formats.bo3.generated, false);
});
function play(pool, format, firstTeam, choices) {
@@ -52,7 +89,7 @@ function play(pool, format, firstTeam, choices) {
}
test('BO3 Competitive: kompletter Ablauf mit Seitenwahl und Entscheidungsmap', () => {
const state = play(pools.competitive, 'bo3', 'B', [
const state = play(competitive7(), 'bo3', 'B', [
{ map: 'de_train' }, // B bannt
{ map: 'de_nuke' }, // A bannt
{ map: 'de_mirage' }, // B pickt
@@ -82,7 +119,7 @@ test('BO1 Wingman: Entscheidungsmap wird automatisch übernommen', () => {
});
test('falsches Team, veraltete Schritte und vergebene Maps werden abgelehnt', () => {
const pool = pools.competitive;
const pool = competitive7();
const ctx = { maps: pool.maps, flow: pool.formats.bo1.flow, firstTeam: 'A', actions: [] };
assert.throws(() => resolveAction(ctx, { team: 'B', stepIndex: 0, map: 'de_nuke' }), { code: 'not_your_turn' });
assert.throws(() => resolveAction(ctx, { team: 'A', stepIndex: 1, map: 'de_nuke' }), { code: 'stale_step' });