Compare commits
56
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fda3f4ea9c | ||
|
|
2d65634d6e | ||
|
|
e3834bb071 | ||
|
|
0ba60dd207 | ||
|
|
3c5b929845 | ||
|
|
c6c39b914f | ||
|
|
0bfac952b6 | ||
|
|
7ae3efef60 | ||
|
|
c50741e49c | ||
|
|
dfcca1be82 | ||
|
|
1b3536452c | ||
|
|
0a40688945 | ||
|
|
4521cdae72 | ||
|
|
479962dc8d | ||
|
|
b616bbafc1 | ||
|
|
f022673c4d | ||
|
|
3040124b33 | ||
|
|
929134f2bc | ||
|
|
472bda31a9 | ||
|
|
ed65b92be1 | ||
|
|
beaf635c23 | ||
|
|
f6735824cc | ||
|
|
2873b5ba3a | ||
|
|
9bb878c641 | ||
|
|
1243e9c852 | ||
|
|
844da4713d | ||
|
|
91f979945e | ||
|
|
c1c9250126 | ||
|
|
eda15bc585 | ||
|
|
1a304cb398 | ||
|
|
cd7f7e0468 | ||
|
|
e1ee009063 | ||
|
|
f90351dd8d | ||
|
|
d2625d23ab | ||
|
|
8b6abdb8b2 | ||
|
|
eb80c4845c | ||
|
|
90996d1b0a | ||
|
|
da986d44c8 | ||
|
|
5383008c8e | ||
|
|
11ccbf01a1 | ||
|
|
5012e8b16d | ||
|
|
1744ff3f73 | ||
|
|
d560c15fef | ||
|
|
364c0b57c2 | ||
|
|
9f2e810fa1 | ||
|
|
e9efe38b76 | ||
|
|
dea0618b69 | ||
|
|
0197cd05ce | ||
|
|
9af83cb47c | ||
|
|
5a332655b2 | ||
|
|
c4bf4f19ae | ||
|
|
2c0f86dccc | ||
|
|
91022e307c | ||
|
|
fe251a13ef | ||
|
|
3e682d7fbc | ||
|
|
a6e7d7d287 |
No files matched your search
@@ -52,7 +52,20 @@ 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
|
||||||
|
# Spielports für den LAN-Reiter des Steam-Serverbrowsers (Steam sucht nur 27015–27020). Leer = aus.
|
||||||
|
CS2_LAN_PORTS=27015-27020
|
||||||
|
# CSTV-Port = Spielport + dieser Abstand (z. B. 27015 -> 27115)
|
||||||
|
CS2_TV_PORT_OFFSET=100
|
||||||
|
# Messer-Server für die Seitenwahl (im Admin-Bereich startbar)
|
||||||
|
KNIFE_MAP=de_mirage
|
||||||
|
KNIFE_WARMUP_SECONDS=90
|
||||||
|
KNIFE_MAX_SERVERS=4
|
||||||
|
# Anfang des Servernamens im Serverbrowser, danach folgen die Teamnamen
|
||||||
|
CS2_SERVERNAME_PREFIX=MaxLAN
|
||||||
DOCKER_BIN=docker
|
DOCKER_BIN=docker
|
||||||
|
|
||||||
# --- Static-Pool-Adapter (SERVER_ADAPTER=static) ---
|
# --- Static-Pool-Adapter (SERVER_ADAPTER=static) ---
|
||||||
@@ -68,3 +81,5 @@ 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=
|
||||||
@@ -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`), die nächste Map laden, den Server stoppen und das Match abschließen oder abbrechen.
|
7. **Während der Serie:** Der Organisator kann die Aufwärmphase beenden (`mp_warmup_end`), das laufende Match pausieren und fortsetzen (`mp_pause_match`, `mp_unpause_match`), die nächste Map laden, den Server stoppen und das Match abschließen oder abbrechen.
|
||||||
8. **Fehlerfälle:** Startet der Server nicht, schlägt die Konfiguration fehl oder fällt der Server aus, zeigt die Seite den Grund. Der Organisator bekommt einen Knopf für den erneuten Versuch: „Server neu starten“ bzw. „Konfiguration wiederholen“.
|
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, nächste Map, Match beenden; sieht Einladungen, Spieler- und CSTV-Verbindung |
|
| Organisator | Token beim Erstellen | Veto starten, Spieler entfernen, Captain setzen, Codes erneuern, Server neu starten/stoppen/konfigurieren, pausieren/fortsetzen, nächste Map, Match beenden; sieht Einladungen, Spieler- und CSTV-Verbindung, die Match-Übersicht |
|
||||||
| Captain | Teamcode (erster Spieler oder ernannt) | alles wie Spieler, dazu Veto-Aktionen seines Teams und Captain-Übergabe |
|
| 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 |
|
||||||
@@ -148,13 +148,28 @@ Beim Start wird jeder Ablauf geprüft:
|
|||||||
- genau eine Entscheidungsmap
|
- genau eine Entscheidungsmap
|
||||||
- Seitenwahl nur direkt nach einem Pick oder der Entscheidungsmap
|
- Seitenwahl nur direkt nach einem Pick oder der Entscheidungsmap
|
||||||
|
|
||||||
Ungültige oder fehlende Abläufe machen nur dieses Format unauswählbar (mit Begründung in der Oberfläche).
|
Passt kein hinterlegter Ablauf zur Map-Anzahl, erzeugt die Anwendung einen (siehe unten). Unauswählbar ist ein Format nur, wenn es zu wenige Maps gibt (mit Begründung in der Oberfläche).
|
||||||
|
|
||||||
|
### Maps im Admin-Bereich wählen
|
||||||
|
|
||||||
|
Unter Einstellungen → **Map-Pools** wählt der Admin pro Modus, welche Maps im Veto stehen (mindestens 2). Die Auswahl steht in der Datenbank und gilt für neue Matches. Laufende Matches behalten ihre Maps und ihren Ablauf. Maps mit `"active": false` in der Konfiguration stehen im Katalog, sind aber erst nach Auswahl aktiv.
|
||||||
|
|
||||||
|
**Ablauf passend zur Map-Anzahl:** Passt ein hinterlegter Ablauf zur Anzahl der gewählten Maps, gilt er. Sonst erzeugt die Anwendung einen Ablauf:
|
||||||
|
|
||||||
|
1. bis zu zwei Bans (X, dann Y),
|
||||||
|
2. die Picks der Serie im Wechsel, die Seite wählt jeweils das andere Team,
|
||||||
|
3. die restlichen Bans im Wechsel,
|
||||||
|
4. die letzte Map ist die Entscheidungsmap, ihre Seite wählt das Team, das nicht zuletzt dran war.
|
||||||
|
|
||||||
|
Jede weitere Map bedeutet also einen Ban mehr. BO3 braucht mindestens 3 Maps, BO5 mindestens 5. Der Admin-Bereich zeigt nach dem Speichern den Ablauf für jedes Format.
|
||||||
|
|
||||||
|
Beispiel Competitive mit allen 10 Maps, BO3: X bannt, Y bannt, X pickt (Y Seite), Y pickt (X Seite), X, Y, X, Y, X bannen → Entscheidungsmap, Y wählt Seite.
|
||||||
|
|
||||||
### Mitgelieferte Abläufe
|
### Mitgelieferte Abläufe
|
||||||
|
|
||||||
**Competitive (7 Maps):** Ancient, Anubis, Dust II, Inferno, Mirage, Nuke, Train
|
**Competitive (Katalog mit 10 Maps):** Ancient, Anubis, Cache, Dust II, Inferno, Mirage, Nuke, Overpass, Train, Vertigo. Alle 10 sind aktiv. Die festen Abläufe unten gelten für die klassischen 7 Maps (Ancient, Anubis, Dust II, Inferno, Mirage, Nuke, Train), bei jeder anderen Auswahl erzeugt die Anwendung den Ablauf.
|
||||||
|
|
||||||
| Format | Ablauf |
|
| Format (7 Maps) | Ablauf |
|
||||||
|---|---|
|
|---|---|
|
||||||
| BO1 | X bannt, Y bannt, X bannt, Y bannt, X bannt, Y bannt → Entscheidungsmap, X wählt Seite |
|
| BO1 | X bannt, Y bannt, X bannt, Y bannt, X bannt, Y bannt → Entscheidungsmap, X wählt Seite |
|
||||||
| BO3 | X bannt, Y bannt, X pickt (Y wählt Seite), Y pickt (X wählt Seite), X bannt, Y bannt → Entscheidungsmap, X wählt Seite |
|
| BO3 | X bannt, Y bannt, X pickt (Y wählt Seite), Y pickt (X wählt Seite), X bannt, Y bannt → Entscheidungsmap, X wählt Seite |
|
||||||
@@ -166,7 +181,7 @@ Ungültige oder fehlende Abläufe machen nur dieses Format unauswählbar (mit Be
|
|||||||
|---|---|
|
|---|---|
|
||||||
| BO1 | X bannt, Y bannt, X bannt → Entscheidungsmap, Y wählt Seite |
|
| BO1 | X bannt, Y bannt, X bannt → Entscheidungsmap, Y wählt Seite |
|
||||||
| BO3 | X bannt, Y pickt (X Seite), X pickt (Y Seite) → Entscheidungsmap, X wählt Seite |
|
| BO3 | X bannt, Y pickt (X Seite), X pickt (Y Seite) → Entscheidungsmap, X wählt Seite |
|
||||||
| BO5 | **nicht unterstützt:** Mit 4 Maps sind keine 5 Karten möglich. Mit einem Pool von mindestens 5 Maps und einem `bo5`-Ablauf wird es automatisch auswählbar. |
|
| BO5 | **nicht unterstützt:** Mit 4 Maps sind keine 5 Karten möglich. Mit mindestens 5 Maps wird es automatisch auswählbar. |
|
||||||
|
|
||||||
### Regel für Startseiten
|
### Regel für Startseiten
|
||||||
|
|
||||||
@@ -201,9 +216,22 @@ Die Serververwaltung ist hinter [`server/src/servers/adapter.js`](server/src/ser
|
|||||||
- Wird alle `SERVER_STATUS_POLL_SECONDS` abgefragt, solange der Server startet, danach alle 30 s.
|
- 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`).
|
2. Warten, bis `status` die Map meldet (max. 2 Minuten), dann die Match-Einstellungen (`mp_teamname_1/2`, `mp_maxrounds`, `mp_overtime_enable`, `mp_match_can_clinch`, `mp_warmup_pausetimer 1`, `CS2_EXTRA_COMMANDS`) und die Turnierregeln (siehe unten).
|
||||||
|
|
||||||
Die zweite Phase kommt erst nach dem Mapwechsel, weil die `gamemode_*.cfg` des Spiels diese Werte beim Laden der Map sonst überschreibt.
|
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`)
|
||||||
@@ -223,6 +251,46 @@ 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.
|
||||||
|
|
||||||
|
### Messer-Server für die Seitenwahl
|
||||||
|
|
||||||
|
Im Admin-Bereich (Reiter „Matches“) startet „Messer-Server starten“ einen Gameserver, der keinem Match gehört und dauerhaft läuft. Teams verbinden sich, spielen eine Messerrunde und gehen wieder.
|
||||||
|
|
||||||
|
- Modus wählbar: **Competitive 5v5** (12 Plätze, Maps aus dem Competitive-Pool) oder **Wingman 2v2** (6 Plätze, Maps aus dem Wingman-Pool).
|
||||||
|
- Offen, ohne Passwort, sichtbar im LAN-Reiter von Steam (`MaxLAN | Messer 1 5v5 (Seitenwahl)`) und auf der Startseite mit „Verbinden“.
|
||||||
|
- Nur Messer, kein Geld, eine Runde. Vorher läuft eine Aufwärmphase zum Verbinden (`KNIFE_WARMUP_SECONDS`, Standard 90). Nach der Runde startet das Spiel von selbst neu (`mp_match_end_restart`). Wird der Server leer, setzt die App ihn zusätzlich zurück.
|
||||||
|
- Fällt ein Messer-Server aus, startet die App ihn neu, höchstens fünfmal in Folge. Danach steht er auf „ausgefallen“ und lässt sich im Admin-Bereich neu starten.
|
||||||
|
- Knöpfe pro Server: „Runde starten“ (Aufwärmphase sofort beenden), „Zurücksetzen“, „Neu starten“ (neuer Container), „Entfernen“.
|
||||||
|
- Messer-Server belegen ihre Ports dauerhaft und nehmen zuerst LAN-Ports. Jeder Messer-Server ist also ein Platz weniger im LAN-Reiter für Matches.
|
||||||
|
- Weitere Einstellungen: `KNIFE_MAP` (Standard-Map, muss in einem Map-Pool sein), `KNIFE_MAX_SERVERS` (Standard 4).
|
||||||
|
|
||||||
|
### Server im Steam-Serverbrowser (LAN-Reiter)
|
||||||
|
|
||||||
|
Der LAN-Reiter des Steam-Serverbrowsers (Steam → Ansicht → Spielserver → LAN) fragt nur die Ports 27015 bis 27020 ab. Deshalb vergibt die App den Spielport zuerst aus `CS2_LAN_PORTS` (Standard `27015-27020`), danach aus dem normalen Portbereich. Der CSTV-Port liegt immer 100 über dem Spielport (Spiel 27015, CSTV 27115; Spiel 27100, CSTV 27200), der Abstand lässt sich mit `CS2_TV_PORT_OFFSET` ändern. Auf jedem Host müssen also auch die Ports 100 über dem Portbereich frei sein. So passen bis zu sechs Server pro Host in den LAN-Reiter. Weitere Server laufen im normalen Portbereich. Sie sind dann nicht im LAN-Reiter sichtbar, Spieler erreichen sie aber über den Verbindungsbefehl.
|
||||||
|
|
||||||
|
- Pro Host lassen sich die LAN-Ports in `CS2_DOCKER_HOSTS` mit `"lanPorts":"27016-27019"` einschränken, wenn dort schon etwas anderes auf einem dieser Ports läuft. `"lanPorts":""` schaltet es für einen Host ab. Im Admin-Bereich steht das in der Spalte „LAN-Ports (Steam)“.
|
||||||
|
- Ist ein Port trotzdem belegt, versucht der Start automatisch die nächsten freien Ports.
|
||||||
|
- Server auf LAN-Ports laufen im Netz des Hosts (`--network host`), weil die Port-Weiterleitung von Docker die Suchanfragen (Broadcasts) des Serverbrowsers nicht weitergibt. Die Firewall des Hosts muss die Ports deshalb selbst erlauben.
|
||||||
|
- Der Servername im Browser ist `MaxLAN | Team A vs. Team B`. Den Anfang ändert `CS2_SERVERNAME_PREFIX`.
|
||||||
|
- Die Server sind mit Passwort geschützt und zeigen ein Schloss. Ohne `CS2_GSLT` laufen sie mit `sv_lan 1` und erscheinen nicht im Internet-Reiter.
|
||||||
|
|
||||||
### Option B: Vorhandene Server (`SERVER_ADAPTER=static`)
|
### Option B: Vorhandene Server (`SERVER_ADAPTER=static`)
|
||||||
|
|
||||||
Für selbst betriebene, dauerhaft laufende CS2-Server:
|
Für selbst betriebene, dauerhaft laufende CS2-Server:
|
||||||
@@ -298,7 +366,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), siehe `server/test/fixtures/echte-logs-wingman.log`.
|
- **Geprüft:** mit echten Logzeilen eines CS2-Servers (Wingman), anonymisiert in `server/test/fixtures/wingman-anonym.log`.
|
||||||
|
|
||||||
Zwei Eigenheiten von CS2 berücksichtigt der Parser:
|
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.
|
||||||
@@ -314,19 +382,101 @@ 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Admin-Bereich
|
||||||
|
|
||||||
|
Unter `/admin` mit dem Passwort aus `ADMIN_PASSWORD` (leer = Admin-Bereich aus). Die Anmeldung gilt 12 Stunden und nur, solange die Anwendung läuft. Nach einem Neustart der Anwendung muss man sich neu anmelden.
|
||||||
|
|
||||||
|
### Reiter „Matches“
|
||||||
|
|
||||||
|
- **Docker-Hosts:** pro Host belegte und freie Plätze, freier Speicher dort, wo das Gameserver-Volume liegt, und die Matches bzw. Messer-Server darauf.
|
||||||
|
- **Messer-Server (Seitenwahl):** starten (Modus 5v5 oder 2v2 und Map wählen), Runde starten, zurücksetzen, neu starten, entfernen. Siehe „Messer-Server für die Seitenwahl“.
|
||||||
|
- **Suche und Filter:** Suche nach Team, Spieler oder Match-ID. Filter Alle, Laufend, Fehler (Zahl rot, wenn es Fehler gibt), Abgeschlossen und Archiv. Der Filter bleibt im Browser gespeichert.
|
||||||
|
- **Matchkarte:** Teams, Modus, Format, Status, Live-Stand, Server- und CSTV-Adresse (`Server 192.168.178.45:27015 · CSTV …:27115`), Zeitpunkt der Server-Anforderung, Pausen-Abzeichen. Bei einem Serverfehler steht die Ursache rot in der Karte.
|
||||||
|
- **Aktionen:** Ansehen, Veto starten, Aufwärmphase beenden, Pausieren/Fortsetzen, Nächste Map, Server neu starten / wiederholen, Server stoppen, Match abschließen, Match abbrechen, Archivieren / Aus dem Archiv holen, Löschen. Destruktive Aktionen fragen nach.
|
||||||
|
- **Details:** Einladungslinks und Codes (erneuerbar), Verbindungsbefehle für Spieler und CSTV, Spielerliste mit „Entfernen“, Rollback der laufenden Map.
|
||||||
|
- **Archiv:** abgeschlossene oder abgebrochene Matches lassen sich archivieren. Nach `ARCHIVE_AFTER_DAYS` Tagen (Standard 3, 0 = nie) geschieht das automatisch. Archivierte Matches stehen nur unter dem Filter „Archiv“.
|
||||||
|
|
||||||
|
Startet man den Server eines laufenden Matches neu, lädt die Anwendung danach die aktuelle Map samt Passwort wieder. Ein frisch gestarteter Server hat die volle Leerlauf-Frist, bevor er automatisch heruntergefahren wird.
|
||||||
|
|
||||||
|
### Reiter „Einstellungen“
|
||||||
|
|
||||||
|
- **Map-Pools:** pro Modus wählen, welche Maps im Veto stehen. Der Veto-Ablauf passt sich der Anzahl an, siehe „Maps im Admin-Bereich wählen“.
|
||||||
|
- **Gameserver:** Runden Competitive und Wingman, Verlängerung, zusätzliche Konsolenbefehle, CSTV an/aus und Verzögerung, Zeit pro Veto-Schritt, Leerlauf-Frist, maximale Laufzeit, Startzeit (inklusive Download) und Archiv-Frist. Die Werte aus der `.env` sind die Vorgabe. Was hier geändert wird, steht in der Datenbank und gilt auch nach einem Neustart. Mit „live“ markierte Werte gehen beim Speichern sofort per RCON an alle laufenden Server. Befehle, die Passwörter ändern oder den Server beenden, sind gesperrt.
|
||||||
|
- **Docker-Hosts:** Hosts hinzufügen, entfernen und bearbeiten (Name, Docker-Ziel, Adresse, Portbereich, LAN-Ports, Volume). Die Liste ersetzt `CS2_DOCKER_HOSTS`. „Liste aus .env verwenden“ schaltet zurück. Hosts mit laufenden Servern lassen sich nicht entfernen und ihre Adresse nicht ändern.
|
||||||
|
- **Image aktualisieren:** lädt das CS2-Image auf allen Hosts gleichzeitig neu (per SSH direkt auf dem Host). Laufende Server laufen weiter.
|
||||||
|
- **Discord-Meldungen:** welche Ereignisse gemeldet werden, plus Testmeldung.
|
||||||
|
|
||||||
## Umgebungsvariablen
|
## Umgebungsvariablen
|
||||||
|
|
||||||
Alle Variablen mit Standardwerten stehen in [`.env.example`](.env.example). Die wichtigsten:
|
Alle Variablen mit Standardwerten stehen in [`.env.example`](.env.example). Die wichtigsten:
|
||||||
@@ -341,6 +491,19 @@ Alle Variablen mit Standardwerten stehen in [`.env.example`](.env.example). Die
|
|||||||
| `SERVER_ADAPTER` | `demo` | `demo`, `docker` oder `static` |
|
| `SERVER_ADAPTER` | `demo` | `demo`, `docker` oder `static` |
|
||||||
| `SERVER_IDLE_TIMEOUT_MINUTES` | `30` | Frist bis zum automatischen Herunterfahren |
|
| `SERVER_IDLE_TIMEOUT_MINUTES` | `30` | Frist bis zum automatischen Herunterfahren |
|
||||||
| `SERVER_MAX_LIFETIME_HOURS` | `8` | harte Obergrenze pro Server |
|
| `SERVER_MAX_LIFETIME_HOURS` | `8` | harte Obergrenze pro Server |
|
||||||
|
| `ADMIN_PASSWORD` | leer | Passwort für den Admin-Bereich `/admin`; leer = Admin-Bereich aus |
|
||||||
|
| `VETO_STEP_SECONDS` | `30` | Zeit pro Veto-Schritt, danach wird automatisch gewählt; `0` = ohne Frist |
|
||||||
|
| `ARCHIVE_AFTER_DAYS` | `3` | abgeschlossene Matches nach so vielen Tagen archivieren; `0` = nie |
|
||||||
|
| `DOCKER_HOST` | leer | Docker-Ziel für einen einzelnen Host, z. B. `ssh://cs2docker` |
|
||||||
|
| `CS2_DOCKER_HOSTS` | leer | mehrere Docker-Hosts als JSON-Liste (siehe „Mehrere Docker-Hosts“) |
|
||||||
|
| `CS2_PORT_RANGE_START`, `CS2_PORT_RANGE_END` | `27015`, `27115` | Bereich der Spielports; CSTV liegt jeweils `CS2_TV_PORT_OFFSET` darüber |
|
||||||
|
| `CS2_LAN_PORTS` | `27015-27020` | Spielports für den LAN-Reiter von Steam, werden zuerst vergeben; leer = aus |
|
||||||
|
| `CS2_TV_PORT_OFFSET` | `100` | Abstand CSTV-Port zum Spielport (27015 → 27115) |
|
||||||
|
| `CS2_SERVERNAME_PREFIX` | `MaxLAN` | Anfang des Servernamens im Serverbrowser |
|
||||||
|
| `CS2_START_TIMEOUT_MINUTES` | `20` | Startzeit eines Servers inklusive Download der Spieldateien |
|
||||||
|
| `KNIFE_MAP` | `de_mirage` | Standard-Map für Messer-Server |
|
||||||
|
| `KNIFE_WARMUP_SECONDS` | `90` | Zeit zum Verbinden, bevor die Messerrunde beginnt |
|
||||||
|
| `KNIFE_MAX_SERVERS` | `4` | höchstens so viele Messer-Server |
|
||||||
| `CS2_TV_DELAY` | `90` | CSTV-Verzögerung in Sekunden |
|
| `CS2_TV_DELAY` | `90` | CSTV-Verzögerung in Sekunden |
|
||||||
| `CS2_GSLT` | leer | Docker: Game Server Login Token; leer = LAN-Modus (`sv_lan 1`) |
|
| `CS2_GSLT` | leer | Docker: Game Server Login Token; leer = LAN-Modus (`sv_lan 1`) |
|
||||||
| `CS2_PUBLIC_HOST` | – | **Pflicht bei Docker:** öffentliche Adresse |
|
| `CS2_PUBLIC_HOST` | – | **Pflicht bei Docker:** öffentliche Adresse |
|
||||||
@@ -350,6 +513,8 @@ 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:
|
||||||
@@ -392,6 +557,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`, `…/series/next`, `…/finish`, `…/cancel` | Organisator |
|
| `…/veto/start`, `…/kick`, `…/codes/regenerate`, `…/server/retry`, `…/server/stop`, `…/server/warmup-end`, `…/server/pause`, `…/server/resume`, `…/series/next`, `…/finish`, `…/cancel` | Organisator |
|
||||||
|
|
||||||
Socket.IO: `match:subscribe {matchId, token}` liefert `match:state` bei jeder Änderung; `overview:subscribe` liefert `overview`.
|
Socket.IO: `match:subscribe {matchId, token}` liefert `match:state` bei jeder Änderung; `overview:subscribe` liefert `overview`.
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 127 KiB |
@@ -10,6 +10,7 @@ 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;
|
||||||
@@ -38,6 +39,7 @@ 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>}
|
||||||
@@ -129,3 +131,28 @@ 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;
|
||||||
|
}
|
||||||
@@ -3,10 +3,43 @@ import { Link } from 'react-router-dom';
|
|||||||
import { api } from '../lib/api.js';
|
import { api } from '../lib/api.js';
|
||||||
import { FORMAT_LABEL, serverLabel } from '../lib/labels.js';
|
import { FORMAT_LABEL, serverLabel } from '../lib/labels.js';
|
||||||
import CopyField from '../components/CopyField.jsx';
|
import CopyField from '../components/CopyField.jsx';
|
||||||
|
import MapImage from '../components/MapImage.jsx';
|
||||||
|
|
||||||
const KEY = 'cs2mv:admin';
|
const KEY = 'cs2mv:admin';
|
||||||
|
const FILTER_KEY = 'cs2mv:admin:filter';
|
||||||
const REFRESH_MS = 3000;
|
const REFRESH_MS = 3000;
|
||||||
|
|
||||||
|
const RUNNING = ['lobby', 'veto', 'server_setup', 'live'];
|
||||||
|
const CLOSED = ['finished', 'cancelled'];
|
||||||
|
const SERVER_ERRORS = ['start_failed', 'failed', 'config_failed'];
|
||||||
|
const hasError = (m) => SERVER_ERRORS.includes(m.server?.status);
|
||||||
|
|
||||||
|
/** Filter der Matchliste. "archive" lädt eine eigene Liste vom Server. */
|
||||||
|
const FILTERS = [
|
||||||
|
{ key: 'all', label: 'Alle', test: () => true },
|
||||||
|
{ key: 'running', label: 'Laufend', test: (m) => RUNNING.includes(m.status) },
|
||||||
|
{ key: 'errors', label: 'Fehler', test: hasError },
|
||||||
|
{ key: 'closed', label: 'Abgeschlossen', test: (m) => CLOSED.includes(m.status) },
|
||||||
|
{ key: 'archive', label: 'Archiv', test: () => true },
|
||||||
|
];
|
||||||
|
|
||||||
|
const readFilter = () => {
|
||||||
|
try {
|
||||||
|
const f = localStorage.getItem(FILTER_KEY);
|
||||||
|
return FILTERS.some((x) => x.key === f) ? f : 'all';
|
||||||
|
} catch {
|
||||||
|
return 'all';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Suche nach Teamnamen, Spielern und Match-ID, ohne Groß-/Kleinschreibung. */
|
||||||
|
function matchesSearch(m, query) {
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
if (!q) return true;
|
||||||
|
const hay = [m.id, m.teams.A.name, m.teams.B.name, ...['A', 'B'].flatMap((t) => m.teams[t].players.map((p) => p.nickname))];
|
||||||
|
return hay.some((x) => String(x ?? '').toLowerCase().includes(q));
|
||||||
|
}
|
||||||
|
|
||||||
const getToken = () => {
|
const getToken = () => {
|
||||||
try {
|
try {
|
||||||
return sessionStorage.getItem(KEY);
|
return sessionStorage.getItem(KEY);
|
||||||
@@ -26,9 +59,24 @@ 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 [filter, setFilterState] = useState(readFilter);
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [archivedCount, setArchivedCount] = useState(0);
|
||||||
|
const setFilter = (f) => {
|
||||||
|
setFilterState(f);
|
||||||
|
// Beim Wechsel zwischen Archiv und normaler Liste neu laden
|
||||||
|
if ((f === 'archive') !== (filter === 'archive')) setMatches(null);
|
||||||
|
try {
|
||||||
|
localStorage.setItem(FILTER_KEY, f);
|
||||||
|
} catch {
|
||||||
|
/* ignorieren */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const logout = useCallback(() => {
|
const logout = useCallback(() => {
|
||||||
api('/admin/logout', { method: 'POST', token }).catch(() => {});
|
api('/admin/logout', { method: 'POST', token }).catch(() => {});
|
||||||
@@ -39,8 +87,10 @@ export default function AdminPage() {
|
|||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await api('/admin/matches', { token });
|
const res = await api(`/admin/matches${filter === 'archive' ? '?archived=1' : ''}`, { token });
|
||||||
setMatches(res.matches);
|
setMatches(res.matches);
|
||||||
|
setArchivedCount(res.archivedCount ?? 0);
|
||||||
|
setHosts(res.hosts ?? []);
|
||||||
setError(null);
|
setError(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err.status === 401) {
|
if (err.status === 401) {
|
||||||
@@ -50,7 +100,7 @@ export default function AdminPage() {
|
|||||||
setError(err.message);
|
setError(err.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [token]);
|
}, [token, filter]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!token) return undefined;
|
if (!token) return undefined;
|
||||||
@@ -92,20 +142,116 @@ 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 && <MapPoolsPanel token={token} setNotice={setNotice} />}
|
||||||
|
{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>}
|
||||||
{matches === null && !error && <p className="muted">Lade Matches …</p>}
|
{tab === 'matches' && <HostUsage hosts={hosts} />}
|
||||||
{matches && matches.length === 0 && <p className="muted">Noch keine Matches.</p>}
|
{tab === 'matches' && <KnifePanel token={token} setNotice={setNotice} />}
|
||||||
|
{tab === 'matches' && matches === null && !error && <p className="muted">Lade Matches …</p>}
|
||||||
|
{tab === 'matches' && (
|
||||||
|
<MatchToolbar
|
||||||
|
matches={filter === 'archive' ? [] : matches ?? []}
|
||||||
|
archivedCount={archivedCount}
|
||||||
|
filter={filter}
|
||||||
|
setFilter={setFilter}
|
||||||
|
query={query}
|
||||||
|
setQuery={setQuery}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{tab === 'matches' && matches && (() => {
|
||||||
|
const active = FILTERS.find((f) => f.key === filter);
|
||||||
|
const shown = matches.filter((m) => active.test(m) && matchesSearch(m, query));
|
||||||
|
if (matches.length === 0) {
|
||||||
|
return <p className="muted">{filter === 'archive' ? 'Das Archiv ist leer.' : 'Noch keine Matches.'}</p>;
|
||||||
|
}
|
||||||
|
if (shown.length === 0) return <p className="muted">Keine Matches für diesen Filter{query ? ` und „${query}“` : ''}.</p>;
|
||||||
|
return (
|
||||||
<div className="admin-list">
|
<div className="admin-list">
|
||||||
{matches?.map((m) => (
|
{shown.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} token={token} reload={load} setNotice={setNotice} />
|
||||||
))}
|
))}
|
||||||
</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);
|
||||||
@@ -197,6 +343,9 @@ 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}`}>
|
||||||
@@ -209,6 +358,33 @@ 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>
|
||||||
|
)}
|
||||||
|
{hasError(m) && m.server.message && (
|
||||||
|
<p className="admin-error small" role="alert">{m.server.message}</p>
|
||||||
|
)}
|
||||||
|
{m.archivedAt && <span className="admin-badge is-archived">Archiviert {formatDateTime(m.archivedAt)}</span>}
|
||||||
|
{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>
|
||||||
@@ -217,12 +393,16 @@ 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' })}
|
||||||
{m.server && !['stopped'].includes(m.server.status) && btn('Server stoppen', { path: '/server/stop', confirm: 'Server stoppen?' })}
|
{m.server && !['stopped'].includes(m.server.status) && btn('Server stoppen', { path: '/server/stop', confirm: 'Server stoppen?' })}
|
||||||
{!closed && btn('Match abschließen', { path: '/finish', confirm: 'Match als abgeschlossen markieren?' })}
|
{!closed && btn('Match abschließen', { path: '/finish', confirm: 'Match als abgeschlossen markieren?' })}
|
||||||
{!closed && btn('Match abbrechen', { path: '/cancel', confirm: 'Match abbrechen?' }, 'btn-ghost danger')}
|
{!closed && btn('Match abbrechen', { path: '/cancel', confirm: 'Match abbrechen?' }, 'btn-ghost danger')}
|
||||||
|
{closed && !m.archivedAt && btn('Archivieren', { path: '/archive', success: 'Match archiviert' })}
|
||||||
|
{m.archivedAt && btn('Aus dem Archiv holen', { path: '/unarchive', success: 'Match ist wieder in der Liste' })}
|
||||||
{btn('Löschen', { method: 'DELETE', path: '', confirm: `Match ${m.teams.A.name} vs. ${m.teams.B.name} endgültig löschen?`, success: 'Match gelöscht' }, 'btn-ghost danger')}
|
{btn('Löschen', { method: 'DELETE', path: '', confirm: `Match ${m.teams.A.name} vs. ${m.teams.B.name} endgültig löschen?`, success: 'Match gelöscht' }, 'btn-ghost danger')}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -299,3 +479,524 @@ 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, lanPorts: '27015-27020', 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: 'lanPorts', label: 'LAN-Ports (Steam)', placeholder: '27015-27020' },
|
||||||
|
{ 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),
|
||||||
|
lanPorts: String(r.lanPorts ?? '').trim(),
|
||||||
|
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`;
|
||||||
|
}
|
||||||
|
/** Suche und Filter über der Matchliste, mit Anzahl pro Filter. */
|
||||||
|
function MatchToolbar({ matches, archivedCount, filter, setFilter, query, setQuery }) {
|
||||||
|
const count = (f) => (f.key === 'archive' ? archivedCount : matches.filter(f.test).length);
|
||||||
|
return (
|
||||||
|
<div className="admin-toolbar">
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
className="admin-search"
|
||||||
|
placeholder="Team, Spieler oder Match-ID suchen"
|
||||||
|
aria-label="Matches durchsuchen"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="admin-filters" role="group" aria-label="Matches filtern">
|
||||||
|
{FILTERS.map((f) => (
|
||||||
|
<button
|
||||||
|
key={f.key}
|
||||||
|
className={`admin-filter ${filter === f.key ? 'is-active' : ''} ${f.key === 'errors' && count(f) > 0 ? 'has-errors' : ''}`}
|
||||||
|
aria-pressed={filter === f.key}
|
||||||
|
onClick={() => setFilter(f.key)}
|
||||||
|
>
|
||||||
|
{f.label}
|
||||||
|
{(f.key !== 'archive' || archivedCount > 0) && <span className="admin-filter-count">{count(f)}</span>}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const KNIFE_STATUS = {
|
||||||
|
starting: { text: 'startet', tone: 'pending' },
|
||||||
|
configuring: { text: 'wird eingerichtet', tone: 'pending' },
|
||||||
|
running: { text: 'bereit', tone: 'ok' },
|
||||||
|
config_failed: { text: 'Einrichtung fehlgeschlagen', tone: 'error' },
|
||||||
|
failed: { text: 'ausgefallen', tone: 'error' },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Messer-Server für die Seitenwahl: laufen dauerhaft, setzen sich nach jeder Messerrunde selbst zurück. */
|
||||||
|
function KnifePanel({ token, setNotice }) {
|
||||||
|
const [data, setData] = useState(null);
|
||||||
|
const [mode, setMode] = useState('');
|
||||||
|
const [map, setMap] = useState('');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await api('/admin/knife', { token });
|
||||||
|
setData(res);
|
||||||
|
setMode((m) => m || res.modes[0]?.id || '');
|
||||||
|
setMap((m) => m || res.modes[0]?.defaultMap || '');
|
||||||
|
} catch {
|
||||||
|
/* Fehler zeigt die Matchliste schon an */
|
||||||
|
}
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
const t = setInterval(load, REFRESH_MS);
|
||||||
|
return () => clearInterval(t);
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
if (!data) return null;
|
||||||
|
|
||||||
|
const run = async (fn, success, question) => {
|
||||||
|
if (question && !window.confirm(question)) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
if (success) setNotice({ tone: 'ok', text: success });
|
||||||
|
load();
|
||||||
|
} catch (err) {
|
||||||
|
setNotice({ tone: 'error', text: err.message });
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const post = (path, body) => api(`/admin/knife${path}`, { method: 'POST', body, token });
|
||||||
|
const full = data.servers.length >= data.maxServers;
|
||||||
|
const modeInfo = data.modes.find((x) => x.id === mode) ?? data.modes[0];
|
||||||
|
const chooseMode = (id) => {
|
||||||
|
setMode(id);
|
||||||
|
setMap(data.modes.find((x) => x.id === id)?.defaultMap ?? '');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="admin-settings knife-panel">
|
||||||
|
<h2>Messer-Server (Seitenwahl)</h2>
|
||||||
|
<p className="small muted">
|
||||||
|
Laufen dauerhaft und ohne Passwort, sichtbar im LAN-Reiter von Steam. Teams verbinden sich, spielen eine Messerrunde
|
||||||
|
und gehen wieder. Nach dem Spiel oder wenn der Server leer ist, setzt er sich selbst zurück.
|
||||||
|
</p>
|
||||||
|
{data.servers.length > 0 && (
|
||||||
|
<div className="knife-grid">
|
||||||
|
{data.servers.map((k) => {
|
||||||
|
const st = KNIFE_STATUS[k.status] ?? { text: k.status, tone: 'pending' };
|
||||||
|
const ready = k.status === 'running';
|
||||||
|
return (
|
||||||
|
<div key={k.id} className="knife-card">
|
||||||
|
<div className="knife-head">
|
||||||
|
<strong>{k.name}</strong>
|
||||||
|
{k.modeShort && <span className="admin-badge knife-mode">{k.modeShort}</span>}
|
||||||
|
<span className="muted small">{k.mapName}</span>
|
||||||
|
<span className={`tone-${st.tone} small knife-state`}>{st.text}</span>
|
||||||
|
</div>
|
||||||
|
{k.connect && <code className="knife-addr">{k.host}:{k.port}</code>}
|
||||||
|
<div className="small muted">
|
||||||
|
{k.playerCount != null ? `${k.playerCount} Spieler` : 'Spieler unbekannt'}
|
||||||
|
{k.message ? ` · ${k.message}` : ''}
|
||||||
|
</div>
|
||||||
|
<div className="admin-actions">
|
||||||
|
<button className="btn btn-sm" disabled={busy || !ready} onClick={() => run(() => post(`/${k.id}/round`), 'Messerrunde gestartet')}>Runde starten</button>
|
||||||
|
<button className="btn btn-sm" disabled={busy || !ready} onClick={() => run(() => post(`/${k.id}/reset`), 'Server zurückgesetzt')}>Zurücksetzen</button>
|
||||||
|
<button className="btn btn-sm" disabled={busy} onClick={() => run(() => post(`/${k.id}/restart`), 'Server wird neu gestartet', `${k.name} komplett neu starten?`)}>Neu starten</button>
|
||||||
|
<button className="btn btn-sm btn-ghost danger" disabled={busy} onClick={() => run(() => api(`/admin/knife/${k.id}`, { method: 'DELETE', token }), `${k.name} entfernt`, `${k.name} stoppen und entfernen?`)}>Entfernen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="admin-actions knife-new">
|
||||||
|
<select value={modeInfo?.id ?? ''} onChange={(e) => chooseMode(e.target.value)} aria-label="Modus für den neuen Messer-Server" disabled={busy || full}>
|
||||||
|
{data.modes.map((m) => <option key={m.id} value={m.id}>{m.label}</option>)}
|
||||||
|
</select>
|
||||||
|
<select value={map} onChange={(e) => setMap(e.target.value)} aria-label="Map für den neuen Messer-Server" disabled={busy || full}>
|
||||||
|
{modeInfo?.maps.map((m) => <option key={m.id} value={m.id}>{m.name}</option>)}
|
||||||
|
</select>
|
||||||
|
<button className="btn btn-sm btn-primary" disabled={busy || full} onClick={() => run(() => api('/admin/knife', { method: 'POST', body: { mode: modeInfo?.id, map }, token }), 'Messer-Server wird gestartet')}>
|
||||||
|
Messer-Server starten
|
||||||
|
</button>
|
||||||
|
<span className="small muted">{data.servers.length} von {data.maxServers}</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const STEP_LABEL = { ban: 'Ban', pick: 'Pick', side: 'Seite' };
|
||||||
|
|
||||||
|
/** Veto-Ablauf als kurze Schrittfolge; X = Team, das das Veto beginnt, Y = das andere Team. */
|
||||||
|
function FlowSteps({ flow }) {
|
||||||
|
return (
|
||||||
|
<ol className="flow-steps">
|
||||||
|
{flow.map((raw, i) => {
|
||||||
|
if (raw === 'decider') return <li key={i} className="flow-decider">Entscheidung</li>;
|
||||||
|
const [type, actor] = raw.split(':');
|
||||||
|
return <li key={i} className={`flow-${type}`}>{STEP_LABEL[type]} {actor === 'X' ? '1' : '2'}</li>;
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Welche Maps gespielt werden, pro Modus. Gilt für neue Matches, laufende behalten ihre Maps. */
|
||||||
|
function MapPoolsPanel({ token, setNotice }) {
|
||||||
|
const [pools, setPools] = useState(null);
|
||||||
|
const [selection, setSelection] = useState({});
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const apply = (res) => {
|
||||||
|
setPools(res.pools);
|
||||||
|
setSelection(Object.fromEntries(res.pools.map((p) => [p.mode, p.maps.filter((m) => m.active).map((m) => m.id)])));
|
||||||
|
};
|
||||||
|
useEffect(() => {
|
||||||
|
api('/admin/map-pools', { token }).then(apply).catch((err) => setNotice({ tone: 'error', text: err.message }));
|
||||||
|
}, [token, setNotice]);
|
||||||
|
if (!pools) return null;
|
||||||
|
|
||||||
|
const toggle = (mode, id) => {
|
||||||
|
const cur = new Set(selection[mode]);
|
||||||
|
if (cur.has(id)) cur.delete(id);
|
||||||
|
else cur.add(id);
|
||||||
|
setSelection({ ...selection, [mode]: [...cur] });
|
||||||
|
};
|
||||||
|
const save = async (mode) => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
apply(await api('/admin/map-pools', { method: 'PUT', body: { mode, maps: selection[mode] }, token }));
|
||||||
|
setNotice({ tone: 'ok', text: 'Maps gespeichert – gilt für neue Matches' });
|
||||||
|
} catch (err) {
|
||||||
|
setNotice({ tone: 'error', text: err.message });
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="admin-settings">
|
||||||
|
<h2>Map-Pools</h2>
|
||||||
|
<p className="small muted">
|
||||||
|
Welche Maps im Veto stehen. Der Veto-Ablauf passt sich der Anzahl an: Für jede weitere Map gibt es einen Ban mehr.
|
||||||
|
Laufende Matches behalten ihre Maps. „1“ ist das Team, das das Veto beginnt.
|
||||||
|
</p>
|
||||||
|
{pools.map((p) => {
|
||||||
|
const chosen = new Set(selection[p.mode]);
|
||||||
|
const saved = new Set(p.maps.filter((m) => m.active).map((m) => m.id));
|
||||||
|
const dirty = chosen.size !== saved.size || [...chosen].some((id) => !saved.has(id));
|
||||||
|
return (
|
||||||
|
<div key={p.mode} className="setting-group">
|
||||||
|
<h3>{p.label} <span className="muted small">{chosen.size} von {p.maps.length} Maps</span></h3>
|
||||||
|
<div className="pool-maps">
|
||||||
|
{p.maps.map((m) => (
|
||||||
|
<label key={m.id} className={`pool-map ${chosen.has(m.id) ? 'is-on' : ''}`}>
|
||||||
|
<input type="checkbox" checked={chosen.has(m.id)} onChange={() => toggle(p.mode, m.id)} />
|
||||||
|
<MapImage map={m} className="pool-map-art" />
|
||||||
|
<span className="pool-map-name">{m.name}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{!dirty && (
|
||||||
|
<div className="pool-flows">
|
||||||
|
{Object.entries(p.formats).map(([f, info]) => (
|
||||||
|
<div key={f} className="pool-flow">
|
||||||
|
<strong>{f.toUpperCase()}</strong>
|
||||||
|
{info.supported ? <FlowSteps flow={info.flow} /> : <span className="muted small">{info.reason}</span>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="admin-actions">
|
||||||
|
<button className="btn btn-sm btn-primary" disabled={busy || !dirty || chosen.size < 2} onClick={() => save(p.mode)}>Speichern</button>
|
||||||
|
{dirty && <button className="btn btn-sm" disabled={busy} onClick={() => setSelection({ ...selection, [p.mode]: [...saved] })}>Verwerfen</button>}
|
||||||
|
{chosen.size < 2 && <span className="small tone-error">Mindestens 2 Maps</span>}
|
||||||
|
{dirty && chosen.size >= 2 && <span className="small muted">Nach dem Speichern steht hier der neue Veto-Ablauf.</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -127,7 +127,7 @@ export default function MatchPage() {
|
|||||||
|
|
||||||
{viewer.role === 'public' && <PublicNotice />}
|
{viewer.role === 'public' && <PublicNotice />}
|
||||||
|
|
||||||
<div className={`match-grid ${state.status !== 'lobby' && viewer.role !== 'public' ? 'stage-wide' : ''}`}>
|
<div className={`match-grid ${state.status !== 'lobby' ? 'stage-wide' : ''}`}>
|
||||||
<TeamCard team={state.teams.A} side="A" {...ctx} />
|
<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') {
|
if (state.viewer.role === 'public' && state.status === 'lobby') {
|
||||||
return <div className="stage-note"><h2>{state.statusLabel}</h2><p className="muted">Details sehen nur Teilnehmer.</p></div>;
|
return <div className="stage-note"><h2>{state.statusLabel}</h2><p className="muted">Das Veto und der Spielstand erscheinen hier, sobald das Match startet.</p></div>;
|
||||||
}
|
}
|
||||||
if (state.status === 'lobby') {
|
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 diese Lobby ohne Zugang. Spieler treten über den Einladungslink ihres Teams bei,
|
Du siehst dieses Match ohne Zugang. Veto, Serienstand und Ergebnisse sind öffentlich.
|
||||||
Zuschauer über den Zuschauerlink. Beides bekommst du vom Organisator.
|
Teamdetails und Verbindungsdaten gibt es nur für Teilnehmer.
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
import { api } from '../lib/api.js';
|
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.' },
|
||||||
@@ -23,6 +24,8 @@ export default function Overview() {
|
|||||||
<CodeEntry />
|
<CodeEntry />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<KnifeServers />
|
||||||
|
|
||||||
{matches === null ? (
|
{matches === null ? (
|
||||||
<p className="muted">Lade Matches …</p>
|
<p className="muted">Lade Matches …</p>
|
||||||
) : (
|
) : (
|
||||||
@@ -32,7 +35,11 @@ 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> : (
|
{list.length === 0 ? <p className="muted small">{g.empty}</p> : g.key === 'running' ? (
|
||||||
|
<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>
|
||||||
@@ -79,6 +86,34 @@ 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);
|
||||||
@@ -104,3 +139,36 @@ function CodeEntry() {
|
|||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
/** Offene Messer-Server für die Seitenwahl, zum direkten Verbinden. */
|
||||||
|
function KnifeServers() {
|
||||||
|
const [servers, setServers] = useState([]);
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
const load = () => api('/knife-servers').then((res) => alive && setServers(res.servers)).catch(() => {});
|
||||||
|
load();
|
||||||
|
const t = setInterval(load, 10_000);
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
clearInterval(t);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
if (servers.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<section className="group knife-public">
|
||||||
|
<h2>Messer-Server für die Seitenwahl <span className="count">{servers.length}</span></h2>
|
||||||
|
<p className="muted small">Ohne Passwort, auch im LAN-Reiter von Steam. Nach eurer Messerrunde setzt sich der Server selbst zurück.</p>
|
||||||
|
<ul className="knife-list">
|
||||||
|
{servers.map((k) => (
|
||||||
|
<li key={k.id}>
|
||||||
|
<strong>{k.name}</strong>
|
||||||
|
{k.modeShort && <span className="knife-mode">{k.modeShort}</span>}
|
||||||
|
<span className="muted small">{k.mapName}</span>
|
||||||
|
<span className="small">{k.playerCount ? `${k.playerCount} Spieler` : 'frei'}</span>
|
||||||
|
<code>{k.connect}</code>
|
||||||
|
<a className="btn btn-sm" href={k.steamUrl}>Verbinden</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -512,3 +512,117 @@ input:focus, select:focus { border-color: var(--link); outline: none; }
|
|||||||
.rollback h3 { margin: 0 0 8px; display: flex; align-items: center; gap: 10px; }
|
.rollback h3 { margin: 0 0 8px; display: flex; align-items: center; gap: 10px; }
|
||||||
.rollback select { min-width: 200px; }
|
.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-badge { font: 600 .75rem var(--body); color: #fff; background: var(--brand-red); padding: 2px 8px; border-radius: 3px; }
|
||||||
|
|
||||||
|
.admin-settings { border: 1px solid var(--line); background: var(--surface); border-radius: var(--radius); padding: 16px 20px; margin-bottom: 20px; }
|
||||||
|
.admin-settings h2 { margin: 0 0 12px; font-size: 1.1rem; }
|
||||||
|
.admin-settings-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 8px 16px; margin-bottom: 12px; }
|
||||||
|
.admin-check { display: flex; align-items: center; gap: 8px; cursor: pointer; }
|
||||||
|
|
||||||
|
.admin-tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--line); margin-bottom: 20px; }
|
||||||
|
.admin-tab { background: none; border: 0; border-bottom: 2px solid transparent; color: var(--muted); padding: 10px 14px; font: inherit; cursor: pointer; }
|
||||||
|
.admin-tab:hover { color: var(--text); }
|
||||||
|
.admin-tab.is-active { color: var(--text); border-bottom-color: var(--primary); }
|
||||||
|
|
||||||
|
.turn-timer { grid-column: 1 / -1; display: flex; align-items: center; gap: 10px; margin-top: 8px; }
|
||||||
|
.turn-bar { flex: 1; height: 6px; border-radius: 3px; background: color-mix(in srgb, var(--team) 18%, transparent); overflow: hidden; }
|
||||||
|
.turn-bar span { display: block; height: 100%; background: var(--team); transition: width .25s linear; }
|
||||||
|
.turn-seconds { min-width: 3ch; text-align: right; color: var(--muted); font-size: .875rem; font-variant-numeric: tabular-nums; }
|
||||||
|
.turn-timer.is-low .turn-bar span { background: var(--error); }
|
||||||
|
.turn-timer.is-low .turn-seconds { color: var(--error); font-weight: 600; }
|
||||||
|
.admin-live { display: flex; gap: 10px; align-items: baseline; margin-top: 4px; font-size: .9rem; }
|
||||||
|
.admin-live strong { font-variant-numeric: tabular-nums; }
|
||||||
|
.admin-badge { display: inline-block; margin-top: 6px; padding: 2px 10px; border-radius: 999px; font-size: .8rem; font-weight: 600; }
|
||||||
|
.admin-badge.is-paused { background: color-mix(in srgb, var(--pending) 22%, transparent); color: var(--pending); border: 1px solid var(--pending); }
|
||||||
|
.btn-warn { border-color: var(--pending); color: var(--pending); }
|
||||||
|
/* Laufende Matches in der Übersicht */
|
||||||
|
.run-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 16px; }
|
||||||
|
.run-card { position: relative; display: flex; flex-direction: column; justify-content: space-between; gap: 10px; min-height: 190px; padding: 16px 18px; border-radius: var(--radius); border: 1px solid var(--line); background: var(--surface); color: var(--text); text-decoration: none; overflow: hidden; isolation: isolate; transition: transform .15s ease, border-color .15s ease; }
|
||||||
|
.run-card:hover { transform: translateY(-2px); border-color: var(--map); }
|
||||||
|
.run-card.is-live { box-shadow: inset 3px 0 0 var(--brand-red); }
|
||||||
|
.run-art { position: absolute; inset: 0; z-index: -1; opacity: .28; }
|
||||||
|
.run-art::after { background: linear-gradient(180deg, rgba(0,0,0,.2), rgba(0,0,0,.92)); }
|
||||||
|
.run-top { display: flex; justify-content: space-between; align-items: center; gap: 8px; font-size: .8rem; color: var(--muted); }
|
||||||
|
.run-live { animation: run-pulse 1.6s ease-in-out infinite; }
|
||||||
|
@keyframes run-pulse { 50% { opacity: .55; } }
|
||||||
|
.run-status { font-weight: 600; }
|
||||||
|
.run-teams { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 8px; text-align: center; }
|
||||||
|
.run-team { font: 700 1.25rem var(--display); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.run-team.a { color: var(--team-a); text-align: right; }
|
||||||
|
.run-team.b { color: var(--team-b); text-align: left; }
|
||||||
|
.run-score { font: 700 2.25rem var(--display); font-variant-numeric: tabular-nums; }
|
||||||
|
.run-score i { font-style: normal; color: var(--muted); padding: 0 4px; }
|
||||||
|
.run-vs { font-size: 1rem; color: var(--muted); font-style: normal; }
|
||||||
|
.run-foot { display: flex; justify-content: space-between; gap: 8px; font-size: .875rem; color: var(--muted); }
|
||||||
|
.run-series { font-variant-numeric: tabular-nums; }
|
||||||
|
.host-usage { margin-bottom: 20px; }
|
||||||
|
.host-usage h2 { font-size: 1.1rem; margin: 0 0 10px; }
|
||||||
|
.host-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 12px; }
|
||||||
|
.host-card { border: 1px solid var(--line); background: var(--surface); border-radius: var(--radius); padding: 12px 14px; }
|
||||||
|
.host-card.is-full { border-color: var(--pending); }
|
||||||
|
.host-head { display: flex; flex-wrap: wrap; gap: 4px 10px; align-items: baseline; margin-bottom: 8px; }
|
||||||
|
.host-count { margin-left: auto; font-variant-numeric: tabular-nums; font-size: .875rem; }
|
||||||
|
.host-bar { height: 6px; border-radius: 3px; background: var(--line); overflow: hidden; margin-bottom: 8px; }
|
||||||
|
.host-bar span { display: block; height: 100%; background: var(--primary); transition: width .3s ease; }
|
||||||
|
.host-card.is-full .host-bar span { background: var(--pending); }
|
||||||
|
.host-matches { list-style: none; margin: 0; padding: 0; font-size: .9rem; }
|
||||||
|
.host-matches li { padding: 3px 0; border-top: 1px solid var(--line); }
|
||||||
|
.host-disk { font-size: .85rem; color: var(--muted); margin: -2px 0 8px; font-variant-numeric: tabular-nums; }
|
||||||
|
.host-disk.is-low { color: var(--error); font-weight: 600; }
|
||||||
|
.admin-address { display: flex; flex-wrap: wrap; gap: 6px 16px; margin-top: 8px; font-size: .85rem; }
|
||||||
|
.admin-address code { font: 500 .85rem ui-monospace, SFMono-Regular, Consolas, monospace; padding: 1px 6px; border: 1px solid var(--line); border-radius: 4px; background: var(--surface); }
|
||||||
|
.admin-settings h3 { margin: 16px 0 8px; font-size: .95rem; }
|
||||||
|
.setting-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 12px 20px; }
|
||||||
|
.setting-item { display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.setting-field { display: flex; flex-direction: column; gap: 4px; font-size: .9rem; }
|
||||||
|
.setting-field input, .setting-field textarea { width: 100%; box-sizing: border-box; font: inherit; color: var(--text); background: var(--bg, transparent); border: 1px solid var(--line); border-radius: 4px; padding: 6px 8px; }
|
||||||
|
.setting-field textarea { resize: vertical; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: .85rem; }
|
||||||
|
.setting-live { display: inline-block; margin-right: 6px; padding: 0 6px; border-radius: 999px; font-size: .7rem; font-weight: 600; color: var(--primary); border: 1px solid var(--primary); }
|
||||||
|
.host-edit { display: flex; flex-direction: column; gap: 10px; margin-bottom: 12px; }
|
||||||
|
.host-edit-row { display: grid; grid-template-columns: 1fr 2fr 1.4fr .8fr .8fr 1.1fr 1.4fr auto; gap: 8px; align-items: end; padding-bottom: 10px; border-bottom: 1px solid var(--line); }
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.host-edit-row { grid-template-columns: 1fr 1fr; }
|
||||||
|
.host-col-dockerHost { grid-column: 1 / -1; }
|
||||||
|
}
|
||||||
|
.pull-results { list-style: none; margin: 8px 0 0; padding: 0; display: flex; flex-direction: column; gap: 4px; font-size: .9rem; }
|
||||||
|
.pull-results li { display: flex; flex-wrap: wrap; gap: 4px 10px; align-items: baseline; }
|
||||||
|
.pull-results code { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; }
|
||||||
|
.pull-running span { color: var(--pending); }
|
||||||
|
.pull-ok span { color: var(--ok, #22c55e); }
|
||||||
|
.pull-failed span { color: var(--error); }
|
||||||
|
.admin-toolbar { display: flex; flex-wrap: wrap; gap: 10px 16px; align-items: center; margin: 0 0 14px; }
|
||||||
|
.admin-search { flex: 1 1 240px; min-width: 0; font: inherit; color: var(--text); background: var(--surface); border: 1px solid var(--line); border-radius: 4px; padding: 7px 10px; }
|
||||||
|
.admin-filters { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||||
|
.admin-filter { display: inline-flex; align-items: center; gap: 6px; font: inherit; font-size: .875rem; color: var(--muted); background: transparent; border: 1px solid var(--line); border-radius: 999px; padding: 4px 12px; cursor: pointer; }
|
||||||
|
.admin-filter:hover { color: var(--text); }
|
||||||
|
.admin-filter.is-active { color: var(--text); border-color: var(--primary); background: color-mix(in srgb, var(--primary) 16%, transparent); }
|
||||||
|
.admin-filter-count { font-variant-numeric: tabular-nums; font-size: .75rem; opacity: .8; }
|
||||||
|
.admin-filter.has-errors .admin-filter-count { color: var(--error); font-weight: 700; opacity: 1; }
|
||||||
|
.admin-error { margin: 8px 0 0; color: var(--error); }
|
||||||
|
.admin-badge.is-archived { background: transparent; color: var(--muted); border: 1px solid var(--line); }
|
||||||
|
.knife-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 12px; margin-bottom: 12px; }
|
||||||
|
.knife-card { border: 1px solid var(--line); border-radius: var(--radius); padding: 12px 14px; display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.knife-head { display: flex; flex-wrap: wrap; gap: 4px 10px; align-items: baseline; }
|
||||||
|
.knife-state { margin-left: auto; }
|
||||||
|
.knife-addr { font: 500 .9rem ui-monospace, SFMono-Regular, Consolas, monospace; align-self: flex-start; padding: 1px 6px; border: 1px solid var(--line); border-radius: 4px; }
|
||||||
|
.knife-new select { font: inherit; color: var(--text); background: var(--surface); border: 1px solid var(--line); border-radius: 4px; padding: 5px 8px; }
|
||||||
|
.knife-new { align-items: center; }
|
||||||
|
.knife-public { margin-bottom: 24px; }
|
||||||
|
.knife-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.knife-list li { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 14px; padding: 10px 14px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); }
|
||||||
|
.knife-list code { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: .85rem; }
|
||||||
|
.knife-list .btn { margin-left: auto; }
|
||||||
|
.knife-mode { display: inline-block; margin: 0; padding: 1px 8px; border-radius: 999px; font-size: .75rem; font-weight: 700; color: var(--primary); border: 1px solid var(--primary); background: transparent; }
|
||||||
|
.pool-maps { display: grid; grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); gap: 8px; margin-bottom: 10px; }
|
||||||
|
.pool-map { position: relative; display: flex; flex-direction: column; justify-content: flex-end; min-height: 78px; isolation: isolate; border: 1px solid var(--line); border-radius: var(--radius); overflow: hidden; cursor: pointer; opacity: .45; transition: opacity .15s ease, border-color .15s ease; }
|
||||||
|
.pool-map.is-on { opacity: 1; border-color: var(--primary); }
|
||||||
|
.pool-map input { position: absolute; top: 6px; left: 6px; z-index: 2; }
|
||||||
|
|
||||||
|
.pool-map-name { position: relative; z-index: 1; padding: 5px 8px; font-size: .9rem; font-weight: 700; text-shadow: 0 1px 6px rgba(0, 0, 0, .7); }
|
||||||
|
.pool-flows { display: flex; flex-direction: column; gap: 6px; margin-bottom: 10px; }
|
||||||
|
.pool-flow { display: flex; flex-wrap: wrap; gap: 6px 10px; align-items: baseline; }
|
||||||
|
.pool-flow strong { min-width: 36px; font-size: .85rem; }
|
||||||
|
.flow-steps { display: flex; flex-wrap: wrap; gap: 4px; list-style: none; margin: 0; padding: 0; }
|
||||||
|
.flow-steps li { font-size: .75rem; padding: 1px 7px; border-radius: 999px; border: 1px solid var(--line); color: var(--muted); }
|
||||||
|
.flow-steps .flow-ban { color: var(--error); border-color: color-mix(in srgb, var(--error) 50%, transparent); }
|
||||||
|
.flow-steps .flow-pick { color: var(--ok); border-color: color-mix(in srgb, var(--ok) 50%, transparent); }
|
||||||
|
.flow-steps .flow-decider { color: var(--text); border-color: var(--primary); }
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Richtet einen Docker-Host für CS2 Mapveto ein. Läuft vom eigenen Rechner aus per SSH.
|
||||||
|
#
|
||||||
|
# Aufruf: bash deploy/docker-host-einrichten.sh <ssh-ziel> [volume]
|
||||||
|
# Beispiel: bash deploy/docker-host-einrichten.sh cs2@192.168.178.111
|
||||||
|
#
|
||||||
|
# Voraussetzungen: SSH-Schlüssel hinterlegt (ohne Passwortabfrage), Docker auf dem Host installiert.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
TARGET="${1:?Aufruf: $0 <ssh-ziel> [volume]}"
|
||||||
|
VOLUME="${2:-cs2-mapveto-gamefiles}"
|
||||||
|
IMAGE="joedwards32/cs2:latest"
|
||||||
|
HOST_PART="${TARGET#*@}"
|
||||||
|
|
||||||
|
echo "→ Verbindung zu $TARGET prüfen"
|
||||||
|
ssh -o BatchMode=yes -o ConnectTimeout=10 "$TARGET" true
|
||||||
|
|
||||||
|
echo "→ Docker prüfen"
|
||||||
|
if ! ssh "$TARGET" "command -v docker >/dev/null 2>&1"; then
|
||||||
|
echo "Docker fehlt auf $HOST_PART. Installation: https://docs.docker.com/engine/install/" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
ssh "$TARGET" "docker info >/dev/null"
|
||||||
|
|
||||||
|
echo "→ Freier Platz für Docker (CS2 braucht rund 90 GB)"
|
||||||
|
ssh "$TARGET" "df -BG /var/lib/docker 2>/dev/null | tail -1 || df -BG / | tail -1"
|
||||||
|
|
||||||
|
echo "→ Volume $VOLUME anlegen (falls nicht vorhanden)"
|
||||||
|
ssh "$TARGET" "docker volume inspect $VOLUME >/dev/null 2>&1 || docker volume create $VOLUME >/dev/null"
|
||||||
|
|
||||||
|
echo "→ Image $IMAGE laden"
|
||||||
|
ssh "$TARGET" "docker pull $IMAGE"
|
||||||
|
|
||||||
|
cat <<EOF
|
||||||
|
|
||||||
|
Fertig. Diesen Eintrag in CS2_DOCKER_HOSTS (JSON-Liste in der .env) aufnehmen:
|
||||||
|
|
||||||
|
{"name":"$HOST_PART","dockerHost":"ssh://$TARGET","publicHost":"$HOST_PART","portStart":27100,"portEnd":27139}
|
||||||
|
|
||||||
|
Hinweise:
|
||||||
|
- name muss pro Host eindeutig sein.
|
||||||
|
- Frei sein müssen: 27015–27020 (LAN-Reiter von Steam), 27100–27139 und jeweils 100 darüber für CSTV
|
||||||
|
(27115–27120, 27200–27239). Pro Host sind bis zu 40 Matches möglich, 6 davon im LAN-Reiter.
|
||||||
|
- Der erste Server-Start lädt die CS2-Spieldateien (rund 60 bis 90 GB). Das dauert lange.
|
||||||
|
EOF
|
||||||
Binary file not shown.
+137
-17
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"$comment": "Map-Pools und Veto-Abläufe. Schritte: ban:X, pick:X, side:X, decider. X = erstes Team (startet das Veto), Y = zweites Team. 'side' folgt immer auf einen Pick bzw. auf 'decider' und bezieht sich auf diese Map. Ungültige Abläufe werden beim Start gemeldet und das Format ist dann nicht auswählbar. Optional pro Map: image = Bild-URL oder Pfad (z. B. /maps/de_dust2.jpg aus client/public). Standardbilder: github.com/ghostcap-gaming/cs2-map-images (Bilder aus dem Spiel, Rechte bei Valve).",
|
"$comment": "Map-Pools und Veto-Abläufe. Schritte: ban:X, pick:X, side:X, decider. X = erstes Team (startet das Veto), Y = zweites Team. 'side' folgt immer auf einen Pick bzw. auf 'decider' und bezieht sich auf diese Map. Welche Maps gespielt werden, wählt der Admin im Admin-Bereich (Einstellungen → Map-Pools); 'active': false = nur im Katalog. Die hier hinterlegten Abläufe gelten, wenn sie zur Anzahl der aktiven Maps passen, sonst erzeugt die Anwendung einen passenden Ablauf. Optional pro Map: image = Bild-URL oder Pfad (z. B. /maps/de_dust2.jpg aus client/public). Standardbilder: github.com/ghostcap-gaming/cs2-map-images (Bilder aus dem Spiel, Rechte bei Valve).",
|
||||||
"pools": {
|
"pools": {
|
||||||
"competitive": {
|
"competitive": {
|
||||||
"label": "Competitive 5 gegen 5",
|
"label": "Competitive 5 gegen 5",
|
||||||
@@ -7,18 +7,104 @@
|
|||||||
"gameType": 0,
|
"gameType": 0,
|
||||||
"gameMode": 1,
|
"gameMode": 1,
|
||||||
"maps": [
|
"maps": [
|
||||||
{ "id": "de_ancient", "name": "Ancient", "color": "#3f8f5a", "image": "/maps/de_ancient.webp" },
|
{
|
||||||
{ "id": "de_anubis", "name": "Anubis", "color": "#c79a3d", "image": "/maps/de_anubis.webp" },
|
"id": "de_ancient",
|
||||||
{ "id": "de_dust2", "name": "Dust II", "color": "#d3a45c", "image": "/maps/de_dust2.webp" },
|
"name": "Ancient",
|
||||||
{ "id": "de_inferno", "name": "Inferno", "color": "#c4573a", "image": "/maps/de_inferno.webp" },
|
"color": "#3f8f5a",
|
||||||
{ "id": "de_mirage", "name": "Mirage", "color": "#b98a4a", "image": "/maps/de_mirage.webp" },
|
"image": "/maps/de_ancient.webp"
|
||||||
{ "id": "de_nuke", "name": "Nuke", "color": "#4a8fbf", "image": "/maps/de_nuke.webp" },
|
},
|
||||||
{ "id": "de_train", "name": "Train", "color": "#7a7f8a", "image": "/maps/de_train.webp" }
|
{
|
||||||
|
"id": "de_anubis",
|
||||||
|
"name": "Anubis",
|
||||||
|
"color": "#c79a3d",
|
||||||
|
"image": "/maps/de_anubis.webp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "de_cache",
|
||||||
|
"name": "Cache",
|
||||||
|
"color": "#6f8a4a",
|
||||||
|
"image": "/maps/de_cache.webp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "de_dust2",
|
||||||
|
"name": "Dust II",
|
||||||
|
"color": "#d3a45c",
|
||||||
|
"image": "/maps/de_dust2.webp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "de_inferno",
|
||||||
|
"name": "Inferno",
|
||||||
|
"color": "#c4573a",
|
||||||
|
"image": "/maps/de_inferno.webp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "de_mirage",
|
||||||
|
"name": "Mirage",
|
||||||
|
"color": "#b98a4a",
|
||||||
|
"image": "/maps/de_mirage.webp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "de_nuke",
|
||||||
|
"name": "Nuke",
|
||||||
|
"color": "#4a8fbf",
|
||||||
|
"image": "/maps/de_nuke.webp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "de_overpass",
|
||||||
|
"name": "Overpass",
|
||||||
|
"color": "#5f8f7a",
|
||||||
|
"image": "/maps/de_overpass.webp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "de_train",
|
||||||
|
"name": "Train",
|
||||||
|
"color": "#7a7f8a",
|
||||||
|
"image": "/maps/de_train.webp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "de_vertigo",
|
||||||
|
"name": "Vertigo",
|
||||||
|
"color": "#8a74b8",
|
||||||
|
"image": "/maps/de_vertigo.webp"
|
||||||
|
}
|
||||||
],
|
],
|
||||||
"flows": {
|
"flows": {
|
||||||
"bo1": ["ban:X", "ban:Y", "ban:X", "ban:Y", "ban:X", "ban:Y", "decider", "side:X"],
|
"bo1": [
|
||||||
"bo3": ["ban:X", "ban:Y", "pick:X", "side:Y", "pick:Y", "side:X", "ban:X", "ban:Y", "decider", "side:X"],
|
"ban:X",
|
||||||
"bo5": ["ban:X", "ban:Y", "pick:X", "side:Y", "pick:Y", "side:X", "pick:X", "side:Y", "pick:Y", "side:X", "decider", "side:Y"]
|
"ban:Y",
|
||||||
|
"ban:X",
|
||||||
|
"ban:Y",
|
||||||
|
"ban:X",
|
||||||
|
"ban:Y",
|
||||||
|
"decider",
|
||||||
|
"side:X"
|
||||||
|
],
|
||||||
|
"bo3": [
|
||||||
|
"ban:X",
|
||||||
|
"ban:Y",
|
||||||
|
"pick:X",
|
||||||
|
"side:Y",
|
||||||
|
"pick:Y",
|
||||||
|
"side:X",
|
||||||
|
"ban:X",
|
||||||
|
"ban:Y",
|
||||||
|
"decider",
|
||||||
|
"side:X"
|
||||||
|
],
|
||||||
|
"bo5": [
|
||||||
|
"ban:X",
|
||||||
|
"ban:Y",
|
||||||
|
"pick:X",
|
||||||
|
"side:Y",
|
||||||
|
"pick:Y",
|
||||||
|
"side:X",
|
||||||
|
"pick:X",
|
||||||
|
"side:Y",
|
||||||
|
"pick:Y",
|
||||||
|
"side:X",
|
||||||
|
"decider",
|
||||||
|
"side:Y"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"wingman": {
|
"wingman": {
|
||||||
@@ -27,14 +113,48 @@
|
|||||||
"gameType": 0,
|
"gameType": 0,
|
||||||
"gameMode": 2,
|
"gameMode": 2,
|
||||||
"maps": [
|
"maps": [
|
||||||
{ "id": "de_inferno", "name": "Inferno", "color": "#c4573a", "image": "/maps/de_inferno.webp" },
|
{
|
||||||
{ "id": "de_nuke", "name": "Nuke", "color": "#4a8fbf", "image": "/maps/de_nuke.webp" },
|
"id": "de_inferno",
|
||||||
{ "id": "de_overpass", "name": "Overpass", "color": "#5f8f7a", "image": "/maps/de_overpass.webp" },
|
"name": "Inferno",
|
||||||
{ "id": "de_vertigo", "name": "Vertigo", "color": "#8a74b8", "image": "/maps/de_vertigo.webp" }
|
"color": "#c4573a",
|
||||||
|
"image": "/maps/de_inferno.webp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "de_nuke",
|
||||||
|
"name": "Nuke",
|
||||||
|
"color": "#4a8fbf",
|
||||||
|
"image": "/maps/de_nuke.webp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "de_overpass",
|
||||||
|
"name": "Overpass",
|
||||||
|
"color": "#5f8f7a",
|
||||||
|
"image": "/maps/de_overpass.webp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "de_vertigo",
|
||||||
|
"name": "Vertigo",
|
||||||
|
"color": "#8a74b8",
|
||||||
|
"image": "/maps/de_vertigo.webp"
|
||||||
|
}
|
||||||
],
|
],
|
||||||
"flows": {
|
"flows": {
|
||||||
"bo1": ["ban:X", "ban:Y", "ban:X", "decider", "side:Y"],
|
"bo1": [
|
||||||
"bo3": ["ban:X", "pick:Y", "side:X", "pick:X", "side:Y", "decider", "side:X"]
|
"ban:X",
|
||||||
|
"ban:Y",
|
||||||
|
"ban:X",
|
||||||
|
"decider",
|
||||||
|
"side:Y"
|
||||||
|
],
|
||||||
|
"bo3": [
|
||||||
|
"ban:X",
|
||||||
|
"pick:Y",
|
||||||
|
"side:X",
|
||||||
|
"pick:X",
|
||||||
|
"side:Y",
|
||||||
|
"decider",
|
||||||
|
"side:X"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+104
-4
@@ -1,4 +1,5 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
|
import { formatPortList } from './config.js';
|
||||||
import { HttpError } from './matchService.js';
|
import { HttpError } from './matchService.js';
|
||||||
import { createRateLimiter, randomToken, safeEqual } from './security.js';
|
import { createRateLimiter, randomToken, safeEqual } from './security.js';
|
||||||
|
|
||||||
@@ -14,7 +15,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 }) {
|
export function createAdminRouter({ service, manager, config, settings, knife, pools }) {
|
||||||
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,10 +77,105 @@ export function createAdminRouter({ service, manager, config }) {
|
|||||||
|
|
||||||
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,
|
||||||
|
lanPorts: formatPortList(h.lanPorts),
|
||||||
|
})),
|
||||||
|
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();
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Map-Pools: welche Maps gespielt werden. Der Veto-Ablauf passt sich der Anzahl an.
|
||||||
|
const poolView = () => ({
|
||||||
|
pools: Object.values(pools).map((p) => ({
|
||||||
|
mode: p.mode,
|
||||||
|
label: p.label,
|
||||||
|
maps: p.allMaps.map((m) => ({ ...m, active: p.maps.some((a) => a.id === m.id) })),
|
||||||
|
formats: p.formats,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
router.get('/map-pools', requireAdmin, wrap(() => poolView()));
|
||||||
|
router.put('/map-pools', requireAdmin, wrap((req) => {
|
||||||
|
settings.saveMapSelection(pools, req.body?.mode, req.body?.maps);
|
||||||
|
return poolView();
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Messer-Server für die Seitenwahl
|
||||||
|
router.get('/knife', requireAdmin, wrap(() => ({
|
||||||
|
servers: knife.list(),
|
||||||
|
modes: knife.modes(),
|
||||||
|
maxServers: config.knife.maxServers,
|
||||||
|
})));
|
||||||
|
router.post('/knife', requireAdmin, wrap((req) => knife.create({ map: req.body?.map, mode: req.body?.mode })));
|
||||||
|
router.post('/knife/:id/reset', requireAdmin, wrap((req) => knife.reset(req.params.id)));
|
||||||
|
router.post('/knife/:id/round', requireAdmin, wrap((req) => knife.startRound(req.params.id)));
|
||||||
|
router.post('/knife/:id/restart', requireAdmin, wrap((req) => knife.restart(req.params.id)));
|
||||||
|
router.delete('/knife/:id', requireAdmin, wrap((req) => knife.remove(req.params.id)));
|
||||||
|
|
||||||
|
router.get('/notifications', requireAdmin, wrap(() => service.notificationSettings()));
|
||||||
|
router.put('/notifications', requireAdmin, wrap((req) => service.setNotificationEvents(req.body?.events)));
|
||||||
|
router.post('/notifications/test', requireAdmin, wrap(async () => {
|
||||||
|
if (!service.notifier.enabled) throw new HttpError(409, 'Kein Discord-Webhook eingerichtet (DISCORD_WEBHOOK_URL)', 'not_configured');
|
||||||
|
const test = { embeds: [{ title: '🧪 Testmeldung', description: 'Die Verbindung zu CS2 Mapveto funktioniert.', color: 0x3b82f6 }] };
|
||||||
|
if (!(await service.notifier.send(test))) {
|
||||||
|
throw new HttpError(502, 'Discord hat die Meldung nicht angenommen (siehe Log)', 'send_failed');
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
}));
|
||||||
|
|
||||||
// Alle Matches mit allen Details (Codes, Verbindungsdaten, Spieler, Veto, Serie, Ereignisse)
|
// Alle Matches mit allen Details (Codes, Verbindungsdaten, Spieler, Veto, Serie, Ereignisse)
|
||||||
router.get('/matches', requireAdmin, wrap(() => {
|
// ?archived=1 liefert das Archiv, sonst alle nicht archivierten Matches
|
||||||
const ids = service.db.prepare('SELECT id FROM matches ORDER BY created_at DESC LIMIT 100').all();
|
router.get('/matches', requireAdmin, wrap(async (req) => {
|
||||||
return { matches: ids.map(({ id }) => service.snapshot(id, adminViewer(id))) };
|
const archived = req.query.archived === '1';
|
||||||
|
const ids = service.db.prepare(archived
|
||||||
|
? 'SELECT id FROM matches WHERE archived_at IS NOT NULL ORDER BY archived_at DESC LIMIT 300'
|
||||||
|
: 'SELECT id FROM matches WHERE archived_at IS NULL ORDER BY created_at DESC LIMIT 150').all();
|
||||||
|
const archivedCount = service.db.prepare('SELECT COUNT(*) AS n FROM matches WHERE archived_at IS NOT NULL').get().n;
|
||||||
|
return {
|
||||||
|
matches: ids.map(({ id }) => service.snapshot(id, adminViewer(id))),
|
||||||
|
archivedCount,
|
||||||
|
hosts: await manager.hostUsage(),
|
||||||
|
};
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const action = (path, fn) => router.post(`/matches/:id/${path}`, requireAdmin, wrap((req) => {
|
const action = (path, fn) => router.post(`/matches/:id/${path}`, requireAdmin, wrap((req) => {
|
||||||
@@ -92,6 +188,10 @@ export function createAdminRouter({ service, manager, config }) {
|
|||||||
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('archive', (v) => service.setArchived(v.matchId, true));
|
||||||
|
action('unarchive', (v) => service.setArchived(v.matchId, false));
|
||||||
|
action('server/pause', (v) => manager.setPaused(v, true));
|
||||||
|
action('server/resume', (v) => manager.setPaused(v, false));
|
||||||
action('series/next', (v) => manager.loadNextMap(v));
|
action('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(
|
||||||
|
|||||||
+20
-4
@@ -5,22 +5,35 @@ 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 { KnifeServers } from './servers/knifeServers.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 service = new MatchService({ db, pools, config });
|
// Im Admin-Bereich gewählte Maps (Einstellungen → Map-Pools)
|
||||||
|
settings.applyMapSelection(pools);
|
||||||
|
const notifier = createDiscordNotifier(config.discordWebhookUrl, { logger });
|
||||||
|
const service = new MatchService({ db, pools, config, notifier });
|
||||||
|
|
||||||
let manager;
|
let manager;
|
||||||
const adapter = adapterOverride ?? createServerAdapter(config, { getUsedPorts: () => manager.usedPorts() });
|
const adapter = adapterOverride ?? createServerAdapter(config, {
|
||||||
|
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();
|
||||||
|
const knife = new KnifeServers({ db, adapter, config, pools, logger });
|
||||||
|
service.startVetoTimer();
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
if (config.trustProxy) app.set('trust proxy', 1);
|
if (config.trustProxy) app.set('trust proxy', 1);
|
||||||
@@ -30,7 +43,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 }));
|
app.use('/api', createApiRouter({ service, manager, config, pools, adapter, settings, knife }));
|
||||||
|
|
||||||
// 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');
|
||||||
@@ -43,15 +56,18 @@ export function createApp(config, { adapter: adapterOverride, logger = console }
|
|||||||
const io = attachRealtime(server, { service, config });
|
const io = attachRealtime(server, { service, config });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
app, server, io, db, service, manager, adapter,
|
app, server, io, db, service, manager, adapter, knife,
|
||||||
async start() {
|
async start() {
|
||||||
await manager.resume();
|
await manager.resume();
|
||||||
manager.startLoops();
|
manager.startLoops();
|
||||||
|
knife.startLoop();
|
||||||
await new Promise((resolve) => server.listen(config.port, resolve));
|
await new Promise((resolve) => server.listen(config.port, resolve));
|
||||||
return server.address().port;
|
return server.address().port;
|
||||||
},
|
},
|
||||||
async close() {
|
async close() {
|
||||||
manager.stopLoops();
|
manager.stopLoops();
|
||||||
|
knife.stopLoop();
|
||||||
|
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();
|
||||||
|
|||||||
@@ -22,6 +22,61 @@ function bool(name, fallback) {
|
|||||||
return ['1', 'true', 'yes', 'ja', 'on'].includes(raw.toLowerCase());
|
return ['1', 'true', 'yes', 'ja', 'on'].includes(raw.toLowerCase());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Portliste aus Text wie "27015-27020" oder "27016,27018-27019". Leer = keine Ports. */
|
||||||
|
export function parsePortList(value, label = 'LAN-Ports') {
|
||||||
|
if (Array.isArray(value)) value = value.join(',');
|
||||||
|
const ports = [];
|
||||||
|
for (const part of String(value ?? '').split(',').map((p) => p.trim()).filter(Boolean)) {
|
||||||
|
const m = /^(\d+)(?:\s*-\s*(\d+))?$/.exec(part);
|
||||||
|
if (!m) throw new Error(`${label}: "${part}" ist kein Port oder Bereich`);
|
||||||
|
const from = Number(m[1]);
|
||||||
|
const to = Number(m[2] ?? m[1]);
|
||||||
|
if (from < 1024 || to > 65535 || to < from || to - from > 100) throw new Error(`${label}: Bereich ${part} ist ungültig`);
|
||||||
|
for (let p = from; p <= to; p += 1) if (!ports.includes(p)) ports.push(p);
|
||||||
|
}
|
||||||
|
return ports;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Portliste kompakt als Text, z. B. [27015, 27016, 27017] -> "27015-27017". */
|
||||||
|
export function formatPortList(ports = []) {
|
||||||
|
const sorted = [...ports].sort((a, b) => a - b);
|
||||||
|
const parts = [];
|
||||||
|
for (let i = 0; i < sorted.length; i += 1) {
|
||||||
|
let j = i;
|
||||||
|
while (j + 1 < sorted.length && sorted[j + 1] === sorted[j] + 1) j += 1;
|
||||||
|
parts.push(i === j ? String(sorted[i]) : `${sorted[i]}-${sorted[j]}`);
|
||||||
|
i = j;
|
||||||
|
}
|
||||||
|
return parts.join(',');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseDockerHosts(raw) {
|
||||||
|
if (!raw) return null;
|
||||||
|
let list;
|
||||||
|
try {
|
||||||
|
list = JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
throw new Error('CS2_DOCKER_HOSTS ist kein gültiges JSON');
|
||||||
|
}
|
||||||
|
if (!Array.isArray(list) || list.length === 0) throw new Error('CS2_DOCKER_HOSTS braucht mindestens einen Eintrag');
|
||||||
|
const names = new Set();
|
||||||
|
return list.map((h) => {
|
||||||
|
if (!h.name || !h.publicHost) throw new Error('Jeder Docker-Host braucht name und publicHost');
|
||||||
|
if (names.has(h.name)) throw new Error(`Docker-Host-Name "${h.name}" kommt doppelt vor`);
|
||||||
|
names.add(h.name);
|
||||||
|
return {
|
||||||
|
name: String(h.name),
|
||||||
|
dockerHost: h.dockerHost ?? null,
|
||||||
|
publicHost: String(h.publicHost),
|
||||||
|
rconHost: h.rconHost ?? h.publicHost,
|
||||||
|
portRangeStart: h.portStart == null ? undefined : Number(h.portStart),
|
||||||
|
portRangeEnd: h.portEnd == null ? undefined : Number(h.portEnd),
|
||||||
|
volume: h.volume ?? undefined,
|
||||||
|
lanPorts: h.lanPorts === undefined ? undefined : parsePortList(h.lanPorts, `LAN-Ports von ${h.name}`),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function str(name, fallback = '') {
|
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;
|
||||||
@@ -41,6 +96,7 @@ 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),
|
||||||
@@ -51,6 +107,17 @@ 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),
|
||||||
|
// Messer-Server für die Seitenwahl (im Admin-Bereich startbar)
|
||||||
|
knife: {
|
||||||
|
map: str('KNIFE_MAP', 'de_mirage'),
|
||||||
|
// Zeit zum Verbinden, bevor die Messerrunde beginnt
|
||||||
|
warmupSeconds: int('KNIFE_WARMUP_SECONDS', 90),
|
||||||
|
maxServers: int('KNIFE_MAX_SERVERS', 4),
|
||||||
|
},
|
||||||
|
// Abgeschlossene oder abgebrochene Matches nach so vielen Tagen automatisch archivieren. 0 = nie
|
||||||
|
archiveAfterDays: int('ARCHIVE_AFTER_DAYS', 3),
|
||||||
|
|
||||||
match: {
|
match: {
|
||||||
tvDelaySeconds: int('CS2_TV_DELAY', 90),
|
tvDelaySeconds: int('CS2_TV_DELAY', 90),
|
||||||
@@ -82,6 +149,16 @@ 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),
|
||||||
|
// Spielports, die der Steam-Serverbrowser im LAN-Reiter absucht (Steam fragt nur 27015–27020 ab).
|
||||||
|
// Diese Ports werden zuerst vergeben, CSTV kommt dann aus dem normalen Portbereich. Leer = aus.
|
||||||
|
lanPorts: parsePortList(str('CS2_LAN_PORTS', '27015-27020'), 'CS2_LAN_PORTS'),
|
||||||
|
// Abstand des CSTV-Ports zum Spielport, z. B. Spiel 27015 -> CSTV 27115
|
||||||
|
tvPortOffset: int('CS2_TV_PORT_OFFSET', 100),
|
||||||
|
// Anfang des Servernamens im Serverbrowser, danach folgen die Teamnamen
|
||||||
|
serverNamePrefix: str('CS2_SERVERNAME_PREFIX', 'MaxLAN'),
|
||||||
|
// Optional: mehrere Docker-Hosts als JSON-Liste, z. B.
|
||||||
|
// [{"name":"host1","dockerHost":"ssh://cs2@192.168.178.111","publicHost":"192.168.178.111","portStart":27100,"portEnd":27139}]
|
||||||
|
hosts: parseDockerHosts(str('CS2_DOCKER_HOSTS')),
|
||||||
},
|
},
|
||||||
|
|
||||||
staticPool: {
|
staticPool: {
|
||||||
|
|||||||
@@ -97,6 +97,31 @@ 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);
|
||||||
|
-- Messer-Server für die Seitenwahl: laufen dauerhaft, gehören keinem Match
|
||||||
|
CREATE TABLE IF NOT EXISTS knife_servers (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
number INTEGER NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
mode TEXT NOT NULL DEFAULT 'competitive',
|
||||||
|
map TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
message TEXT,
|
||||||
|
handle TEXT,
|
||||||
|
host TEXT,
|
||||||
|
port INTEGER,
|
||||||
|
tv_port INTEGER,
|
||||||
|
rcon_password TEXT NOT NULL,
|
||||||
|
player_count INTEGER,
|
||||||
|
restarts INTEGER NOT NULL DEFAULT 0,
|
||||||
|
started_at INTEGER,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL
|
||||||
|
);
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export function openDatabase(file) {
|
export function openDatabase(file) {
|
||||||
@@ -124,4 +149,9 @@ 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');
|
||||||
|
// Abgeschlossene Matches lassen sich archivieren; sie verschwinden dann aus der normalen Admin-Liste
|
||||||
|
if (!columns('matches').has('archived_at')) db.exec('ALTER TABLE matches ADD COLUMN archived_at INTEGER');
|
||||||
|
if (!columns('knife_servers').has('mode')) db.exec("ALTER TABLE knife_servers ADD COLUMN mode TEXT NOT NULL DEFAULT 'competitive'");
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
// Meldungen an einen Discord-Webhook, z. B. für Streamer. Fehler werden nur protokolliert
|
||||||
|
// und bremsen den Matchablauf nie aus.
|
||||||
|
|
||||||
|
const WEBHOOK_PATTERN = /^https:\/\/(discord\.com|discordapp\.com)\/api\/webhooks\/\d+\/[\w-]+$/;
|
||||||
|
const TIMEOUT_MS = 5000;
|
||||||
|
const MAX_LENGTH = 1900;
|
||||||
|
|
||||||
|
/** Ereignisse, die gemeldet werden können. Alle sind standardmäßig aktiv. */
|
||||||
|
export const NOTIFY_EVENTS = [
|
||||||
|
{ key: 'veto_start', label: 'Veto gestartet' },
|
||||||
|
{ key: 'match_start', label: 'Match gestartet (Aufwärmphase beendet)' },
|
||||||
|
{ key: 'map_end', label: 'Map beendet (mit Ergebnis)' },
|
||||||
|
{ key: 'series_end', label: 'Serie entschieden' },
|
||||||
|
{ key: 'match_closed', label: 'Match abgeschlossen oder abgebrochen' },
|
||||||
|
{ key: 'server_error', label: 'Serverfehler' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} url Webhook-URL aus DISCORD_WEBHOOK_URL, leer = keine Meldungen
|
||||||
|
* @param {object} options fetchImpl und logger, vor allem für Tests
|
||||||
|
*/
|
||||||
|
export function createDiscordNotifier(url, { fetchImpl = fetch, logger = console } = {}) {
|
||||||
|
if (!url) return { enabled: false, send: async () => false };
|
||||||
|
// Die URL enthält ein Geheimnis. Sie wird deshalb nie in Fehlermeldungen ausgegeben.
|
||||||
|
if (!WEBHOOK_PATTERN.test(url)) throw new Error('DISCORD_WEBHOOK_URL ist keine gültige Discord-Webhook-URL');
|
||||||
|
|
||||||
|
return {
|
||||||
|
enabled: true,
|
||||||
|
/** Liefert true, wenn Discord die Nachricht angenommen hat. */
|
||||||
|
async send(message) {
|
||||||
|
// Text oder vollständige Discord-Payload (z. B. mit embeds)
|
||||||
|
const payload = typeof message === 'string' ? { content: message.slice(0, MAX_LENGTH) } : message;
|
||||||
|
try {
|
||||||
|
const res = await fetchImpl(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
signal: AbortSignal.timeout(TIMEOUT_MS),
|
||||||
|
});
|
||||||
|
if (res.ok) return true;
|
||||||
|
logger.warn(`Discord-Webhook antwortet mit HTTP ${res.status}`);
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn(`Discord-Meldung fehlgeschlagen: ${err.name}`);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
+5
-2
@@ -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 }) {
|
export function createApiRouter({ service, manager, config, pools, adapter, settings, knife }) {
|
||||||
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 }) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
router.use(express.json({ limit: '32kb' }));
|
router.use(express.json({ limit: '32kb' }));
|
||||||
router.use('/admin', createAdminRouter({ service, manager, config }));
|
router.use('/admin', createAdminRouter({ service, manager, config, settings, knife, pools }));
|
||||||
|
|
||||||
const limiters = {
|
const limiters = {
|
||||||
create: createRateLimiter({ windowMs: 60_000, max: 10 }),
|
create: createRateLimiter({ windowMs: 60_000, max: 10 }),
|
||||||
@@ -71,6 +71,9 @@ export function createApiRouter({ service, manager, config, pools, adapter }) {
|
|||||||
|
|
||||||
router.get('/matches', wrap(() => ({ matches: service.listMatches() })));
|
router.get('/matches', wrap(() => ({ matches: service.listMatches() })));
|
||||||
|
|
||||||
|
// Messer-Server für die Seitenwahl: offen, ohne Passwort, deshalb für alle sichtbar
|
||||||
|
router.get('/knife-servers', wrap(() => ({ servers: knife.list().filter((k) => k.status === 'running') })));
|
||||||
|
|
||||||
// Stream-Übersicht aller Matches; mit STREAM_KEY nur mit passendem ?key=
|
// Stream-Übersicht aller Matches; mit STREAM_KEY nur mit passendem ?key=
|
||||||
router.get('/stream/overview', wrap((req) => {
|
router.get('/stream/overview', wrap((req) => {
|
||||||
if (config.streamKey && !safeEqual(String(req.query.key ?? ''), config.streamKey)) {
|
if (config.streamKey && !safeEqual(String(req.query.key ?? ''), config.streamKey)) {
|
||||||
|
|||||||
+40
-16
@@ -1,5 +1,5 @@
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import { BEST_OF, validateFlow } from './veto.js';
|
import { BEST_OF, generateFlow, validateFlow } from './veto.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lädt die Map-Pools und markiert jede Kombination aus Modus und Format als unterstützt oder nicht.
|
* Lädt die Map-Pools und markiert jede Kombination aus Modus und Format als unterstützt oder nicht.
|
||||||
@@ -22,32 +22,56 @@ export function buildMapPools(raw) {
|
|||||||
}
|
}
|
||||||
ids.add(map.id);
|
ids.add(map.id);
|
||||||
}
|
}
|
||||||
const formats = {};
|
// "active": false = Map ist im Katalog, wird aber erst nach Auswahl im Admin-Bereich gespielt
|
||||||
for (const format of Object.keys(BEST_OF)) {
|
const active = pool.maps.filter((m) => m.active !== false).map((m) => m.id);
|
||||||
const flow = pool.flows?.[format];
|
|
||||||
if (!flow) {
|
|
||||||
formats[format] = { supported: false, reason: 'Kein Veto-Ablauf für dieses Format hinterlegt' };
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const errors = validateFlow(flow, pool.maps.length, format);
|
|
||||||
formats[format] = errors.length
|
|
||||||
? { supported: false, reason: errors.join('; ') }
|
|
||||||
: { supported: true, flow };
|
|
||||||
if (errors.length) console.warn(`[map-pools] ${mode}/${format} deaktiviert: ${errors.join('; ')}`);
|
|
||||||
}
|
|
||||||
pools[mode] = {
|
pools[mode] = {
|
||||||
mode,
|
mode,
|
||||||
label: pool.label ?? mode,
|
label: pool.label ?? mode,
|
||||||
teamSize: pool.teamSize,
|
teamSize: pool.teamSize,
|
||||||
gameType: pool.gameType,
|
gameType: pool.gameType,
|
||||||
gameMode: pool.gameMode,
|
gameMode: pool.gameMode,
|
||||||
maps: pool.maps,
|
allMaps: pool.maps.map(({ active: _ignored, ...m }) => m),
|
||||||
formats,
|
flows: pool.flows ?? {},
|
||||||
|
maps: [],
|
||||||
|
formats: {},
|
||||||
};
|
};
|
||||||
|
setActiveMaps(pools[mode], active, { warn: true });
|
||||||
}
|
}
|
||||||
return pools;
|
return pools;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Veto-Abläufe für die aktiven Maps: Ein in der Konfiguration hinterlegter Ablauf gilt, wenn er zur Map-Anzahl passt,
|
||||||
|
* sonst erzeugt die Anwendung einen passenden Ablauf.
|
||||||
|
*/
|
||||||
|
export function formatsFor(pool, mapCount, { warn = false } = {}) {
|
||||||
|
const formats = {};
|
||||||
|
for (const format of Object.keys(BEST_OF)) {
|
||||||
|
const configured = pool.flows?.[format];
|
||||||
|
if (configured && validateFlow(configured, mapCount, format).length === 0) {
|
||||||
|
formats[format] = { supported: true, flow: configured, generated: false };
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const flow = generateFlow(mapCount, format);
|
||||||
|
if (!flow) {
|
||||||
|
formats[format] = { supported: false, reason: `${format.toUpperCase()} braucht mindestens ${Math.max(BEST_OF[format], 2)} Maps` };
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const errors = validateFlow(flow, mapCount, format);
|
||||||
|
formats[format] = errors.length ? { supported: false, reason: errors.join('; ') } : { supported: true, flow, generated: true };
|
||||||
|
if (errors.length && warn) console.warn(`[map-pools] ${pool.mode}/${format} deaktiviert: ${errors.join('; ')}`);
|
||||||
|
}
|
||||||
|
return formats;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Aktive Maps eines Pools setzen (Reihenfolge wie im Katalog) und die Formate neu berechnen. */
|
||||||
|
export function setActiveMaps(pool, ids, options) {
|
||||||
|
const wanted = new Set(ids);
|
||||||
|
pool.maps = pool.allMaps.filter((m) => wanted.has(m.id));
|
||||||
|
pool.formats = formatsFor(pool, pool.maps.length, options);
|
||||||
|
return pool;
|
||||||
|
}
|
||||||
|
|
||||||
export function publicPools(pools) {
|
export function publicPools(pools) {
|
||||||
return Object.values(pools).map((p) => ({
|
return Object.values(pools).map((p) => ({
|
||||||
mode: p.mode,
|
mode: p.mode,
|
||||||
|
|||||||
+177
-3
@@ -4,6 +4,7 @@ import { formatCode, hashToken, normalizeCode, randomCode, randomId, randomToken
|
|||||||
import { applyLogLines, initialLiveState, seriesScore, splitLogBody } from './liveScore.js';
|
import { applyLogLines, initialLiveState, seriesScore, splitLogBody } from './liveScore.js';
|
||||||
import { trimLiveState } from './rounds.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') {
|
||||||
@@ -41,11 +42,12 @@ function cleanSteamId(value) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class MatchService extends EventEmitter {
|
export class MatchService extends EventEmitter {
|
||||||
constructor({ db, pools, config }) {
|
constructor({ db, pools, config, notifier = { enabled: false, send: async () => {} } }) {
|
||||||
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();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -339,10 +341,68 @@ 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;
|
||||||
@@ -516,6 +576,16 @@ 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) {
|
||||||
@@ -524,6 +594,18 @@ 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 });
|
||||||
@@ -543,6 +625,34 @@ export class MatchService extends EventEmitter {
|
|||||||
.run(matchId, match.current_map_number);
|
.run(matchId, match.current_map_number);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Abgeschlossenes Match ins Archiv legen bzw. zurückholen. */
|
||||||
|
setArchived(matchId, archived) {
|
||||||
|
const match = this.getMatch(matchId);
|
||||||
|
if (archived && !CLOSED_STATUSES.includes(match.status)) {
|
||||||
|
throw new HttpError(409, 'Nur abgeschlossene oder abgebrochene Matches lassen sich archivieren', 'not_closed');
|
||||||
|
}
|
||||||
|
this.db.prepare('UPDATE matches SET archived_at = ? WHERE id = ?').run(archived ? this.now() : null, matchId);
|
||||||
|
this.log(matchId, 'status', archived ? 'Match archiviert (Admin)' : 'Match aus dem Archiv geholt (Admin)');
|
||||||
|
this.changed(matchId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Abgeschlossene Matches nach `archiveAfterDays` Tagen automatisch archivieren. Liefert die Anzahl. */
|
||||||
|
archiveClosed(now = this.now()) {
|
||||||
|
const days = this.config.archiveAfterDays;
|
||||||
|
if (!days) return 0;
|
||||||
|
const cutoff = now - days * 86_400_000;
|
||||||
|
const ids = this.db.prepare(`
|
||||||
|
SELECT id FROM matches
|
||||||
|
WHERE archived_at IS NULL AND status IN ('finished', 'cancelled') AND COALESCE(finished_at, updated_at) < ?
|
||||||
|
`).all(cutoff).map((r) => r.id);
|
||||||
|
const stmt = this.db.prepare('UPDATE matches SET archived_at = ? WHERE id = ?');
|
||||||
|
for (const id of ids) {
|
||||||
|
stmt.run(now, id);
|
||||||
|
this.log(id, 'status', `Match automatisch archiviert (nach ${days} Tagen)`);
|
||||||
|
}
|
||||||
|
return ids.length;
|
||||||
|
}
|
||||||
|
|
||||||
close(viewer, status) {
|
close(viewer, status) {
|
||||||
this.require(viewer, 'organizer');
|
this.require(viewer, 'organizer');
|
||||||
this.closeMatch(viewer.matchId, status);
|
this.closeMatch(viewer.matchId, status);
|
||||||
@@ -555,6 +665,12 @@ 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);
|
||||||
}
|
}
|
||||||
@@ -569,6 +685,47 @@ 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 (?, ?, ?, ?)')
|
||||||
@@ -662,7 +819,14 @@ 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,
|
||||||
@@ -745,6 +909,8 @@ export class MatchService extends EventEmitter {
|
|||||||
status: match.status,
|
status: match.status,
|
||||||
statusLabel: MATCH_STATUS[match.status],
|
statusLabel: MATCH_STATUS[match.status],
|
||||||
createdAt: match.created_at,
|
createdAt: match.created_at,
|
||||||
|
finishedAt: match.finished_at ?? null,
|
||||||
|
archivedAt: match.archived_at ?? null,
|
||||||
updatedAt: match.updated_at,
|
updatedAt: match.updated_at,
|
||||||
vetoStart: match.veto_start,
|
vetoStart: match.veto_start,
|
||||||
currentMapNumber: match.current_map_number,
|
currentMapNumber: match.current_map_number,
|
||||||
@@ -756,7 +922,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
|
veto: publicView && match.status === 'lobby'
|
||||||
? null
|
? null
|
||||||
: {
|
: {
|
||||||
started: match.status !== 'lobby',
|
started: match.status !== 'lobby',
|
||||||
@@ -764,6 +930,8 @@ 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,
|
||||||
@@ -813,10 +981,16 @@ 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;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,224 @@
|
|||||||
|
// Gameserver-Einstellungen, die der Admin zur Laufzeit ändern kann. Die Werte aus der .env sind die
|
||||||
|
// Vorgabe; Änderungen landen in der Tabelle settings und überschreiben die Vorgabe auch nach einem Neustart.
|
||||||
|
|
||||||
|
import { HttpError } from './matchService.js';
|
||||||
|
import { parseDockerHosts } from './config.js';
|
||||||
|
import { setActiveMaps } from './mapPools.js';
|
||||||
|
|
||||||
|
const SETTINGS_KEY = 'server_settings';
|
||||||
|
const HOSTS_KEY = 'docker_hosts';
|
||||||
|
const MAPS_KEY = 'map_selection';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Änderbare Felder. `get`/`set` greifen auf das gemeinsame config-Objekt zu, das Match-Service,
|
||||||
|
* Server-Manager und Docker-Adapter lesen. `live` = wird an laufende Server geschickt.
|
||||||
|
*/
|
||||||
|
export const SERVER_SETTING_FIELDS = [
|
||||||
|
{ key: 'maxRoundsCompetitive', group: 'match', label: 'Runden Competitive (mp_maxrounds)', type: 'int', min: 2, max: 60, live: true,
|
||||||
|
get: (c) => c.match.maxRoundsCompetitive, set: (c, v) => { c.match.maxRoundsCompetitive = v; } },
|
||||||
|
{ key: 'maxRoundsWingman', group: 'match', label: 'Runden Wingman (mp_maxrounds)', type: 'int', min: 2, max: 60, live: true,
|
||||||
|
get: (c) => c.match.maxRoundsWingman, set: (c, v) => { c.match.maxRoundsWingman = v; } },
|
||||||
|
{ key: 'overtime', group: 'match', label: 'Verlängerung (mp_overtime_enable)', type: 'bool', live: true,
|
||||||
|
get: (c) => c.match.overtime, set: (c, v) => { c.match.overtime = v; } },
|
||||||
|
{ key: 'extraCommands', group: 'match', label: 'Zusätzliche Konsolenbefehle (durch ; getrennt)', type: 'commands', live: true,
|
||||||
|
get: (c) => c.match.extraCommands, set: (c, v) => { c.match.extraCommands = v; } },
|
||||||
|
{ key: 'tvEnabled', group: 'tv', label: 'CSTV aktiv (wirkt ab dem nächsten Mapwechsel)', type: 'bool', live: false,
|
||||||
|
get: (c) => c.match.tvEnabled, set: (c, v) => { c.match.tvEnabled = v; } },
|
||||||
|
{ key: 'tvDelaySeconds', group: 'tv', label: 'CSTV-Verzögerung in Sekunden (tv_delay)', type: 'int', min: 0, max: 300, live: true,
|
||||||
|
get: (c) => c.match.tvDelaySeconds, set: (c, v) => { c.match.tvDelaySeconds = v; } },
|
||||||
|
{ key: 'vetoStepSeconds', group: 'times', label: 'Zeit pro Veto-Schritt in Sekunden (0 = ohne Frist)', type: 'int', min: 0, max: 600, live: false,
|
||||||
|
get: (c) => c.vetoStepSeconds, set: (c, v) => { c.vetoStepSeconds = v; } },
|
||||||
|
{ key: 'idleTimeoutMinutes', group: 'times', label: 'Leerlauf bis zum Abschalten in Minuten', type: 'int', min: 1, max: 1440, live: false,
|
||||||
|
get: (c) => c.idleTimeoutMinutes, set: (c, v) => { c.idleTimeoutMinutes = v; } },
|
||||||
|
{ key: 'maxLifetimeHours', group: 'times', label: 'Maximale Laufzeit eines Servers in Stunden', type: 'int', min: 1, max: 72, live: false,
|
||||||
|
get: (c) => c.maxLifetimeHours, set: (c, v) => { c.maxLifetimeHours = v; } },
|
||||||
|
{ key: 'archiveAfterDays', group: 'times', label: 'Abgeschlossene Matches archivieren nach Tagen (0 = nie)', type: 'int', min: 0, max: 365, live: false,
|
||||||
|
get: (c) => c.archiveAfterDays, set: (c, v) => { c.archiveAfterDays = v; } },
|
||||||
|
{ key: 'startTimeoutMinutes', group: 'times', label: 'Startzeit eines Servers in Minuten (inkl. Download)', type: 'int', min: 5, max: 240, live: false,
|
||||||
|
get: (c) => c.docker.startTimeoutMinutes, set: (c, v) => { c.docker.startTimeoutMinutes = v; } },
|
||||||
|
];
|
||||||
|
|
||||||
|
const fieldFor = (key) => SERVER_SETTING_FIELDS.find((f) => f.key === key);
|
||||||
|
|
||||||
|
/** Konsolenbefehle aus Text: getrennt durch ; oder Zeilenumbruch. Befehle, die Zugänge ändern oder den Server beenden, sind gesperrt. */
|
||||||
|
export function parseCommands(value) {
|
||||||
|
const list = (Array.isArray(value) ? value : String(value ?? '').split(/[;\r\n]+/))
|
||||||
|
.map((c) => String(c).replace(/[\r\n;]/g, ' ').trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
if (list.length > 30) throw new HttpError(400, 'Höchstens 30 zusätzliche Befehle', 'invalid');
|
||||||
|
for (const c of list) {
|
||||||
|
if (c.length > 120) throw new HttpError(400, `Befehl zu lang: ${c.slice(0, 30)} …`, 'invalid');
|
||||||
|
if (/^(rcon_password|sv_password|tv_password|rcon|quit|exit|_restart|logaddress_\w*)\b/i.test(c)) {
|
||||||
|
throw new HttpError(400, `Befehl nicht erlaubt: ${c.split(' ')[0]}`, 'invalid');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseValue(field, raw) {
|
||||||
|
if (field.type === 'bool') {
|
||||||
|
if (typeof raw !== 'boolean') throw new HttpError(400, `${field.label}: an oder aus erwartet`, 'invalid');
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
if (field.type === 'int') {
|
||||||
|
const value = Number(raw);
|
||||||
|
if (raw === '' || raw === null || !Number.isInteger(value) || value < field.min || value > field.max) {
|
||||||
|
throw new HttpError(400, `${field.label}: ganze Zahl von ${field.min} bis ${field.max} erwartet`, 'invalid');
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return parseCommands(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
||||||
|
|
||||||
|
export class ServerSettings {
|
||||||
|
constructor({ db, config }) {
|
||||||
|
this.db = db;
|
||||||
|
this.config = config;
|
||||||
|
// Vorgaben aus der .env merken, damit der Admin zurück auf die Vorgabe kann
|
||||||
|
this.defaults = Object.fromEntries(SERVER_SETTING_FIELDS.map((f) => [f.key, structuredClone(f.get(config))]));
|
||||||
|
this.defaultHosts = config.docker.hosts;
|
||||||
|
}
|
||||||
|
|
||||||
|
readJson(key) {
|
||||||
|
const row = this.db.prepare('SELECT value FROM settings WHERE key = ?').get(key);
|
||||||
|
if (!row) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(row.value);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJson(key, value) {
|
||||||
|
this.db.prepare('INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value')
|
||||||
|
.run(key, JSON.stringify(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Beim Start: gespeicherte Werte auf das config-Objekt legen (vor dem Anlegen des Docker-Adapters). */
|
||||||
|
load() {
|
||||||
|
const saved = this.readJson(SETTINGS_KEY) ?? {};
|
||||||
|
for (const field of SERVER_SETTING_FIELDS) {
|
||||||
|
if (!(field.key in saved)) continue;
|
||||||
|
try {
|
||||||
|
field.set(this.config, parseValue(field, saved[field.key]));
|
||||||
|
} catch {
|
||||||
|
// Ungültiger gespeicherter Wert: Vorgabe aus der .env behalten
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const hosts = this.readJson(HOSTS_KEY);
|
||||||
|
if (hosts) {
|
||||||
|
try {
|
||||||
|
this.config.docker.hosts = parseDockerHosts(JSON.stringify(hosts));
|
||||||
|
} catch {
|
||||||
|
// Kaputte Host-Liste ignorieren, die .env gilt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
view() {
|
||||||
|
const saved = this.readJson(SETTINGS_KEY) ?? {};
|
||||||
|
return SERVER_SETTING_FIELDS.map(({ key, group, label, type, min, max, live, get }) => ({
|
||||||
|
key, group, label, type, min, max, live,
|
||||||
|
value: get(this.config),
|
||||||
|
default: this.defaults[key],
|
||||||
|
overridden: key in saved,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Übernimmt geänderte Werte. Liefert die Schlüssel, die sich wirklich geändert haben. */
|
||||||
|
update(input = {}) {
|
||||||
|
const parsed = Object.entries(input).map(([key, raw]) => {
|
||||||
|
const field = fieldFor(key);
|
||||||
|
if (!field) throw new HttpError(400, `Unbekannte Einstellung: ${key}`, 'invalid');
|
||||||
|
return [field, parseValue(field, raw)];
|
||||||
|
});
|
||||||
|
const saved = this.readJson(SETTINGS_KEY) ?? {};
|
||||||
|
const changed = [];
|
||||||
|
for (const [field, value] of parsed) {
|
||||||
|
if (!same(field.get(this.config), value)) changed.push(field.key);
|
||||||
|
field.set(this.config, value);
|
||||||
|
if (same(value, this.defaults[field.key])) delete saved[field.key];
|
||||||
|
else saved[field.key] = value;
|
||||||
|
}
|
||||||
|
this.writeJson(SETTINGS_KEY, saved);
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Prüft und speichert eine neue Host-Liste (Format wie CS2_DOCKER_HOSTS). */
|
||||||
|
parseHosts(list) {
|
||||||
|
if (!Array.isArray(list)) throw new HttpError(400, 'Host-Liste erwartet', 'invalid');
|
||||||
|
let hosts;
|
||||||
|
try {
|
||||||
|
hosts = parseDockerHosts(JSON.stringify(list));
|
||||||
|
} catch (err) {
|
||||||
|
throw new HttpError(400, err.message, 'invalid');
|
||||||
|
}
|
||||||
|
for (const h of hosts) {
|
||||||
|
if (h.dockerHost && !/^(ssh:\/\/[\w.@-]+(:\d+)?|tcp:\/\/[\w.-]+:\d+)$/.test(h.dockerHost)) {
|
||||||
|
throw new HttpError(400, `Ungültiges Docker-Ziel für ${h.name}: ${h.dockerHost}`, 'invalid');
|
||||||
|
}
|
||||||
|
if (!/^[\w.-]+$/.test(h.name)) throw new HttpError(400, `Ungültiger Name: ${h.name}`, 'invalid');
|
||||||
|
if (!/^[\w.-]+$/.test(h.publicHost) || !/^[\w.-]+$/.test(h.rconHost)) {
|
||||||
|
throw new HttpError(400, `Ungültige Adresse für ${h.name}`, 'invalid');
|
||||||
|
}
|
||||||
|
if (h.volume && !/^[\w.-]+$/.test(h.volume)) throw new HttpError(400, `Ungültiger Volume-Name für ${h.name}`, 'invalid');
|
||||||
|
const lan = h.lanPorts ?? this.config.docker.lanPorts ?? [];
|
||||||
|
const rangeStart = h.portRangeStart ?? this.config.docker.portRangeStart;
|
||||||
|
const rangeEnd = h.portRangeEnd ?? this.config.docker.portRangeEnd;
|
||||||
|
if (lan.some((p) => p >= rangeStart && p <= rangeEnd)) {
|
||||||
|
throw new HttpError(400, `LAN-Ports von ${h.name} dürfen nicht im normalen Portbereich liegen`, 'invalid');
|
||||||
|
}
|
||||||
|
const start = h.portRangeStart ?? this.config.docker.portRangeStart;
|
||||||
|
const end = h.portRangeEnd ?? this.config.docker.portRangeEnd;
|
||||||
|
if (!Number.isInteger(start) || !Number.isInteger(end) || start < 1024 || end > 65535 || end - start < 1) {
|
||||||
|
throw new HttpError(400, `Ungültiger Portbereich für ${h.name}`, 'invalid');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hosts;
|
||||||
|
}
|
||||||
|
|
||||||
|
saveHosts(list, hosts) {
|
||||||
|
this.writeJson(HOSTS_KEY, list);
|
||||||
|
this.config.docker.hosts = hosts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Entfernt die gespeicherte Host-Liste; danach gilt wieder die .env. */
|
||||||
|
resetHosts() {
|
||||||
|
this.db.prepare('DELETE FROM settings WHERE key = ?').run(HOSTS_KEY);
|
||||||
|
this.config.docker.hosts = this.defaultHosts;
|
||||||
|
return this.defaultHosts;
|
||||||
|
}
|
||||||
|
|
||||||
|
hostsOverridden() {
|
||||||
|
return this.readJson(HOSTS_KEY) != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Beim Start: im Admin-Bereich gewählte Maps auf die Pools legen. Unbekannte Maps werden ignoriert. */
|
||||||
|
applyMapSelection(pools) {
|
||||||
|
const saved = this.readJson(MAPS_KEY) ?? {};
|
||||||
|
for (const [mode, ids] of Object.entries(saved)) {
|
||||||
|
const pool = pools[mode];
|
||||||
|
if (!pool || !Array.isArray(ids)) continue;
|
||||||
|
const known = ids.filter((id) => pool.allMaps.some((m) => m.id === id));
|
||||||
|
if (known.length >= 2) setActiveMaps(pool, known);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Aktive Maps eines Modus setzen und speichern. Gilt für neue Matches, laufende behalten ihre Maps. */
|
||||||
|
saveMapSelection(pools, mode, ids) {
|
||||||
|
const pool = pools[mode];
|
||||||
|
if (!pool) throw new HttpError(400, `Unbekannter Modus: ${mode}`, 'invalid');
|
||||||
|
if (!Array.isArray(ids)) throw new HttpError(400, 'Map-Liste erwartet', 'invalid');
|
||||||
|
const unique = [...new Set(ids.map(String))];
|
||||||
|
const unknown = unique.filter((id) => !pool.allMaps.some((m) => m.id === id));
|
||||||
|
if (unknown.length) throw new HttpError(400, `Unbekannte Map: ${unknown.join(', ')}`, 'invalid');
|
||||||
|
if (unique.length < 2) throw new HttpError(400, 'Mindestens 2 Maps auswählen', 'invalid');
|
||||||
|
setActiveMaps(pool, unique);
|
||||||
|
const saved = this.readJson(MAPS_KEY) ?? {};
|
||||||
|
saved[mode] = pool.maps.map((m) => m.id);
|
||||||
|
this.writeJson(MAPS_KEY, saved);
|
||||||
|
return pool;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { DemoServerAdapter } from './demoAdapter.js';
|
import { DemoServerAdapter } from './demoAdapter.js';
|
||||||
import { DockerServerAdapter } from './dockerAdapter.js';
|
import { DockerHostPool } 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 DockerServerAdapter(config.docker, deps);
|
return new DockerHostPool(config.docker, deps);
|
||||||
case 'static':
|
case 'static':
|
||||||
return new StaticPoolServerAdapter(config.staticPool, deps);
|
return new StaticPoolServerAdapter(config.staticPool, deps);
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -63,6 +63,13 @@ 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,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,49 @@ 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, listRoundBackups, loadRoundBackup, rconStatus } from './rconConfigure.js';
|
||||||
import { runRcon } from './rcon.js';
|
import { runRcon } from './rcon.js';
|
||||||
|
import { sanitizeConsoleText } from './cs2Config.js';
|
||||||
|
|
||||||
const exec = promisify(execFile);
|
const exec = promisify(execFile);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plant Spiel- und CSTV-Port für einen neuen Server. Der CSTV-Port liegt immer `tvPortOffset` (Standard 100)
|
||||||
|
* über dem Spielport. Spielports kommen zuerst aus den LAN-Ports (damit der Server im LAN-Reiter des
|
||||||
|
* Steam-Serverbrowsers auftaucht), danach aus dem normalen Bereich. null = kein Platz.
|
||||||
|
*/
|
||||||
|
export function planPorts(host, used) {
|
||||||
|
const offset = host.tvPortOffset ?? 100;
|
||||||
|
const free = (p) => !used.has(p) && p <= 65535;
|
||||||
|
const candidates = [...(host.lanPorts ?? [])];
|
||||||
|
for (let p = host.portRangeStart; p <= host.portRangeEnd; p += 1) candidates.push(p);
|
||||||
|
const port = candidates.find((p) => free(p) && free(p + offset));
|
||||||
|
return port === undefined ? null : { port, tvPort: port + offset };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wie viele Server passen noch auf einen Host, wenn bereits `used` belegt ist. */
|
||||||
|
export function countSlots(host, used) {
|
||||||
|
const taken = new Set(used);
|
||||||
|
let count = 0;
|
||||||
|
for (;;) {
|
||||||
|
const plan = planPorts(host, taken);
|
||||||
|
if (!plan) return count;
|
||||||
|
taken.add(plan.port);
|
||||||
|
taken.add(plan.tvPort);
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fehler von "docker run", wenn ein Port auf dem Host schon von etwas anderem belegt ist. */
|
||||||
|
const PORT_BUSY = /port is already allocated|address already in use/i;
|
||||||
|
const DISK_CACHE_MS = 60_000;
|
||||||
|
|
||||||
|
/** Liest Gesamt- und freien Speicher aus einer Zeile von `df -B1` (Spalten: Filesystem, 1B-blocks, Used, Available …). */
|
||||||
|
export function parseDf(output) {
|
||||||
|
const parts = String(output).trim().split(/\s+/);
|
||||||
|
const totalBytes = Number(parts[1]);
|
||||||
|
const freeBytes = Number(parts[3]);
|
||||||
|
if (!Number.isFinite(totalBytes) || !Number.isFinite(freeBytes)) return null;
|
||||||
|
return { totalBytes, freeBytes };
|
||||||
|
}
|
||||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
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.
|
||||||
@@ -36,17 +77,25 @@ export class DockerServerAdapter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async docker(args, timeout = 60_000) {
|
async docker(args, timeout = 60_000) {
|
||||||
if (this.runDocker) return this.runDocker(args);
|
if (this.runDocker) return this.runDocker(args, this.options.name);
|
||||||
const { stdout } = await exec(this.options.bin, args, { timeout, maxBuffer: 4 * 1024 * 1024 });
|
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, env });
|
||||||
return stdout.trim();
|
return stdout.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
allocatePort() {
|
allocatePorts(blocked = new Set()) {
|
||||||
const used = this.getUsedPorts();
|
const used = new Set([...this.getUsedPorts(), ...blocked]);
|
||||||
for (let p = this.options.portRangeStart; p + 1 <= this.options.portRangeEnd; p += 2) {
|
const plan = planPorts(this.options, used);
|
||||||
if (!used.has(p) && !used.has(p + 1)) return p;
|
if (!plan) throw new Error('Kein freier Port im Bereich CS2_PORT_RANGE_START–CS2_PORT_RANGE_END');
|
||||||
|
return plan;
|
||||||
}
|
}
|
||||||
throw new Error('Kein freier Port im Bereich CS2_PORT_RANGE_START–CS2_PORT_RANGE_END');
|
|
||||||
|
/** Servername im Steam-Serverbrowser, z. B. "MaxLAN | Team A vs. Team B". */
|
||||||
|
serverName(matchId, teamNames) {
|
||||||
|
const prefix = this.options.serverNamePrefix ? sanitizeConsoleText(this.options.serverNamePrefix, 24) : '';
|
||||||
|
if (!teamNames) return `${prefix} ${matchId}`.trim();
|
||||||
|
const title = `${sanitizeConsoleText(teamNames.A)} vs. ${sanitizeConsoleText(teamNames.B)}`;
|
||||||
|
return (prefix ? `${prefix} | ${title}` : title).slice(0, 63);
|
||||||
}
|
}
|
||||||
|
|
||||||
async start(args) {
|
async start(args) {
|
||||||
@@ -97,13 +146,34 @@ export class DockerServerAdapter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async launch({ matchId, mode, secrets }) {
|
/**
|
||||||
const port = this.allocatePort();
|
* Im Host-Netz meldet Docker keinen Portkonflikt, CS2 startet dann einfach nicht. Deshalb vorher per SSH
|
||||||
const tvPort = port + 1;
|
* nachsehen, ob ein fremder Dienst den Port belegt. Ohne SSH-Ziel (oder in Tests) gilt der Port als frei.
|
||||||
|
*/
|
||||||
|
async hostPortsFree(ports) {
|
||||||
|
if (this.runDocker || !this.options.dockerHost?.startsWith('ssh://')) return true;
|
||||||
|
try {
|
||||||
|
const filter = ports.map((p) => `sport = :${Number(p)}`).join(' or ');
|
||||||
|
const { stdout } = await exec('ssh', [
|
||||||
|
'-o', 'BatchMode=yes', '-o', 'ConnectTimeout=5', this.options.dockerHost.slice('ssh://'.length),
|
||||||
|
`ss -Hlnut '( ${filter} )'`,
|
||||||
|
], { timeout: 10_000 });
|
||||||
|
return stdout.trim() === '';
|
||||||
|
} catch {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async launch({ matchId, mode, secrets, teamNames, serverName }, blocked = new Set()) {
|
||||||
|
const { port, tvPort } = this.allocatePorts(blocked);
|
||||||
|
const lan = (this.options.lanPorts ?? []).includes(port);
|
||||||
|
if (lan && blocked.size < 10 && !(await this.hostPortsFree([port, tvPort]))) {
|
||||||
|
return this.launch({ matchId, mode, secrets, teamNames, serverName }, new Set([...blocked, port, tvPort]));
|
||||||
|
}
|
||||||
const container = `cs2mv-${matchId.replace(/[^a-zA-Z0-9_-]/g, '')}`.toLowerCase();
|
const container = `cs2mv-${matchId.replace(/[^a-zA-Z0-9_-]/g, '')}`.toLowerCase();
|
||||||
const env = {
|
const env = {
|
||||||
...(this.lanMode ? { CS2_LAN: '1' } : { SRCDS_TOKEN: this.options.gslt, CS2_LAN: '0' }),
|
...(this.lanMode ? { CS2_LAN: '1' } : { SRCDS_TOKEN: this.options.gslt, CS2_LAN: '0' }),
|
||||||
CS2_SERVERNAME: `CS2 Mapveto ${matchId}`,
|
CS2_SERVERNAME: serverName ? sanitizeConsoleText(serverName, 63) : this.serverName(matchId, teamNames),
|
||||||
CS2_PORT: String(port),
|
CS2_PORT: String(port),
|
||||||
CS2_RCONPW: secrets.rconPassword,
|
CS2_RCONPW: secrets.rconPassword,
|
||||||
CS2_PW: secrets.serverPassword,
|
CS2_PW: secrets.serverPassword,
|
||||||
@@ -122,22 +192,42 @@ export class DockerServerAdapter {
|
|||||||
};
|
};
|
||||||
const args = ['run', '-d', '--name', container, '--label', 'cs2-mapveto=1'];
|
const args = ['run', '-d', '--name', container, '--label', 'cs2-mapveto=1'];
|
||||||
for (const [k, v] of Object.entries(env)) args.push('-e', `${k}=${v}`);
|
for (const [k, v] of Object.entries(env)) args.push('-e', `${k}=${v}`);
|
||||||
|
if (lan) {
|
||||||
|
// Der LAN-Reiter von Steam sucht per Broadcast. Die Port-Weiterleitung von Docker reicht Broadcasts
|
||||||
|
// nicht an den Container weiter, deshalb laufen Server auf LAN-Ports direkt im Netz des Hosts.
|
||||||
|
args.push('--network', 'host');
|
||||||
|
} else {
|
||||||
args.push(
|
args.push(
|
||||||
'-p', `${port}:${port}/tcp`,
|
'-p', `${port}:${port}/tcp`,
|
||||||
'-p', `${port}:${port}/udp`,
|
'-p', `${port}:${port}/udp`,
|
||||||
'-p', `${tvPort}:${tvPort}/udp`,
|
'-p', `${tvPort}:${tvPort}/udp`,
|
||||||
'-v', `${this.options.volume}:/home/steam/cs2-dedicated`,
|
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
args.push('-v', `${this.options.volume}:/home/steam/cs2-dedicated`);
|
||||||
if (this.options.extraArgs) args.push(...this.options.extraArgs.split(' ').filter(Boolean));
|
if (this.options.extraArgs) args.push(...this.options.extraArgs.split(' ').filter(Boolean));
|
||||||
args.push(this.options.image);
|
args.push(this.options.image);
|
||||||
|
|
||||||
// Ein evtl. übrig gebliebener Container gleichen Namens (z. B. nach Retry) wird ersetzt.
|
// Ein evtl. übrig gebliebener Container gleichen Namens (z. B. nach Retry) wird ersetzt.
|
||||||
await this.docker(['rm', '-f', container]).catch(() => {});
|
await this.docker(['rm', '-f', container]).catch(() => {});
|
||||||
// Das erste "docker run" kann das Image laden und dauert dann länger.
|
// Das erste "docker run" kann das Image laden und dauert dann länger.
|
||||||
|
try {
|
||||||
await this.docker(args, 15 * 60_000);
|
await this.docker(args, 15 * 60_000);
|
||||||
|
} catch (err) {
|
||||||
|
// Port von einem fremden Dienst belegt (die App kennt nur ihre eigenen Server): mit anderen Ports erneut versuchen
|
||||||
|
if (!PORT_BUSY.test(`${err.message} ${err.stderr ?? ''}`) || blocked.size >= 10) throw err;
|
||||||
|
await this.docker(['rm', '-f', container]).catch(() => {});
|
||||||
|
return this.launch({ matchId, mode, secrets, teamNames, serverName }, new Set([...blocked, port, tvPort]));
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
handle: { container, rconHost: this.options.rconHost, port, startedAt: Date.now(), rconPassword: secrets.rconPassword },
|
handle: {
|
||||||
|
container,
|
||||||
|
hostName: this.options.name,
|
||||||
|
rconHost: this.options.rconHost,
|
||||||
|
port,
|
||||||
|
startedAt: Date.now(),
|
||||||
|
rconPassword: secrets.rconPassword,
|
||||||
|
},
|
||||||
host: this.options.host,
|
host: this.options.host,
|
||||||
port,
|
port,
|
||||||
tvPort,
|
tvPort,
|
||||||
@@ -192,3 +282,199 @@ 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,
|
||||||
|
lanPorts: h.lanPorts ?? options.lanPorts ?? [],
|
||||||
|
tvPortOffset: options.tvPortOffset ?? 100,
|
||||||
|
};
|
||||||
|
const merged = { ...options, ...host, host: host.publicHost };
|
||||||
|
// Die Startzeit kann der Admin zur Laufzeit ändern, deshalb immer aus den gemeinsamen Einstellungen lesen
|
||||||
|
Object.defineProperty(merged, 'startTimeoutMinutes', { get: () => options.startTimeoutMinutes, enumerable: true });
|
||||||
|
return merged;
|
||||||
|
});
|
||||||
|
this.adapters = new Map(this.hosts.map((h) => [h.name, new DockerServerAdapter(h, {
|
||||||
|
getUsedPorts: () => (this.depsGetUsedPorts ? this.depsGetUsedPorts(h.publicHost) : new Set()),
|
||||||
|
runDocker: this.runDocker,
|
||||||
|
})]));
|
||||||
|
this.diskCache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Docker-Befehl direkt auf dem Host ausführen. Bei ssh:// per SSH, damit der Pull die Anmeldedaten des Hosts
|
||||||
|
* nutzt und nicht den Credential-Helper des Rechners, auf dem die App läuft.
|
||||||
|
*/
|
||||||
|
async hostDocker(name, args, timeout) {
|
||||||
|
const host = this.hosts.find((h) => h.name === name);
|
||||||
|
if (!this.runDocker && host?.dockerHost?.startsWith('ssh://')) {
|
||||||
|
const { stdout } = await exec('ssh', [
|
||||||
|
'-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10', host.dockerHost.slice('ssh://'.length),
|
||||||
|
['docker', ...args].map((a) => `'${a}'`).join(' '),
|
||||||
|
], { timeout, maxBuffer: 4 * 1024 * 1024 });
|
||||||
|
return stdout.trim();
|
||||||
|
}
|
||||||
|
return this.adapters.get(name).docker(args, timeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lädt das CS2-Image auf allen Hosts gleichzeitig neu. Laufende Server bleiben unberührt. */
|
||||||
|
async pullImage() {
|
||||||
|
if (this.pull.running) throw new Error('Das Image wird bereits aktualisiert');
|
||||||
|
if (!/^[\w./:@-]+$/.test(this.options.image)) throw new Error('Ungültiger Image-Name');
|
||||||
|
const hosts = this.hosts.map((h) => h.name);
|
||||||
|
this.pull = {
|
||||||
|
running: true,
|
||||||
|
startedAt: Date.now(),
|
||||||
|
finishedAt: null,
|
||||||
|
image: this.options.image,
|
||||||
|
results: hosts.map((name) => ({ name, state: 'running', message: null })),
|
||||||
|
};
|
||||||
|
await Promise.all(hosts.map(async (name, i) => {
|
||||||
|
const result = this.pull.results[i];
|
||||||
|
try {
|
||||||
|
await this.hostDocker(name, ['pull', '--quiet', this.options.image], 20 * 60_000);
|
||||||
|
const digest = await this.hostDocker(name, ['image', 'inspect', this.options.image, '--format', '{{index .RepoDigests 0}}'], 30_000)
|
||||||
|
.catch(() => '');
|
||||||
|
Object.assign(result, { state: 'ok', message: digest.split('@')[1]?.slice(0, 19) ?? 'aktualisiert' });
|
||||||
|
} catch (err) {
|
||||||
|
Object.assign(result, { state: 'failed', message: String(err.stderr || err.message).trim().split('\n').pop().slice(0, 200) });
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
this.pull.running = false;
|
||||||
|
this.pull.finishedAt = Date.now();
|
||||||
|
return this.pull;
|
||||||
|
}
|
||||||
|
|
||||||
|
pullStatus() {
|
||||||
|
return this.pull;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Freier Speicher auf einem Host, per SSH: dort, wo das Gameserver-Volume liegt. Ergebnis wird eine Minute lang
|
||||||
|
* zwischengespeichert, weil die Admin-Seite alle paar Sekunden abfragt. Ohne ssh:// gibt es null.
|
||||||
|
*/
|
||||||
|
async diskUsage(name) {
|
||||||
|
const host = this.hosts.find((h) => h.name === name);
|
||||||
|
if (!host?.dockerHost?.startsWith('ssh://')) return null;
|
||||||
|
const cached = this.diskCache.get(name);
|
||||||
|
if (cached && Date.now() - cached.at < DISK_CACHE_MS) return cached.value;
|
||||||
|
if (!this.diskInflight.has(name)) {
|
||||||
|
this.diskInflight.set(name, (async () => {
|
||||||
|
let value = null;
|
||||||
|
try {
|
||||||
|
const { stdout } = await exec('ssh', [
|
||||||
|
'-o', 'BatchMode=yes', '-o', 'ConnectTimeout=5', host.dockerHost.slice('ssh://'.length),
|
||||||
|
`df -B1 "$(docker volume inspect ${host.volume} --format '{{with .Options}}{{.device}}{{end}}' 2>/dev/null | grep . || docker volume inspect ${host.volume} --format '{{.Mountpoint}}' 2>/dev/null || echo /var/lib/docker)" 2>/dev/null || df -B1 /var/lib/docker 2>/dev/null || df -B1 /`,
|
||||||
|
], { timeout: 10_000 });
|
||||||
|
value = parseDf(stdout.trim().split('\n').pop());
|
||||||
|
} catch {
|
||||||
|
value = null;
|
||||||
|
}
|
||||||
|
this.diskCache.set(name, { at: Date.now(), value });
|
||||||
|
this.diskInflight.delete(name);
|
||||||
|
return value;
|
||||||
|
})());
|
||||||
|
}
|
||||||
|
return this.diskInflight.get(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wie viele Server noch auf einen Host passen. */
|
||||||
|
freeSlots(host) {
|
||||||
|
return countSlots(host, this.getUsedPorts(host.publicHost));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Auslastung pro Host: Anzahl Serverplätze insgesamt und belegt. */
|
||||||
|
hostStats() {
|
||||||
|
return this.hosts.map((h) => {
|
||||||
|
const total = countSlots(h, new Set());
|
||||||
|
return { name: h.name, publicHost: h.publicHost, total, used: total - this.freeSlots(h) };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getUsedPorts(publicHost) {
|
||||||
|
return this.depsGetUsedPorts?.(publicHost) ?? new Set();
|
||||||
|
}
|
||||||
|
|
||||||
|
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) return adapter;
|
||||||
|
// Server aus der Zeit vor der Host-Liste (Name "default") oder nach einer Umbenennung: über die Adresse finden
|
||||||
|
const byAddress = handle?.rconHost && this.hosts.find((h) => h.rconHost === handle.rconHost || h.publicHost === handle.rconHost);
|
||||||
|
if (byAddress) return this.adapters.get(byAddress.name);
|
||||||
|
throw new Error(`Docker-Host "${name}" ist nicht (mehr) konfiguriert`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async status(handle) {
|
||||||
|
return this.adapterFor(handle).status(handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
async configure(handle, plan) {
|
||||||
|
return this.adapterFor(handle).configure(handle, plan);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listRoundBackups(handle, matchId) {
|
||||||
|
return this.adapterFor(handle).listRoundBackups(handle, matchId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadRoundBackup(handle, file) {
|
||||||
|
return this.adapterFor(handle).loadRoundBackup(handle, file);
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendCommands(handle, commands) {
|
||||||
|
return this.adapterFor(handle).sendCommands(handle, commands);
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop(handle) {
|
||||||
|
return this.adapterFor(handle).stop(handle);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
// Messer-Server für die Seitenwahl: Gameserver, die dauerhaft laufen und keinem Match gehören.
|
||||||
|
// Die Teams gehen drauf, spielen ihre Messerrunde und gehen wieder. Nach Spielende oder wenn der Server
|
||||||
|
// leer wird, setzt er sich von selbst zurück. Fällt ein Server aus, startet ihn die App neu.
|
||||||
|
|
||||||
|
import { HttpError } from '../matchService.js';
|
||||||
|
import { randomPassword } from '../security.js';
|
||||||
|
|
||||||
|
const SLOW_POLL_MS = 10_000;
|
||||||
|
// Nach so vielen Ausfällen in Folge gibt die App auf, damit ein kaputter Host nicht endlos neu startet
|
||||||
|
const MAX_RESTARTS = 5;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Konsolenbefehle für eine Messerrunde: nur Messer, kein Geld, eine Runde, danach automatisch neues Spiel.
|
||||||
|
* `pool` bestimmt den Spielmodus (Competitive 5v5 oder Wingman 2v2), `warmupSeconds` die Zeit zum Verbinden.
|
||||||
|
*/
|
||||||
|
export function buildKnifeCommands({ map, pool = { gameType: 0, gameMode: 1 }, warmupSeconds = 90 }) {
|
||||||
|
return {
|
||||||
|
map,
|
||||||
|
beforeMapChange: [
|
||||||
|
`game_type ${Number(pool.gameType)}`,
|
||||||
|
`game_mode ${Number(pool.gameMode)}`,
|
||||||
|
'sv_password ""',
|
||||||
|
'tv_enable 0',
|
||||||
|
`changelevel ${map}`,
|
||||||
|
],
|
||||||
|
afterMapChange: [
|
||||||
|
'bot_kick',
|
||||||
|
'bot_quota 0',
|
||||||
|
// Eine Runde entscheidet, danach startet das Spiel von selbst neu (für die nächsten Teams)
|
||||||
|
'mp_maxrounds 1',
|
||||||
|
'mp_match_can_clinch 1',
|
||||||
|
'mp_match_end_restart 1',
|
||||||
|
'mp_match_restart_delay 10',
|
||||||
|
'mp_halftime 0',
|
||||||
|
'mp_overtime_enable 0',
|
||||||
|
// Nur Messer: keine Startwaffen, kein Geld, nichts zu kaufen
|
||||||
|
'mp_ct_default_primary ""',
|
||||||
|
'mp_t_default_primary ""',
|
||||||
|
'mp_ct_default_secondary ""',
|
||||||
|
'mp_t_default_secondary ""',
|
||||||
|
'mp_ct_default_grenades ""',
|
||||||
|
'mp_t_default_grenades ""',
|
||||||
|
'mp_give_player_c4 0',
|
||||||
|
'mp_startmoney 0',
|
||||||
|
'mp_maxmoney 0',
|
||||||
|
'mp_afterroundmoney 0',
|
||||||
|
'mp_buytime 0',
|
||||||
|
'mp_free_armor 1',
|
||||||
|
'mp_weapons_allow_map_placed 0',
|
||||||
|
'mp_death_drop_gun 0',
|
||||||
|
'mp_death_drop_grenade 0',
|
||||||
|
'mp_freezetime 3',
|
||||||
|
'mp_roundtime 2',
|
||||||
|
'mp_roundtime_defuse 2',
|
||||||
|
'mp_roundtime_hostage 2',
|
||||||
|
'mp_autoteambalance 0',
|
||||||
|
'mp_limitteams 0',
|
||||||
|
'mp_autokick 0',
|
||||||
|
// Aufwärmphase zum Verbinden, danach beginnt die Messerrunde
|
||||||
|
'mp_do_warmup_period 1',
|
||||||
|
`mp_warmuptime ${Math.max(10, Math.min(600, Number(warmupSeconds) || 90))}`,
|
||||||
|
'mp_warmup_pausetimer 0',
|
||||||
|
'mp_warmup_start',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Befehle, mit denen ein laufender Messer-Server für die nächsten Teams zurückgesetzt wird. */
|
||||||
|
export const KNIFE_RESET_COMMANDS = ['mp_warmup_start'];
|
||||||
|
|
||||||
|
export class KnifeServers {
|
||||||
|
constructor({ db, adapter, config, pools = {}, logger = console }) {
|
||||||
|
this.db = db;
|
||||||
|
this.pools = pools;
|
||||||
|
this.adapter = adapter;
|
||||||
|
this.config = config;
|
||||||
|
this.logger = logger;
|
||||||
|
this.busy = new Set();
|
||||||
|
this.lastPoll = new Map();
|
||||||
|
this.timer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
rows() {
|
||||||
|
return this.db.prepare('SELECT * FROM knife_servers ORDER BY number').all();
|
||||||
|
}
|
||||||
|
|
||||||
|
row(id) {
|
||||||
|
return this.db.prepare('SELECT * FROM knife_servers WHERE id = ?').get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
update(id, fields) {
|
||||||
|
const keys = Object.keys(fields);
|
||||||
|
if (!keys.length) return;
|
||||||
|
this.db.prepare(`UPDATE knife_servers SET ${keys.map((k) => `${k} = @${k}`).join(', ')}, updated_at = @updated_at WHERE id = @id`)
|
||||||
|
.run({ ...fields, updated_at: Date.now(), id });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ansicht für Admin und Startseite. Ohne RCON-Passwort und Handle. */
|
||||||
|
view(row) {
|
||||||
|
const pool = this.pools[row.mode];
|
||||||
|
const size = pool?.teamSize;
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
number: row.number,
|
||||||
|
name: row.name,
|
||||||
|
mode: row.mode,
|
||||||
|
modeLabel: pool?.label ?? row.mode,
|
||||||
|
modeShort: size ? `${size}v${size}` : null,
|
||||||
|
map: row.map,
|
||||||
|
mapName: pool?.maps.find((m) => m.id === row.map)?.name ?? row.map,
|
||||||
|
status: row.status,
|
||||||
|
message: row.message,
|
||||||
|
host: row.host,
|
||||||
|
port: row.port,
|
||||||
|
playerCount: row.player_count,
|
||||||
|
restarts: row.restarts,
|
||||||
|
startedAt: row.started_at,
|
||||||
|
connect: row.host && row.port ? `connect ${row.host}:${row.port}` : null,
|
||||||
|
steamUrl: row.host && row.port ? `steam://connect/${row.host}:${row.port}` : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
list() {
|
||||||
|
return this.rows().map((r) => this.view(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ports aller Messer-Server auf einem Host (für die Portvergabe zusammen mit den Match-Servern). */
|
||||||
|
usedPorts(host) {
|
||||||
|
const rows = host
|
||||||
|
? this.db.prepare('SELECT host, port, tv_port FROM knife_servers WHERE host = ? AND port IS NOT NULL').all(host)
|
||||||
|
: this.db.prepare('SELECT host, port, tv_port FROM knife_servers WHERE port IS NOT NULL').all();
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Modi für Messer-Server (5v5, 2v2) mit ihren Maps, aus den Map-Pools. */
|
||||||
|
modes() {
|
||||||
|
return Object.entries(this.pools).map(([id, pool]) => {
|
||||||
|
const maps = pool.maps.map((m) => ({ id: m.id, name: m.name }));
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
label: pool.label,
|
||||||
|
teamSize: pool.teamSize,
|
||||||
|
maps,
|
||||||
|
defaultMap: maps.some((m) => m.id === this.config.knife.map) ? this.config.knife.map : maps[0]?.id,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Neuen Messer-Server starten. Liefert die Ansicht des neuen Servers. */
|
||||||
|
async create({ map, mode = 'competitive' }) {
|
||||||
|
if (this.adapter.name === 'static') throw new HttpError(409, 'Mit festen Servern (static) gibt es keine Messer-Server', 'unsupported');
|
||||||
|
const info = this.modes().find((m) => m.id === mode);
|
||||||
|
if (!info) throw new HttpError(400, `Unbekannter Modus: ${mode}`, 'invalid');
|
||||||
|
const chosen = String(map || info.defaultMap);
|
||||||
|
if (!info.maps.some((m) => m.id === chosen)) throw new HttpError(400, `Map ${chosen} gibt es nicht für ${info.label}`, 'invalid');
|
||||||
|
const max = this.config.knife.maxServers;
|
||||||
|
const count = this.db.prepare('SELECT COUNT(*) AS n FROM knife_servers').get().n;
|
||||||
|
if (count >= max) throw new HttpError(409, `Höchstens ${max} Messer-Server`, 'limit');
|
||||||
|
const used = new Set(this.rows().map((r) => r.number));
|
||||||
|
let number = 1;
|
||||||
|
while (used.has(number)) number += 1;
|
||||||
|
const id = `knife${number}`;
|
||||||
|
const now = Date.now();
|
||||||
|
this.db.prepare(`
|
||||||
|
INSERT INTO knife_servers (id, number, name, mode, map, status, message, rcon_password, restarts, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, 'starting', 'Server wird gestartet', ?, 0, ?, ?)
|
||||||
|
`).run(id, number, `Messer ${number}`, mode, chosen, randomPassword(24), now, now);
|
||||||
|
await this.launch(id);
|
||||||
|
return this.view(this.row(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
async launch(id) {
|
||||||
|
const row = this.row(id);
|
||||||
|
this.busy.add(id);
|
||||||
|
try {
|
||||||
|
const prefix = this.config.docker?.serverNamePrefix;
|
||||||
|
const size = this.pools[row.mode]?.teamSize;
|
||||||
|
const result = await this.adapter.start({
|
||||||
|
matchId: id,
|
||||||
|
// Wingman: 6 Plätze, Competitive: 12 Plätze (wie bei den Match-Servern)
|
||||||
|
mode: row.mode,
|
||||||
|
secrets: { rconPassword: row.rcon_password, serverPassword: '', tvPassword: randomPassword(12) },
|
||||||
|
serverName: `${prefix ? `${prefix} | ` : ''}${row.name} ${size ? `${size}v${size} ` : ''}(Seitenwahl)`,
|
||||||
|
});
|
||||||
|
this.update(id, {
|
||||||
|
status: 'starting',
|
||||||
|
message: 'Container läuft, Spielserver lädt',
|
||||||
|
handle: JSON.stringify(result.handle),
|
||||||
|
host: result.host,
|
||||||
|
port: result.port,
|
||||||
|
tv_port: result.tvPort,
|
||||||
|
player_count: null,
|
||||||
|
started_at: Date.now(),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
this.update(id, { status: 'failed', message: `Start fehlgeschlagen: ${err.message}` });
|
||||||
|
throw new HttpError(502, `Messer-Server konnte nicht gestartet werden: ${err.message}`, 'start_failed');
|
||||||
|
} finally {
|
||||||
|
this.busy.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async configure(id) {
|
||||||
|
const row = this.row(id);
|
||||||
|
this.busy.add(id);
|
||||||
|
this.update(id, { status: 'configuring', message: `Lade ${row.map}` });
|
||||||
|
try {
|
||||||
|
await this.adapter.configure(JSON.parse(row.handle), buildKnifeCommands({ map: row.map, pool: this.pools[row.mode], warmupSeconds: this.config.knife.warmupSeconds }));
|
||||||
|
this.update(id, { status: 'running', message: 'Bereit für die Messerrunde', restarts: 0 });
|
||||||
|
} catch (err) {
|
||||||
|
this.update(id, { status: 'config_failed', message: `Konfiguration fehlgeschlagen: ${err.message}` });
|
||||||
|
} finally {
|
||||||
|
this.busy.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async poll() {
|
||||||
|
for (const row of this.rows()) {
|
||||||
|
if (this.busy.has(row.id) || !row.handle) continue;
|
||||||
|
const last = this.lastPoll.get(row.id) ?? 0;
|
||||||
|
if (row.status === 'running' && Date.now() - last < SLOW_POLL_MS) continue;
|
||||||
|
this.lastPoll.set(row.id, Date.now());
|
||||||
|
await this.pollOne(row).catch((err) => this.logger.warn?.(`[messer] ${row.name}: ${err.message}`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async pollOne(row) {
|
||||||
|
let status;
|
||||||
|
try {
|
||||||
|
status = await this.adapter.status(JSON.parse(row.handle));
|
||||||
|
} catch (err) {
|
||||||
|
status = { state: row.status === 'starting' ? 'starting' : 'running', message: err.message };
|
||||||
|
}
|
||||||
|
if (status.state === 'failed' || status.state === 'stopped') {
|
||||||
|
await this.recover(row, status.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (status.state === 'running' && row.status === 'starting') {
|
||||||
|
await this.configure(row.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (row.status === 'config_failed' && status.state === 'running') {
|
||||||
|
// Konfiguration noch einmal versuchen, der Server läuft ja
|
||||||
|
await this.configure(row.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (row.status !== 'running' || status.playerCount === undefined) return;
|
||||||
|
const players = status.playerCount;
|
||||||
|
if (players !== row.player_count) this.update(row.id, { player_count: players });
|
||||||
|
// Alle sind gegangen: Server für die nächsten Teams zurücksetzen
|
||||||
|
if (row.player_count > 0 && players === 0) await this.reset(row.id, 'Server leer – für die nächsten Teams zurückgesetzt');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ausgefallenen Server neu starten, damit immer Messer-Server laufen. */
|
||||||
|
async recover(row, reason) {
|
||||||
|
if (row.restarts >= MAX_RESTARTS) {
|
||||||
|
if (row.status !== 'failed') this.update(row.id, { status: 'failed', message: `Ausgefallen, ${MAX_RESTARTS} Neustarts ohne Erfolg: ${reason ?? ''}`.trim() });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.logger.warn?.(`[messer] ${row.name} ausgefallen (${reason}), starte neu`);
|
||||||
|
this.update(row.id, { restarts: row.restarts + 1, status: 'starting', message: `Neustart nach Ausfall (${row.restarts + 1}/${MAX_RESTARTS})` });
|
||||||
|
await this.adapter.stop(JSON.parse(row.handle)).catch(() => {});
|
||||||
|
await this.launch(row.id).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
async reset(id, message = 'Spiel zurückgesetzt (Admin)') {
|
||||||
|
const row = this.requireRow(id);
|
||||||
|
if (row.status !== 'running') throw new HttpError(409, 'Der Server ist noch nicht bereit', 'wrong_state');
|
||||||
|
try {
|
||||||
|
await this.adapter.sendCommands(JSON.parse(row.handle), KNIFE_RESET_COMMANDS);
|
||||||
|
} catch (err) {
|
||||||
|
throw new HttpError(502, `Befehl konnte nicht gesendet werden: ${err.message}`, 'server_error');
|
||||||
|
}
|
||||||
|
this.update(id, { message });
|
||||||
|
}
|
||||||
|
|
||||||
|
async startRound(id) {
|
||||||
|
const row = this.requireRow(id);
|
||||||
|
if (row.status !== 'running') throw new HttpError(409, 'Der Server ist noch nicht bereit', 'wrong_state');
|
||||||
|
try {
|
||||||
|
await this.adapter.sendCommands(JSON.parse(row.handle), ['mp_warmup_end']);
|
||||||
|
} catch (err) {
|
||||||
|
throw new HttpError(502, `Befehl konnte nicht gesendet werden: ${err.message}`, 'server_error');
|
||||||
|
}
|
||||||
|
this.update(id, { message: 'Messerrunde läuft (Admin gestartet)' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Server komplett neu starten (neuer Container), z. B. wenn er hängt. */
|
||||||
|
async restart(id) {
|
||||||
|
const row = this.requireRow(id);
|
||||||
|
if (this.busy.has(id)) throw new HttpError(409, 'Für diesen Server läuft bereits eine Aktion', 'busy');
|
||||||
|
if (row.handle) await this.adapter.stop(JSON.parse(row.handle)).catch(() => {});
|
||||||
|
this.update(id, { restarts: 0, status: 'starting', message: 'Neustart durch Admin' });
|
||||||
|
await this.launch(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(id) {
|
||||||
|
const row = this.requireRow(id);
|
||||||
|
if (row.handle) await this.adapter.stop(JSON.parse(row.handle)).catch(() => {});
|
||||||
|
this.db.prepare('DELETE FROM knife_servers WHERE id = ?').run(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
requireRow(id) {
|
||||||
|
const row = this.row(id);
|
||||||
|
if (!row) throw new HttpError(404, 'Messer-Server nicht gefunden', 'not_found');
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
startLoop() {
|
||||||
|
this.timer = setInterval(() => this.poll().catch((e) => this.logger.error(e)), this.config.statusPollSeconds * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
stopLoop() {
|
||||||
|
clearInterval(this.timer);
|
||||||
|
this.timer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -160,9 +160,124 @@ 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 knives = this.db.prepare('SELECT id, name, status, host FROM knife_servers WHERE host IS NOT NULL').all();
|
||||||
|
const stats = this.adapter.hostStats();
|
||||||
|
const disks = await Promise.all(stats.map((h) => this.adapter.diskUsage?.(h.name) ?? null));
|
||||||
|
return stats.map((h, i) => ({
|
||||||
|
...h,
|
||||||
|
disk: disks[i],
|
||||||
|
matches: [
|
||||||
|
...rows
|
||||||
|
.filter((r) => r.host === h.publicHost)
|
||||||
|
.map((r) => ({ id: r.match_id, title: `${r.team_a_name} vs. ${r.team_b_name}`, status: r.status })),
|
||||||
|
...knives
|
||||||
|
.filter((k) => k.host === h.publicHost)
|
||||||
|
.map((k) => ({ id: k.id, title: `${k.name} (Seitenwahl)`, status: k.status })),
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Server, die gerade laufen oder starten, mit dem Docker-Host aus ihrem Handle. */
|
||||||
|
activeServers() {
|
||||||
|
return this.db.prepare(`
|
||||||
|
SELECT s.match_id, s.status, s.host, s.handle, m.mode
|
||||||
|
FROM servers s JOIN matches m ON m.id = s.match_id
|
||||||
|
WHERE s.status != 'stopped' AND s.handle IS NOT NULL
|
||||||
|
`).all().map((r) => {
|
||||||
|
let handle = null;
|
||||||
|
try {
|
||||||
|
handle = JSON.parse(r.handle);
|
||||||
|
} catch {
|
||||||
|
// kaputter Handle: Server zählt trotzdem als aktiv
|
||||||
|
}
|
||||||
|
return { ...r, handle, hostName: handle?.hostName ?? null };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Übernimmt eine neue Liste von Docker-Hosts. Hosts mit laufenden Servern dürfen nicht wegfallen
|
||||||
|
* und ihre Adresse nicht ändern, sonst verliert die App den Kontakt zu diesen Servern.
|
||||||
|
*/
|
||||||
|
setDockerHosts(hosts) {
|
||||||
|
if (!this.adapter.setHosts) throw new HttpError(409, 'Nur mit dem Docker-Adapter möglich', 'unsupported');
|
||||||
|
const before = new Map(this.adapter.hosts.map((h) => [h.name, h]));
|
||||||
|
// Ohne Liste gilt der eine Host aus DOCKER_HOST/CS2_PUBLIC_HOST
|
||||||
|
const nextList = hosts ?? [{ name: 'default', publicHost: this.config.docker.host, dockerHost: process.env.DOCKER_HOST || null }];
|
||||||
|
for (const server of this.activeServers()) {
|
||||||
|
const name = server.hostName ?? this.adapter.hosts[0]?.name;
|
||||||
|
const old = before.get(name);
|
||||||
|
const next = nextList.find((h) => h.name === name);
|
||||||
|
if (!next) {
|
||||||
|
throw new HttpError(409, `Auf ${name} läuft noch ein Server. Erst stoppen, dann den Host entfernen.`, 'host_busy');
|
||||||
|
}
|
||||||
|
if (old && (next.publicHost !== old.publicHost || (next.dockerHost ?? null) !== (old.dockerHost ?? null))) {
|
||||||
|
throw new HttpError(409, `Auf ${name} läuft noch ein Server. Adresse und Docker-Ziel erst danach ändern.`, 'host_busy');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.adapter.setHosts(hosts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schickt geänderte Match-Einstellungen an alle bereiten Server. Liefert, wie viele Server
|
||||||
|
* aktualisiert wurden und welche nicht erreichbar waren.
|
||||||
|
*/
|
||||||
|
async applyLiveSettings(changed) {
|
||||||
|
const keys = new Set(changed);
|
||||||
|
const rows = this.activeServers().filter((r) => r.status === 'configured');
|
||||||
|
if (!this.adapter.sendCommands || rows.length === 0) return { updated: 0, failed: [] };
|
||||||
|
const m = this.config.match;
|
||||||
|
const result = { updated: 0, failed: [] };
|
||||||
|
for (const row of rows) {
|
||||||
|
const wingman = row.mode === 'wingman';
|
||||||
|
const commands = [];
|
||||||
|
if (keys.has(wingman ? 'maxRoundsWingman' : 'maxRoundsCompetitive')) {
|
||||||
|
commands.push(`mp_maxrounds ${wingman ? m.maxRoundsWingman : m.maxRoundsCompetitive}`);
|
||||||
|
}
|
||||||
|
if (keys.has('overtime')) commands.push(`mp_overtime_enable ${m.overtime ? 1 : 0}`);
|
||||||
|
if (keys.has('tvDelaySeconds')) commands.push(`tv_delay ${m.tvDelaySeconds}`);
|
||||||
|
if (keys.has('extraCommands')) commands.push(...m.extraCommands);
|
||||||
|
if (commands.length === 0) continue;
|
||||||
|
try {
|
||||||
|
await this.adapter.sendCommands(row.handle, commands);
|
||||||
|
result.updated += 1;
|
||||||
|
this.service.log(row.match_id, 'server', `Einstellungen übernommen (Admin): ${commands.join('; ')}`);
|
||||||
|
} catch (err) {
|
||||||
|
result.failed.push(row.match_id);
|
||||||
|
this.logger.warn?.(`Einstellungen für ${row.match_id} nicht übernommen: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ports, die auf einem bestimmten Docker-Host belegt sind. */
|
||||||
|
usedPortsOn(host) {
|
||||||
|
const used = new Set();
|
||||||
|
for (const r of this.db.prepare(`SELECT port, tv_port FROM servers WHERE host = ? AND status != 'stopped' AND port IS NOT NULL`).all(host)) {
|
||||||
|
used.add(r.port);
|
||||||
|
used.add(r.tv_port);
|
||||||
|
}
|
||||||
|
// Messer-Server belegen ihre Ports dauerhaft
|
||||||
|
for (const r of this.db.prepare('SELECT port, tv_port FROM knife_servers WHERE host = ? AND port IS NOT NULL').all(host)) {
|
||||||
|
used.add(r.port);
|
||||||
|
used.add(r.tv_port);
|
||||||
|
}
|
||||||
|
return used;
|
||||||
|
}
|
||||||
|
|
||||||
usedPorts() {
|
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
|
||||||
|
UNION ALL SELECT host, port, tv_port FROM knife_servers WHERE port IS NOT NULL
|
||||||
|
`).all()) {
|
||||||
used.add(r.port);
|
used.add(r.port);
|
||||||
used.add(r.tv_port);
|
used.add(r.tv_port);
|
||||||
used.add(`${r.host}:${r.port}`);
|
used.add(`${r.host}:${r.port}`);
|
||||||
@@ -213,7 +328,12 @@ export class ServerManager {
|
|||||||
|
|
||||||
const match = this.service.getMatch(matchId);
|
const match = this.service.getMatch(matchId);
|
||||||
try {
|
try {
|
||||||
const result = await this.adapter.start({ matchId, mode: match.mode, secrets });
|
const result = await this.adapter.start({
|
||||||
|
matchId,
|
||||||
|
mode: match.mode,
|
||||||
|
secrets,
|
||||||
|
teamNames: { A: match.team_a_name, B: match.team_b_name },
|
||||||
|
});
|
||||||
this.update(matchId, {
|
this.update(matchId, {
|
||||||
handle: JSON.stringify(result.handle),
|
handle: JSON.stringify(result.handle),
|
||||||
host: result.host,
|
host: result.host,
|
||||||
@@ -265,6 +385,11 @@ 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');
|
||||||
@@ -281,7 +406,8 @@ export class ServerManager {
|
|||||||
maybeConfigure(matchId) {
|
maybeConfigure(matchId) {
|
||||||
const match = this.service.getMatch(matchId);
|
const match = this.service.getMatch(matchId);
|
||||||
const row = this.row(matchId);
|
const row = this.row(matchId);
|
||||||
if (match.status === 'server_setup' && row?.status === 'ready') {
|
// Auch im laufenden Match: nach einem Neustart des Servers die aktuelle Map samt Passwort wieder laden
|
||||||
|
if (['server_setup', 'live'].includes(match.status) && row?.status === 'ready') {
|
||||||
this.configure(matchId).catch((err) => this.logger.error(err));
|
this.configure(matchId).catch((err) => this.logger.error(err));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -303,7 +429,8 @@ 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;
|
||||||
this.update(matchId, { status: 'configuring', message: `Lade Map ${seriesMap.number}: ${mapName}` });
|
// Ein Mapwechsel beendet die Pause, CS2 lädt die Map neu
|
||||||
|
this.update(matchId, { status: 'configuring', paused: 0, message: `Lade Map ${seriesMap.number}: ${mapName}` });
|
||||||
this.service.log(matchId, 'server', `Konfiguration für Map ${seriesMap.number} (${mapName}) gestartet`);
|
this.service.log(matchId, 'server', `Konfiguration für Map ${seriesMap.number} (${mapName}) gestartet`);
|
||||||
|
|
||||||
const plan = buildMapCommands({
|
const plan = buildMapCommands({
|
||||||
@@ -355,7 +482,7 @@ export class ServerManager {
|
|||||||
await this.provision(matchId);
|
await this.provision(matchId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (row.status === 'config_failed' || (row.status === 'ready' && match.status === 'server_setup')) {
|
if (row.status === 'config_failed' || (row.status === 'ready' && ['server_setup', 'live'].includes(match.status))) {
|
||||||
this.service.log(matchId, 'server', 'Organisator wiederholt die Konfiguration');
|
this.service.log(matchId, 'server', 'Organisator wiederholt die Konfiguration');
|
||||||
this.configure(matchId).catch((err) => this.logger.error(err));
|
this.configure(matchId).catch((err) => this.logger.error(err));
|
||||||
return;
|
return;
|
||||||
@@ -387,10 +514,36 @@ 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);
|
||||||
@@ -432,13 +585,14 @@ 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 });
|
this.update(matchId, { status: 'stopped', stopped_at: Date.now(), message: reason, player_count: null, paused: 0 });
|
||||||
this.service.log(matchId, 'server', `Server gestoppt: ${reason}`);
|
this.service.log(matchId, 'server', `Server gestoppt: ${reason}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- Automatisches Herunterfahren ----------
|
// ---------- Automatisches Herunterfahren ----------
|
||||||
|
|
||||||
async reap(now = Date.now()) {
|
async reap(now = Date.now()) {
|
||||||
|
this.service.archiveClosed(now);
|
||||||
const timeoutMs = this.config.idleTimeoutMinutes * 60_000;
|
const timeoutMs = this.config.idleTimeoutMinutes * 60_000;
|
||||||
const rows = this.db.prepare(`SELECT * FROM servers WHERE status IN (${ACTIVE.map(() => '?').join(',')})`).all(...ACTIVE);
|
const rows = this.db.prepare(`SELECT * FROM servers WHERE status IN (${ACTIVE.map(() => '?').join(',')})`).all(...ACTIVE);
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
@@ -453,11 +607,12 @@ export class ServerManager {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const webActive = this.service.presence(row.match_id).size > 0;
|
const webActive = this.service.presence(row.match_id).size > 0;
|
||||||
let lastUse = match.last_activity_at;
|
// Ein (neu) gestarteter Server zählt als Nutzung, sonst stoppt ein Neustart nach langer Pause sofort wieder
|
||||||
|
let lastUse = Math.max(match.last_activity_at, row.started_at ?? 0);
|
||||||
if (row.status === 'configured' && row.player_count !== null) {
|
if (row.status === 'configured' && row.player_count !== null) {
|
||||||
// Echter Server mit bekannter Spielerzahl: entscheidend ist, ob jemand auf dem Server ist.
|
// Echter Server mit bekannter Spielerzahl: entscheidend ist, ob jemand auf dem Server ist.
|
||||||
if (row.player_count > 0) continue;
|
if (row.player_count > 0) continue;
|
||||||
lastUse = Math.max(row.configured_at ?? 0, row.last_player_seen_at ?? 0);
|
lastUse = Math.max(row.configured_at ?? 0, row.last_player_seen_at ?? 0, row.started_at ?? 0);
|
||||||
} else if (webActive) {
|
} else if (webActive) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,35 @@ export function validateFlow(flow, mapCount, format) {
|
|||||||
return errors;
|
return errors;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erzeugt einen Veto-Ablauf für eine beliebige Anzahl Maps. Bans und Picks wechseln sich zwischen den Teams ab:
|
||||||
|
* erst bis zu zwei Bans, dann die Picks (die Seite wählt jeweils das andere Team), dann die restlichen Bans.
|
||||||
|
* Die letzte Map ist die Entscheidungsmap, ihre Seite wählt das Team, das nicht zuletzt dran war.
|
||||||
|
* Liefert null, wenn es für das Format zu wenige Maps gibt.
|
||||||
|
*/
|
||||||
|
export function generateFlow(mapCount, format) {
|
||||||
|
const bestOf = BEST_OF[format];
|
||||||
|
if (!bestOf || mapCount < Math.max(bestOf, 2)) return null;
|
||||||
|
const picks = bestOf - 1;
|
||||||
|
const bans = mapCount - bestOf;
|
||||||
|
const before = Math.min(2, bans);
|
||||||
|
const steps = [];
|
||||||
|
let actor = 'X';
|
||||||
|
const next = () => {
|
||||||
|
const current = actor;
|
||||||
|
actor = actor === 'X' ? 'Y' : 'X';
|
||||||
|
return current;
|
||||||
|
};
|
||||||
|
for (let i = 0; i < before; i += 1) steps.push(`ban:${next()}`);
|
||||||
|
for (let i = 0; i < picks; i += 1) {
|
||||||
|
const picker = next();
|
||||||
|
steps.push(`pick:${picker}`, `side:${picker === 'X' ? 'Y' : 'X'}`);
|
||||||
|
}
|
||||||
|
for (let i = before; i < bans; i += 1) steps.push(`ban:${next()}`);
|
||||||
|
steps.push('decider', `side:${actor}`);
|
||||||
|
return steps;
|
||||||
|
}
|
||||||
|
|
||||||
function teamFor(actor, firstTeam) {
|
function teamFor(actor, firstTeam) {
|
||||||
const second = firstTeam === 'A' ? 'B' : 'A';
|
const second = firstTeam === 'A' ? 'B' : 'A';
|
||||||
return actor === 'X' ? firstTeam : second;
|
return actor === 'X' ? firstTeam : second;
|
||||||
|
|||||||
@@ -401,6 +401,7 @@ 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
|
||||||
@@ -476,3 +477,133 @@ test('Spieler verlassen die Lobby, Captain geht über', async () => {
|
|||||||
await close();
|
await close();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
test('Neustart im laufenden Match: Map wird wieder geladen, Server läuft die volle Frist', async () => {
|
||||||
|
const { app, call, close } = await setup({ idleTimeoutMinutes: 30 });
|
||||||
|
try {
|
||||||
|
const { id, org, tokens } = await readyWingmanMatch(call, 'bo1');
|
||||||
|
await call('POST', `/matches/${id}/veto/start`, { token: org });
|
||||||
|
await playVeto(call, id, { A: tokens.a1, B: tokens.b1 });
|
||||||
|
await app.manager.poll();
|
||||||
|
await waitFor(() => app.service.getMatch(id).status === 'live', { label: 'live' });
|
||||||
|
|
||||||
|
// Lange nichts los, dann stoppt der Server
|
||||||
|
const hourAgo = Date.now() - 60 * 60_000;
|
||||||
|
app.db.prepare('UPDATE matches SET last_activity_at = ? WHERE id = ?').run(hourAgo, id);
|
||||||
|
await call('POST', `/matches/${id}/server/stop`, { token: org });
|
||||||
|
await waitFor(() => app.service.serverRow(id).status === 'stopped', { label: 'gestoppt' });
|
||||||
|
|
||||||
|
// Neustart durch den Organisator: Server fährt hoch und lädt die Map wieder (Match ist weiter "live")
|
||||||
|
const res = await call('POST', `/matches/${id}/server/retry`, { token: org });
|
||||||
|
assert.equal(res.status, 200, JSON.stringify(res.body));
|
||||||
|
await waitFor(async () => { await app.manager.poll(); return app.service.serverRow(id).status === 'configured'; }, { label: 'neu konfiguriert' });
|
||||||
|
assert.equal(app.service.getMatch(id).status, 'live');
|
||||||
|
|
||||||
|
// Die Abschaltung darf den frisch gestarteten Server nicht sofort wieder stoppen
|
||||||
|
await app.manager.reap();
|
||||||
|
assert.equal(app.service.serverRow(id).status, 'configured');
|
||||||
|
// Nach Ablauf der Frist ab dem Neustart dagegen schon
|
||||||
|
await app.manager.reap(Date.now() + 31 * 60_000);
|
||||||
|
assert.equal(app.service.serverRow(id).status, 'stopped');
|
||||||
|
} finally {
|
||||||
|
await close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
test('Archiv: nur abgeschlossene Matches, eigene Liste, automatisch nach Frist', async () => {
|
||||||
|
const { app, call, close } = await setup({ adminPassword: 'admin-geheim', archiveAfterDays: 3 });
|
||||||
|
try {
|
||||||
|
const token = (await call('POST', '/admin/login', { body: { password: 'admin-geheim' } })).body.token;
|
||||||
|
const mk = async (a, b) => (await call('POST', '/matches', { body: { teamA: a, teamB: b, mode: 'wingman', format: 'bo1' } })).body;
|
||||||
|
const open = await mk('Offen A', 'Offen B');
|
||||||
|
const done = await mk('Fertig A', 'Fertig B');
|
||||||
|
const old = await mk('Alt A', 'Alt B');
|
||||||
|
|
||||||
|
// Laufende Matches lassen sich nicht archivieren
|
||||||
|
assert.equal((await call('POST', `/admin/matches/${open.id}/archive`, { token })).status, 409);
|
||||||
|
|
||||||
|
await call('POST', `/admin/matches/${done.id}/finish`, { token });
|
||||||
|
assert.equal((await call('POST', `/admin/matches/${done.id}/archive`, { token })).status, 200);
|
||||||
|
let list = (await call('GET', '/admin/matches', { token })).body;
|
||||||
|
assert.ok(!list.matches.some((m) => m.id === done.id), 'archiviertes Match fehlt in der normalen Liste');
|
||||||
|
assert.equal(list.archivedCount, 1);
|
||||||
|
const archive = (await call('GET', '/admin/matches?archived=1', { token })).body;
|
||||||
|
assert.deepEqual(archive.matches.map((m) => m.id), [done.id]);
|
||||||
|
assert.ok(archive.matches[0].archivedAt > 0);
|
||||||
|
|
||||||
|
// Zurückholen
|
||||||
|
await call('POST', `/admin/matches/${done.id}/unarchive`, { token });
|
||||||
|
list = (await call('GET', '/admin/matches', { token })).body;
|
||||||
|
assert.ok(list.matches.some((m) => m.id === done.id));
|
||||||
|
|
||||||
|
// Automatisch: abgebrochenes Match, älter als 3 Tage
|
||||||
|
await call('POST', `/admin/matches/${old.id}/cancel`, { token });
|
||||||
|
assert.equal(app.service.archiveClosed(Date.now() + 2 * 86_400_000), 0, 'nach 2 Tagen noch nicht');
|
||||||
|
await app.manager.reap(Date.now() + 4 * 86_400_000);
|
||||||
|
assert.ok(app.service.getMatch(old.id).archived_at > 0, 'nach 4 Tagen archiviert');
|
||||||
|
assert.ok(app.service.getMatch(done.id).archived_at > 0, 'zurückgeholtes Match wird nach Ablauf der Frist wieder archiviert');
|
||||||
|
assert.equal(app.service.getMatch(open.id).archived_at, null, 'offene Matches bleiben');
|
||||||
|
} finally {
|
||||||
|
await close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
test('Messer-Server: nur Admin startet, Startseite sieht laufende, Ports zählen bei der Vergabe mit', async () => {
|
||||||
|
const { app, call, close } = await setup({ adminPassword: 'admin-geheim' });
|
||||||
|
try {
|
||||||
|
assert.equal((await call('POST', '/admin/knife', { body: {} })).status, 401);
|
||||||
|
const token = (await call('POST', '/admin/login', { body: { password: 'admin-geheim' } })).body.token;
|
||||||
|
const info = (await call('GET', '/admin/knife', { token })).body;
|
||||||
|
assert.deepEqual(info.modes.map((m) => m.id).sort(), ['competitive', 'wingman']);
|
||||||
|
const created = await call('POST', '/admin/knife', { token, body: { mode: 'competitive', map: 'de_inferno' } });
|
||||||
|
assert.equal(created.status, 200, JSON.stringify(created.body));
|
||||||
|
|
||||||
|
// Noch nicht bereit: Startseite zeigt ihn nicht
|
||||||
|
assert.equal((await call('GET', '/knife-servers')).body.servers.length, 0);
|
||||||
|
await app.knife.poll();
|
||||||
|
await waitFor(() => app.knife.row(created.body.id).status === 'running', { label: 'Messer-Server bereit' });
|
||||||
|
const pub = (await call('GET', '/knife-servers')).body.servers;
|
||||||
|
assert.equal(pub.length, 1);
|
||||||
|
assert.equal(pub[0].map, 'de_inferno');
|
||||||
|
assert.ok(!JSON.stringify(pub).includes(app.knife.row(created.body.id).rcon_password));
|
||||||
|
|
||||||
|
// Ports des Messer-Servers sind für Matches belegt
|
||||||
|
const row = app.knife.row(created.body.id);
|
||||||
|
assert.ok(app.manager.usedPortsOn(row.host).has(row.port));
|
||||||
|
|
||||||
|
assert.equal((await call('DELETE', `/admin/knife/${created.body.id}`, { token })).status, 200);
|
||||||
|
assert.equal(app.knife.list().length, 0);
|
||||||
|
} finally {
|
||||||
|
await close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
test('Map-Pools im Admin: Auswahl gilt für neue Matches, Veto passt sich an, laufende Matches behalten ihre Maps', async () => {
|
||||||
|
const { app, call, close } = await setup({ adminPassword: 'admin-geheim' });
|
||||||
|
try {
|
||||||
|
const token = (await call('POST', '/admin/login', { body: { password: 'admin-geheim' } })).body.token;
|
||||||
|
const before = (await call('POST', '/matches', { body: { teamA: 'Vorher A', teamB: 'Vorher B', mode: 'competitive', format: 'bo3' } })).body;
|
||||||
|
const view = (await call('GET', '/admin/map-pools', { token })).body;
|
||||||
|
const comp = view.pools.find((p) => p.mode === 'competitive');
|
||||||
|
assert.ok(['de_overpass', 'de_vertigo', 'de_cache'].every((id) => comp.maps.some((m) => m.id === id && m.active)));
|
||||||
|
|
||||||
|
// Nur 5 Maps: BO5 geht noch, BO3 hat dann 2 Bans
|
||||||
|
const five = ['de_cache', 'de_dust2', 'de_inferno', 'de_mirage', 'de_nuke'];
|
||||||
|
const saved = await call('PUT', '/admin/map-pools', { token, body: { mode: 'competitive', maps: five } });
|
||||||
|
assert.equal(saved.status, 200, JSON.stringify(saved.body));
|
||||||
|
const pool = saved.body.pools.find((p) => p.mode === 'competitive');
|
||||||
|
assert.equal(pool.maps.filter((m) => m.active).length, 5);
|
||||||
|
assert.deepEqual(pool.formats.bo3.flow, ['ban:X', 'ban:Y', 'pick:X', 'side:Y', 'pick:Y', 'side:X', 'decider', 'side:X']);
|
||||||
|
|
||||||
|
// Neue Matches bekommen die neue Auswahl, das alte behält seine 10 Maps
|
||||||
|
const after = (await call('POST', '/matches', { body: { teamA: 'Nachher A', teamB: 'Nachher B', mode: 'competitive', format: 'bo3' } })).body;
|
||||||
|
assert.equal(JSON.parse(app.service.getMatch(after.id).maps).length, 5);
|
||||||
|
assert.equal(JSON.parse(app.service.getMatch(before.id).maps).length, 10);
|
||||||
|
// Öffentliche Konfiguration (Match erstellen) zeigt die neuen Formate
|
||||||
|
const cfg = (await call('GET', '/config')).body;
|
||||||
|
assert.equal(cfg.pools.find((p) => p.mode === 'competitive').maps.length, 5);
|
||||||
|
|
||||||
|
// Ungültig: zu wenige oder unbekannte Maps
|
||||||
|
assert.equal((await call('PUT', '/admin/map-pools', { token, body: { mode: 'competitive', maps: ['de_cache'] } })).status, 400);
|
||||||
|
assert.equal((await call('PUT', '/admin/map-pools', { token, body: { mode: 'competitive', maps: ['de_cache', 'de_foo'] } })).status, 400);
|
||||||
|
assert.equal((await call('PUT', '/admin/map-pools', { body: { mode: 'competitive', maps: five } })).status, 401);
|
||||||
|
} finally {
|
||||||
|
await close();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { createDiscordNotifier } from '../src/discord.js';
|
||||||
|
|
||||||
|
const URL_OK = 'https://discord.com/api/webhooks/123456/geheim_TOKEN-1';
|
||||||
|
|
||||||
|
test('Ohne URL ist der Notifier aus und tut nichts', async () => {
|
||||||
|
let called = false;
|
||||||
|
const n = createDiscordNotifier('', { fetchImpl: async () => { called = true; } });
|
||||||
|
assert.equal(n.enabled, false);
|
||||||
|
await n.send('Test');
|
||||||
|
assert.equal(called, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Ungültige URL wird beim Start abgelehnt, ohne die URL zu zeigen', () => {
|
||||||
|
assert.throws(() => createDiscordNotifier('https://example.com/geheim'), (err) => {
|
||||||
|
assert.match(err.message, /keine gültige Discord-Webhook-URL/);
|
||||||
|
assert.ok(!err.message.includes('geheim'));
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Nachricht wird als JSON an den Webhook geschickt, überlange Texte gekürzt', async () => {
|
||||||
|
const sent = [];
|
||||||
|
const n = createDiscordNotifier(URL_OK, {
|
||||||
|
fetchImpl: async (url, init) => { sent.push({ url, init }); return { ok: true, status: 204 }; },
|
||||||
|
});
|
||||||
|
await n.send('Hallo');
|
||||||
|
await n.send('x'.repeat(5000));
|
||||||
|
assert.equal(sent[0].url, URL_OK);
|
||||||
|
assert.equal(sent[0].init.method, 'POST');
|
||||||
|
assert.equal(JSON.parse(sent[0].init.body).content, 'Hallo');
|
||||||
|
assert.equal(JSON.parse(sent[1].init.body).content.length, 1900);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Fehler des Webhooks brechen den Ablauf nicht ab und werden protokolliert ohne URL', async () => {
|
||||||
|
const warnings = [];
|
||||||
|
const logger = { warn: (m) => warnings.push(m) };
|
||||||
|
const failing = createDiscordNotifier(URL_OK, { logger, fetchImpl: async () => { throw new TypeError('fetch failed'); } });
|
||||||
|
await failing.send('Test');
|
||||||
|
const bad = createDiscordNotifier(URL_OK, { logger, fetchImpl: async () => ({ ok: false, status: 404 }) });
|
||||||
|
await bad.send('Test');
|
||||||
|
assert.equal(warnings.length, 2);
|
||||||
|
assert.match(warnings[1], /HTTP 404/);
|
||||||
|
assert.ok(warnings.every((w) => !w.includes('TOKEN')));
|
||||||
|
});
|
||||||
@@ -39,7 +39,7 @@ test('Ohne GSLT startet der Container im LAN-Modus, mit GSLT mit Token', async (
|
|||||||
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_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, '27200');
|
||||||
assert.ok(runArgs.includes('cs2-mapveto-gamefiles:/home/steam/cs2-dedicated'));
|
assert.ok(runArgs.includes('cs2-mapveto-gamefiles:/home/steam/cs2-dedicated'));
|
||||||
assert.equal(r.host, '192.168.178.111');
|
assert.equal(r.host, '192.168.178.111');
|
||||||
|
|
||||||
@@ -74,7 +74,7 @@ test('Zweiter Container startet erst, wenn der erste seinen Update-Check abgesch
|
|||||||
d.finishUpdate(first.handle.container);
|
d.finishUpdate(first.handle.container);
|
||||||
const r2 = await second;
|
const r2 = await second;
|
||||||
assert.equal(d.calls.filter((c) => c[0] === 'run').length, 2);
|
assert.equal(d.calls.filter((c) => c[0] === 'run').length, 2);
|
||||||
assert.equal(r2.port, 27102, 'zweiter Container bekommt die nächsten Ports');
|
assert.equal(r2.port, 27101, 'zweiter Container bekommt den nächsten Port');
|
||||||
d.finishUpdate(r2.handle.container);
|
d.finishUpdate(r2.handle.container);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { DockerHostPool } from '../src/servers/dockerAdapter.js';
|
||||||
|
|
||||||
|
const secrets = { rconPassword: 'Rcon123', serverPassword: 'Pw123', tvPassword: 'Tv123' };
|
||||||
|
|
||||||
|
const hostA = { name: 'a', publicHost: '10.0.0.1', portRangeStart: 27100, portRangeEnd: 27103 }; // 2 Paare
|
||||||
|
const hostB = { name: 'b', publicHost: '10.0.0.2', portRangeStart: 27100, portRangeEnd: 27139 }; // 20 Paare
|
||||||
|
|
||||||
|
function options(hosts) {
|
||||||
|
return {
|
||||||
|
bin: 'docker', image: 'joedwards32/cs2:latest', host: '10.0.0.9', rconHost: '10.0.0.9',
|
||||||
|
portRangeStart: 27015, portRangeEnd: 27115, gslt: '', volume: 'cs2-mapveto-gamefiles', extraArgs: '',
|
||||||
|
startTimeoutMinutes: 1, hosts,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Docker-Aufrufe merken, Container gelten sofort als fertig. */
|
||||||
|
function fakeDocker() {
|
||||||
|
const calls = [];
|
||||||
|
const run = async (args) => {
|
||||||
|
calls.push(args);
|
||||||
|
if (args[0] === 'inspect') return 'running';
|
||||||
|
if (args[0] === 'logs') return 'Starting CS2 Dedicated Server';
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
return { run, calls };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nur die Konfiguration der Hosts, ohne auf echte Container zu warten. */
|
||||||
|
function pool(hosts, used = {}) {
|
||||||
|
const docker = fakeDocker();
|
||||||
|
const p = new DockerHostPool(options(hosts), {
|
||||||
|
getUsedPorts: (publicHost) => new Set(used[publicHost] ?? []),
|
||||||
|
runDocker: docker.run,
|
||||||
|
});
|
||||||
|
for (const a of p.adapters.values()) a.waitForUpdateCheck = async () => {};
|
||||||
|
return { pool: p, calls: docker.calls };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Ohne CS2_DOCKER_HOSTS gibt es genau einen Host, wie bisher', () => {
|
||||||
|
const { pool: p } = pool(null);
|
||||||
|
assert.equal(p.hosts.length, 1);
|
||||||
|
assert.equal(p.hosts[0].publicHost, '10.0.0.9');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Ein Match landet auf dem Host mit den meisten freien Ports', async () => {
|
||||||
|
const { pool: p } = pool([hostA, hostB], { '10.0.0.1': [27100] }); // A hat ein Paar belegt, B ist frei
|
||||||
|
const r = await p.start({ matchId: 'm1', mode: 'competitive', secrets });
|
||||||
|
assert.equal(r.handle.hostName, 'b');
|
||||||
|
assert.equal(r.host, '10.0.0.2');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Ein voller Host wird übersprungen', async () => {
|
||||||
|
const fullA = Array.from({ length: 4 }, (_, i) => 27100 + i); // alle Ports von A belegt
|
||||||
|
const { pool: p } = pool([hostA, hostB], { '10.0.0.1': fullA });
|
||||||
|
const r = await p.start({ matchId: 'm2', mode: 'wingman', secrets });
|
||||||
|
assert.equal(r.handle.hostName, 'b');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Sind alle Hosts voll, gibt es einen klaren Fehler', async () => {
|
||||||
|
const fullA = Array.from({ length: 4 }, (_, i) => 27100 + i);
|
||||||
|
const fullB = Array.from({ length: 40 }, (_, i) => 27100 + i);
|
||||||
|
const { pool: p } = pool([hostA, hostB], { '10.0.0.1': fullA, '10.0.0.2': fullB });
|
||||||
|
await assert.rejects(p.start({ matchId: 'm3', mode: 'wingman', secrets }), /Kein Docker-Host mit freien Ports/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Status, Stopp und Befehle gehen an den Host des Matches', async () => {
|
||||||
|
const { pool: p, calls } = pool([hostA, hostB], { '10.0.0.1': [27100] });
|
||||||
|
const r = await p.start({ matchId: 'm4', mode: 'competitive', secrets });
|
||||||
|
calls.length = 0;
|
||||||
|
await p.stop(r.handle);
|
||||||
|
assert.deepEqual(calls.at(-1)[0], 'rm');
|
||||||
|
assert.equal(calls.at(-1).at(-1), r.handle.container);
|
||||||
|
// Ein Handle von einem Host, der weder per Name noch per Adresse bekannt ist, wird abgelehnt
|
||||||
|
await assert.rejects(p.status({ ...r.handle, hostName: 'gelöscht', rconHost: '10.9.9.9' }), /nicht \(mehr\) konfiguriert/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Alte Handles ohne Host-Namen laufen auf dem ersten Host', () => {
|
||||||
|
const { pool: p } = pool([hostA, hostB]);
|
||||||
|
assert.equal(p.adapterFor({ container: 'x' }), p.adapters.get('a'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Auslastung pro Host: Serverplätze insgesamt und belegt', () => {
|
||||||
|
const { pool: p } = pool([hostA, hostB], { '10.0.0.1': [27100] });
|
||||||
|
assert.deepEqual(p.hostStats(), [
|
||||||
|
{ name: 'a', publicHost: '10.0.0.1', total: 4, used: 1 },
|
||||||
|
{ name: 'b', publicHost: '10.0.0.2', total: 40, used: 0 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
test('df-Ausgabe wird in Gesamt- und freien Speicher zerlegt', async () => {
|
||||||
|
const { parseDf } = await import('../src/servers/dockerAdapter.js');
|
||||||
|
assert.deepEqual(parseDf('/dev/sda1 1000000000000 400000000000 600000000000 40% /var/lib/docker'), {
|
||||||
|
totalBytes: 1000000000000,
|
||||||
|
freeBytes: 600000000000,
|
||||||
|
});
|
||||||
|
assert.equal(parseDf('kaputt'), null);
|
||||||
|
});
|
||||||
|
test('Alter Handle ohne passenden Host-Namen wird über die Adresse dem richtigen Host zugeordnet', async () => {
|
||||||
|
const docker = fakeDocker();
|
||||||
|
const p = new DockerHostPool(options([
|
||||||
|
{ ...hostA, rconHost: '10.0.0.1' },
|
||||||
|
{ ...hostB, rconHost: '10.0.0.2' },
|
||||||
|
]), { runDocker: docker.run });
|
||||||
|
await p.stop({ container: 'cs2mv-alt', hostName: 'default', rconHost: '10.0.0.2', port: 27100 });
|
||||||
|
assert.deepEqual(docker.calls.at(-1), ['rm', '-f', 'cs2mv-alt']);
|
||||||
|
assert.equal(p.adapterFor({ hostName: 'default', rconHost: '10.0.0.2' }).options.name, 'b');
|
||||||
|
assert.throws(() => p.adapterFor({ hostName: 'weg', rconHost: '10.9.9.9' }), /nicht \(mehr\) konfiguriert/);
|
||||||
|
});
|
||||||
+84
@@ -0,0 +1,84 @@
|
|||||||
|
10/25/2026 - 18:06:44.021 - rcon from "192.168.178.31:54420": command "status"
|
||||||
|
10/25/2026 - 18:06:48.883 - "Spieler1<0><[U:1:1000001]><>" entered the game
|
||||||
|
10/25/2026 - 18:07:05.771 - "Spieler1<0><[U:1:1000001]>" switched from team <Unassigned> to <CT>
|
||||||
|
10/25/2026 - 18:07:18.570 - "Spieler1<0><[U:1:1000001]><CT>" [1331 132 138] killed "Sarge<10><BOT><TERRORIST>" [1410 322 128] with "usp_silencer" (headshot)
|
||||||
|
10/25/2026 - 18:07:52.100 - rcon from "192.168.178.31:61554": command "mp_warmup_pausetimer 0"
|
||||||
|
10/25/2026 - 18:07:52.381 - rcon from "192.168.178.31:61554": command "mp_warmup_end"
|
||||||
|
10/25/2026 - 18:07:52.396 - Starting Freeze period
|
||||||
|
10/25/2026 - 18:07:52.396 - JSON_BEGIN{
|
||||||
|
10/25/2026 - 18:07:52.396 - "name": "round_stats",
|
||||||
|
10/25/2026 - 18:07:52.396 - "round_number" : "1",
|
||||||
|
10/25/2026 - 18:07:52.396 - "map" : "de_inferno",
|
||||||
|
10/25/2026 - 18:07:52.396 - }}JSON_END
|
||||||
|
10/25/2026 - 18:07:52.396 - World triggered "Match_Start" on "de_inferno"
|
||||||
|
10/25/2026 - 18:07:52.396 - Team playing "CT": MaxLAN Blau
|
||||||
|
10/25/2026 - 18:07:52.396 - Team playing "TERRORIST": MaxLAN Rot
|
||||||
|
10/25/2026 - 18:07:52.396 - "Darryl<12><BOT><TERRORIST>" triggered "Got_The_Bomb"
|
||||||
|
10/25/2026 - 18:07:52.396 - MatchStatus: Team playing "CT": MaxLAN Blau
|
||||||
|
10/25/2026 - 18:07:52.396 - MatchStatus: Team playing "TERRORIST": MaxLAN Rot
|
||||||
|
10/25/2026 - 18:07:52.396 - MatchStatus: Score: 0:0 on map "de_inferno" RoundsPlayed: 0
|
||||||
|
10/25/2026 - 18:08:01.869 - World triggered "Round_Start"
|
||||||
|
10/25/2026 - 18:08:24.463 - "Darryl<12><BOT><TERRORIST>" triggered "Planted_The_Bomb" at bombsite A
|
||||||
|
10/25/2026 - 18:08:30.541 - "Darryl<12><BOT><TERRORIST>" [2059 398 160] killed "Maximus<11><BOT><CT>" [2356 255 126] with "glock"
|
||||||
|
10/25/2026 - 18:08:30.541 - Team "TERRORIST" triggered "SFUI_Notice_Terrorists_Win" (CT "0") (T "1")
|
||||||
|
10/25/2026 - 18:08:30.541 - Team "CT" scored "0" with "3" players
|
||||||
|
10/25/2026 - 18:08:30.541 - Team "TERRORIST" scored "1" with "2" players
|
||||||
|
10/25/2026 - 18:08:30.541 - MatchStatus: Team playing "CT": MaxLAN Blau
|
||||||
|
10/25/2026 - 18:08:30.541 - MatchStatus: Team playing "TERRORIST": MaxLAN Rot
|
||||||
|
10/25/2026 - 18:08:30.541 - MatchStatus: Score: 0:1 on map "de_inferno" RoundsPlayed: 1
|
||||||
|
10/25/2026 - 18:08:30.541 - World triggered "Round_End"
|
||||||
|
10/25/2026 - 18:08:33.541 - Starting Freeze period
|
||||||
|
10/25/2026 - 18:08:33.541 - MatchStatus: Team playing "CT": MaxLAN Blau
|
||||||
|
10/25/2026 - 18:08:33.541 - MatchStatus: Team playing "TERRORIST": MaxLAN Rot
|
||||||
|
10/25/2026 - 18:08:33.541 - MatchStatus: Score: 0:1 on map "de_inferno" RoundsPlayed: 1
|
||||||
|
10/25/2026 - 18:08:36.543 - World triggered "Round_Start"
|
||||||
|
10/25/2026 - 18:09:08.839 - "Spieler1<0><[U:1:1000001]><CT>" [2254 1009 159] killed "Sarge<10><BOT><TERRORIST>" [2287 352 132] with "famas" (headshot)
|
||||||
|
10/25/2026 - 18:09:08.839 - Team "CT" triggered "SFUI_Notice_CTs_Win" (CT "1") (T "1")
|
||||||
|
10/25/2026 - 18:09:08.839 - Team "CT" scored "1" with "3" players
|
||||||
|
10/25/2026 - 18:09:08.839 - Team "TERRORIST" scored "1" with "3" players
|
||||||
|
10/25/2026 - 18:09:08.839 - MatchStatus: Team playing "CT": MaxLAN Blau
|
||||||
|
10/25/2026 - 18:09:08.839 - MatchStatus: Team playing "TERRORIST": MaxLAN Rot
|
||||||
|
10/25/2026 - 18:09:08.839 - MatchStatus: Score: 1:1 on map "de_inferno" RoundsPlayed: 2
|
||||||
|
10/25/2026 - 18:09:08.839 - World triggered "Round_End"
|
||||||
|
10/25/2026 - 18:09:17.339 - "Spieler1<0><[U:1:1000001]>" switched from team <CT> to <TERRORIST>
|
||||||
|
10/25/2026 - 18:09:17.339 - "Sarge<10><BOT>" switched from team <TERRORIST> to <CT>
|
||||||
|
10/25/2026 - 18:09:17.339 - Starting Freeze period
|
||||||
|
10/25/2026 - 18:09:17.339 - "Spieler1<0><[U:1:1000001]><TERRORIST>" triggered "Got_The_Bomb"
|
||||||
|
10/25/2026 - 18:09:17.339 - MatchStatus: Team playing "CT": MaxLAN Blau
|
||||||
|
10/25/2026 - 18:09:17.339 - MatchStatus: Team playing "TERRORIST": MaxLAN Rot
|
||||||
|
10/25/2026 - 18:09:17.339 - MatchStatus: Score: 1:1 on map "de_inferno" RoundsPlayed: 2
|
||||||
|
10/25/2026 - 18:09:26.841 - World triggered "Round_Start"
|
||||||
|
10/25/2026 - 18:09:35.935 - "Spieler1<0><[U:1:1000001]><TERRORIST>" [1167 474 111] killed "Darryl<12><BOT><CT>" [1326 435 129] with "deagle" (headshot)
|
||||||
|
10/25/2026 - 18:09:35.935 - Team "TERRORIST" triggered "SFUI_Notice_Terrorists_Win" (CT "1") (T "2")
|
||||||
|
10/25/2026 - 18:09:35.935 - Team "CT" scored "1" with "3" players
|
||||||
|
10/25/2026 - 18:09:35.935 - Team "TERRORIST" scored "2" with "4" players
|
||||||
|
10/25/2026 - 18:09:35.935 - MatchStatus: Team playing "CT": MaxLAN Rot
|
||||||
|
10/25/2026 - 18:09:35.935 - MatchStatus: Team playing "TERRORIST": MaxLAN Blau
|
||||||
|
10/25/2026 - 18:09:35.935 - MatchStatus: Score: 1:2 on map "de_inferno" RoundsPlayed: 3
|
||||||
|
10/25/2026 - 18:09:35.935 - World triggered "Round_End"
|
||||||
|
10/25/2026 - 18:09:38.935 - Starting Freeze period
|
||||||
|
10/25/2026 - 18:09:38.935 - MatchStatus: Team playing "CT": MaxLAN Rot
|
||||||
|
10/25/2026 - 18:09:38.935 - MatchStatus: Team playing "TERRORIST": MaxLAN Blau
|
||||||
|
10/25/2026 - 18:09:38.935 - MatchStatus: Score: 1:2 on map "de_inferno" RoundsPlayed: 3
|
||||||
|
10/25/2026 - 18:09:41.936 - World triggered "Round_Start"
|
||||||
|
10/25/2026 - 18:09:59.311 - "Spieler1<0><[U:1:1000001]><TERRORIST>" [1364 124 132] killed "Sarge<10><BOT><CT>" [1746 -51 135] with "ak47"
|
||||||
|
10/25/2026 - 18:09:59.311 - Team "TERRORIST" triggered "SFUI_Notice_Terrorists_Win" (CT "1") (T "3")
|
||||||
|
10/25/2026 - 18:09:59.311 - Team "CT" scored "1" with "5" players
|
||||||
|
10/25/2026 - 18:09:59.311 - Team "TERRORIST" scored "3" with "2" players
|
||||||
|
10/25/2026 - 18:09:59.311 - MatchStatus: Team playing "CT": MaxLAN Rot
|
||||||
|
10/25/2026 - 18:09:59.311 - MatchStatus: Team playing "TERRORIST": MaxLAN Blau
|
||||||
|
10/25/2026 - 18:09:59.311 - MatchStatus: Score: 1:3 on map "de_inferno" RoundsPlayed: 4
|
||||||
|
10/25/2026 - 18:09:59.311 - World triggered "Round_End"
|
||||||
|
10/25/2026 - 18:09:59.311 - ACCOLADE, FINAL: {killreward}, Spieler1<0>, VALUE: 1800.000000, POS: 1, SCORE: 70.000000
|
||||||
|
10/25/2026 - 18:09:59.311 - MatchStatus: Team playing "CT": MaxLAN Rot
|
||||||
|
10/25/2026 - 18:09:59.311 - MatchStatus: Team playing "TERRORIST": MaxLAN Blau
|
||||||
|
10/25/2026 - 18:09:59.311 - MatchStatus: Score: 1:3 on map "de_inferno" RoundsPlayed: 4
|
||||||
|
10/25/2026 - 18:09:59.311 - Game Over: scrimcomp2v2 de_inferno score 1:3 after 3 min
|
||||||
|
10/25/2026 - 18:10:24.326 - World triggered "Match_Start" on "de_inferno"
|
||||||
|
10/25/2026 - 18:10:24.326 - Team playing "CT": MaxLAN Rot
|
||||||
|
10/25/2026 - 18:10:24.326 - Team playing "TERRORIST": MaxLAN Blau
|
||||||
|
10/25/2026 - 18:10:24.326 - MatchStatus: Score: 0:0 on map "de_inferno" RoundsPlayed: -1
|
||||||
|
10/25/2026 - 18:10:26.336 - World triggered "Warmup_Start"
|
||||||
|
10/25/2026 - 18:10:31.304 - World triggered "Game_Commencing"
|
||||||
|
10/25/2026 - 18:10:31.304 - World triggered "Match_Start" on "de_inferno"
|
||||||
|
10/25/2026 - 18:10:33.293 - World triggered "Warmup_Start"
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { loadConfig } from '../src/config.js';
|
||||||
|
import { openDatabase } from '../src/db.js';
|
||||||
|
import { buildKnifeCommands, KnifeServers } from '../src/servers/knifeServers.js';
|
||||||
|
|
||||||
|
const pools = {
|
||||||
|
competitive: { label: 'Competitive 5 gegen 5', gameType: 0, gameMode: 1, teamSize: 5, maps: [{ id: 'de_mirage', name: 'Mirage' }, { id: 'de_inferno', name: 'Inferno' }] },
|
||||||
|
wingman: { label: 'Wingman 2 gegen 2', gameType: 0, gameMode: 2, teamSize: 2, maps: [{ id: 'de_vertigo', name: 'Vertigo' }] },
|
||||||
|
};
|
||||||
|
const quiet = { log() {}, warn() {}, error() {} };
|
||||||
|
|
||||||
|
/** Adapter, dessen Zustand der Test steuert. */
|
||||||
|
function fakeAdapter() {
|
||||||
|
const a = {
|
||||||
|
name: 'docker',
|
||||||
|
starts: [],
|
||||||
|
configures: [],
|
||||||
|
commands: [],
|
||||||
|
stops: 0,
|
||||||
|
state: 'starting',
|
||||||
|
players: null,
|
||||||
|
async start(args) {
|
||||||
|
a.starts.push(args);
|
||||||
|
return { handle: { container: `cs2mv-${args.matchId}`, n: a.starts.length }, host: '10.0.0.5', port: 27015 + a.starts.length - 1, tvPort: 27115 };
|
||||||
|
},
|
||||||
|
async status() { return { state: a.state, playerCount: a.players }; },
|
||||||
|
async configure(handle, plan) { a.configures.push(plan); },
|
||||||
|
async sendCommands(handle, cmds) { a.commands.push(...cmds); },
|
||||||
|
async stop() { a.stops += 1; },
|
||||||
|
};
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setup() {
|
||||||
|
const config = loadConfig({ knife: { map: 'de_mirage', warmupSeconds: 60, maxServers: 2 }, docker: { serverNamePrefix: 'MaxLAN' } });
|
||||||
|
const adapter = fakeAdapter();
|
||||||
|
const knife = new KnifeServers({ db: openDatabase(':memory:'), adapter, config, pools, logger: quiet });
|
||||||
|
return { knife, adapter };
|
||||||
|
}
|
||||||
|
const poll = async (knife) => { knife.lastPoll.clear(); await knife.poll(); };
|
||||||
|
|
||||||
|
test('Messerrunde: nur Messer, eine Runde, danach automatisch neues Spiel', () => {
|
||||||
|
const plan = buildKnifeCommands({ map: 'de_inferno', warmupSeconds: 45 });
|
||||||
|
assert.equal(plan.beforeMapChange.at(-1), 'changelevel de_inferno');
|
||||||
|
assert.ok(plan.beforeMapChange.includes('sv_password ""'), 'offen, ohne Passwort');
|
||||||
|
for (const c of ['mp_maxrounds 1', 'mp_match_end_restart 1', 'mp_ct_default_secondary ""', 'mp_startmoney 0', 'mp_warmuptime 45']) {
|
||||||
|
assert.ok(plan.afterMapChange.includes(c), `${c} fehlt`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Start, Konfiguration und Ansicht ohne Geheimnisse', async () => {
|
||||||
|
const { knife, adapter } = setup();
|
||||||
|
const created = await knife.create({});
|
||||||
|
assert.equal(created.name, 'Messer 1');
|
||||||
|
assert.equal(created.status, 'starting');
|
||||||
|
assert.equal(adapter.starts[0].serverName, 'MaxLAN | Messer 1 5v5 (Seitenwahl)');
|
||||||
|
assert.equal(adapter.starts[0].mode, 'competitive');
|
||||||
|
assert.equal(adapter.starts[0].secrets.serverPassword, '');
|
||||||
|
|
||||||
|
adapter.state = 'running';
|
||||||
|
await poll(knife);
|
||||||
|
assert.equal(knife.row('knife1').status, 'running');
|
||||||
|
assert.equal(adapter.configures[0].map, 'de_mirage');
|
||||||
|
|
||||||
|
const [view] = knife.list();
|
||||||
|
assert.equal(view.connect, 'connect 10.0.0.5:27015');
|
||||||
|
assert.ok(!JSON.stringify(view).includes(knife.row('knife1').rcon_password), 'kein RCON-Passwort in der Ansicht');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Wingman 2v2: eigener Spielmodus, Maps nur aus dem Wingman-Pool', async () => {
|
||||||
|
const { knife, adapter } = setup();
|
||||||
|
const k = await knife.create({ mode: 'wingman' });
|
||||||
|
assert.equal(k.modeShort, '2v2');
|
||||||
|
assert.equal(k.map, 'de_vertigo', 'Standard-Map gibt es im Wingman-Pool nicht, also die erste');
|
||||||
|
assert.equal(adapter.starts[0].mode, 'wingman');
|
||||||
|
assert.equal(adapter.starts[0].serverName, 'MaxLAN | Messer 1 2v2 (Seitenwahl)');
|
||||||
|
adapter.state = 'running';
|
||||||
|
await poll(knife);
|
||||||
|
assert.ok(adapter.configures[0].beforeMapChange.includes('game_mode 2'));
|
||||||
|
await assert.rejects(knife.create({ mode: 'wingman', map: 'de_mirage' }), /gibt es nicht für Wingman/);
|
||||||
|
await assert.rejects(knife.create({ mode: 'deathmatch' }), /Unbekannter Modus/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Grenzen: nur Maps aus den Pools, höchstens maxServers', async () => {
|
||||||
|
const { knife } = setup();
|
||||||
|
await assert.rejects(knife.create({ map: 'de_unbekannt' }), /gibt es nicht/);
|
||||||
|
await knife.create({ map: 'de_inferno' });
|
||||||
|
await knife.create({});
|
||||||
|
await assert.rejects(knife.create({}), /Höchstens 2/);
|
||||||
|
// Freie Nummer wird wiederverwendet
|
||||||
|
await knife.remove('knife1');
|
||||||
|
assert.equal((await knife.create({})).name, 'Messer 1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Teams gehen runter: Server setzt sich für die nächsten zurück', async () => {
|
||||||
|
const { knife, adapter } = setup();
|
||||||
|
await knife.create({});
|
||||||
|
adapter.state = 'running';
|
||||||
|
await poll(knife);
|
||||||
|
adapter.players = 4;
|
||||||
|
await poll(knife);
|
||||||
|
assert.equal(adapter.commands.length, 0);
|
||||||
|
adapter.players = 0;
|
||||||
|
await poll(knife);
|
||||||
|
assert.deepEqual(adapter.commands, ['mp_warmup_start']);
|
||||||
|
assert.match(knife.row('knife1').message, /zurückgesetzt/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Ausfall: Server startet neu, nach zu vielen Ausfällen gibt die App auf', async () => {
|
||||||
|
const { knife, adapter } = setup();
|
||||||
|
await knife.create({});
|
||||||
|
adapter.state = 'failed';
|
||||||
|
await poll(knife);
|
||||||
|
assert.equal(adapter.starts.length, 2, 'nach Ausfall neu gestartet');
|
||||||
|
assert.equal(knife.row('knife1').restarts, 1);
|
||||||
|
for (let i = 0; i < 6; i++) await poll(knife);
|
||||||
|
assert.equal(adapter.starts.length, 6, 'höchstens 5 Neustarts');
|
||||||
|
assert.equal(knife.row('knife1').status, 'failed');
|
||||||
|
// Neustart durch den Admin setzt den Zähler zurück
|
||||||
|
adapter.state = 'starting';
|
||||||
|
await knife.restart('knife1');
|
||||||
|
assert.equal(knife.row('knife1').restarts, 0);
|
||||||
|
assert.equal(adapter.starts.length, 7);
|
||||||
|
});
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { formatPortList, parsePortList } from '../src/config.js';
|
||||||
|
import { countSlots, DockerHostPool, planPorts } from '../src/servers/dockerAdapter.js';
|
||||||
|
|
||||||
|
const host = { portRangeStart: 27100, portRangeEnd: 27105, lanPorts: [27015, 27016] };
|
||||||
|
const secrets = { rconPassword: 'Rcon123', serverPassword: 'Pw123', tvPassword: 'Tv123' };
|
||||||
|
|
||||||
|
test('Portlisten werden gelesen und kompakt geschrieben', () => {
|
||||||
|
assert.deepEqual(parsePortList('27015-27017, 27020'), [27015, 27016, 27017, 27020]);
|
||||||
|
assert.deepEqual(parsePortList(''), []);
|
||||||
|
assert.throws(() => parsePortList('abc'), /kein Port/);
|
||||||
|
assert.throws(() => parsePortList('80-90'), /ungültig/);
|
||||||
|
assert.equal(formatPortList([27020, 27015, 27016, 27017]), '27015-27017,27020');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Spielport kommt zuerst aus den LAN-Ports, CSTV liegt immer 100 darüber', () => {
|
||||||
|
assert.deepEqual(planPorts(host, new Set()), { port: 27015, tvPort: 27115 });
|
||||||
|
assert.deepEqual(planPorts(host, new Set([27015, 27115])), { port: 27016, tvPort: 27116 });
|
||||||
|
// LAN-Ports voll: weiter im normalen Bereich, CSTV ebenfalls +100
|
||||||
|
assert.deepEqual(planPorts(host, new Set([27015, 27016, 27115, 27116])), { port: 27100, tvPort: 27200 });
|
||||||
|
// Spielport frei, aber sein CSTV-Port belegt: nächster Spielport
|
||||||
|
assert.deepEqual(planPorts({ ...host, lanPorts: [] }, new Set([27200])), { port: 27101, tvPort: 27201 });
|
||||||
|
assert.equal(planPorts({ ...host, lanPorts: [] }, new Set([27100, 27101, 27102, 27103, 27104, 27105])), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Abstand zwischen Spiel- und CSTV-Port ist einstellbar', () => {
|
||||||
|
assert.deepEqual(planPorts({ ...host, tvPortOffset: 5 }, new Set()), { port: 27015, tvPort: 27020 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Plätze pro Host: 2 LAN-Ports plus 6 Spielports im normalen Bereich', () => {
|
||||||
|
assert.equal(countSlots(host, new Set()), 8);
|
||||||
|
assert.equal(countSlots({ ...host, lanPorts: [] }, new Set()), 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
function fakePool({ busy = [] } = {}) {
|
||||||
|
const calls = [];
|
||||||
|
const runDocker = async (args) => {
|
||||||
|
calls.push(args);
|
||||||
|
if (args[0] === 'run') {
|
||||||
|
const ports = args.filter((a, i) => args[i - 1] === '-p').map((a) => Number(a.split(':')[0]));
|
||||||
|
const portEnv = args.find((a) => a.startsWith('CS2_PORT='));
|
||||||
|
if (args.includes('--network') && portEnv) ports.push(Number(portEnv.split('=')[1]));
|
||||||
|
if (ports.some((p) => busy.includes(p))) {
|
||||||
|
const err = new Error('Command failed');
|
||||||
|
err.stderr = 'Bind for 0.0.0.0:27015 failed: port is already allocated';
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (args[0] === 'inspect') return 'running';
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
const p = new DockerHostPool({
|
||||||
|
bin: 'docker', image: 'joedwards32/cs2:latest', host: '10.0.0.9', rconHost: '10.0.0.9',
|
||||||
|
portRangeStart: 27100, portRangeEnd: 27139, gslt: '', volume: 'v', extraArgs: '',
|
||||||
|
startTimeoutMinutes: 1, lanPorts: [27015, 27016], serverNamePrefix: 'MaxLAN',
|
||||||
|
hosts: [{ name: 'a', publicHost: '10.0.0.1' }],
|
||||||
|
}, { runDocker });
|
||||||
|
for (const a of p.adapters.values()) a.waitForUpdateCheck = async () => {};
|
||||||
|
return { pool: p, calls };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Servername zeigt die Teams, Spielport ist ein LAN-Port', async () => {
|
||||||
|
const { pool: p, calls } = fakePool();
|
||||||
|
const r = await p.start({ matchId: 'm1', mode: 'wingman', secrets, teamNames: { A: 'Rote "Füchse"', B: 'Blau' } });
|
||||||
|
assert.equal(r.port, 27015);
|
||||||
|
assert.equal(r.tvPort, 27115);
|
||||||
|
const run = calls.find((c) => c[0] === 'run');
|
||||||
|
assert.ok(run.includes('CS2_SERVERNAME=MaxLAN | Rote Füchse vs. Blau'));
|
||||||
|
// Auf LAN-Ports im Host-Netz, damit Broadcasts des Serverbrowsers ankommen; keine Port-Weiterleitung
|
||||||
|
assert.equal(run[run.indexOf('--network') + 1], 'host');
|
||||||
|
assert.ok(!run.includes('-p'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Server außerhalb der LAN-Ports laufen weiter mit Port-Weiterleitung', async () => {
|
||||||
|
const { pool: p, calls } = fakePool();
|
||||||
|
await p.start({ matchId: 'x1', mode: 'wingman', secrets, teamNames: { A: 'A', B: 'B' } });
|
||||||
|
await p.start({ matchId: 'x2', mode: 'wingman', secrets, teamNames: { A: 'A', B: 'B' } });
|
||||||
|
calls.length = 0;
|
||||||
|
// Die App kennt ihre Ports aus der Datenbank; hier sind beide LAN-Ports belegt
|
||||||
|
const adapter = p.adapters.get('a');
|
||||||
|
adapter.getUsedPorts = () => new Set([27015, 27016, 27115, 27116]);
|
||||||
|
await adapter.launch({ matchId: 'x3', mode: 'wingman', secrets });
|
||||||
|
const run = calls.find((c) => c[0] === 'run');
|
||||||
|
assert.ok(!run.includes('--network'));
|
||||||
|
assert.ok(run.includes('27100:27100/udp'));
|
||||||
|
assert.ok(run.includes('27200:27200/udp'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Ist ein LAN-Port von einem fremden Dienst belegt, nimmt der Start den nächsten', async () => {
|
||||||
|
const { pool: p, calls } = fakePool({ busy: [27015] });
|
||||||
|
const r = await p.start({ matchId: 'm2', mode: 'competitive', secrets, teamNames: { A: 'A', B: 'B' } });
|
||||||
|
assert.equal(r.port, 27016);
|
||||||
|
assert.equal(calls.filter((c) => c[0] === 'run').length, 2);
|
||||||
|
});
|
||||||
@@ -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 Logzeilen eines CS2-Servers (Wingman, 4 Runden, Halbzeit nach 2)', () => {
|
test('Echte, anonymisierte Logzeilen eines CS2-Servers (Wingman, 4 Runden, Halbzeit nach 2)', () => {
|
||||||
const lines = fs.readFileSync(path.join(here, 'fixtures', 'echte-logs-wingman.log'), 'utf8').split(/\r?\n/).filter(Boolean);
|
const lines = fs.readFileSync(path.join(here, 'fixtures', 'wingman-anonym.log'), 'utf8').split(/\r?\n/).filter(Boolean);
|
||||||
const warmupEnd = lines.findIndex((l) => l.includes('command "mp_warmup_end"'));
|
const 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 };
|
||||||
|
|
||||||
|
|||||||
@@ -64,6 +64,8 @@ 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', () => {
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { loadConfig } from '../src/config.js';
|
||||||
|
import { openDatabase } from '../src/db.js';
|
||||||
|
import { ServerSettings } from '../src/serverSettings.js';
|
||||||
|
import { DockerHostPool } from '../src/servers/dockerAdapter.js';
|
||||||
|
|
||||||
|
function setup() {
|
||||||
|
const config = loadConfig({ match: { maxRoundsCompetitive: 24, extraCommands: [] }, vetoStepSeconds: 30 });
|
||||||
|
const db = openDatabase(':memory:');
|
||||||
|
return { config, db, settings: new ServerSettings({ db, config }) };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Geänderte Einstellungen landen in der Konfiguration und überstehen einen Neustart', () => {
|
||||||
|
const { config, db, settings } = setup();
|
||||||
|
const changed = settings.update({ maxRoundsCompetitive: 30, extraCommands: 'mp_freezetime 15; sv_cheats 0', vetoStepSeconds: 30 });
|
||||||
|
assert.deepEqual(changed, ['maxRoundsCompetitive', 'extraCommands']);
|
||||||
|
assert.equal(config.match.maxRoundsCompetitive, 30);
|
||||||
|
assert.deepEqual(config.match.extraCommands, ['mp_freezetime 15', 'sv_cheats 0']);
|
||||||
|
|
||||||
|
// Neustart: neue Konfiguration aus der .env, gespeicherte Werte werden wieder darübergelegt
|
||||||
|
const fresh = loadConfig({ match: { maxRoundsCompetitive: 24, extraCommands: [] } });
|
||||||
|
new ServerSettings({ db, config: fresh }).load();
|
||||||
|
assert.equal(fresh.match.maxRoundsCompetitive, 30);
|
||||||
|
assert.deepEqual(fresh.match.extraCommands, ['mp_freezetime 15', 'sv_cheats 0']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Ungültige Werte und gesperrte Befehle werden abgelehnt', () => {
|
||||||
|
const { config, settings } = setup();
|
||||||
|
assert.throws(() => settings.update({ maxRoundsCompetitive: 500 }), /ganze Zahl von 2 bis 60/);
|
||||||
|
assert.throws(() => settings.update({ overtime: 'ja' }), /an oder aus/);
|
||||||
|
assert.throws(() => settings.update({ extraCommands: 'rcon_password neu' }), /nicht erlaubt/);
|
||||||
|
assert.throws(() => settings.update({ unbekannt: 1 }), /Unbekannte Einstellung/);
|
||||||
|
// Nichts davon wurde übernommen
|
||||||
|
assert.equal(config.match.maxRoundsCompetitive, 24);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Zurück auf die Vorgabe entfernt die gespeicherte Abweichung', () => {
|
||||||
|
const { settings } = setup();
|
||||||
|
settings.update({ maxRoundsCompetitive: 30 });
|
||||||
|
assert.equal(settings.view().find((f) => f.key === 'maxRoundsCompetitive').overridden, true);
|
||||||
|
settings.update({ maxRoundsCompetitive: 24 });
|
||||||
|
assert.equal(settings.view().find((f) => f.key === 'maxRoundsCompetitive').overridden, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Docker-Hosts: Prüfung der Liste und Übernahme in den Pool, Startzeit wirkt sofort', () => {
|
||||||
|
const { config, settings } = setup();
|
||||||
|
assert.throws(() => settings.parseHosts([{ name: 'a', publicHost: '1.2.3.4', dockerHost: 'ssh://x; rm -rf /' }]), /Ungültiges Docker-Ziel/);
|
||||||
|
assert.throws(() => settings.parseHosts([{ name: 'a', publicHost: '1.2.3.4', portStart: 80, portEnd: 90 }]), /Portbereich/);
|
||||||
|
|
||||||
|
const hosts = settings.parseHosts([
|
||||||
|
{ name: 'a', publicHost: '10.0.0.1', dockerHost: 'ssh://server@10.0.0.1', portStart: 27100, portEnd: 27139 },
|
||||||
|
{ name: 'b', publicHost: '10.0.0.2', dockerHost: 'ssh://b', portStart: 27100, portEnd: 27119 },
|
||||||
|
]);
|
||||||
|
const pool = new DockerHostPool({ ...config.docker, startTimeoutMinutes: 20, lanPorts: [], hosts: null });
|
||||||
|
pool.setHosts(hosts);
|
||||||
|
assert.deepEqual(pool.hostStats().map((h) => [h.name, h.total]), [['a', 40], ['b', 20]]);
|
||||||
|
|
||||||
|
pool.options.startTimeoutMinutes = 45;
|
||||||
|
assert.equal(pool.adapters.get('b').options.startTimeoutMinutes, 45);
|
||||||
|
});
|
||||||
@@ -2,11 +2,14 @@ import { test } from 'node:test';
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { computeVetoState, resolveAction, validateFlow } from '../src/veto.js';
|
import { computeVetoState, generateFlow, resolveAction, validateFlow } from '../src/veto.js';
|
||||||
import { buildMapPools, loadMapPools } from '../src/mapPools.js';
|
import { buildMapPools, loadMapPools, setActiveMaps } from '../src/mapPools.js';
|
||||||
|
|
||||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const pools = loadMapPools(path.join(here, '..', 'config', 'map-pools.json'));
|
const pools = loadMapPools(path.join(here, '..', 'config', 'map-pools.json'));
|
||||||
|
// Die klassischen 7 Competitive-Maps: dafür gilt der fest hinterlegte Ablauf
|
||||||
|
const SEVEN = ['de_ancient', 'de_anubis', 'de_dust2', 'de_inferno', 'de_mirage', 'de_nuke', 'de_train'];
|
||||||
|
const competitive7 = () => setActiveMaps(structuredClone(pools.competitive), SEVEN);
|
||||||
|
|
||||||
test('alle ausgelieferten Abläufe sind gültig, Wingman-BO5 ist nicht unterstützt', () => {
|
test('alle ausgelieferten Abläufe sind gültig, Wingman-BO5 ist nicht unterstützt', () => {
|
||||||
assert.equal(pools.competitive.formats.bo1.supported, true);
|
assert.equal(pools.competitive.formats.bo1.supported, true);
|
||||||
@@ -37,8 +40,42 @@ test('ungültiger Ablauf in der Konfiguration deaktiviert nur dieses Format', ()
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
assert.equal(custom.mini.formats.bo1.supported, true);
|
assert.equal(custom.mini.formats.bo1.supported, true);
|
||||||
assert.equal(custom.mini.formats.bo3.supported, false);
|
assert.equal(custom.mini.formats.bo1.generated, false, 'passender fester Ablauf wird genommen');
|
||||||
assert.match(custom.mini.formats.bo5.reason, /Kein Veto-Ablauf/);
|
// Ungültiger fester Ablauf: die Anwendung erzeugt einen passenden
|
||||||
|
assert.equal(custom.mini.formats.bo3.supported, true);
|
||||||
|
assert.equal(custom.mini.formats.bo3.generated, true);
|
||||||
|
assert.match(custom.mini.formats.bo5.reason, /mindestens 5 Maps/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Erzeugte Abläufe sind für jede Map-Anzahl gültig', () => {
|
||||||
|
for (let n = 2; n <= 12; n += 1) {
|
||||||
|
for (const format of ['bo1', 'bo3', 'bo5']) {
|
||||||
|
const flow = generateFlow(n, format);
|
||||||
|
const need = { bo1: 2, bo3: 3, bo5: 5 }[format];
|
||||||
|
if (n < need) {
|
||||||
|
assert.equal(flow, null, `${format} mit ${n} Maps`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
assert.deepEqual(validateFlow(flow, n, format), [], `${format} mit ${n} Maps: ${flow.join(' ')}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Bans und Picks wechseln sich ab, die Seite der Entscheidungsmap wählt das Team, das nicht zuletzt dran war
|
||||||
|
assert.deepEqual(generateFlow(5, 'bo3'), ['ban:X', 'ban:Y', 'pick:X', 'side:Y', 'pick:Y', 'side:X', 'decider', 'side:X']);
|
||||||
|
assert.deepEqual(generateFlow(3, 'bo1'), ['ban:X', 'ban:Y', 'decider', 'side:X']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Aktive Maps wählen: Formate passen sich an, Reihenfolge bleibt wie im Katalog', () => {
|
||||||
|
const pool = structuredClone(pools.competitive);
|
||||||
|
setActiveMaps(pool, ['de_vertigo', 'de_cache', 'de_mirage', 'de_unbekannt']);
|
||||||
|
assert.deepEqual(pool.maps.map((m) => m.id), ['de_cache', 'de_mirage', 'de_vertigo']);
|
||||||
|
assert.equal(pool.formats.bo1.supported, true);
|
||||||
|
assert.equal(pool.formats.bo3.supported, true);
|
||||||
|
assert.equal(pool.formats.bo5.supported, false);
|
||||||
|
// 10 Maps (alle): kein fester Ablauf passt, es wird erzeugt
|
||||||
|
assert.equal(pools.competitive.maps.length, 10);
|
||||||
|
assert.equal(pools.competitive.formats.bo3.generated, true);
|
||||||
|
// 7 klassische Maps: fester Ablauf aus der Konfiguration
|
||||||
|
assert.equal(competitive7().formats.bo3.generated, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
function play(pool, format, firstTeam, choices) {
|
function play(pool, format, firstTeam, choices) {
|
||||||
@@ -52,7 +89,7 @@ function play(pool, format, firstTeam, choices) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
test('BO3 Competitive: kompletter Ablauf mit Seitenwahl und Entscheidungsmap', () => {
|
test('BO3 Competitive: kompletter Ablauf mit Seitenwahl und Entscheidungsmap', () => {
|
||||||
const state = play(pools.competitive, 'bo3', 'B', [
|
const state = play(competitive7(), 'bo3', 'B', [
|
||||||
{ map: 'de_train' }, // B bannt
|
{ map: 'de_train' }, // B bannt
|
||||||
{ map: 'de_nuke' }, // A bannt
|
{ map: 'de_nuke' }, // A bannt
|
||||||
{ map: 'de_mirage' }, // B pickt
|
{ map: 'de_mirage' }, // B pickt
|
||||||
@@ -82,7 +119,7 @@ test('BO1 Wingman: Entscheidungsmap wird automatisch übernommen', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('falsches Team, veraltete Schritte und vergebene Maps werden abgelehnt', () => {
|
test('falsches Team, veraltete Schritte und vergebene Maps werden abgelehnt', () => {
|
||||||
const pool = pools.competitive;
|
const pool = competitive7();
|
||||||
const ctx = { maps: pool.maps, flow: pool.formats.bo1.flow, firstTeam: 'A', actions: [] };
|
const ctx = { maps: pool.maps, flow: pool.formats.bo1.flow, firstTeam: 'A', actions: [] };
|
||||||
assert.throws(() => resolveAction(ctx, { team: 'B', stepIndex: 0, map: 'de_nuke' }), { code: 'not_your_turn' });
|
assert.throws(() => resolveAction(ctx, { team: 'B', stepIndex: 0, map: 'de_nuke' }), { code: 'not_your_turn' });
|
||||||
assert.throws(() => resolveAction(ctx, { team: 'A', stepIndex: 1, map: 'de_nuke' }), { code: 'stale_step' });
|
assert.throws(() => resolveAction(ctx, { team: 'A', stepIndex: 1, map: 'de_nuke' }), { code: 'stale_step' });
|
||||||
|
|||||||
Reference in new issue
Block a user