Compare commits

..
Author SHA1 Message Date
mregosar 46d1954a6d 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.
2026-10-25 23:16:44 +01:00
mregosar 30ec5359b1 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.
2026-10-25 22:54:38 +01:00
mregosar 5527c2ce58 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.
2026-10-25 22:46:51 +01:00
mregosar 1b7c13b592 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.
2026-10-25 22:40:52 +01:00
mregosar e3a1ed4a63 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.
2026-10-25 22:16:57 +01:00
mregosar 84dad0993c 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.
2026-10-25 22:09:43 +01:00
mregosar 3a109883b3 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).
2026-10-25 21:54:30 +01:00
mregosar 85041b2bff 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.
2026-10-25 19:32:53 +01:00
mregosar ea2207899f 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.
2026-10-25 19:23:53 +01:00
mregosar 263c7e7b7c 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
2026-10-25 19:20:10 +01:00
mregosar 306b3aa7e5 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.
2026-10-25 18:54:23 +01:00
mregosar 34e5fc7d0e Anleitung: CS2-Server mit Portainer und dauerhaftem Volume
docs/portainer-cs2.md beschreibt Stack, einmaligen Download, Updates ohne
Neu-Download, Anbindung per Static- oder Docker-Adapter, mehrere Server
und Fehlersuche. Standard-Image auf joedwards32/cs2 umgestellt, da
cm2network/cs2 nicht mehr gepflegt wird.
2026-10-25 18:07:17 +01:00
mregosar 8be8b3d879 Organisator kann im selben Browser selbst mitspielen
Ein Aufrufer darf Organisator- und Spieler-Zugang kombinieren
(Authorization + X-Player-Token). Berechtigungen prüfen jetzt einzelne
Merkmale statt einer Rangstufe. In der Lobby gibt es „Selbst mitspielen?“,
Teamlinks funktionieren auch im Organisator-Browser. Online-Status wird
direkt nach dem Beitritt aktualisiert.
2026-10-25 17:53:39 +01:00
mregosar 3a74e16bb8 Größere Map-Kacheln und Ban/Pick-Leiste
Ab dem Veto bekommt das Map-Board die volle Breite (4 Spalten), die
Teamkarten rücken darunter. Die Schrittleiste zeigt pro Schritt das Team.
2026-10-25 17:45:41 +01:00
mregosar f1fc1642ab MaxLAN-Branding: Logo in der Kopfzeile, Farben aus dem Logo
Team A in Logo-Blau, Team B in Logo-Rot, schwarze Kopfzeile mit rotem
Abschluss; CT/T-Farben aus den Emblemen, Warnungen in Orange.
2026-10-25 17:42:38 +01:00
mregosar db4a6bedf7 CT/T-Embleme ergänzt, kein Verbindungshinweis beim ersten Laden 2026-10-25 17:34:03 +01:00
mregosar 7be0d85ab5 Map-Bilder und Seitenwahl mit Emblemen
Map-Bilder aus ghostcap-gaming/cs2-map-images lokal als WebP gespeichert
(npm run images lädt und verkleinert sie). Bilder in Veto-Kacheln,
Pool-Vorschau, Serie und Seitenwahl; CT/T-Embleme aus client/public/sides
mit Ersatzsymbol, falls die Dateien fehlen.
2026-10-25 17:23:55 +01:00
mregosar e304556346 Ein bereiter Spieler pro Team reicht standardmäßig für den Veto-Start
REQUIRE_FULL_TEAMS ist jetzt standardmäßig false; vollständige Teams
lassen sich weiterhin per Umgebungsvariable erzwingen.
2026-10-25 17:11:46 +01:00
mregosar bf438511d6 Lobby zeigt konkret, was einem Team zum Veto-Start fehlt 2026-10-25 17:09:31 +01:00
mregosar f7520a6ca7 Frontend, README und Konfigurationsvorlage
React-Oberfläche mit Übersicht, Match-Erstellung, Beitritt über Team- und
Zuschauercodes, Teamkarten, visuellem Map-Veto, Serie und Serverpanel.
README beschreibt Einrichtung, Umgebungsvariablen, Rollen, Veto-Abläufe,
Serveradapter und CSTV-Zuschauerzugang.
2026-10-25 17:02:18 +01:00
mregosar f08297dd0d Backend: Match-Lobbys, Map-Veto, Echtzeit und CS2-Serveradapter
Express + Socket.IO + SQLite. Veto-Engine mit konfigurierbaren Map-Pools
und validierten Abläufen, rollenbasierte Berechtigungen, Server-Lebenszyklus
mit Demo-, Docker- und Static-Pool-Adapter (RCON), automatisches Herunterfahren.
2026-10-25 16:50:22 +01:00
38 changed files with 51 additions and 2224 deletions

No files matched your search

-5
View File
@@ -52,9 +52,6 @@ CS2_PORT_RANGE_END=27115
# Gemeinsames Volume für die Spieldateien (~60 GB), damit nicht jedes Match neu herunterlädt # Gemeinsames Volume für die Spieldateien (~60 GB), damit nicht jedes Match neu herunterlädt
CS2_DOCKER_VOLUME=cs2-mapveto-gamefiles CS2_DOCKER_VOLUME=cs2-mapveto-gamefiles
CS2_DOCKER_EXTRA_ARGS= 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 CS2_START_TIMEOUT_MINUTES=20
DOCKER_BIN=docker DOCKER_BIN=docker
@@ -71,5 +68,3 @@ LOG_DEBUG=false
SERIES_AUTO_ADVANCE_SECONDS=30 SERIES_AUTO_ADVANCE_SECONDS=30
# Schlüssel für die Stream-Übersicht aller Matches (/stream?key=...); leer = offen # Schlüssel für die Stream-Übersicht aller Matches (/stream?key=...); leer = offen
STREAM_KEY= STREAM_KEY=
# Discord-Webhook für Meldungen (Veto, Match-Start, Map- und Serienende, Fehler). Leer = aus
DISCORD_WEBHOOK_URL=
+5 -96
View File
@@ -75,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. 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. - 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). - 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`), 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. 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.
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“. 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 ### Rollen und Berechtigungen
| Rolle | Zugang über | Darf | | Rolle | Zugang über | Darf |
|---|---|---| |---|---|---|
| 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 | | 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 |
| Captain | Teamcode (erster Spieler oder ernannt) | alles wie Spieler, dazu Veto-Aktionen seines Teams und Captain-Übergabe | | 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 | | 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 | | Zuschauer | Zuschauercode | alles live verfolgen; sieht nur den CSTV-Zugang, nicht das Spielerpasswort |
@@ -201,22 +201,9 @@ 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. - Wird alle `SERVER_STATUS_POLL_SECONDS` abgefragt, solange der Server startet, danach alle 30 s.
3. **`configure(handle, plan)`**: in zwei Phasen. 3. **`configure(handle, plan)`**: in zwei Phasen.
1. `game_type`, `game_mode`, `sv_password`, `tv_enable`, `tv_delay`, `tv_password`, dann `changelevel <map>`. 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`) und die Turnierregeln (siehe unten). 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`).
Die zweite Phase kommt erst nach dem Mapwechsel, weil die `gamemode_*.cfg` des Spiels diese Werte beim Laden der Map sonst überschreibt. 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. 4. **`stop(handle)`**: Server beenden bzw. freigeben.
### Option A: Docker (`SERVER_ADAPTER=docker`) ### Option A: Docker (`SERVER_ADAPTER=docker`)
@@ -236,24 +223,6 @@ Voraussetzungen:
Container heißen `cs2mv-<matchId>` und tragen das Label `cs2-mapveto=1`. Stoppen bedeutet `docker rm -f`. 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.
### Option B: Vorhandene Server (`SERVER_ADAPTER=static`) ### Option B: Vorhandene Server (`SERVER_ADAPTER=static`)
Für selbst betriebene, dauerhaft laufende CS2-Server: Für selbst betriebene, dauerhaft laufende CS2-Server:
@@ -329,7 +298,7 @@ Beim Konfigurieren setzt CS2 Mapveto auf dem Server `log on` und `logaddress_add
- Stream-Seite - 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. - **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. - **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`. - **Geprüft:** mit echten Logzeilen eines CS2-Servers (Wingman), siehe `server/test/fixtures/echte-logs-wingman.log`.
Zwei Eigenheiten von CS2 berücksichtigt der Parser: 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. - CS2 sendet `Match_Start` schon beim Laden der Map. Gezählt wird erst, nachdem der Organisator „Aufwärmphase beenden“ geklickt hat.
@@ -345,75 +314,17 @@ Unter `/stream` (in der Kopfzeile verlinkt) zeigt eine 1920×1080-Seite alle lau
- **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. - **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. - **Unten:** offene Lobbys und die Ergebnisse der letzten drei Stunden.
- **Datenschutz:** Codes und Verbindungsdaten erscheinen nicht. - **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. - **Schlüssel:** Mit `STREAM_KEY` ist die Seite nur mit `/stream?key=<schlüssel>` erreichbar, sonst offen. `?bg=transparent` funktioniert ebenfalls.
### Stream-Übersicht eines Matches ### Stream-Übersicht eines Matches
Unter `/stream/<Zuschauercode>` gibt es eine Vollbild-Übersicht für den Stream. Den Link findet der Organisator bei den Einladungen. 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. - **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). - **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. - **Laufband:** zeigt das letzte für Zuschauer relevante Ereignis, keine technischen Servermeldungen.
- **Datenschutz:** Verbindungsdaten und Passwörter erscheinen dort nie. - **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.
--- ---
## Umgebungsvariablen ## Umgebungsvariablen
@@ -439,8 +350,6 @@ Alle Variablen mit Standardwerten stehen in [`.env.example`](.env.example). Die
| `LOG_RECEIVER_URL` | `PUBLIC_URL` | Adresse, unter der der CS2-Server das Backend für Live-Logs erreicht | | `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 | | `LOG_DEBUG` | `false` | empfangene Logzeilen im Terminal ausgeben |
| `STREAM_KEY` | leer | Schlüssel für die Stream-Übersicht aller Matches (`/stream?key=…`); leer = offen | | `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 | | `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: Benötigte Zugangsdaten für den Echtbetrieb:
@@ -483,6 +392,6 @@ Alle Aktionen laufen per `POST` mit `Authorization: Bearer <token>`. Der Token i
| `…/ready`, `…/leave` | Spieler | | `…/ready`, `…/leave` | Spieler |
| `…/veto/action` `{stepIndex, map?, side?}` | Captain am Zug | | `…/veto/action` `{stepIndex, map?, side?}` | Captain am Zug |
| `…/captain` `{playerId}` | Organisator, Captain des Teams | | `…/captain` `{playerId}` | Organisator, Captain des Teams |
| `…/veto/start`, `…/kick`, `…/codes/regenerate`, `…/server/retry`, `…/server/stop`, `…/server/warmup-end`, `…/server/pause`, `…/server/resume`, `…/series/next`, `…/finish`, `…/cancel` | Organisator | | `…/veto/start`, `…/kick`, `…/codes/regenerate`, `…/server/retry`, `…/server/stop`, `…/server/warmup-end`, `…/series/next`, `…/finish`, `…/cancel` | Organisator |
Socket.IO: `match:subscribe {matchId, token}` liefert `match:state` bei jeder Änderung; `overview:subscribe` liefert `overview`. Socket.IO: `match:subscribe {matchId, token}` liefert `match:state` bei jeder Änderung; `overview:subscribe` liefert `overview`.
-1
View File
@@ -14,7 +14,6 @@ export default function App() {
<Routes> <Routes>
{/* Stream-Übersicht ohne Kopfzeile, als Vollbild bzw. OBS-Browserquelle */} {/* Stream-Übersicht ohne Kopfzeile, als Vollbild bzw. OBS-Browserquelle */}
<Route path="/stream/:code" element={<StreamPage />} /> <Route path="/stream/:code" element={<StreamPage />} />
<Route path="/stream/match/:matchId" element={<StreamOverviewPage />} />
<Route path="/stream" element={<StreamOverviewPage />} /> <Route path="/stream" element={<StreamOverviewPage />} />
<Route path="*" element={<MainLayout />} /> <Route path="*" element={<MainLayout />} />
</Routes> </Routes>
-2
View File
@@ -29,8 +29,6 @@ export default function OrganizerPanel({ state, act, busy, matchId, token }) {
<div className="stream-link"> <div className="stream-link">
<CopyField label="Stream-Übersicht (als Browserquelle in OBS, 1920×1080)" value={links.stream} /> <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> <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> </div>
)} )}
</div> </div>
-27
View File
@@ -10,7 +10,6 @@ export default function VetoBoard({ state, viewer, act, busy, compact = false })
const { current } = veto; const { current } = veto;
const [selected, setSelected] = useState(null); const [selected, setSelected] = useState(null);
useEffect(() => setSelected(null), [current?.index]); useEffect(() => setSelected(null), [current?.index]);
const left = useCountdown(veto.remainingMs);
const mapsById = Object.fromEntries(state.maps.map((m) => [m.id, m])); const mapsById = Object.fromEntries(state.maps.map((m) => [m.id, m]));
const yourTurn = !!current && viewer.isCaptain && viewer.team === current.team; const yourTurn = !!current && viewer.isCaptain && viewer.team === current.team;
@@ -39,7 +38,6 @@ export default function VetoBoard({ state, viewer, act, busy, compact = false })
? 'Live-Ansicht – nur Captains können wählen.' ? 'Live-Ansicht – nur Captains können wählen.'
: 'Das andere Team ist am Zug.'} : 'Das andere Team ist am Zug.'}
</span> </span>
{left !== null && <TimerBar left={left} totalMs={(veto.stepSeconds || 30) * 1000} />}
</div> </div>
)} )}
{!current && veto.done && !compact && <div className="turn turn-done">Veto abgeschlossen</div>} {!current && veto.done && !compact && <div className="turn turn-done">Veto abgeschlossen</div>}
@@ -131,28 +129,3 @@ function statusText(m, teams) {
default: return 'Verfügbar'; 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;
}
+6 -457
View File
@@ -26,11 +26,9 @@ const storeToken = (t) => {
export default function AdminPage() { export default function AdminPage() {
const [token, setToken] = useState(getToken); const [token, setToken] = useState(getToken);
const [matches, setMatches] = useState(null); const [matches, setMatches] = useState(null);
const [hosts, setHosts] = useState([]);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [notice, setNotice] = useState(null); const [notice, setNotice] = useState(null);
const [busy, setBusy] = useState(null); const [busy, setBusy] = useState(null);
const [tab, setTab] = useState('matches');
const logout = useCallback(() => { const logout = useCallback(() => {
api('/admin/logout', { method: 'POST', token }).catch(() => {}); api('/admin/logout', { method: 'POST', token }).catch(() => {});
@@ -43,7 +41,6 @@ export default function AdminPage() {
try { try {
const res = await api('/admin/matches', { token }); const res = await api('/admin/matches', { token });
setMatches(res.matches); setMatches(res.matches);
setHosts(res.hosts ?? []);
setError(null); setError(null);
} catch (err) { } catch (err) {
if (err.status === 401) { if (err.status === 401) {
@@ -95,96 +92,20 @@ export default function AdminPage() {
<button className="btn btn-sm" onClick={logout}>Abmelden</button> <button className="btn btn-sm" onClick={logout}>Abmelden</button>
</header> </header>
<nav className="admin-tabs" aria-label="Admin-Bereiche">
<button className={`admin-tab ${tab === 'matches' ? 'is-active' : ''}`} onClick={() => setTab('matches')}>Matches</button>
<button className={`admin-tab ${tab === 'settings' ? 'is-active' : ''}`} onClick={() => setTab('settings')}>Einstellungen</button>
</nav>
{tab === 'settings' && token && <ServerSettingsPanel token={token} setNotice={setNotice} />}
{tab === 'settings' && token && <DockerHostsPanel token={token} setNotice={setNotice} />}
{tab === 'settings' && token && <NotificationSettings token={token} setNotice={setNotice} />}
{notice && <div className={`toast toast-${notice.tone}`} role="status">{notice.text}</div>} {notice && <div className={`toast toast-${notice.tone}`} role="status">{notice.text}</div>}
{error && <div className="alert alert-error">{error}</div>} {error && <div className="alert alert-error">{error}</div>}
{tab === 'matches' && <HostUsage hosts={hosts} />} {matches === null && !error && <p className="muted">Lade Matches …</p>}
{tab === 'matches' && matches === null && !error && <p className="muted">Lade Matches …</p>} {matches && matches.length === 0 && <p className="muted">Noch keine Matches.</p>}
{tab === 'matches' && matches && matches.length === 0 && <p className="muted">Noch keine Matches.</p>}
{tab === 'matches' && <div className="admin-list"> <div className="admin-list">
{matches?.map((m) => ( {matches?.map((m) => (
<MatchAdminCard key={m.id} m={m} run={run} busy={busy} token={token} reload={load} setNotice={setNotice} /> <MatchAdminCard key={m.id} m={m} run={run} busy={busy} />
))} ))}
</div>} </div>
</div> </div>
); );
} }
/** Auswahl, welche Ereignisse Discord erhalten. Die Werte werden erst beim Speichern übernommen. */
function NotificationSettings({ token, setNotice }) {
const [settings, setSettings] = useState(null);
const [events, setEvents] = useState({});
const [busy, setBusy] = useState(false);
useEffect(() => {
api('/admin/notifications', { token })
.then((res) => { setSettings(res); setEvents(res.events); })
.catch((err) => setNotice({ tone: 'error', text: err.message }));
}, [token, setNotice]);
const save = async () => {
setBusy(true);
try {
const res = await api('/admin/notifications', { method: 'PUT', body: { events }, token });
setSettings(res);
setEvents(res.events);
setNotice({ tone: 'ok', text: 'Meldungen gespeichert' });
} catch (err) {
setNotice({ tone: 'error', text: err.message });
} finally {
setBusy(false);
}
};
const test = async () => {
setBusy(true);
try {
await api('/admin/notifications/test', { method: 'POST', token });
setNotice({ tone: 'ok', text: 'Testmeldung gesendet' });
} catch (err) {
setNotice({ tone: 'error', text: err.message });
} finally {
setBusy(false);
}
};
if (!settings) return null;
const dirty = settings.catalog.some(({ key }) => !!events[key] !== settings.events[key]);
return (
<section className="admin-settings">
<h2>Discord-Meldungen</h2>
{!settings.enabled && (
<p className="alert alert-error">Kein Webhook eingerichtet. Setze DISCORD_WEBHOOK_URL und starte die Anwendung neu.</p>
)}
<div className="admin-settings-grid">
{settings.catalog.map(({ key, label }) => (
<label key={key} className="admin-check">
<input
type="checkbox"
checked={!!events[key]}
onChange={(e) => setEvents({ ...events, [key]: e.target.checked })}
/>
{label}
</label>
))}
</div>
<div className="admin-actions">
<button className="btn btn-sm btn-primary" onClick={save} disabled={busy || !dirty}>Speichern</button>
<button className="btn btn-sm" onClick={test} disabled={busy || !settings.enabled}>Testmeldung senden</button>
</div>
</section>
);
}
function AdminLogin({ onLogin }) { function AdminLogin({ onLogin }) {
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [error, setError] = useState(null); const [error, setError] = useState(null);
@@ -216,56 +137,7 @@ function AdminLogin({ onLogin }) {
); );
} }
/** Rollback: nur auf der Admin-Seite. Lädt die Sicherungen der aktuellen Map und spielt eine davon zurück. */ function MatchAdminCard({ m, run, busy }) {
function RollbackPanel({ matchId, token, reload, setNotice }) {
const [points, setPoints] = useState(null);
const [file, setFile] = useState('');
const [loading, setLoading] = useState(false);
const load = async () => {
setLoading(true);
try {
const res = await api(`/admin/matches/${matchId}/rollback-points`, { token });
setPoints(res.points);
setFile(res.points[0]?.file ?? '');
} catch (err) {
setNotice({ tone: 'error', text: err.message });
} finally {
setLoading(false);
}
};
const rollback = async () => {
const p = points.find((x) => x.file === file);
if (!p || !window.confirm(`Match auf den Stand nach Runde ${p.round} zurückspielen? Spätere Runden dieser Map gehen verloren.`)) return;
try {
await api(`/admin/matches/${matchId}/rollback`, { method: 'POST', body: { file }, token });
setNotice({ tone: 'ok', text: `Zurückgespielt auf Runde ${p.round}` });
setPoints(null);
reload();
} catch (err) {
setNotice({ tone: 'error', text: err.message });
}
};
return (
<div className="rollback">
<h3>Runde zurückspielen <span className="admin-badge">nur Admin</span></h3>
{!points && <button className="btn btn-sm" onClick={load} disabled={loading}>{loading ? 'Lade …' : 'Sicherungen laden'}</button>}
{points && points.length === 0 && <p className="small muted">Keine Sicherungen für diese Map gefunden.</p>}
{points && points.length > 0 && (
<div className="row">
<select value={file} onChange={(e) => setFile(e.target.value)}>
{points.map((p) => <option key={p.file} value={p.file}>Nach Runde {p.round}</option>)}
</select>
<button className="btn btn-sm btn-danger" onClick={rollback}>Zurückspielen</button>
</div>
)}
</div>
);
}
function MatchAdminCard({ m, run, busy, token, reload, setNotice }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const server = serverLabel(m.server); const server = serverLabel(m.server);
const closed = ['finished', 'cancelled'].includes(m.status); const closed = ['finished', 'cancelled'].includes(m.status);
@@ -276,9 +148,6 @@ function MatchAdminCard({ m, run, busy, token, reload, setNotice }) {
</button> </button>
); );
const hasNext = m.series.some((s) => s.number === m.currentMapNumber + 1); const hasNext = m.series.some((s) => s.number === m.currentMapNumber + 1);
const current = m.series.find((s) => s.number === m.currentMapNumber);
const configured = m.server?.status === 'configured';
const paused = !!m.server?.paused;
return ( return (
<section className={`panel admin-card status-${m.status}`}> <section className={`panel admin-card status-${m.status}`}>
@@ -291,29 +160,6 @@ function MatchAdminCard({ m, run, busy, token, reload, setNotice }) {
{m.modeLabel}, {FORMAT_LABEL[m.format]} · {m.statusLabel} · <span className={`tone-${server.tone}`}>{server.text}</span> {m.modeLabel}, {FORMAT_LABEL[m.format]} · {m.statusLabel} · <span className={`tone-${server.tone}`}>{server.text}</span>
{m.format !== 'bo1' && <> · Serie {m.seriesScore.A}:{m.seriesScore.B}</>} {m.format !== 'bo1' && <> · Serie {m.seriesScore.A}:{m.seriesScore.B}</>}
</div> </div>
{m.status === 'live' && current && (
<div className="admin-live">
<span>Map {current.number}: {current.name}</span>
<strong>{current.score.A} : {current.score.B}</strong>
{current.live?.roundsPlayed != null && <span className="muted">Runde {current.live.roundsPlayed + 1}</span>}
</div>
)}
{m.server && (m.server.requestedAt || (m.server.address && m.server.status !== 'stopped')) && (
<div className="admin-address">
{m.server.address && m.server.status !== 'stopped' && (
<>
<span><span className="muted">Server</span> <code>{m.server.address.host}:{m.server.address.port}</code></span>
{m.server.address.tvPort && (
<span><span className="muted">CSTV</span> <code>{m.server.address.host}:{m.server.address.tvPort}</code></span>
)}
</>
)}
{m.server.requestedAt && (
<span><span className="muted">Angefordert</span> {formatDateTime(m.server.requestedAt)}</span>
)}
</div>
)}
{paused && <span className="admin-badge is-paused">Pausiert</span>}
</div> </div>
<button className="link-btn" onClick={() => setOpen(!open)}>{open ? 'Weniger' : 'Details'}</button> <button className="link-btn" onClick={() => setOpen(!open)}>{open ? 'Weniger' : 'Details'}</button>
</div> </div>
@@ -322,8 +168,6 @@ function MatchAdminCard({ m, run, busy, token, reload, setNotice }) {
<Link className="btn btn-sm" to={`/m/${id}`}>Ansehen</Link> <Link className="btn btn-sm" to={`/m/${id}`}>Ansehen</Link>
{m.status === 'lobby' && btn('Veto starten', { path: '/veto/start' }, 'btn-primary')} {m.status === 'lobby' && btn('Veto starten', { path: '/veto/start' }, 'btn-primary')}
{(m.server?.status === 'configured') && btn('Aufwärmphase beenden', { path: '/server/warmup-end' })} {(m.server?.status === 'configured') && btn('Aufwärmphase beenden', { path: '/server/warmup-end' })}
{configured && !paused && btn('Pausieren', { path: '/server/pause', confirm: 'Match pausieren?' }, 'btn-warn')}
{configured && paused && btn('Fortsetzen', { path: '/server/resume' }, 'btn-primary')}
{(m.server?.status === 'configured') && hasNext && btn('Nächste Map', { path: '/series/next' })} {(m.server?.status === 'configured') && hasNext && btn('Nächste Map', { path: '/series/next' })}
{m.server && ['start_failed', 'failed', 'stopped', 'config_failed', 'ready'].includes(m.server.status) && !closed && {m.server && ['start_failed', 'failed', 'stopped', 'config_failed', 'ready'].includes(m.server.status) && !closed &&
btn('Server neu starten / wiederholen', { path: '/server/retry' })} btn('Server neu starten / wiederholen', { path: '/server/retry' })}
@@ -387,10 +231,6 @@ function MatchAdminCard({ m, run, busy, token, reload, setNotice }) {
))} ))}
</div> </div>
{m.status === 'live' && m.server?.status === 'configured' && (
<RollbackPanel matchId={id} token={token} reload={reload} setNotice={setNotice} />
)}
{m.veto && !m.veto.done && m.veto.current && ( {m.veto && !m.veto.done && m.veto.current && (
<p className="small">Veto: {m.teams[m.veto.current.team].name} ist am Zug ({m.veto.current.type}).</p> <p className="small">Veto: {m.teams[m.veto.current.team].name} ist am Zug ({m.veto.current.type}).</p>
)} )}
@@ -406,294 +246,3 @@ function MatchAdminCard({ m, run, busy, token, reload, setNotice }) {
</section> </section>
); );
} }
/** Auslastung der Docker-Hosts: wie viele Gameserver läuft auf welchem Host, mit Liste der Matches. */
function HostUsage({ hosts }) {
if (hosts.length === 0) return null;
return (
<section className="host-usage">
<h2>Docker-Hosts</h2>
<div className="host-grid">
{hosts.map((h) => {
const percent = h.total ? Math.round((h.used / h.total) * 100) : 0;
return (
<div key={h.name} className={`host-card ${percent >= 90 ? 'is-full' : ''}`}>
<div className="host-head">
<strong>{h.name}</strong>
<span className="muted small">{h.publicHost}</span>
<span className="host-count">{h.used} / {h.total} belegt</span>
</div>
<div className="host-bar" role="progressbar" aria-valuenow={percent} aria-valuemin={0} aria-valuemax={100}>
<span style={{ width: `${percent}%` }} />
</div>
{h.disk && (
<div className={`host-disk ${h.disk.freeBytes < 20e9 ? 'is-low' : ''}`}>
Speicher: {gb(h.disk.freeBytes)} frei von {gb(h.disk.totalBytes)}
</div>
)}
{h.matches.length === 0 ? (
<p className="muted small">Keine Gameserver</p>
) : (
<ul className="host-matches">
{h.matches.map((m) => <li key={m.id}>{m.title} <span className="muted small">{m.status}</span></li>)}
</ul>
)}
</div>
);
})}
</div>
</section>
);
}
function gb(bytes) {
return `${Math.round(bytes / 1e9)} GB`;
}
const SETTING_GROUPS = [
{ key: 'match', title: 'Match-Regeln' },
{ key: 'tv', title: 'CSTV' },
{ key: 'times', title: 'Zeiten' },
];
const toInput = (field, value) => (field.type === 'commands' ? value.join('; ') : value);
const shownDefault = (field) => {
if (field.type === 'bool') return field.default ? 'an' : 'aus';
if (field.type === 'commands') return field.default.length ? field.default.join('; ') : 'keine';
return field.default;
};
/** Gameserver-Einstellungen: Vorgaben aus der .env, im Admin-Bereich änderbar. */
function ServerSettingsPanel({ token, setNotice }) {
const [fields, setFields] = useState(null);
const [values, setValues] = useState({});
const [busy, setBusy] = useState(false);
const apply = (list) => {
setFields(list);
setValues(Object.fromEntries(list.map((f) => [f.key, toInput(f, f.value)])));
};
useEffect(() => {
api('/admin/server-settings', { token })
.then((res) => apply(res.fields))
.catch((err) => setNotice({ tone: 'error', text: err.message }));
}, [token, setNotice]);
if (!fields) return null;
const changedKeys = fields.filter((f) => String(values[f.key]) !== String(toInput(f, f.value))).map((f) => f.key);
const save = async () => {
setBusy(true);
try {
const body = Object.fromEntries(changedKeys.map((key) => {
const field = fields.find((f) => f.key === key);
return [key, field.type === 'int' ? Number(values[key]) : values[key]];
}));
const res = await api('/admin/server-settings', { method: 'PUT', body: { values: body }, token });
apply(res.fields);
const parts = ['Einstellungen gespeichert'];
if (res.live.updated) parts.push(`${res.live.updated} laufende${res.live.updated === 1 ? 'r' : ''} Server aktualisiert`);
if (res.live.failed.length) parts.push(`${res.live.failed.length} Server nicht erreichbar`);
setNotice({ tone: res.live.failed.length ? 'error' : 'ok', text: parts.join(' · ') });
} catch (err) {
setNotice({ tone: 'error', text: err.message });
} finally {
setBusy(false);
}
};
const input = (f) => {
if (f.type === 'bool') {
return (
<label className="admin-check">
<input type="checkbox" checked={!!values[f.key]} onChange={(e) => setValues({ ...values, [f.key]: e.target.checked })} />
{f.label}
</label>
);
}
return (
<label className="setting-field">
<span>{f.label}</span>
{f.type === 'commands' ? (
<textarea rows={2} value={values[f.key]} placeholder="z. B. mp_freezetime 15; sv_alltalk 0"
onChange={(e) => setValues({ ...values, [f.key]: e.target.value })} />
) : (
<input type="number" min={f.min} max={f.max} value={values[f.key]}
onChange={(e) => setValues({ ...values, [f.key]: e.target.value })} />
)}
</label>
);
};
return (
<section className="admin-settings">
<h2>Gameserver</h2>
<p className="small muted">
Markierte Werte (live) gehen beim Speichern sofort an alle laufenden Server. Die übrigen gelten für neue Server und Vetos.
</p>
{SETTING_GROUPS.map((g) => (
<div key={g.key} className="setting-group">
<h3>{g.title}</h3>
<div className="setting-grid">
{fields.filter((f) => f.group === g.key).map((f) => (
<div key={f.key} className="setting-item">
{input(f)}
<div className="small muted">
{f.live && <span className="setting-live">live</span>}
{f.overridden ? `Vorgabe aus .env: ${shownDefault(f)}` : 'Vorgabe aus .env'}
</div>
</div>
))}
</div>
</div>
))}
<div className="admin-actions">
<button className="btn btn-sm btn-primary" onClick={save} disabled={busy || changedKeys.length === 0}>Speichern</button>
{changedKeys.length > 0 && (
<button className="btn btn-sm" disabled={busy} onClick={() => apply(fields)}>Verwerfen</button>
)}
</div>
</section>
);
}
const EMPTY_HOST = { name: '', dockerHost: 'ssh://', publicHost: '', portStart: 27100, portEnd: 27139, volume: 'cs2-mapveto-gamefiles' };
const HOST_COLUMNS = [
{ key: 'name', label: 'Name', placeholder: 'host1' },
{ key: 'dockerHost', label: 'Docker-Ziel', placeholder: 'ssh://server@192.168.178.45' },
{ key: 'publicHost', label: 'Adresse für Spieler', placeholder: '192.168.178.45' },
{ key: 'portStart', label: 'Port von', type: 'number' },
{ key: 'portEnd', label: 'Port bis', type: 'number' },
{ key: 'volume', label: 'Volume', placeholder: 'cs2-mapveto-gamefiles' },
];
/** Docker-Hosts verwalten und das CS2-Image auf allen Hosts gleichzeitig aktualisieren. */
function DockerHostsPanel({ token, setNotice }) {
const [data, setData] = useState(null);
const [rows, setRows] = useState([]);
const [busy, setBusy] = useState(false);
const apply = useCallback((res) => {
setData(res);
setRows(res.hosts.map((h) => ({ ...EMPTY_HOST, ...h, dockerHost: h.dockerHost ?? '' })));
}, []);
const load = useCallback(() => api('/admin/docker-hosts', { token }), [token]);
useEffect(() => {
load().then(apply).catch((err) => setNotice({ tone: 'error', text: err.message }));
}, [load, apply, setNotice]);
// Während das Image lädt, alle 3 Sekunden den Stand holen (ohne die bearbeiteten Zeilen zu überschreiben)
const pulling = !!data?.pull?.running;
useEffect(() => {
if (!pulling) return undefined;
const timer = setInterval(() => {
load().then((res) => setData((d) => ({ ...d, pull: res.pull }))).catch(() => {});
}, 3000);
return () => clearInterval(timer);
}, [pulling, load]);
if (!data) return null;
if (!data.editable) return null;
const original = JSON.stringify(data.hosts.map((h) => ({ ...EMPTY_HOST, ...h, dockerHost: h.dockerHost ?? '' })));
const dirty = JSON.stringify(rows) !== original;
const setCell = (i, key, value) => setRows(rows.map((r, j) => (j === i ? { ...r, [key]: value } : r)));
const run = async (fn, success) => {
setBusy(true);
try {
const res = await fn();
apply(res);
if (success) setNotice({ tone: 'ok', text: success });
} catch (err) {
setNotice({ tone: 'error', text: err.message });
} finally {
setBusy(false);
}
};
const save = () => run(() => api('/admin/docker-hosts', {
method: 'PUT',
token,
body: {
hosts: rows.map((r) => ({
name: r.name.trim(),
dockerHost: r.dockerHost.trim() || null,
publicHost: r.publicHost.trim(),
...(r.rconHost && r.rconHost !== r.publicHost ? { rconHost: r.rconHost } : {}),
portStart: Number(r.portStart),
portEnd: Number(r.portEnd),
volume: r.volume.trim() || undefined,
})),
},
}), 'Docker-Hosts gespeichert');
const reset = () => {
if (!window.confirm('Hosts aus der .env verwenden? Die hier gespeicherte Liste wird gelöscht.')) return;
run(() => api('/admin/docker-hosts', { method: 'DELETE', token }), 'Hosts aus der .env aktiv');
};
const pull = () => {
if (!window.confirm(`Image ${data.image} auf allen Hosts neu laden? Laufende Server laufen weiter.`)) return;
run(() => api('/admin/docker-hosts/pull', { method: 'POST', token }));
};
return (
<section className="admin-settings">
<h2>Docker-Hosts</h2>
<p className="small muted">
{data.overridden ? 'Diese Liste ist im Admin-Bereich gespeichert und ersetzt CS2_DOCKER_HOSTS aus der .env.' : 'Aktuell gilt die Liste aus der .env.'}
{' '}Hosts mit laufenden Servern lassen sich erst nach dem Stoppen entfernen.
</p>
<div className="host-edit">
{rows.map((r, i) => (
<div key={i} className="host-edit-row">
{HOST_COLUMNS.map((c) => (
<label key={c.key} className={`setting-field host-col-${c.key}`}>
<span>{c.label}</span>
<input type={c.type ?? 'text'} value={r[c.key] ?? ''} placeholder={c.placeholder}
onChange={(e) => setCell(i, c.key, e.target.value)} />
</label>
))}
<button className="link-btn danger" disabled={busy || rows.length === 1}
onClick={() => setRows(rows.filter((_, j) => j !== i))}>Entfernen</button>
</div>
))}
</div>
<div className="admin-actions">
<button className="btn btn-sm" disabled={busy} onClick={() => setRows([...rows, { ...EMPTY_HOST }])}>Host hinzufügen</button>
<button className="btn btn-sm btn-primary" disabled={busy || !dirty} onClick={save}>Hosts speichern</button>
{dirty && <button className="btn btn-sm" disabled={busy} onClick={() => apply(data)}>Verwerfen</button>}
{data.overridden && <button className="btn btn-sm btn-ghost" disabled={busy} onClick={reset}>Liste aus .env verwenden</button>}
</div>
<h3>Image aktualisieren</h3>
<p className="small muted">
Lädt <code>{data.image}</code> auf allen Hosts gleichzeitig neu. Laufende Server laufen weiter, neue Server starten mit dem neuen Image.
</p>
<div className="admin-actions">
<button className="btn btn-sm btn-primary" disabled={busy || pulling || dirty} onClick={pull}>
{pulling ? 'Wird aktualisiert …' : 'Auf allen Hosts aktualisieren'}
</button>
</div>
{data.pull?.results?.length > 0 && (
<ul className="pull-results">
{data.pull.results.map((r) => (
<li key={r.name} className={`pull-${r.state}`}>
<strong>{r.name}</strong>
<span>{r.state === 'running' ? 'lädt …' : r.state === 'ok' ? 'aktuell' : 'fehlgeschlagen'}</span>
{r.message && <code className="small">{r.message}</code>}
</li>
))}
{data.pull.finishedAt && (
<li className="small muted">Zuletzt fertig: {new Date(data.pull.finishedAt).toLocaleString('de-DE')}</li>
)}
</ul>
)}
</section>
);
}
/** Datum und Uhrzeit auf Deutsch, z. B. 29.10.2026, 12:54 Uhr */
function formatDateTime(ms) {
return `${new Date(ms).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })} Uhr`;
}
+5 -5
View File
@@ -127,7 +127,7 @@ export default function MatchPage() {
{viewer.role === 'public' && <PublicNotice />} {viewer.role === 'public' && <PublicNotice />}
<div className={`match-grid ${state.status !== 'lobby' ? 'stage-wide' : ''}`}> <div className={`match-grid ${state.status !== 'lobby' && viewer.role !== 'public' ? 'stage-wide' : ''}`}>
<TeamCard team={state.teams.A} side="A" {...ctx} /> <TeamCard team={state.teams.A} side="A" {...ctx} />
<section className="stage"> <section className="stage">
<Stage {...ctx} /> <Stage {...ctx} />
@@ -156,8 +156,8 @@ function describeViewer(viewer, teams) {
function Stage(ctx) { function Stage(ctx) {
const { state, isOrg, act, busy } = ctx; const { state, isOrg, act, busy } = ctx;
if (state.viewer.role === 'public' && state.status === 'lobby') { if (state.viewer.role === 'public') {
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>; return <div className="stage-note"><h2>{state.statusLabel}</h2><p className="muted">Details sehen nur Teilnehmer.</p></div>;
} }
if (state.status === 'lobby') { if (state.status === 'lobby') {
const { A, B } = state.teams; const { A, B } = state.teams;
@@ -230,8 +230,8 @@ function MapPoolPreview({ maps }) {
function PublicNotice() { function PublicNotice() {
return ( return (
<div className="alert"> <div className="alert">
Du siehst dieses Match ohne Zugang. Veto, Serienstand und Ergebnisse sind öffentlich. Du siehst diese Lobby ohne Zugang. Spieler treten über den Einladungslink ihres Teams bei,
Teamdetails und Verbindungsdaten gibt es nur für Teilnehmer. Zuschauer über den Zuschauerlink. Beides bekommst du vom Organisator.
</div> </div>
); );
} }
+1 -34
View File
@@ -4,7 +4,6 @@ import { api } from '../lib/api.js';
import { FORMAT_LABEL, serverLabel, timeAgo } from '../lib/labels.js'; import { FORMAT_LABEL, serverLabel, timeAgo } from '../lib/labels.js';
import { getAccess } from '../lib/session.js'; import { getAccess } from '../lib/session.js';
import { useOverview } from '../lib/useMatch.js'; import { useOverview } from '../lib/useMatch.js';
import MapImage from '../components/MapImage.jsx';
const GROUPS = [ const GROUPS = [
{ key: 'open', title: 'Offen', empty: 'Keine offenen Lobbys. Erstelle ein Match, um zu starten.' }, { key: 'open', title: 'Offen', empty: 'Keine offenen Lobbys. Erstelle ein Match, um zu starten.' },
@@ -33,11 +32,7 @@ export default function Overview() {
return ( return (
<section key={g.key} className={`group group-${g.key}`}> <section key={g.key} className={`group group-${g.key}`}>
<h2>{g.title} <span className="count">{list.length}</span></h2> <h2>{g.title} <span className="count">{list.length}</span></h2>
{list.length === 0 ? <p className="muted small">{g.empty}</p> : g.key === 'running' ? ( {list.length === 0 ? <p className="muted small">{g.empty}</p> : (
<div className="run-grid">
{list.map((m) => <RunningCard key={m.id} match={m} />)}
</div>
) : (
<ul className="match-list"> <ul className="match-list">
{list.map((m) => <MatchRow key={m.id} match={m} />)} {list.map((m) => <MatchRow key={m.id} match={m} />)}
</ul> </ul>
@@ -84,34 +79,6 @@ function MatchRow({ match: m }) {
); );
} }
/** 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>
);
}
function CodeEntry() { function CodeEntry() {
const [code, setCode] = useState(''); const [code, setCode] = useState('');
const [error, setError] = useState(null); const [error, setError] = useState(null);
+11 -37
View File
@@ -1,5 +1,4 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { api } from '../lib/api.js'; import { api } from '../lib/api.js';
import { FORMAT_LABEL } from '../lib/labels.js'; import { FORMAT_LABEL } from '../lib/labels.js';
import { getSocket } from '../lib/useMatch.js'; import { getSocket } from '../lib/useMatch.js';
@@ -15,13 +14,11 @@ const PAGE_SECONDS = 12;
/** /**
* Stream-Übersicht aller laufenden Vetos und Matches: /stream * Stream-Übersicht aller laufenden Vetos und Matches: /stream
* Mit STREAM_KEY: /stream?key=<schlüssel>. Optional ?bg=transparent. * 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() { export default function StreamOverviewPage() {
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
const key = params.get('key') ?? ''; const key = params.get('key') ?? '';
const transparent = params.get('bg') === 'transparent'; const transparent = params.get('bg') === 'transparent';
const { matchId } = useParams();
const { data, error } = useStreamOverview(key); const { data, error } = useStreamOverview(key);
const now = useClock(); const now = useClock();
const [page, setPage] = useState(0); const [page, setPage] = useState(0);
@@ -32,10 +29,8 @@ export default function StreamOverviewPage() {
return () => document.documentElement.classList.remove('stream-mode', 'stream-transparent'); return () => document.documentElement.classList.remove('stream-mode', 'stream-transparent');
}, [transparent]); }, [transparent]);
const activeMatches = (data?.active ?? []).filter((m) => !matchId || m.id === matchId);
// Mehr Matches als auf eine Seite passen: automatisch weiterblättern // Mehr Matches als auf eine Seite passen: automatisch weiterblättern
const pages = Math.max(1, Math.ceil(activeMatches.length / PER_PAGE)); const pages = data ? Math.max(1, Math.ceil(data.active.length / PER_PAGE)) : 1;
useEffect(() => { useEffect(() => {
if (pages <= 1) { setPage(0); return undefined; } if (pages <= 1) { setPage(0); return undefined; }
const t = setInterval(() => setPage((p) => (p + 1) % pages), PAGE_SECONDS * 1000); const t = setInterval(() => setPage((p) => (p + 1) % pages), PAGE_SECONDS * 1000);
@@ -54,7 +49,7 @@ export default function StreamOverviewPage() {
); );
} }
const active = activeMatches; const active = data?.active ?? [];
const shown = active.slice(page * PER_PAGE, page * PER_PAGE + PER_PAGE); const shown = active.slice(page * PER_PAGE, page * PER_PAGE + PER_PAGE);
const liveCount = active.filter((m) => m.status === 'live').length; const liveCount = active.filter((m) => m.status === 'live').length;
const vetoCount = active.filter((m) => m.status === 'veto').length; const vetoCount = active.filter((m) => m.status === 'veto').length;
@@ -78,24 +73,11 @@ export default function StreamOverviewPage() {
{!data && <div className="so-empty">Lade Matches …</div>} {!data && <div className="so-empty">Lade Matches …</div>}
{data && shown.length === 0 && ( {data && shown.length === 0 && (
<div className="so-empty"> <div className="so-empty">
{matchId ? ( <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>
<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> </div>
)} )}
{shown.map((m) => ( {shown.map((m) => <MatchCard key={m.id} m={m} />)}
<a key={m.id} className="so-link" href={matchHref(m.id, key)}>
<MatchCard m={m} />
</a>
))}
</main> </main>
<footer className="so-bottom"> <footer className="so-bottom">
@@ -105,10 +87,8 @@ export default function StreamOverviewPage() {
<ul> <ul>
{data.upcoming.map((m) => ( {data.upcoming.map((m) => (
<li key={m.id}> <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>
<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>
<small>{FORMAT_LABEL[m.format]}{m.ready.A && m.ready.B ? ', bereit' : ''}</small>
</a>
</li> </li>
))} ))}
</ul> </ul>
@@ -120,11 +100,9 @@ export default function StreamOverviewPage() {
<ul> <ul>
{data.results.map((m) => ( {data.results.map((m) => (
<li key={m.id}> <li key={m.id}>
<a className="so-link" href={matchHref(m.id, key)}> <span className={`a ${winner(m) === 'A' ? 'won' : ''}`}>{m.teams.A}</span>
<span className={`a ${winner(m) === 'A' ? 'won' : ''}`}>{m.teams.A}</span> <b>{resultScore(m)}</b>
<b>{resultScore(m)}</b> <span className={`b ${winner(m) === 'B' ? 'won' : ''}`}>{m.teams.B}</span>
<span className={`b ${winner(m) === 'B' ? 'won' : ''}`}>{m.teams.B}</span>
</a>
</li> </li>
))} ))}
</ul> </ul>
@@ -136,11 +114,6 @@ export default function StreamOverviewPage() {
); );
} }
/** 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)}` : ''}`;
}
function useStreamOverview(key) { function useStreamOverview(key) {
const [data, setData] = useState(null); const [data, setData] = useState(null);
const [error, setError] = useState(null); const [error, setError] = useState(null);
@@ -172,6 +145,7 @@ function winner(m) {
const [a, b] = resultScore(m).split(':').map(Number); const [a, b] = resultScore(m).split(':').map(Number);
return a === b ? null : a > b ? 'A' : 'B'; return a === b ? null : a > b ? 'A' : 'B';
} }
function MatchCard({ m }) { function MatchCard({ m }) {
const current = m.series.find((s) => s.number === m.currentMapNumber); const current = m.series.find((s) => s.number === m.currentMapNumber);
const isBo1 = m.format === 'bo1'; const isBo1 = m.format === 'bo1';
-6
View File
@@ -114,9 +114,3 @@
.so-strip li b { font-variant-numeric: tabular-nums; } .so-strip li b { font-variant-numeric: tabular-nums; }
.so-strip li .won { text-decoration: underline; text-underline-offset: 5px; } .so-strip li .won { text-decoration: underline; text-underline-offset: 5px; }
.so-none { color: var(--muted); font-size: 22px; margin: 0; } .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; }
-83
View File
@@ -506,86 +506,3 @@ input:focus, select:focus { border-color: var(--link); outline: none; }
.admin-login { margin-top: 40px; } .admin-login { margin-top: 40px; }
.danger { color: var(--error); } .danger { color: var(--error); }
@media (max-width: 640px) { .admin-players { grid-template-columns: 1fr; } } @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.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); }
-44
View File
@@ -1,44 +0,0 @@
#!/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.
- Ports 27100–27139 dürfen auf dem Host frei sein. Pro Host sind bis zu 20 Matches möglich.
- Der erste Server-Start lädt die CS2-Spieldateien (rund 60 bis 90 GB). Das dauert lange.
EOF
Binary file not shown.
+3 -70
View File
@@ -14,7 +14,7 @@ function bearer(req) {
* (nur im Speicher, verfällt nach 12 Stunden oder beim Neustart). Admin-Aktionen nutzen die bestehenden * (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. * Organisator-Funktionen mit einem Admin-Zugang, die Berechtigungen bleiben also an einer Stelle.
*/ */
export function createAdminRouter({ service, manager, config, settings }) { export function createAdminRouter({ service, manager, config }) {
const router = express.Router(); const router = express.Router();
const sessions = new Map(); // Token -> Ablaufzeit (ms) const sessions = new Map(); // Token -> Ablaufzeit (ms)
const loginLimit = createRateLimiter({ windowMs: 60_000, max: 5 }); const loginLimit = createRateLimiter({ windowMs: 60_000, max: 5 });
@@ -76,68 +76,10 @@ export function createAdminRouter({ service, manager, config, settings }) {
router.get('/session', requireAdmin, (req, res) => 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,
})),
overridden: settings.hostsOverridden(),
editable: !!manager.adapter.setHosts,
image: config.docker.image,
pull: manager.adapter.pullStatus?.() ?? 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.
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();
}));
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) // Alle Matches mit allen Details (Codes, Verbindungsdaten, Spieler, Veto, Serie, Ereignisse)
router.get('/matches', requireAdmin, wrap(async () => { router.get('/matches', requireAdmin, wrap(() => {
const ids = service.db.prepare('SELECT id FROM matches ORDER BY created_at DESC LIMIT 100').all(); const ids = service.db.prepare('SELECT id FROM matches ORDER BY created_at DESC LIMIT 100').all();
return { matches: ids.map(({ id }) => service.snapshot(id, adminViewer(id))), hosts: await manager.hostUsage() }; return { matches: ids.map(({ id }) => service.snapshot(id, adminViewer(id))) };
})); }));
const action = (path, fn) => router.post(`/matches/:id/${path}`, requireAdmin, wrap((req) => { const action = (path, fn) => router.post(`/matches/:id/${path}`, requireAdmin, wrap((req) => {
@@ -150,8 +92,6 @@ export function createAdminRouter({ service, manager, config, settings }) {
action('server/stop', (v) => manager.stopByOrganizer(v)); action('server/stop', (v) => manager.stopByOrganizer(v));
action('server/retry', (v) => manager.retry(v)); action('server/retry', (v) => manager.retry(v));
action('server/warmup-end', (v) => manager.endWarmup(v)); action('server/warmup-end', (v) => manager.endWarmup(v));
action('server/pause', (v) => manager.setPaused(v, true));
action('server/resume', (v) => manager.setPaused(v, false));
action('series/next', (v) => manager.loadNextMap(v)); action('series/next', (v) => manager.loadNextMap(v));
action('veto/start', (v) => service.startVeto(v)); action('veto/start', (v) => service.startVeto(v));
action('veto/action', (v, b) => service.vetoAction( action('veto/action', (v, b) => service.vetoAction(
@@ -161,13 +101,6 @@ export function createAdminRouter({ service, manager, config, settings }) {
action('kick', (v, b) => service.kick(v, b.playerId)); action('kick', (v, b) => service.kick(v, b.playerId));
action('codes/regenerate', (v, b) => service.regenerateCode(v, b.target)); 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) // Match endgültig löschen (Server wird vorher freigegeben)
router.delete('/matches/:id', requireAdmin, wrap(async (req) => { router.delete('/matches/:id', requireAdmin, wrap(async (req) => {
await manager.deleteMatch(req.params.id); await manager.deleteMatch(req.params.id);
+3 -13
View File
@@ -5,31 +5,22 @@ import express from 'express';
import { REPO_ROOT } from './config.js'; import { REPO_ROOT } from './config.js';
import { openDatabase } from './db.js'; import { openDatabase } from './db.js';
import { createApiRouter } from './http.js'; import { createApiRouter } from './http.js';
import { createDiscordNotifier } from './discord.js';
import { loadMapPools } from './mapPools.js'; import { loadMapPools } from './mapPools.js';
import { MatchService } from './matchService.js'; import { MatchService } from './matchService.js';
import { attachRealtime } from './realtime.js'; import { attachRealtime } from './realtime.js';
import { createServerAdapter } from './servers/adapter.js'; import { createServerAdapter } from './servers/adapter.js';
import { ServerManager } from './servers/serverManager.js'; import { ServerManager } from './servers/serverManager.js';
import { ServerSettings } from './serverSettings.js';
/** Baut die komplette Anwendung zusammen (auch für Tests nutzbar). */ /** Baut die komplette Anwendung zusammen (auch für Tests nutzbar). */
export function createApp(config, { adapter: adapterOverride, logger = console } = {}) { export function createApp(config, { adapter: adapterOverride, logger = console } = {}) {
const db = openDatabase(config.databaseFile); 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 pools = loadMapPools(config.mapPoolFile);
const notifier = createDiscordNotifier(config.discordWebhookUrl, { logger }); const service = new MatchService({ db, pools, config });
const service = new MatchService({ db, pools, config, notifier });
let manager; let manager;
const adapter = adapterOverride ?? createServerAdapter(config, { const adapter = adapterOverride ?? createServerAdapter(config, { getUsedPorts: () => manager.usedPorts() });
getUsedPorts: (host) => (host ? manager.usedPortsOn(host) : manager.usedPorts()),
});
manager = new ServerManager({ db, service, adapter, config, logger }); manager = new ServerManager({ db, service, adapter, config, logger });
manager.init(); manager.init();
service.startVetoTimer();
const app = express(); const app = express();
if (config.trustProxy) app.set('trust proxy', 1); if (config.trustProxy) app.set('trust proxy', 1);
@@ -39,7 +30,7 @@ export function createApp(config, { adapter: adapterOverride, logger = console }
res.set('Referrer-Policy', 'no-referrer'); res.set('Referrer-Policy', 'no-referrer');
next(); next();
}); });
app.use('/api', createApiRouter({ service, manager, config, pools, adapter, settings })); app.use('/api', createApiRouter({ service, manager, config, pools, adapter }));
// Gebautes Frontend ausliefern (npm run build), SPA-Fallback auf index.html. // Gebautes Frontend ausliefern (npm run build), SPA-Fallback auf index.html.
const dist = path.join(REPO_ROOT, 'client', 'dist'); const dist = path.join(REPO_ROOT, 'client', 'dist');
@@ -61,7 +52,6 @@ export function createApp(config, { adapter: adapterOverride, logger = console }
}, },
async close() { async close() {
manager.stopLoops(); manager.stopLoops();
service.stopVetoTimer();
// Laufende Server-Aktionen (Start, Konfiguration) kurz abwarten, bevor die Datenbank schließt // Laufende Server-Aktionen (Start, Konfiguration) kurz abwarten, bevor die Datenbank schließt
await manager.idle(5000); await manager.idle(5000);
io.disposeRealtime(); io.disposeRealtime();
-32
View File
@@ -22,32 +22,6 @@ function bool(name, fallback) {
return ['1', 'true', 'yes', 'ja', 'on'].includes(raw.toLowerCase()); return ['1', 'true', 'yes', 'ja', 'on'].includes(raw.toLowerCase());
} }
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,
};
});
}
function str(name, fallback = '') { function str(name, fallback = '') {
const raw = process.env[name]; const raw = process.env[name];
return raw === undefined || raw === '' ? fallback : raw; return raw === undefined || raw === '' ? fallback : raw;
@@ -67,7 +41,6 @@ export function loadConfig(overrides = {}) {
createMatchPassword: str('CREATE_MATCH_PASSWORD'), createMatchPassword: str('CREATE_MATCH_PASSWORD'),
// Optionaler Schlüssel für die Stream-Übersicht aller Matches (/stream?key=…); leer = offen // Optionaler Schlüssel für die Stream-Übersicht aller Matches (/stream?key=…); leer = offen
streamKey: str('STREAM_KEY'), streamKey: str('STREAM_KEY'),
discordWebhookUrl: str('DISCORD_WEBHOOK_URL'),
// Passwort für den Admin-Modus (/admin). Leer = Admin-Modus ist aus. // Passwort für den Admin-Modus (/admin). Leer = Admin-Modus ist aus.
adminPassword: str('ADMIN_PASSWORD'), adminPassword: str('ADMIN_PASSWORD'),
requireFullTeams: bool('REQUIRE_FULL_TEAMS', false), requireFullTeams: bool('REQUIRE_FULL_TEAMS', false),
@@ -78,8 +51,6 @@ export function loadConfig(overrides = {}) {
maxLifetimeHours: int('SERVER_MAX_LIFETIME_HOURS', 8), maxLifetimeHours: int('SERVER_MAX_LIFETIME_HOURS', 8),
statusPollSeconds: int('SERVER_STATUS_POLL_SECONDS', 5), statusPollSeconds: int('SERVER_STATUS_POLL_SECONDS', 5),
reaperIntervalSeconds: int('SERVER_REAPER_INTERVAL_SECONDS', 60), 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),
match: { match: {
tvDelaySeconds: int('CS2_TV_DELAY', 90), tvDelaySeconds: int('CS2_TV_DELAY', 90),
@@ -111,9 +82,6 @@ export function loadConfig(overrides = {}) {
volume: str('CS2_DOCKER_VOLUME', 'cs2-mapveto-gamefiles'), volume: str('CS2_DOCKER_VOLUME', 'cs2-mapveto-gamefiles'),
extraArgs: str('CS2_DOCKER_EXTRA_ARGS'), extraArgs: str('CS2_DOCKER_EXTRA_ARGS'),
startTimeoutMinutes: int('CS2_START_TIMEOUT_MINUTES', 20), startTimeoutMinutes: int('CS2_START_TIMEOUT_MINUTES', 20),
// 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: { staticPool: {
-6
View File
@@ -97,10 +97,6 @@ CREATE TABLE IF NOT EXISTS events (
created_at INTEGER NOT NULL created_at INTEGER NOT NULL
); );
CREATE INDEX IF NOT EXISTS events_match ON events(match_id, id); CREATE INDEX IF NOT EXISTS events_match ON events(match_id, id);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
`; `;
export function openDatabase(file) { export function openDatabase(file) {
@@ -128,6 +124,4 @@ function migrate(db) {
for (const [name, type] of add) { for (const [name, type] of add) {
if (!seriesColumns.has(name)) db.exec(`ALTER TABLE series_maps ADD COLUMN ${name} ${type}`); 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');
} }
-48
View File
@@ -1,48 +0,0 @@
// 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;
},
};
}
+2 -2
View File
@@ -14,7 +14,7 @@ function accessTokens(req) {
return [bearer(req), req.get('x-player-token')?.trim() || null]; return [bearer(req), req.get('x-player-token')?.trim() || null];
} }
export function createApiRouter({ service, manager, config, pools, adapter, settings }) { export function createApiRouter({ service, manager, config, pools, adapter }) {
const router = express.Router(); const router = express.Router();
// Live-Logs vom CS2-Server (logaddress_add_http). Vor dem JSON-Parser, weil CS2 Klartext sendet. // Live-Logs vom CS2-Server (logaddress_add_http). Vor dem JSON-Parser, weil CS2 Klartext sendet.
@@ -32,7 +32,7 @@ export function createApiRouter({ service, manager, config, pools, adapter, sett
}); });
router.use(express.json({ limit: '32kb' })); router.use(express.json({ limit: '32kb' }));
router.use('/admin', createAdminRouter({ service, manager, config, settings })); router.use('/admin', createAdminRouter({ service, manager, config }));
const limiters = { const limiters = {
create: createRateLimiter({ windowMs: 60_000, max: 10 }), create: createRateLimiter({ windowMs: 60_000, max: 10 }),
+3 -164
View File
@@ -2,9 +2,7 @@ import { EventEmitter } from 'node:events';
import { BEST_OF, computeVetoState, resolveAction, VetoError } from './veto.js'; import { BEST_OF, computeVetoState, resolveAction, VetoError } from './veto.js';
import { formatCode, hashToken, normalizeCode, randomCode, randomId, randomToken, safeEqual } from './security.js'; import { formatCode, hashToken, normalizeCode, randomCode, randomId, randomToken, safeEqual } from './security.js';
import { applyLogLines, initialLiveState, seriesScore, splitLogBody } from './liveScore.js'; import { applyLogLines, initialLiveState, seriesScore, splitLogBody } from './liveScore.js';
import { trimLiveState } from './rounds.js';
import { sanitizeConsoleText } from './servers/cs2Config.js'; import { sanitizeConsoleText } from './servers/cs2Config.js';
import { NOTIFY_EVENTS } from './discord.js';
export class HttpError extends Error { export class HttpError extends Error {
constructor(status, message, code = 'error') { constructor(status, message, code = 'error') {
@@ -42,12 +40,11 @@ function cleanSteamId(value) {
} }
export class MatchService extends EventEmitter { export class MatchService extends EventEmitter {
constructor({ db, pools, config, notifier = { enabled: false, send: async () => {} } }) { constructor({ db, pools, config }) {
super(); super();
this.db = db; this.db = db;
this.pools = pools; this.pools = pools;
this.config = config; this.config = config;
this.notifier = notifier;
this.presence = () => new Set(); this.presence = () => new Set();
} }
@@ -341,68 +338,10 @@ export class MatchService extends EventEmitter {
.run(firstTeam, this.now(), this.now(), match.id); .run(firstTeam, this.now(), this.now(), match.id);
const how = match.veto_start === 'coin' ? 'per Münzwurf ausgelost' : 'vom Organisator festgelegt'; const how = 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.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); 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 }) { vetoAction(viewer, { stepIndex, map, side }) {
this.require(viewer, 'captain'); this.require(viewer, 'captain');
let finished = false; let finished = false;
@@ -509,20 +448,6 @@ export class MatchService extends EventEmitter {
.run(matchId, 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. */ /** Organisator hat die Aufwärmphase beendet: ab dem nächsten Match_Start wird gezählt. */
markWarmupEnded(matchId, ended = true) { markWarmupEnded(matchId, ended = true) {
const match = this.getMatch(matchId); const match = this.getMatch(matchId);
@@ -576,16 +501,6 @@ export class MatchService extends EventEmitter {
const winner = state.winner ? this.teamName(match, state.winner) : 'Unentschieden'; const winner = state.winner ? this.teamName(match, state.winner) : 'Unentschieden';
this.log(matchId, 'score', 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}`); `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) { if (ended) {
@@ -594,18 +509,6 @@ export class MatchService extends EventEmitter {
const needed = Math.floor(BEST_OF[ended.match.format] / 2) + 1; const needed = Math.floor(BEST_OF[ended.match.format] / 2) + 1;
const decided = score.A >= needed ? 'A' : score.B >= needed ? 'B' : null; 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) 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); const hasNext = maps.some((m) => m.number === ended.row.number + 1);
this.changed(matchId); this.changed(matchId);
this.emit('mapEnded', { matchId, number: ended.row.number, decided, hasNext }); this.emit('mapEnded', { matchId, number: ended.row.number, decided, hasNext });
@@ -637,12 +540,6 @@ export class MatchService extends EventEmitter {
this.db.prepare("UPDATE series_maps SET status = 'done' WHERE match_id = ? AND status IN ('live','next')").run(match.id); 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', message ?? (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.setStatus(match.id, status);
this.emit('closed', match.id); this.emit('closed', match.id);
} }
@@ -657,47 +554,6 @@ export class MatchService extends EventEmitter {
this.db.prepare('UPDATE matches SET last_activity_at = ? WHERE id = ?').run(this.now(), matchId); 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) { log(matchId, type, message) {
this.db this.db
.prepare('INSERT INTO events (match_id, type, message, created_at) VALUES (?, ?, ?, ?)') .prepare('INSERT INTO events (match_id, type, message, created_at) VALUES (?, ?, ?, ?)')
@@ -791,14 +647,7 @@ export class MatchService extends EventEmitter {
return { return {
seriesScore: maps.length ? seriesScore(maps) : null, seriesScore: maps.length ? seriesScore(maps) : null,
currentMap: current && (current.status === 'live' || current.status === 'done') currentMap: current && (current.status === 'live' || current.status === 'done')
? { ? { number: current.number, name: this.mapName(m, current.map), score: { A: current.score_a, B: current.score_b }, finished: 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, : null,
id: m.id, id: m.id,
teamA: m.team_a_name, teamA: m.team_a_name,
@@ -892,7 +741,7 @@ export class MatchService extends EventEmitter {
requireFullTeams: this.config.requireFullTeams, requireFullTeams: this.config.requireFullTeams,
autoAdvanceSeconds: this.config.autoAdvanceSeconds, autoAdvanceSeconds: this.config.autoAdvanceSeconds,
maps: pool.maps, maps: pool.maps,
veto: publicView && match.status === 'lobby' veto: publicView
? null ? null
: { : {
started: match.status !== 'lobby', started: match.status !== 'lobby',
@@ -900,8 +749,6 @@ export class MatchService extends EventEmitter {
steps: veto.steps, steps: veto.steps,
maps: veto.maps, maps: veto.maps,
current: match.status === 'veto' ? veto.current : null, current: match.status === 'veto' ? veto.current : null,
remainingMs: this.vetoRemainingMs(match),
stepSeconds: this.config.vetoStepSeconds,
done: veto.done, done: veto.done,
history: actions.map((a) => ({ history: actions.map((a) => ({
index: a.idx, index: a.idx,
@@ -934,8 +781,6 @@ export class MatchService extends EventEmitter {
spectator: `${base}/watch/${formatCode(match.code_spectator)}`, spectator: `${base}/watch/${formatCode(match.code_spectator)}`,
// Vollbild-Übersicht für den Stream (OBS-Browserquelle), nutzt den Zuschauercode // Vollbild-Übersicht für den Stream (OBS-Browserquelle), nutzt den Zuschauercode
stream: `${base}/stream/${formatCode(match.code_spectator)}`, 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.config.streamKey)}` : ''}`,
}, },
}; };
} }
@@ -951,16 +796,10 @@ export class MatchService extends EventEmitter {
status: row.status, status: row.status,
message: row.message, message: row.message,
playerCount: row.player_count, playerCount: row.player_count,
paused: !!row.paused,
attempts: row.attempts, attempts: row.attempts,
updatedAt: row.updated_at, updatedAt: row.updated_at,
idleTimeoutMinutes: this.config.idleTimeoutMinutes, idleTimeoutMinutes: this.config.idleTimeoutMinutes,
}; };
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'; const configured = row.status === 'configured';
if (!configured || viewer.role === 'public') return view; if (!configured || viewer.role === 'public') return view;
-62
View File
@@ -1,62 +0,0 @@
// 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,
};
}
-187
View File
@@ -1,187 +0,0 @@
// 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';
const SETTINGS_KEY = 'server_settings';
const HOSTS_KEY = 'docker_hosts';
/**
* Ä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: '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 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;
}
}
+2 -2
View File
@@ -29,7 +29,7 @@
*/ */
import { DemoServerAdapter } from './demoAdapter.js'; import { DemoServerAdapter } from './demoAdapter.js';
import { DockerHostPool } from './dockerAdapter.js'; import { DockerServerAdapter } from './dockerAdapter.js';
import { StaticPoolServerAdapter } from './staticPoolAdapter.js'; import { StaticPoolServerAdapter } from './staticPoolAdapter.js';
export function createServerAdapter(config, deps) { export function createServerAdapter(config, deps) {
@@ -37,7 +37,7 @@ export function createServerAdapter(config, deps) {
case 'demo': case 'demo':
return new DemoServerAdapter(config.demo); return new DemoServerAdapter(config.demo);
case 'docker': case 'docker':
return new DockerHostPool(config.docker, deps); return new DockerServerAdapter(config.docker, deps);
case 'static': case 'static':
return new StaticPoolServerAdapter(config.staticPool, deps); return new StaticPoolServerAdapter(config.staticPool, deps);
default: default:
+1 -17
View File
@@ -1,8 +1,6 @@
// Erzeugt die Konsolenbefehle, mit denen ein laufender CS2-Server für eine Map der Serie konfiguriert wird. // 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. // 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) { export function sanitizeConsoleText(value, maxLength = 32) {
return String(value ?? '') return String(value ?? '')
.replace(/["';\\\r\n\t]/g, '') .replace(/["';\\\r\n\t]/g, '')
@@ -25,7 +23,7 @@ export function sanitizeConsoleText(value, maxLength = 32) {
* @param {object} p.secrets { serverPassword, tvPassword } * @param {object} p.secrets { serverPassword, tvPassword }
* @param {object} p.settings config.match * @param {object} p.settings config.match
*/ */
export function buildMapCommands({ match, pool, seriesMap, secrets, settings, logUrl, backupPrefix }) { export function buildMapCommands({ match, pool, seriesMap, secrets, settings, logUrl }) {
const isWingman = pool.mode === 'wingman'; const isWingman = pool.mode === 'wingman';
const ctTeam = seriesMap.ctTeam ?? 'A'; const ctTeam = seriesMap.ctTeam ?? 'A';
const tTeam = ctTeam === 'A' ? 'B' : 'A'; const tTeam = ctTeam === 'A' ? 'B' : 'A';
@@ -42,13 +40,6 @@ export function buildMapCommands({ match, pool, seriesMap, secrets, settings, lo
...(logUrl && /^https?:\/\/[\w.:/-]+$/.test(logUrl) ...(logUrl && /^https?:\/\/[\w.:/-]+$/.test(logUrl)
? ['log on', 'logaddress_delall_http', `logaddress_add_http "${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}`, `changelevel ${seriesMap.map}`,
]; ];
@@ -63,13 +54,6 @@ export function buildMapCommands({ match, pool, seriesMap, secrets, settings, lo
'mp_limitteams 0', 'mp_limitteams 0',
// Aufwärmphase läuft, bis der Organisator das Match startet (mp_warmup_end) // Aufwärmphase läuft, bis der Organisator das Match startet (mp_warmup_end)
'mp_warmup_pausetimer 1', '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, ...settings.extraCommands,
]; ];
-10
View File
@@ -2,8 +2,6 @@
// Er simuliert nur Startdauer und Konfiguration, damit der Ablauf ohne Hosting getestet werden kann. // 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. // Das Flag simulated=true sorgt dafür, dass das Frontend den Server ausdrücklich als Simulation kennzeichnet.
import { backupPrefix, parseBackupFiles } from '../rounds.js';
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
export class DemoServerAdapter { export class DemoServerAdapter {
@@ -43,14 +41,6 @@ export class DemoServerAdapter {
handle.map = plan.map; 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() {}
async sendCommands(handle, commands) { async sendCommands(handle, commands) {
return commands.map(() => '(Demo – nicht ausgeführt)'); return commands.map(() => '(Demo – nicht ausgeführt)');
} }
+4 -228
View File
@@ -1,19 +1,9 @@
import { execFile } from 'node:child_process'; import { execFile } from 'node:child_process';
import { promisify } from 'node:util'; import { promisify } from 'node:util';
import { configureViaRcon, listRoundBackups, loadRoundBackup, rconStatus } from './rconConfigure.js'; import { configureViaRcon, rconStatus } from './rconConfigure.js';
import { runRcon } from './rcon.js'; import { runRcon } from './rcon.js';
const exec = promisify(execFile); const exec = promisify(execFile);
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)); const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
// Diese Zeile schreibt das Image, sobald SteamCMD fertig ist und der Spielserver startet. // Diese Zeile schreibt das Image, sobald SteamCMD fertig ist und der Spielserver startet.
@@ -46,9 +36,8 @@ export class DockerServerAdapter {
} }
async docker(args, timeout = 60_000) { async docker(args, timeout = 60_000) {
if (this.runDocker) return this.runDocker(args, this.options.name); if (this.runDocker) return this.runDocker(args);
const env = this.options.dockerHost ? { ...process.env, DOCKER_HOST: this.options.dockerHost } : process.env; const { stdout } = await exec(this.options.bin, args, { timeout, maxBuffer: 4 * 1024 * 1024 });
const { stdout } = await exec(this.options.bin, args, { timeout, maxBuffer: 4 * 1024 * 1024, env });
return stdout.trim(); return stdout.trim();
} }
@@ -123,8 +112,6 @@ export class DockerServerAdapter {
CS2_GAMETYPE: '0', CS2_GAMETYPE: '0',
CS2_GAMEMODE: mode === 'wingman' ? '2' : '1', CS2_GAMEMODE: mode === 'wingman' ? '2' : '1',
CS2_SERVER_HIBERNATE: '0', CS2_SERVER_HIBERNATE: '0',
// Ohne diesen Startparameter legt CS2 keine Rundensicherungen an (Rollback)
CS2_ADDITIONAL_ARGS: '-allow_round_backup_files',
TV_ENABLE: '1', TV_ENABLE: '1',
TV_PORT: String(tvPort), TV_PORT: String(tvPort),
TV_PW: secrets.tvPassword, TV_PW: secrets.tvPassword,
@@ -148,14 +135,7 @@ export class DockerServerAdapter {
await this.docker(args, 15 * 60_000); await this.docker(args, 15 * 60_000);
return { return {
handle: { handle: { container, rconHost: this.options.rconHost, port, startedAt: Date.now(), rconPassword: secrets.rconPassword },
container,
hostName: this.options.name,
rconHost: this.options.rconHost,
port,
startedAt: Date.now(),
rconPassword: secrets.rconPassword,
},
host: this.options.host, host: this.options.host,
port, port,
tvPort, tvPort,
@@ -194,14 +174,6 @@ export class DockerServerAdapter {
await configureViaRcon(this.target(handle), plan); 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) { async sendCommands(handle, commands) {
return runRcon(this.target(handle), commands); return runRcon(this.target(handle), commands);
} }
@@ -210,199 +182,3 @@ export class DockerServerAdapter {
await this.docker(['rm', '-f', handle.container]).catch(() => {}); 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, runDocker } = {}) {
this.name = 'docker';
this.simulated = false;
this.depsGetUsedPorts = getUsedPorts;
this.runDocker = runDocker;
this.options = options;
this.diskCache = new Map();
this.diskInflight = new Map();
this.pull = { 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,
};
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;
}
/**
* 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);
}
/** Freie Port-Paare auf einem Host. */
freeSlots(host) {
const used = this.getUsedPorts(host.publicHost);
let free = 0;
for (let p = host.portRangeStart; p + 1 <= host.portRangeEnd; p += 2) {
if (!used.has(p) && !used.has(p + 1)) free += 1;
}
return free;
}
/** Auslastung pro Host: Anzahl Port-Paare insgesamt und belegt. */
hostStats() {
return this.hosts.map((h) => {
const total = Math.floor((h.portRangeEnd - h.portRangeStart + 1) / 2);
return { name: h.name, publicHost: h.publicHost, total, used: total - this.freeSlots(h) };
});
}
getUsedPorts(publicHost) {
return this.depsGetUsedPorts?.(publicHost) ?? new Set();
}
pickHost() {
const best = this.hosts
.map((h) => ({ host: h, free: this.freeSlots(h) }))
.filter((x) => x.free > 0)
.sort((a, b) => b.free - a.free)[0];
if (!best) throw new Error('Kein Docker-Host mit freien Ports');
return best.host;
}
async start(args) {
return this.adapters.get(this.pickHost().name).start(args);
}
adapterFor(handle) {
const name = handle?.hostName ?? this.hosts[0].name;
const adapter = this.adapters.get(name);
if (!adapter) throw new Error(`Docker-Host "${name}" ist nicht (mehr) konfiguriert`);
return adapter;
}
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);
}
async stop(handle) {
return this.adapterFor(handle).stop(handle);
}
}
-17
View File
@@ -1,4 +1,3 @@
import { parseBackupFiles } from '../rounds.js';
import { parseStatus, runRcon } from './rcon.js'; import { parseStatus, runRcon } from './rcon.js';
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
@@ -43,19 +42,3 @@ export async function rconStatus(target) {
const [output] = await runRcon(target, ['status'], { timeoutMs: 4000 }); const [output] = await runRcon(target, ['status'], { timeoutMs: 4000 });
return parseStatus(output); 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 });
}
+2 -174
View File
@@ -1,6 +1,5 @@
import { buildMapCommands, sanitizeConsoleText } from './cs2Config.js'; import { buildMapCommands, sanitizeConsoleText } from './cs2Config.js';
import { DemoMatchSimulator } from './demoMatchSimulator.js'; import { DemoMatchSimulator } from './demoMatchSimulator.js';
import { backupPrefix, describeBackup, isValidBackupFile, pointsForMap } from '../rounds.js';
import { HttpError, CLOSED_STATUSES } from '../matchService.js'; import { HttpError, CLOSED_STATUSES } from '../matchService.js';
import { randomPassword } from '../security.js'; import { randomPassword } from '../security.js';
@@ -87,43 +86,6 @@ export class ServerManager {
this.timers.push(setInterval(() => this.reap().catch((e) => this.logger.error(e)), this.config.reaperIntervalSeconds * 1000)); 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. */ /** Löscht ein Match samt Server-Datensatz. Ein laufender Server wird vorher freigegeben. */
async deleteMatch(matchId) { async deleteMatch(matchId) {
this.service.getMatch(matchId); this.service.getMatch(matchId);
@@ -160,107 +122,6 @@ export class ServerManager {
this.service.changed(matchId); 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 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 })),
}));
}
/** 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;
}
/** 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);
}
return used;
}
usedPorts() { usedPorts() {
const used = new Set(); 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`).all()) {
@@ -366,11 +227,6 @@ export class ServerManager {
} else if (status.state === 'failed') { } else if (status.state === 'failed') {
Object.assign(fields, { status: current.status === 'starting' ? 'start_failed' : 'failed', message: status.message }); Object.assign(fields, { status: current.status === 'starting' ? 'start_failed' : 'failed', message: status.message });
this.service.log(matchId, 'server', `Server-Fehler: ${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') { } else if (status.state === 'stopped') {
Object.assign(fields, { status: 'stopped', stopped_at: Date.now(), message: status.message ?? 'Server wurde extern beendet' }); 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'); this.service.log(matchId, 'server', 'Server wurde außerhalb der Anwendung beendet');
@@ -409,8 +265,7 @@ export class ServerManager {
if (!seriesMap) throw new HttpError(409, 'Das Veto ist noch nicht abgeschlossen', 'veto_pending'); 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; const mapName = pool.maps.find((m) => m.id === seriesMap.map)?.name ?? seriesMap.map;
// Ein Mapwechsel beendet die Pause, CS2 lädt die Map neu this.update(matchId, { status: 'configuring', message: `Lade Map ${seriesMap.number}: ${mapName}` });
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`); this.service.log(matchId, 'server', `Konfiguration für Map ${seriesMap.number} (${mapName}) gestartet`);
const plan = buildMapCommands({ const plan = buildMapCommands({
@@ -420,7 +275,6 @@ export class ServerManager {
secrets: { serverPassword: row.server_password, tvPassword: row.tv_password }, secrets: { serverPassword: row.server_password, tvPassword: row.tv_password },
settings: this.config.match, settings: this.config.match,
logUrl: `${this.config.logReceiverUrl}/api/logs/${matchId}/${row.config_key}`, logUrl: `${this.config.logReceiverUrl}/api/logs/${matchId}/${row.config_key}`,
backupPrefix: backupPrefix(matchId),
}); });
this.simulator.stop(matchId); this.simulator.stop(matchId);
this.service.resetLiveScore(matchId, seriesMap.number); this.service.resetLiveScore(matchId, seriesMap.number);
@@ -494,36 +348,10 @@ export class ServerManager {
throw new HttpError(502, `Befehl konnte nicht gesendet werden: ${err.message}`, 'server_error'); 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'); 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); if (this.adapter.simulated) this.startDemoSimulation(viewer.matchId);
this.service.changed(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. */ /** Demo-Modus: simuliert einen Matchverlauf, indem echte CS2-Logzeilen erzeugt und ausgewertet werden. */
startDemoSimulation(matchId) { startDemoSimulation(matchId) {
const match = this.service.getMatch(matchId); const match = this.service.getMatch(matchId);
@@ -565,7 +393,7 @@ export class ServerManager {
const row = this.row(matchId); const row = this.row(matchId);
if (!row || row.status === 'stopped') return; if (!row || row.status === 'stopped') return;
if (row.handle) await this.safeStop(row); if (row.handle) await this.safeStop(row);
this.update(matchId, { status: 'stopped', stopped_at: Date.now(), message: reason, player_count: null, paused: 0 }); this.update(matchId, { status: 'stopped', stopped_at: Date.now(), message: reason, player_count: null });
this.service.log(matchId, 'server', `Server gestoppt: ${reason}`); this.service.log(matchId, 'server', `Server gestoppt: ${reason}`);
} }
+1 -9
View File
@@ -1,4 +1,4 @@
import { configureViaRcon, listRoundBackups, loadRoundBackup, rconStatus } from './rconConfigure.js'; import { configureViaRcon, rconStatus } from './rconConfigure.js';
import { runRcon } from './rcon.js'; import { runRcon } from './rcon.js';
/** /**
@@ -45,14 +45,6 @@ export class StaticPoolServerAdapter {
await configureViaRcon(this.target(handle), plan); 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) { async sendCommands(handle, commands) {
return runRcon(this.target(handle), commands); return runRcon(this.target(handle), commands);
} }
-38
View File
@@ -107,9 +107,6 @@ test('kompletter Ablauf: Lobby, Berechtigungen, Veto, Server-Konfiguration', asy
const orgView = (await call('GET', `/matches/${id}`, { token: org })).body; const orgView = (await call('GET', `/matches/${id}`, { token: org })).body;
assert.equal(orgView.viewer.role, 'organizer'); assert.equal(orgView.viewer.role, 'organizer');
const { codes } = orgView.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 // Zuschauer- und fremde Codes erlauben keinen Teambeitritt
assert.equal((await call('POST', `/matches/${id}/join`, { body: { code: codes.spectator, nickname: 'spy' } })).status, 403); assert.equal((await call('POST', `/matches/${id}/join`, { body: { code: codes.spectator, nickname: 'spy' } })).status, 403);
@@ -401,7 +398,6 @@ test('Admin-Modus: Anmeldung, Verwaltung aller Matches, Sperre ohne Anmeldung',
// Ein Match abbrechen wie ein Admin // 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}/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'); assert.equal(app.service.getMatch(id).status, 'cancelled');
// Endgültig löschen // Endgültig löschen
@@ -416,40 +412,6 @@ test('Admin-Modus: Anmeldung, Verwaltung aller Matches, Sperre ohne Anmeldung',
} }
}); });
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 () => { test('Admin-Modus ohne ADMIN_PASSWORD ist komplett aus', async () => {
const { call, close } = await setup({ adminPassword: '' }); const { call, close } = await setup({ adminPassword: '' });
try { try {
-46
View File
@@ -1,46 +0,0 @@
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')));
});
-1
View File
@@ -37,7 +37,6 @@ test('Ohne GSLT startet der Container im LAN-Modus, mit GSLT mit Token', async (
assert.equal(env.SRCDS_TOKEN, undefined); assert.equal(env.SRCDS_TOKEN, undefined);
assert.equal(env.CS2_GAMEMODE, '2'); assert.equal(env.CS2_GAMEMODE, '2');
assert.equal(env.CS2_MAXPLAYERS, '6', '4 Spieler + CSTV + Reserve'); 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.CS2_PORT, '27100');
assert.equal(env.TV_PORT, '27101'); assert.equal(env.TV_PORT, '27101');
assert.ok(runArgs.includes('cs2-mapveto-gamefiles:/home/steam/cs2-dedicated')); assert.ok(runArgs.includes('cs2-mapveto-gamefiles:/home/steam/cs2-dedicated'));
-98
View File
@@ -1,98 +0,0 @@
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 nicht mehr konfiguriert ist, wird abgelehnt
await assert.rejects(p.status({ ...r.handle, hostName: 'gelöscht' }), /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: Port-Paare 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: 2, used: 1 },
{ name: 'b', publicHost: '10.0.0.2', total: 20, 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);
});
-84
View File
@@ -1,84 +0,0 @@
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"
+2 -2
View File
@@ -60,8 +60,8 @@ test('Seitenzuordnung nach dem Rundenende hat Vorrang, veraltete Zeilen zu Runde
assert.deepEqual(s.rounds.map((r) => r.winner), ['A', 'B']); assert.deepEqual(s.rounds.map((r) => r.winner), ['A', 'B']);
}); });
test('Echte, anonymisierte Logzeilen eines CS2-Servers (Wingman, 4 Runden, Halbzeit nach 2)', () => { test('Echte 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 lines = fs.readFileSync(path.join(here, 'fixtures', 'echte-logs-wingman.log'), 'utf8').split(/\r?\n/).filter(Boolean);
const warmupEnd = lines.findIndex((l) => l.includes('command "mp_warmup_end"')); 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 ctx = { teamNames: { A: 'MaxLAN Blau', B: 'MaxLAN Rot' }, maxRounds: 4, overtimeRounds: 6 };
-2
View File
@@ -64,8 +64,6 @@ test('Konsolentexte können keine Befehle einschleusen', () => {
assert.ok(afterMapChange.includes('mp_teamname_1 "Bravo"')); assert.ok(afterMapChange.includes('mp_teamname_1 "Bravo"'));
assert.ok(afterMapChange.includes('mp_teamname_2 "Alphaquit"')); assert.ok(afterMapChange.includes('mp_teamname_2 "Alphaquit"'));
assert.ok(afterMapChange.includes('mp_maxrounds 16')); 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', () => { test('Log-Weiterleitung wird vor dem Mapwechsel gesetzt, unsichere URLs nicht', () => {
-54
View File
@@ -1,54 +0,0 @@
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
@@ -1,61 +0,0 @@
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, hosts: null });
pool.setHosts(hosts);
assert.deepEqual(pool.hostStats().map((h) => [h.name, h.total]), [['a', 20], ['b', 10]]);
pool.options.startTimeoutMinutes = 45;
assert.equal(pool.adapters.get('b').options.startTimeoutMinutes, 45);
});