Compare commits
65
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
d7677f3afe | ||
|
|
0341921655 | ||
|
|
fde3baafb5 | ||
|
|
82239c8447 | ||
|
|
b9dafbc6ab | ||
|
|
a4218c47d1 | ||
|
|
22996cc4e4 | ||
|
|
330048acfc | ||
|
|
726974fe82 | ||
|
|
413df03237 | ||
|
|
d78f399612 | ||
|
|
6ee2de5a46 | ||
|
|
7c74c6c4eb | ||
|
|
59d6dc7177 | ||
|
|
8996294d7e | ||
|
|
54bfe198d8 | ||
|
|
43dc5b837a | ||
|
|
5fade769e7 |
No files matched your search
@@ -0,0 +1,11 @@
|
|||||||
|
**/node_modules
|
||||||
|
client/dist
|
||||||
|
data
|
||||||
|
*.db
|
||||||
|
*.db-*
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
.git
|
||||||
|
.claude
|
||||||
|
npm-debug.log*
|
||||||
+22
-1
@@ -39,7 +39,7 @@ DEMO_START_SECONDS=20
|
|||||||
DEMO_FAIL_FIRST_CONFIGURE=false
|
DEMO_FAIL_FIRST_CONFIGURE=false
|
||||||
|
|
||||||
# --- Docker-Adapter (SERVER_ADAPTER=docker) ---
|
# --- Docker-Adapter (SERVER_ADAPTER=docker) ---
|
||||||
# Pflicht: Game Server Login Token für App-ID 730, https://steamcommunity.com/dev/managegameservers
|
# Game Server Login Token für App-ID 730 (https://steamcommunity.com/dev/managegameservers); leer = LAN-Modus (sv_lan 1)
|
||||||
CS2_GSLT=
|
CS2_GSLT=
|
||||||
# Pflicht: Adresse, unter der Spieler den Docker-Host erreichen (IP oder DNS-Name)
|
# Pflicht: Adresse, unter der Spieler den Docker-Host erreichen (IP oder DNS-Name)
|
||||||
CS2_PUBLIC_HOST=
|
CS2_PUBLIC_HOST=
|
||||||
@@ -52,9 +52,30 @@ 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
|
||||||
|
# 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) ---
|
||||||
# host:port:rconPasswort[:tvPort], mehrere durch Komma getrennt
|
# host:port:rconPasswort[:tvPort], mehrere durch Komma getrennt
|
||||||
CS2_STATIC_SERVERS=
|
CS2_STATIC_SERVERS=
|
||||||
|
|
||||||
|
# --- Live-Ergebnisse ---
|
||||||
|
# Adresse, unter der der CS2-Server dieses Backend erreicht (Standard: PUBLIC_URL), z. B. http://192.168.178.31:3000
|
||||||
|
LOG_RECEIVER_URL=
|
||||||
|
# true: empfangene Logzeilen im Terminal ausgeben (Fehlersuche)
|
||||||
|
LOG_DEBUG=false
|
||||||
|
# Sekunden nach Map-Ende bis zur nächsten Map bzw. zum Abschluss der Serie; -1 = nur manuell
|
||||||
|
SERIES_AUTO_ADVANCE_SECONDS=30
|
||||||
|
# Schlüssel für die Stream-Übersicht aller Matches (/stream?key=...); leer = offen
|
||||||
|
STREAM_KEY=
|
||||||
|
# Discord-Webhook für Meldungen (Veto, Match-Start, Map- und Serienende, Fehler). Leer = aus
|
||||||
|
DISCORD_WEBHOOK_URL=
|
||||||
@@ -9,3 +9,4 @@ data/
|
|||||||
.env.local
|
.env.local
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
.claude/
|
.claude/
|
||||||
|
dist/
|
||||||
+33
@@ -0,0 +1,33 @@
|
|||||||
|
# CS2 Mapveto als ein Container: Webseite, Backend, Datenbank (SQLite) und Docker-Client,
|
||||||
|
# mit dem die App pro Match eigene CS2-Server-Container auf demselben Docker-Host startet.
|
||||||
|
|
||||||
|
# ---------- Bauen ----------
|
||||||
|
FROM node:22-bookworm-slim AS build
|
||||||
|
WORKDIR /app
|
||||||
|
# Werkzeuge, falls better-sqlite3 kein fertiges Binärpaket findet
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends python3 make g++ \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
COPY client/package.json client/
|
||||||
|
COPY server/package.json server/
|
||||||
|
RUN npm ci
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build && npm test && npm prune --omit=dev
|
||||||
|
|
||||||
|
# ---------- Laufzeit ----------
|
||||||
|
FROM node:22-bookworm-slim
|
||||||
|
# Docker-Client (nur das Programm, kein Daemon): spricht über /var/run/docker.sock mit dem Host
|
||||||
|
COPY --from=docker:27-cli /usr/local/bin/docker /usr/local/bin/docker
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production \
|
||||||
|
PORT=3000 \
|
||||||
|
DATABASE_FILE=/data/cs2-mapveto.db
|
||||||
|
COPY --from=build /app/package.json ./
|
||||||
|
COPY --from=build /app/node_modules ./node_modules
|
||||||
|
COPY --from=build /app/server ./server
|
||||||
|
COPY --from=build /app/client/dist ./client/dist
|
||||||
|
VOLUME /data
|
||||||
|
EXPOSE 3000
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s \
|
||||||
|
CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||3000)+'/api/config').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
|
||||||
|
CMD ["node", "server/src/index.js"]
|
||||||
@@ -14,6 +14,17 @@ Webanwendung zur Organisation von Counter-Strike-2-Matches: Match-Lobbys mit Tea
|
|||||||
|
|
||||||
## Schnellstart
|
## Schnellstart
|
||||||
|
|
||||||
|
**Als Container (empfohlen für den Betrieb):** ein Container mit Webseite, Backend und Datenbank, der die
|
||||||
|
CS2-Server-Container auf demselben Docker-Host startet. Siehe [docs/container.md](docs/container.md):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
docker -H ssh://USER@DOCKER-HOST-IP build -t cs2-mapveto:latest .
|
||||||
|
```
|
||||||
|
|
||||||
|
…dann den Stack aus der Anleitung in Portainer anlegen.
|
||||||
|
|
||||||
|
**Zum Entwickeln:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install
|
npm install
|
||||||
cp .env.example .env # optional, Standardwerte funktionieren für die Demo
|
cp .env.example .env # optional, Standardwerte funktionieren für die Demo
|
||||||
@@ -64,14 +75,14 @@ Die Tests decken Veto-Engine und Ablaufvalidierung, RCON-Protokoll (gegen einen
|
|||||||
6. **Server konfigurieren:** Nach dem Veto wird der bereits laufende Server automatisch eingerichtet: Spielmodus, erste Map, Teamnamen und Startseiten, Match-Einstellungen, Server- und CSTV-Passwort. Die restliche Map-Reihenfolge ist gespeichert.
|
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 |
|
||||||
@@ -190,9 +201,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`)
|
||||||
@@ -212,6 +236,34 @@ 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.
|
||||||
|
|
||||||
|
### 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:
|
||||||
@@ -269,7 +321,108 @@ Bekannte Werte: `game_type 0` + `game_mode 1` = Competitive, `game_mode 2` = Win
|
|||||||
### Bekannte Grenzen von Vanilla-CS2
|
### Bekannte Grenzen von Vanilla-CS2
|
||||||
|
|
||||||
- Ohne Server-Plugin kann CS2 Spieler **nicht automatisch einem Team zuordnen**. Spieler wählen ihr Team im Spiel selbst. Teamnamen und Startseiten setzt die Anwendung. Für feste Teamzuordnung, Pausen und automatischen Mapwechsel eignen sich Plugins wie MatchZy oder Get5-Nachfolger. Sie lassen sich über einen eigenen Adapter anbinden; die SteamID64 der Spieler wird dafür bereits erfasst.
|
- Ohne Server-Plugin kann CS2 Spieler **nicht automatisch einem Team zuordnen**. Spieler wählen ihr Team im Spiel selbst. Teamnamen und Startseiten setzt die Anwendung. Für feste Teamzuordnung, Pausen und automatischen Mapwechsel eignen sich Plugins wie MatchZy oder Get5-Nachfolger. Sie lassen sich über einen eigenen Adapter anbinden; die SteamID64 der Spieler wird dafür bereits erfasst.
|
||||||
- Ergebnisse werden nicht ausgelesen. Der Organisator lädt die nächste Map manuell.
|
- Das Team eines Spielers ergibt sich aus der Seite, die er im Spiel wählt. Wechselt ein Spieler ins falsche Team, zählen seine Runden für das falsche Team.
|
||||||
|
|
||||||
|
### Live-Ergebnisse
|
||||||
|
|
||||||
|
Beim Konfigurieren setzt CS2 Mapveto auf dem Server `log on` und `logaddress_add_http "<LOG_RECEIVER_URL>/api/logs/<match>/<schlüssel>"`. Der CS2-Server schickt damit seine Logzeilen per HTTP an das Backend. Der Schlüssel ist pro Match und Serverstart zufällig, Zeilen mit falschem Schlüssel werden abgelehnt.
|
||||||
|
|
||||||
|
- **Was ausgewertet wird:**
|
||||||
|
- Matchstart nach der Aufwärmphase
|
||||||
|
- jedes Rundenende mit Sieger und Grund (Bombe, Entschärfung, Zeit, Ausschalten)
|
||||||
|
- die Seitenzuordnung der Teams
|
||||||
|
- das Spielende
|
||||||
|
- **Was angezeigt wird:**
|
||||||
|
- Live-Spielstand und Rundenverlauf pro Map
|
||||||
|
- Serienstand
|
||||||
|
- Ergebnisse in der Übersicht
|
||||||
|
- Stream-Seite
|
||||||
|
- **Nach dem Spielende:** Ist die Serie noch offen, lädt CS2 Mapveto nach `SERIES_AUTO_ADVANCE_SECONDS` (Standard 30) die nächste Map. Ist sie entschieden (z. B. 2:0 im Best of 3), wird das Match abgeschlossen und der Server freigegeben. Mit `-1` geht beides nur manuell.
|
||||||
|
- **Voraussetzung:** Der CS2-Server muss `LOG_RECEIVER_URL` erreichen, im LAN z. B. `http://192.168.178.31:3000`. Ob Daten ankommen, zeigt das Serverpanel des Organisators. Mit `LOG_DEBUG=true` gibt das Backend alle empfangenen Zeilen aus.
|
||||||
|
- **Geprüft:** mit echten Logzeilen eines CS2-Servers (Wingman), anonymisiert in `server/test/fixtures/wingman-anonym.log`.
|
||||||
|
|
||||||
|
Zwei Eigenheiten von CS2 berücksichtigt der Parser:
|
||||||
|
- CS2 sendet `Match_Start` schon beim Laden der Map. Gezählt wird erst, nachdem der Organisator „Aufwärmphase beenden“ geklickt hat.
|
||||||
|
- Direkt nach der Halbzeit nennt CS2 zu Rundenbeginn noch die alte Seitenzuordnung. Ausgewertet werden deshalb nur die Zeilen direkt nach einem Rundenende.
|
||||||
|
|
||||||
|
### Stream-Übersicht aller Matches
|
||||||
|
|
||||||
|
Unter `/stream` (in der Kopfzeile verlinkt) zeigt eine 1920×1080-Seite alle laufenden Vetos und Matches gleichzeitig, live aktualisiert:
|
||||||
|
|
||||||
|
- **Karten:** eine Karte pro Match.
|
||||||
|
- Im Veto: wer am Zug ist, Fortschritt, Map-Board.
|
||||||
|
- Im Match: aktuelle Map mit Live-Stand, CT/T-Seiten und Serienstand.
|
||||||
|
- **Raster:** passt sich der Anzahl an (1, 2, 4 oder 6 Karten). Bei mehr als 6 Matches blättert die Seite alle 12 Sekunden weiter.
|
||||||
|
- **Unten:** offene Lobbys und die Ergebnisse der letzten drei Stunden.
|
||||||
|
- **Datenschutz:** Codes und Verbindungsdaten erscheinen nicht.
|
||||||
|
- **Einzelnes Match:** Ein Klick auf eine Karte oder einen Eintrag öffnet die Übersicht nur dieses Matches unter `/stream/match/<matchId>`. Der Link enthält keinen Zuschauercode, der Schlüssel `?key=` wird übernommen.
|
||||||
|
- **Schlüssel:** Mit `STREAM_KEY` ist die Seite nur mit `/stream?key=<schlüssel>` erreichbar, sonst offen. `?bg=transparent` funktioniert ebenfalls.
|
||||||
|
|
||||||
|
### Stream-Übersicht eines Matches
|
||||||
|
|
||||||
|
Unter `/stream/<Zuschauercode>` gibt es eine Vollbild-Übersicht für den Stream. Den Link findet der Organisator bei den Einladungen.
|
||||||
|
|
||||||
|
Für die Übersicht eines einzelnen Matches ohne Zuschauercode gibt es `/stream/match/<matchId>`. Der Organisator findet den Link unter „Match-Übersicht“ im Bereich Einladungen. Ist `STREAM_KEY` gesetzt, hängt der Link den Schlüssel an, damit Organisatoren die Seite öffnen können.
|
||||||
|
|
||||||
|
- **Format:** 1920×1080, skaliert auf jede Fenstergröße. In OBS als Browserquelle mit 1920×1080 einbinden. Mit `?bg=transparent` ist der Hintergrund durchsichtig.
|
||||||
|
- **Ablauf:** Die Seite wechselt automatisch zwischen Lobby (Aufstellungen), Map-Veto (live) und Match (Spielstand, Rundenverlauf, Map-Leiste).
|
||||||
|
- **Laufband:** zeigt das letzte für Zuschauer relevante Ereignis, keine technischen Servermeldungen.
|
||||||
|
- **Datenschutz:** Verbindungsdaten und Passwörter erscheinen dort nie.
|
||||||
|
|
||||||
|
### Discord-Meldungen für Streamer
|
||||||
|
|
||||||
|
Mit `DISCORD_WEBHOOK_URL` schickt die Anwendung kurze Meldungen in einen Discord-Kanal:
|
||||||
|
|
||||||
|
- Veto gestartet
|
||||||
|
- Match gestartet (Aufwärmphase beendet)
|
||||||
|
- Map beendet mit Ergebnis
|
||||||
|
- Serie entschieden
|
||||||
|
- Match abgeschlossen oder abgebrochen
|
||||||
|
- Serverfehler
|
||||||
|
|
||||||
|
Jede Meldung enthält einen Link auf die Match-Übersicht (`/stream/match/<id>`). Ist `STREAM_KEY` gesetzt, entfällt der Link, damit der Schlüssel nicht im Kanal landet. Ein Fehler beim Senden bremst den Matchablauf nicht. Er wird nur im Log vermerkt. Die Webhook-URL enthält ein Geheimnis und erscheint nie in Logs oder Oberflächen.
|
||||||
|
|
||||||
|
Webhook anlegen: Discord-Kanal → Einstellungen → Integrationen → Webhooks → Neuer Webhook, dann die URL kopieren.
|
||||||
|
|
||||||
|
Welche Ereignisse gemeldet werden, wählt der Admin im Admin-Bereich unter „Discord-Meldungen“. Die Auswahl wird in der Datenbank gespeichert. Die Webhook-URL bleibt in der Konfiguration, weil sie ein Geheimnis ist. Die Schaltfläche „Testmeldung senden“ prüft die Verbindung.
|
||||||
|
### Hinter einem Reverse-Proxy
|
||||||
|
|
||||||
|
Die Webseite lässt sich hinter einen Reverse-Proxy wie nginx, Caddy oder Traefik stellen, etwa um sie per HTTPS unter einer eigenen Adresse anzubieten.
|
||||||
|
|
||||||
|
- **Eigene (Sub-)Domain, kein Unterpfad.** Die Anwendung nutzt absolute Pfade (`/api`, `/socket.io`, `/stream`, `/admin`). Unter einem Unterpfad wie `example.de/veto/` funktioniert sie nicht. Nimm eine eigene Subdomain, z. B. `veto.example.de`.
|
||||||
|
- **WebSockets durchreichen.** Die Live-Updates laufen über Socket.IO. Der Proxy muss den `Upgrade`-Header weitergeben, sonst bleibt die Seite bei „Verbinde mit Lobby“ hängen.
|
||||||
|
- **`.env` anpassen:**
|
||||||
|
- `PUBLIC_URL=https://veto.example.de`, damit Einladungs- und Stream-Links stimmen
|
||||||
|
- `TRUST_PROXY=true`, damit die Begrenzung der Anmeldeversuche die echte Client-IP sieht und nicht die des Proxys
|
||||||
|
- **Nicht über den Proxy:**
|
||||||
|
- Die CS2-Spielverbindungen laufen per UDP direkt zum Docker-Host. Für Spieler aus dem Internet braucht es dort Portfreigaben und einen GSLT (`CS2_GSLT`).
|
||||||
|
- Die Logs der CS2-Server können weiter über das LAN kommen. `LOG_RECEIVER_URL` bleibt dann z. B. `http://192.168.178.31:3000`.
|
||||||
|
- **Vor dem Freigeben absichern:** Öffentlich erreichbar sind dann auch `/admin` und, ohne Schlüssel, die Stream-Übersicht. Setze ein starkes `ADMIN_PASSWORD` und einen `STREAM_KEY`.
|
||||||
|
|
||||||
|
Beispiel für nginx:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
server_name veto.example.de;
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/veto.example.de/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/veto.example.de/privkey.pem;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://192.168.178.31:3000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_read_timeout 1h;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Ein `502 Bad Gateway` vom Proxy heißt, dass er die Anwendung unter `proxy_pass` nicht erreicht: Sie läuft nicht, oder Adresse bzw. Port stimmen nicht.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -288,14 +441,21 @@ Alle Variablen mit Standardwerten stehen in [`.env.example`](.env.example). Die
|
|||||||
| `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 |
|
||||||
| `CS2_TV_DELAY` | `90` | CSTV-Verzögerung in Sekunden |
|
| `CS2_TV_DELAY` | `90` | CSTV-Verzögerung in Sekunden |
|
||||||
| `CS2_GSLT` | – | **Pflicht bei Docker:** Game Server Login Token |
|
| `CS2_GSLT` | leer | Docker: Game Server Login Token; leer = LAN-Modus (`sv_lan 1`) |
|
||||||
| `CS2_PUBLIC_HOST` | – | **Pflicht bei Docker:** öffentliche Adresse |
|
| `CS2_PUBLIC_HOST` | – | **Pflicht bei Docker:** öffentliche Adresse |
|
||||||
| `CS2_STATIC_SERVERS` | – | **Pflicht bei Static:** Serverliste |
|
| `CS2_STATIC_SERVERS` | – | **Pflicht bei Static:** Serverliste |
|
||||||
| `DEMO_FAIL_FIRST_CONFIGURE` | `false` | Demo: Konfigurationsfehler simulieren |
|
| `DEMO_FAIL_FIRST_CONFIGURE` | `false` | Demo: Konfigurationsfehler simulieren |
|
||||||
|
| `DEMO_ROUND_SECONDS` | `4` | Demo: Sekunden pro simulierter Runde |
|
||||||
|
| `LOG_RECEIVER_URL` | `PUBLIC_URL` | Adresse, unter der der CS2-Server das Backend für Live-Logs erreicht |
|
||||||
|
| `LOG_DEBUG` | `false` | empfangene Logzeilen im Terminal ausgeben |
|
||||||
|
| `STREAM_KEY` | leer | Schlüssel für die Stream-Übersicht aller Matches (`/stream?key=…`); leer = offen |
|
||||||
|
| `DISCORD_WEBHOOK_URL` | leer | Discord-Webhook für Meldungen zu Veto, Match-Start, Map- und Serienende sowie Serverfehlern; leer = aus |
|
||||||
|
| `TRUST_PROXY` | `false` | `true`, wenn die Anwendung hinter einem Reverse-Proxy läuft (echte Client-IP für die Begrenzung der Anmeldeversuche) |
|
||||||
|
| `SERIES_AUTO_ADVANCE_SECONDS` | `30` | Wartezeit nach Map-Ende bis zur nächsten Map bzw. zum Abschluss; `-1` = manuell |
|
||||||
|
|
||||||
Benötigte Zugangsdaten für den Echtbetrieb:
|
Benötigte Zugangsdaten für den Echtbetrieb:
|
||||||
|
|
||||||
- **Docker:** GSLT, Zugriff auf den Docker-Daemon, öffentliche IP bzw. Portfreigaben
|
- **Docker:** Zugriff auf den Docker-Daemon (lokal oder per `DOCKER_HOST=ssh://…`), Portfreigaben, für Internet-Betrieb ein GSLT (im LAN nicht nötig)
|
||||||
- **Static:** RCON-Passwörter der Server
|
- **Static:** RCON-Passwörter der Server
|
||||||
|
|
||||||
RCON-Passwörter für Docker-Container erzeugt das Backend selbst.
|
RCON-Passwörter für Docker-Container erzeugt das Backend selbst.
|
||||||
@@ -333,6 +493,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`.
|
||||||
@@ -4,9 +4,24 @@ import { api } from './lib/api.js';
|
|||||||
import Overview from './pages/Overview.jsx';
|
import Overview from './pages/Overview.jsx';
|
||||||
import CreateMatch from './pages/CreateMatch.jsx';
|
import CreateMatch from './pages/CreateMatch.jsx';
|
||||||
import MatchPage from './pages/MatchPage.jsx';
|
import MatchPage from './pages/MatchPage.jsx';
|
||||||
|
import StreamPage from './pages/StreamPage.jsx';
|
||||||
|
import StreamOverviewPage from './pages/StreamOverviewPage.jsx';
|
||||||
|
import AdminPage from './pages/AdminPage.jsx';
|
||||||
import { JoinPage, WatchPage } from './pages/AccessPages.jsx';
|
import { JoinPage, WatchPage } from './pages/AccessPages.jsx';
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
{/* Stream-Übersicht ohne Kopfzeile, als Vollbild bzw. OBS-Browserquelle */}
|
||||||
|
<Route path="/stream/:code" element={<StreamPage />} />
|
||||||
|
<Route path="/stream/match/:matchId" element={<StreamOverviewPage />} />
|
||||||
|
<Route path="/stream" element={<StreamOverviewPage />} />
|
||||||
|
<Route path="*" element={<MainLayout />} />
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MainLayout() {
|
||||||
const [config, setConfig] = useState(null);
|
const [config, setConfig] = useState(null);
|
||||||
const [configError, setConfigError] = useState(null);
|
const [configError, setConfigError] = useState(null);
|
||||||
|
|
||||||
@@ -28,6 +43,8 @@ export default function App() {
|
|||||||
)}
|
)}
|
||||||
<nav>
|
<nav>
|
||||||
<Link to="/" className="nav-link">Übersicht</Link>
|
<Link to="/" className="nav-link">Übersicht</Link>
|
||||||
|
<a href="/stream" target="_blank" rel="noreferrer" className="nav-link">Stream-Übersicht</a>
|
||||||
|
<Link to="/admin" className="nav-link">Admin</Link>
|
||||||
<Link to="/neu" className="btn btn-primary btn-sm">Match erstellen</Link>
|
<Link to="/neu" className="btn btn-primary btn-sm">Match erstellen</Link>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
@@ -36,6 +53,7 @@ export default function App() {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Overview />} />
|
<Route path="/" element={<Overview />} />
|
||||||
<Route path="/neu" element={<CreateMatch config={config} />} />
|
<Route path="/neu" element={<CreateMatch config={config} />} />
|
||||||
|
<Route path="/admin" element={<AdminPage />} />
|
||||||
<Route path="/m/:id" element={<MatchPage />} />
|
<Route path="/m/:id" element={<MatchPage />} />
|
||||||
<Route path="/join/:code" element={<JoinPage config={config} />} />
|
<Route path="/join/:code" element={<JoinPage config={config} />} />
|
||||||
<Route path="/watch/:code" element={<WatchPage />} />
|
<Route path="/watch/:code" element={<WatchPage />} />
|
||||||
|
|||||||
@@ -25,6 +25,14 @@ export default function OrganizerPanel({ state, act, busy, matchId, token }) {
|
|||||||
{open && <button className="link-btn" disabled={!!busy} onClick={() => regen(key, label)}>Neuer Code</button>}
|
{open && <button className="link-btn" disabled={!!busy} onClick={() => regen(key, label)}>Neuer Code</button>}
|
||||||
</div>
|
</div>
|
||||||
<CopyField value={links[key]} />
|
<CopyField value={links[key]} />
|
||||||
|
{key === 'spectator' && (
|
||||||
|
<div className="stream-link">
|
||||||
|
<CopyField label="Stream-Übersicht (als Browserquelle in OBS, 1920×1080)" value={links.stream} />
|
||||||
|
<a className="btn btn-sm" href={links.stream} target="_blank" rel="noreferrer">Stream-Ansicht öffnen</a>
|
||||||
|
<CopyField label="Match-Übersicht (nur dieses Match, ohne Zuschauercode)" value={links.overview} />
|
||||||
|
<a className="btn btn-sm" href={links.overview} target="_blank" rel="noreferrer">Match-Übersicht öffnen</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<details className="org-link">
|
<details className="org-link">
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
// Rundenverlauf einer Map: ein Feld pro Runde in der Farbe des Siegerteams,
|
||||||
|
// mit Symbol für den Grund. Nach der Halbzeit (und jeder Verlängerungshälfte) ein Trenner.
|
||||||
|
|
||||||
|
export const REASON_LABEL = {
|
||||||
|
bomb: 'Bombe explodiert',
|
||||||
|
defuse: 'Bombe entschärft',
|
||||||
|
time: 'Zeit abgelaufen',
|
||||||
|
elimination: 'Alle ausgeschaltet',
|
||||||
|
surrender: 'Aufgegeben',
|
||||||
|
other: 'Runde gewonnen',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ReasonIcon({ reason, size = 12 }) {
|
||||||
|
const common = { width: size, height: size, viewBox: '0 0 16 16', 'aria-hidden': true, className: 'reason-icon' };
|
||||||
|
switch (reason) {
|
||||||
|
case 'bomb':
|
||||||
|
return (
|
||||||
|
<svg {...common}><circle cx="7" cy="9.5" r="5" fill="currentColor" /><path d="M10.5 5.5 13 3" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /><circle cx="13.6" cy="2.4" r="1.4" fill="currentColor" /></svg>
|
||||||
|
);
|
||||||
|
case 'defuse':
|
||||||
|
return (
|
||||||
|
<svg {...common}><path d="M3 13 9.5 6.5M6.5 3.5 12.5 9.5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" /><circle cx="11.5" cy="4.5" r="2.5" fill="none" stroke="currentColor" strokeWidth="1.8" /></svg>
|
||||||
|
);
|
||||||
|
case 'time':
|
||||||
|
return (
|
||||||
|
<svg {...common}><circle cx="8" cy="8" r="6" fill="none" stroke="currentColor" strokeWidth="1.8" /><path d="M8 4.5V8l2.5 1.8" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /></svg>
|
||||||
|
);
|
||||||
|
case 'elimination':
|
||||||
|
return (
|
||||||
|
<svg {...common}><circle cx="8" cy="8" r="5.5" fill="none" stroke="currentColor" strokeWidth="1.6" /><path d="M8 1v4M8 11v4M1 8h4M11 8h4" stroke="currentColor" strokeWidth="1.6" /></svg>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return <svg {...common}><circle cx="8" cy="8" r="3" fill="currentColor" /></svg>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RoundStrip({ rounds = [], maxRounds = 24, teams, size = 'md' }) {
|
||||||
|
const half = maxRounds / 2;
|
||||||
|
const total = Math.max(rounds.length, maxRounds);
|
||||||
|
const cells = [];
|
||||||
|
for (let i = 1; i <= total; i++) {
|
||||||
|
const r = rounds.find((x) => x.number === i);
|
||||||
|
const breakBefore = i > 1 && (i - 1 === half || (i - 1 > maxRounds && (i - 1 - maxRounds) % 3 === 0));
|
||||||
|
cells.push(
|
||||||
|
<li key={i}
|
||||||
|
className={`round ${r ? `won-${r.winner}` : 'open'} ${breakBefore ? 'break' : ''}`}
|
||||||
|
title={r ? `Runde ${i}: ${teams[r.winner].name} (${REASON_LABEL[r.reason] ?? ''})` : `Runde ${i}`}>
|
||||||
|
{r ? <ReasonIcon reason={r.reason} /> : null}
|
||||||
|
</li>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <ol className={`round-strip round-strip-${size}`} aria-label="Rundenverlauf">{cells}</ol>;
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import MapImage from './MapImage.jsx';
|
import MapImage from './MapImage.jsx';
|
||||||
import SideIcon from './SideIcon.jsx';
|
import SideIcon from './SideIcon.jsx';
|
||||||
|
import RoundStrip from './RoundStrip.jsx';
|
||||||
|
|
||||||
const STATUS = { pending: 'Ausstehend', next: 'Wird eingerichtet', live: 'Aktuell', done: 'Gespielt' };
|
const STATUS = { pending: 'Ausstehend', next: 'Wird eingerichtet', live: 'Aktuell', done: 'Gespielt' };
|
||||||
|
|
||||||
@@ -13,11 +14,26 @@ export default function SeriesList({ state }) {
|
|||||||
const map = state.maps.find((m) => m.id === s.map);
|
const map = state.maps.find((m) => m.id === s.map);
|
||||||
const ct = s.ctTeam ? teams[s.ctTeam].name : null;
|
const ct = s.ctTeam ? teams[s.ctTeam].name : null;
|
||||||
const t = s.ctTeam ? teams[s.ctTeam === 'A' ? 'B' : 'A'].name : null;
|
const t = s.ctTeam ? teams[s.ctTeam === 'A' ? 'B' : 'A'].name : null;
|
||||||
|
const isLive = s.live?.status === 'live' && s.status !== 'done';
|
||||||
|
const hasScore = s.live && (s.live.rounds.length > 0 || s.status === 'done');
|
||||||
return (
|
return (
|
||||||
<li key={s.number} className={`series-item is-${s.status}`} style={{ '--map': map?.color ?? '#556' }}>
|
<li key={s.number} className={`series-item is-${s.status} ${isLive ? 'is-playing' : ''}`} style={{ '--map': map?.color ?? '#556' }}>
|
||||||
<MapImage map={map} className="series-art" />
|
<MapImage map={map} className="series-art" />
|
||||||
<span className="series-no">Map {s.number}</span>
|
<span className="series-no">Map {s.number}</span>
|
||||||
<strong className="series-map">{s.mapName}</strong>
|
<strong className="series-map">{s.mapName}</strong>
|
||||||
|
<span className="series-status">
|
||||||
|
{isLive ? <span className="live-badge">Live</span> : STATUS[s.status]}
|
||||||
|
</span>
|
||||||
|
{hasScore && (
|
||||||
|
<span className="series-score" aria-label={`${teams.A.name} ${s.score.A} zu ${s.score.B} ${teams.B.name}`}>
|
||||||
|
<span className={`team-a ${s.winner === 'A' ? 'is-winner' : ''}`}>{s.score.A}</span>
|
||||||
|
<span className="colon">:</span>
|
||||||
|
<span className={`team-b ${s.winner === 'B' ? 'is-winner' : ''}`}>{s.score.B}</span>
|
||||||
|
{s.status === 'done' && (
|
||||||
|
<small>{s.winner ? `${teams[s.winner].name} gewinnt` : 'Unentschieden'}</small>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<span className="series-origin">
|
<span className="series-origin">
|
||||||
{s.pickedBy ? `Pick von ${teams[s.pickedBy].name}` : 'Entscheidungsmap'}
|
{s.pickedBy ? `Pick von ${teams[s.pickedBy].name}` : 'Entscheidungsmap'}
|
||||||
</span>
|
</span>
|
||||||
@@ -29,7 +45,11 @@ export default function SeriesList({ state }) {
|
|||||||
</>
|
</>
|
||||||
) : 'Seiten im Spiel festlegen'}
|
) : 'Seiten im Spiel festlegen'}
|
||||||
</span>
|
</span>
|
||||||
<span className="series-status">{STATUS[s.status]}</span>
|
{hasScore && (
|
||||||
|
<div className="series-rounds">
|
||||||
|
<RoundStrip rounds={s.live.rounds} maxRounds={state.maxRounds} teams={teams} size="sm" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -10,6 +10,32 @@ const RETRY = {
|
|||||||
};
|
};
|
||||||
const ACTIVE = ['starting', 'ready', 'configuring', 'configured', 'config_failed', 'failed'];
|
const ACTIVE = ['starting', 'ready', 'configuring', 'configured', 'config_failed', 'failed'];
|
||||||
|
|
||||||
|
/** Zeigt dem Organisator, ob Live-Daten vom Server ankommen und ob die Map vorbei ist. */
|
||||||
|
function LiveDataStatus({ state, hasNext }) {
|
||||||
|
const current = state.series.find((s) => s.number === state.currentMapNumber);
|
||||||
|
if (!current) return null;
|
||||||
|
const updatedAt = current.live?.updatedAt;
|
||||||
|
if (current.status === 'done') {
|
||||||
|
const auto = state.autoAdvanceSeconds >= 0;
|
||||||
|
return (
|
||||||
|
<div className="alert alert-ok">
|
||||||
|
Map {current.number} ist beendet.{' '}
|
||||||
|
{auto
|
||||||
|
? (hasNext ? `Die nächste Map wird nach ${state.autoAdvanceSeconds} s automatisch geladen, sofern die Serie nicht entschieden ist.` : 'Das Match wird automatisch abgeschlossen.')
|
||||||
|
: (hasNext ? 'Lade jetzt die nächste Map.' : 'Das war die letzte Map – schließ das Match ab.')}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<p className="small live-data">
|
||||||
|
<span className={`status-dot ${updatedAt ? 'tone-ok' : 'tone-muted'}`} />
|
||||||
|
{updatedAt
|
||||||
|
? `Live-Daten vom Server empfangen, zuletzt um ${new Date(updatedAt).toLocaleTimeString('de-DE')}.`
|
||||||
|
: 'Noch keine Live-Daten. Sie kommen, sobald die Aufwärmphase endet und der Server diese Webseite erreicht (LOG_RECEIVER_URL).'}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function ServerPanel({ state, isOrg, act, busy }) {
|
export default function ServerPanel({ state, isOrg, act, busy }) {
|
||||||
const { server } = state;
|
const { server } = state;
|
||||||
const label = serverLabel(server);
|
const label = serverLabel(server);
|
||||||
@@ -63,6 +89,8 @@ export default function ServerPanel({ state, isOrg, act, busy }) {
|
|||||||
<p className="small muted">CSTV ist auf diesem Server deaktiviert.</p>
|
<p className="small muted">CSTV ist auf diesem Server deaktiviert.</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{isOrg && server?.status === 'configured' && <LiveDataStatus state={state} hasNext={hasNext} />}
|
||||||
|
|
||||||
{isOrg && server && !closed && (
|
{isOrg && server && !closed && (
|
||||||
<div className="org-controls">
|
<div className="org-controls">
|
||||||
{canRetry && (
|
{canRetry && (
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,701 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { api } from '../lib/api.js';
|
||||||
|
import { FORMAT_LABEL, serverLabel } from '../lib/labels.js';
|
||||||
|
import CopyField from '../components/CopyField.jsx';
|
||||||
|
|
||||||
|
const KEY = 'cs2mv:admin';
|
||||||
|
const REFRESH_MS = 3000;
|
||||||
|
|
||||||
|
const getToken = () => {
|
||||||
|
try {
|
||||||
|
return sessionStorage.getItem(KEY);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const storeToken = (t) => {
|
||||||
|
try {
|
||||||
|
if (t) sessionStorage.setItem(KEY, t);
|
||||||
|
else sessionStorage.removeItem(KEY);
|
||||||
|
} catch {
|
||||||
|
/* ignorieren */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AdminPage() {
|
||||||
|
const [token, setToken] = useState(getToken);
|
||||||
|
const [matches, setMatches] = useState(null);
|
||||||
|
const [hosts, setHosts] = useState([]);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [notice, setNotice] = useState(null);
|
||||||
|
const [busy, setBusy] = useState(null);
|
||||||
|
const [tab, setTab] = useState('matches');
|
||||||
|
|
||||||
|
const logout = useCallback(() => {
|
||||||
|
api('/admin/logout', { method: 'POST', token }).catch(() => {});
|
||||||
|
storeToken(null);
|
||||||
|
setToken(null);
|
||||||
|
setMatches(null);
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await api('/admin/matches', { token });
|
||||||
|
setMatches(res.matches);
|
||||||
|
setHosts(res.hosts ?? []);
|
||||||
|
setError(null);
|
||||||
|
} catch (err) {
|
||||||
|
if (err.status === 401) {
|
||||||
|
storeToken(null);
|
||||||
|
setToken(null);
|
||||||
|
} else {
|
||||||
|
setError(err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token) return undefined;
|
||||||
|
load();
|
||||||
|
const t = setInterval(load, REFRESH_MS);
|
||||||
|
return () => clearInterval(t);
|
||||||
|
}, [token, load]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!notice) return undefined;
|
||||||
|
const t = setTimeout(() => setNotice(null), 5000);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [notice]);
|
||||||
|
|
||||||
|
if (!token) return <AdminLogin onLogin={(t) => { storeToken(t); setToken(t); }} />;
|
||||||
|
|
||||||
|
/** Aktion ausführen; destruktive Aktionen müssen bestätigt werden. */
|
||||||
|
const run = async ({ method = 'POST', path, body, confirm: question, success }) => {
|
||||||
|
if (question && !window.confirm(question)) return;
|
||||||
|
setBusy(path);
|
||||||
|
try {
|
||||||
|
await api(`/admin${path}`, { method, body, token });
|
||||||
|
setNotice({ tone: 'ok', text: success ?? 'Erledigt' });
|
||||||
|
load();
|
||||||
|
} catch (err) {
|
||||||
|
setNotice({ tone: 'error', text: err.message });
|
||||||
|
} finally {
|
||||||
|
setBusy(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="admin">
|
||||||
|
<header className="admin-head">
|
||||||
|
<div>
|
||||||
|
<h1>Admin</h1>
|
||||||
|
<p className="muted small">Alle Matches, Server und Teams. Aktualisiert sich alle 3 Sekunden.</p>
|
||||||
|
</div>
|
||||||
|
<button className="btn btn-sm" onClick={logout}>Abmelden</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<nav className="admin-tabs" aria-label="Admin-Bereiche">
|
||||||
|
<button className={`admin-tab ${tab === 'matches' ? 'is-active' : ''}`} onClick={() => setTab('matches')}>Matches</button>
|
||||||
|
<button className={`admin-tab ${tab === 'settings' ? 'is-active' : ''}`} onClick={() => setTab('settings')}>Einstellungen</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{tab === 'settings' && token && <ServerSettingsPanel token={token} setNotice={setNotice} />}
|
||||||
|
{tab === 'settings' && token && <DockerHostsPanel token={token} setNotice={setNotice} />}
|
||||||
|
{tab === 'settings' && token && <NotificationSettings token={token} setNotice={setNotice} />}
|
||||||
|
{notice && <div className={`toast toast-${notice.tone}`} role="status">{notice.text}</div>}
|
||||||
|
{error && <div className="alert alert-error">{error}</div>}
|
||||||
|
{tab === 'matches' && <HostUsage hosts={hosts} />}
|
||||||
|
{tab === 'matches' && matches === null && !error && <p className="muted">Lade Matches …</p>}
|
||||||
|
{tab === 'matches' && matches && matches.length === 0 && <p className="muted">Noch keine Matches.</p>}
|
||||||
|
|
||||||
|
{tab === 'matches' && <div className="admin-list">
|
||||||
|
{matches?.map((m) => (
|
||||||
|
<MatchAdminCard key={m.id} m={m} run={run} busy={busy} token={token} reload={load} setNotice={setNotice} />
|
||||||
|
))}
|
||||||
|
</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 }) {
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const submit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await api('/admin/login', { method: 'POST', body: { password } });
|
||||||
|
onLogin(res.token);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<form className="create admin-login" onSubmit={submit}>
|
||||||
|
<h1>Admin-Anmeldung</h1>
|
||||||
|
<p className="lead">Das Passwort ist in der Konfiguration unter <code>ADMIN_PASSWORD</code> hinterlegt.</p>
|
||||||
|
<label className="field">
|
||||||
|
<span>Passwort</span>
|
||||||
|
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} autoFocus autoComplete="current-password" required />
|
||||||
|
</label>
|
||||||
|
{error && <div className="alert alert-error">{error}</div>}
|
||||||
|
<button className="btn btn-primary btn-lg" disabled={busy}>{busy ? 'Melde an …' : 'Anmelden'}</button>
|
||||||
|
<p className="small muted"><Link to="/">Zur Übersicht</Link></p>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rollback: nur auf der Admin-Seite. Lädt die Sicherungen der aktuellen Map und spielt eine davon zurück. */
|
||||||
|
function RollbackPanel({ matchId, token, reload, setNotice }) {
|
||||||
|
const [points, setPoints] = useState(null);
|
||||||
|
const [file, setFile] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await api(`/admin/matches/${matchId}/rollback-points`, { token });
|
||||||
|
setPoints(res.points);
|
||||||
|
setFile(res.points[0]?.file ?? '');
|
||||||
|
} catch (err) {
|
||||||
|
setNotice({ tone: 'error', text: err.message });
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const rollback = async () => {
|
||||||
|
const p = points.find((x) => x.file === file);
|
||||||
|
if (!p || !window.confirm(`Match auf den Stand nach Runde ${p.round} zurückspielen? Spätere Runden dieser Map gehen verloren.`)) return;
|
||||||
|
try {
|
||||||
|
await api(`/admin/matches/${matchId}/rollback`, { method: 'POST', body: { file }, token });
|
||||||
|
setNotice({ tone: 'ok', text: `Zurückgespielt auf Runde ${p.round}` });
|
||||||
|
setPoints(null);
|
||||||
|
reload();
|
||||||
|
} catch (err) {
|
||||||
|
setNotice({ tone: 'error', text: err.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rollback">
|
||||||
|
<h3>Runde zurückspielen <span className="admin-badge">nur Admin</span></h3>
|
||||||
|
{!points && <button className="btn btn-sm" onClick={load} disabled={loading}>{loading ? 'Lade …' : 'Sicherungen laden'}</button>}
|
||||||
|
{points && points.length === 0 && <p className="small muted">Keine Sicherungen für diese Map gefunden.</p>}
|
||||||
|
{points && points.length > 0 && (
|
||||||
|
<div className="row">
|
||||||
|
<select value={file} onChange={(e) => setFile(e.target.value)}>
|
||||||
|
{points.map((p) => <option key={p.file} value={p.file}>Nach Runde {p.round}</option>)}
|
||||||
|
</select>
|
||||||
|
<button className="btn btn-sm btn-danger" onClick={rollback}>Zurückspielen</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MatchAdminCard({ m, run, busy, token, reload, setNotice }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const server = serverLabel(m.server);
|
||||||
|
const closed = ['finished', 'cancelled'].includes(m.status);
|
||||||
|
const id = m.id;
|
||||||
|
const btn = (label, cfg, cls = '') => (
|
||||||
|
<button className={`btn btn-sm ${cls}`} disabled={!!busy} onClick={() => run({ ...cfg, path: `/matches/${id}${cfg.path}` })}>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
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 (
|
||||||
|
<section className={`panel admin-card status-${m.status}`}>
|
||||||
|
<div className="admin-card-head">
|
||||||
|
<div>
|
||||||
|
<strong className="team-a">{m.teams.A.name}</strong>
|
||||||
|
<span className="vs"> vs. </span>
|
||||||
|
<strong className="team-b">{m.teams.B.name}</strong>
|
||||||
|
<div className="small muted">
|
||||||
|
{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}</>}
|
||||||
|
</div>
|
||||||
|
{m.status === 'live' && current && (
|
||||||
|
<div className="admin-live">
|
||||||
|
<span>Map {current.number}: {current.name}</span>
|
||||||
|
<strong>{current.score.A} : {current.score.B}</strong>
|
||||||
|
{current.live?.roundsPlayed != null && <span className="muted">Runde {current.live.roundsPlayed + 1}</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{m.server && (m.server.requestedAt || (m.server.address && m.server.status !== 'stopped')) && (
|
||||||
|
<div className="admin-address">
|
||||||
|
{m.server.address && m.server.status !== 'stopped' && (
|
||||||
|
<>
|
||||||
|
<span><span className="muted">Server</span> <code>{m.server.address.host}:{m.server.address.port}</code></span>
|
||||||
|
{m.server.address.tvPort && (
|
||||||
|
<span><span className="muted">CSTV</span> <code>{m.server.address.host}:{m.server.address.tvPort}</code></span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{m.server.requestedAt && (
|
||||||
|
<span><span className="muted">Angefordert</span> {formatDateTime(m.server.requestedAt)}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{paused && <span className="admin-badge is-paused">Pausiert</span>}
|
||||||
|
</div>
|
||||||
|
<button className="link-btn" onClick={() => setOpen(!open)}>{open ? 'Weniger' : 'Details'}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="admin-actions">
|
||||||
|
<Link className="btn btn-sm" to={`/m/${id}`}>Ansehen</Link>
|
||||||
|
{m.status === 'lobby' && btn('Veto starten', { path: '/veto/start' }, 'btn-primary')}
|
||||||
|
{(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 && ['start_failed', 'failed', 'stopped', 'config_failed', 'ready'].includes(m.server.status) && !closed &&
|
||||||
|
btn('Server neu starten / wiederholen', { path: '/server/retry' })}
|
||||||
|
{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 abbrechen', { path: '/cancel', confirm: 'Match abbrechen?' }, '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>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className="admin-details">
|
||||||
|
<div className="admin-grid">
|
||||||
|
<div>
|
||||||
|
<h3>Einladungen</h3>
|
||||||
|
<div className="small muted">Team A</div>
|
||||||
|
<CopyField value={m.organizer.links.A} />
|
||||||
|
<div className="small muted">Team B</div>
|
||||||
|
<CopyField value={m.organizer.links.B} />
|
||||||
|
<div className="small muted">Zuschauer</div>
|
||||||
|
<CopyField value={m.organizer.links.spectator} />
|
||||||
|
<div className="small muted">Stream-Ansicht</div>
|
||||||
|
<CopyField value={m.organizer.links.stream} />
|
||||||
|
<div className="row">
|
||||||
|
{['A', 'B', 'spectator'].map((t) => (
|
||||||
|
<button key={t} className="link-btn" disabled={!!busy}
|
||||||
|
onClick={() => run({ path: `/matches/${id}/codes/regenerate`, body: { target: t }, confirm: 'Neuer Code? Der alte Link wird ungültig.', success: 'Neuer Code erzeugt' })}>
|
||||||
|
Code {t === 'spectator' ? 'Zuschauer' : t} erneuern
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3>Server</h3>
|
||||||
|
{m.server?.connect && <CopyField label="Spieler-Verbindung" value={m.server.connect.command} />}
|
||||||
|
{m.server?.gotv && <CopyField label="CSTV" value={m.server.gotv.command} />}
|
||||||
|
<p className="small muted">{m.server?.message ?? 'kein Server'}</p>
|
||||||
|
<p className="small muted">Versuche: {m.server?.attempts ?? 0} · Spieler auf dem Server: {m.server?.playerCount ?? '–'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>Spieler</h3>
|
||||||
|
<div className="admin-players">
|
||||||
|
{['A', 'B'].map((t) => (
|
||||||
|
<div key={t}>
|
||||||
|
<div className="small muted">{m.teams[t].name} ({m.teams[t].readiness.count}/{m.teams[t].teamSize})</div>
|
||||||
|
<ul>
|
||||||
|
{m.teams[t].players.map((p) => (
|
||||||
|
<li key={p.id}>
|
||||||
|
{p.nickname} {p.isCaptain && <em>Captain</em>} {p.ready && <span className="tone-ok">bereit</span>}
|
||||||
|
{!closed && (
|
||||||
|
<button className="link-btn danger" disabled={!!busy}
|
||||||
|
onClick={() => run({ path: `/matches/${id}/kick`, body: { playerId: p.id }, confirm: `${p.nickname} entfernen?`, success: `${p.nickname} entfernt` })}>
|
||||||
|
Entfernen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{m.teams[t].players.length === 0 && <li className="muted">niemand</li>}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{m.status === 'live' && m.server?.status === 'configured' && (
|
||||||
|
<RollbackPanel matchId={id} token={token} reload={reload} setNotice={setNotice} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{m.veto && !m.veto.done && m.veto.current && (
|
||||||
|
<p className="small">Veto: {m.teams[m.veto.current.team].name} ist am Zug ({m.veto.current.type}).</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<h3>Verlauf</h3>
|
||||||
|
<ol className="admin-log">
|
||||||
|
{m.events.slice(0, 15).map((e, i) => (
|
||||||
|
<li key={i}><time>{new Date(e.at).toLocaleTimeString('de-DE')}</time> {e.message}</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</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`;
|
||||||
|
}
|
||||||
@@ -106,7 +106,13 @@ export default function MatchPage() {
|
|||||||
<div className="versus">
|
<div className="versus">
|
||||||
<h1>
|
<h1>
|
||||||
<span className="team-a">{state.teams.A.name}</span>
|
<span className="team-a">{state.teams.A.name}</span>
|
||||||
<span className="vs">vs.</span>
|
{state.series.length > 1 ? (
|
||||||
|
<span className="series-total" title="Serienstand (gewonnene Maps)">
|
||||||
|
{state.seriesScore.A}<span className="colon">:</span>{state.seriesScore.B}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="vs">vs.</span>
|
||||||
|
)}
|
||||||
<span className="team-b">{state.teams.B.name}</span>
|
<span className="team-b">{state.teams.B.name}</span>
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
@@ -121,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} />
|
||||||
@@ -150,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;
|
||||||
@@ -224,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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ 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.' },
|
||||||
@@ -32,7 +33,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>
|
||||||
@@ -54,10 +59,19 @@ function MatchRow({ match: m }) {
|
|||||||
<Link to={`/m/${m.id}`} className="match-row">
|
<Link to={`/m/${m.id}`} className="match-row">
|
||||||
<div className="match-row-teams">
|
<div className="match-row-teams">
|
||||||
<span className="team-a">{m.teamA}</span>
|
<span className="team-a">{m.teamA}</span>
|
||||||
<span className="vs">vs.</span>
|
{m.seriesScore && m.format !== 'bo1' ? (
|
||||||
|
<span className="row-score" title="Serienstand">{m.seriesScore.A}:{m.seriesScore.B}</span>
|
||||||
|
) : (
|
||||||
|
<span className="vs">vs.</span>
|
||||||
|
)}
|
||||||
<span className="team-b">{m.teamB}</span>
|
<span className="team-b">{m.teamB}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="match-row-meta">
|
<div className="match-row-meta">
|
||||||
|
{m.currentMap && (
|
||||||
|
<span className={m.currentMap.finished ? '' : 'row-live'}>
|
||||||
|
{m.currentMap.finished ? '' : <span className="live-badge">Live</span>} {m.currentMap.name} {m.currentMap.score.A}:{m.currentMap.score.B}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<span>{m.modeLabel}</span>
|
<span>{m.modeLabel}</span>
|
||||||
<span>{FORMAT_LABEL[m.format]}</span>
|
<span>{FORMAT_LABEL[m.format]}</span>
|
||||||
{m.group === 'open' && <span>{m.players.A + m.players.B}/{m.players.teamSize * 2} Spieler</span>}
|
{m.group === 'open' && <span>{m.players.A + m.players.B}/{m.players.teamSize * 2} Spieler</span>}
|
||||||
@@ -70,6 +84,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);
|
||||||
|
|||||||
@@ -0,0 +1,258 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
import { api } from '../lib/api.js';
|
||||||
|
import { FORMAT_LABEL } from '../lib/labels.js';
|
||||||
|
import { getSocket } from '../lib/useMatch.js';
|
||||||
|
import MapImage from '../components/MapImage.jsx';
|
||||||
|
import SideIcon from '../components/SideIcon.jsx';
|
||||||
|
import { ScaledStage, useClock } from './StreamPage.jsx';
|
||||||
|
import '../stream.css';
|
||||||
|
import '../stream-overview.css';
|
||||||
|
|
||||||
|
const PER_PAGE = 6;
|
||||||
|
const PAGE_SECONDS = 12;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stream-Übersicht aller laufenden Vetos und Matches: /stream
|
||||||
|
* Mit STREAM_KEY: /stream?key=<schlüssel>. Optional ?bg=transparent.
|
||||||
|
* Ein einzelnes Match: /stream/match/<matchId>, daraus führen die Karten der Übersicht.
|
||||||
|
*/
|
||||||
|
export default function StreamOverviewPage() {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const key = params.get('key') ?? '';
|
||||||
|
const transparent = params.get('bg') === 'transparent';
|
||||||
|
const { matchId } = useParams();
|
||||||
|
const { data, error } = useStreamOverview(key);
|
||||||
|
const now = useClock();
|
||||||
|
const [page, setPage] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.documentElement.classList.toggle('stream-transparent', transparent);
|
||||||
|
document.documentElement.classList.add('stream-mode');
|
||||||
|
return () => document.documentElement.classList.remove('stream-mode', 'stream-transparent');
|
||||||
|
}, [transparent]);
|
||||||
|
|
||||||
|
const activeMatches = (data?.active ?? []).filter((m) => !matchId || m.id === matchId);
|
||||||
|
|
||||||
|
// Mehr Matches als auf eine Seite passen: automatisch weiterblättern
|
||||||
|
const pages = Math.max(1, Math.ceil(activeMatches.length / PER_PAGE));
|
||||||
|
useEffect(() => {
|
||||||
|
if (pages <= 1) { setPage(0); return undefined; }
|
||||||
|
const t = setInterval(() => setPage((p) => (p + 1) % pages), PAGE_SECONDS * 1000);
|
||||||
|
return () => clearInterval(t);
|
||||||
|
}, [pages]);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<ScaledStage>
|
||||||
|
<div className="st-message">
|
||||||
|
<img src="/brand/maxlan-logo.webp" alt="MaxLAN e.V." />
|
||||||
|
<h1>Stream-Übersicht nicht verfügbar</h1>
|
||||||
|
<p>{error}</p>
|
||||||
|
</div>
|
||||||
|
</ScaledStage>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const active = activeMatches;
|
||||||
|
const shown = active.slice(page * PER_PAGE, page * PER_PAGE + PER_PAGE);
|
||||||
|
const liveCount = active.filter((m) => m.status === 'live').length;
|
||||||
|
const vetoCount = active.filter((m) => m.status === 'veto').length;
|
||||||
|
const density = shown.length <= 1 ? 1 : shown.length <= 4 ? 2 : 3;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScaledStage>
|
||||||
|
<div className="so-root">
|
||||||
|
<header className="st-top">
|
||||||
|
<img className="st-logo" src="/brand/maxlan-logo.webp" alt="MaxLAN e.V." />
|
||||||
|
<div className="so-title">
|
||||||
|
<strong>Counter-Strike 2</strong>
|
||||||
|
{liveCount > 0 && <span className="so-count so-count-live">{liveCount} live</span>}
|
||||||
|
{vetoCount > 0 && <span className="so-count">{vetoCount} im Veto</span>}
|
||||||
|
{pages > 1 && <span className="so-count">Seite {page + 1} / {pages}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="st-clock"><time>{now.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}</time></div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className={`so-grid density-${density} count-${shown.length}`}>
|
||||||
|
{!data && <div className="so-empty">Lade Matches …</div>}
|
||||||
|
{data && shown.length === 0 && (
|
||||||
|
<div className="so-empty">
|
||||||
|
{matchId ? (
|
||||||
|
<>
|
||||||
|
<h2>Dieses Match läuft gerade nicht</h2>
|
||||||
|
<p>Es erscheint hier, sobald das Veto oder das Match beginnt.</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<h2>Gerade läuft kein Match</h2>
|
||||||
|
<p>{data.upcoming.length ? 'Die nächsten Lobbys stehen unten.' : 'Sobald ein Veto beginnt, erscheint es hier.'}</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{shown.map((m) => (
|
||||||
|
<a key={m.id} className="so-link" href={matchHref(m.id, key)}>
|
||||||
|
<MatchCard m={m} />
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer className="so-bottom">
|
||||||
|
<section className="so-strip">
|
||||||
|
<h3>Gleich</h3>
|
||||||
|
{data?.upcoming.length ? (
|
||||||
|
<ul>
|
||||||
|
{data.upcoming.map((m) => (
|
||||||
|
<li key={m.id}>
|
||||||
|
<a className="so-link" href={matchHref(m.id, key)}>
|
||||||
|
<span className="a">{m.teams.A}</span> <span className="vs">vs</span> <span className="b">{m.teams.B}</span>
|
||||||
|
<small>{FORMAT_LABEL[m.format]}{m.ready.A && m.ready.B ? ', bereit' : ''}</small>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : <p className="so-none">Keine offenen Lobbys</p>}
|
||||||
|
</section>
|
||||||
|
<section className="so-strip">
|
||||||
|
<h3>Ergebnisse</h3>
|
||||||
|
{data?.results.length ? (
|
||||||
|
<ul>
|
||||||
|
{data.results.map((m) => (
|
||||||
|
<li key={m.id}>
|
||||||
|
<a className="so-link" href={matchHref(m.id, key)}>
|
||||||
|
<span className={`a ${winner(m) === 'A' ? 'won' : ''}`}>{m.teams.A}</span>
|
||||||
|
<b>{resultScore(m)}</b>
|
||||||
|
<span className={`b ${winner(m) === 'B' ? 'won' : ''}`}>{m.teams.B}</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : <p className="so-none">Noch keine Ergebnisse</p>}
|
||||||
|
</section>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</ScaledStage>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Link zur Stream-Übersicht eines einzelnen Matches. Der Zuschauercode bleibt geheim, daher nur die Match-ID. */
|
||||||
|
function matchHref(id, key) {
|
||||||
|
return `/stream/match/${encodeURIComponent(id)}${key ? `?key=${encodeURIComponent(key)}` : ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function useStreamOverview(key) {
|
||||||
|
const [data, setData] = useState(null);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
useEffect(() => {
|
||||||
|
const socket = getSocket();
|
||||||
|
const subscribe = () => socket.emit('stream:subscribe', { key });
|
||||||
|
const onData = (d) => { setData(d); setError(null); };
|
||||||
|
const onError = (e) => setError(e.error);
|
||||||
|
socket.on('stream:overview', onData);
|
||||||
|
socket.on('stream:error', onError);
|
||||||
|
socket.on('connect', subscribe);
|
||||||
|
if (socket.connected) subscribe();
|
||||||
|
api(`/stream/overview?key=${encodeURIComponent(key)}`).then(onData).catch((e) => setError(e.message));
|
||||||
|
return () => {
|
||||||
|
socket.off('stream:overview', onData);
|
||||||
|
socket.off('stream:error', onError);
|
||||||
|
socket.off('connect', subscribe);
|
||||||
|
};
|
||||||
|
}, [key]);
|
||||||
|
return { data, error };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bei BO1 zählt der Map-Stand, sonst der Serienstand. */
|
||||||
|
function resultScore(m) {
|
||||||
|
if (m.format === 'bo1' && m.series[0]) return `${m.series[0].score.A}:${m.series[0].score.B}`;
|
||||||
|
return `${m.seriesScore.A}:${m.seriesScore.B}`;
|
||||||
|
}
|
||||||
|
function winner(m) {
|
||||||
|
const [a, b] = resultScore(m).split(':').map(Number);
|
||||||
|
return a === b ? null : a > b ? 'A' : 'B';
|
||||||
|
}
|
||||||
|
function MatchCard({ m }) {
|
||||||
|
const current = m.series.find((s) => s.number === m.currentMapNumber);
|
||||||
|
const isBo1 = m.format === 'bo1';
|
||||||
|
const playing = m.status === 'live' && current?.live?.status === 'live' && current.status !== 'done';
|
||||||
|
const badge = m.status === 'veto' ? 'Veto' : m.status === 'server_setup' ? 'Server' : playing ? 'Live' : current?.status === 'done' ? 'Map beendet' : 'Aufwärmen';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className={`so-card status-${m.status} ${playing ? 'is-playing' : ''}`} style={{ '--map': current?.color ?? '#556' }}>
|
||||||
|
{m.status !== 'veto' && current && <MapImage map={current} className="so-card-art" />}
|
||||||
|
<div className="so-card-head">
|
||||||
|
<span>{m.modeLabel}, {FORMAT_LABEL[m.format]}</span>
|
||||||
|
<span className={`so-badge badge-${badge === 'Live' ? 'live' : 'other'}`}>{badge}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="so-teams">
|
||||||
|
<span className="so-team a">{m.teams.A}</span>
|
||||||
|
<span className="so-series">
|
||||||
|
{!isBo1 && m.status !== 'veto' ? <>{m.seriesScore.A}<i>:</i>{m.seriesScore.B}</> : <i className="so-vs">vs</i>}
|
||||||
|
</span>
|
||||||
|
<span className="so-team b">{m.teams.B}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{m.status === 'veto' && <VetoBody m={m} />}
|
||||||
|
{m.status === 'server_setup' && current && (
|
||||||
|
<div className="so-setup">Server wird eingerichtet: Map {current.number}, {current.name}</div>
|
||||||
|
)}
|
||||||
|
{m.status === 'live' && current && <LiveBody m={m} current={current} />}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function VetoBody({ m }) {
|
||||||
|
const { veto, teams } = m;
|
||||||
|
const c = veto.current;
|
||||||
|
const action = c && ({ ban: 'bannt', pick: 'wählt', side: 'wählt die Seite' })[c.type];
|
||||||
|
return (
|
||||||
|
<div className="so-veto">
|
||||||
|
<div className="so-turn">
|
||||||
|
{c ? <><strong className={c.team === 'A' ? 'a' : 'b'}>{teams[c.team]}</strong> {action}</> : 'Veto abgeschlossen'}
|
||||||
|
<small>Schritt {Math.min(veto.stepsDone + 1, veto.stepsTotal)} von {veto.stepsTotal}</small>
|
||||||
|
</div>
|
||||||
|
<div className="so-vmaps" style={{ '--count': veto.maps.length }}>
|
||||||
|
{veto.maps.map((x) => (
|
||||||
|
<div key={x.id} className={`so-vmap status-${x.status} ${x.by ? `by-${x.by}` : ''}`} style={{ '--map': x.color ?? '#556' }}>
|
||||||
|
<MapImage map={x} />
|
||||||
|
<span className="so-vmap-name">{x.name}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LiveBody({ m, current }) {
|
||||||
|
const sides = current.live?.sides;
|
||||||
|
const sideOf = (t) => (sides ? (sides.CT === t ? 'CT' : 'T') : null);
|
||||||
|
const finished = current.status === 'done';
|
||||||
|
const rounds = current.live?.roundsPlayed ?? 0;
|
||||||
|
return (
|
||||||
|
<div className="so-live">
|
||||||
|
<div className="so-map">
|
||||||
|
<strong>{current.name}</strong>
|
||||||
|
<span>{finished ? 'Endstand' : current.live?.status === 'live' ? `Runde ${rounds + 1}` : 'Aufwärmphase'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="so-score">
|
||||||
|
{sideOf('A') && <SideIcon side={sideOf('A')} size={44} />}
|
||||||
|
<b className={`a ${current.winner === 'A' ? 'won' : ''}`}>{current.score.A}</b>
|
||||||
|
<i>:</i>
|
||||||
|
<b className={`b ${current.winner === 'B' ? 'won' : ''}`}>{current.score.B}</b>
|
||||||
|
{sideOf('B') && <SideIcon side={sideOf('B')} size={44} />}
|
||||||
|
</div>
|
||||||
|
{m.series.length > 1 && (
|
||||||
|
<ol className="so-maps">
|
||||||
|
{m.series.map((s) => (
|
||||||
|
<li key={s.number} className={`is-${s.status} ${s.number === current.number ? 'is-current' : ''}`}>
|
||||||
|
<span>{s.name}</span>
|
||||||
|
{(s.status === 'done' || s.number === current.number) && <b>{s.score.A}:{s.score.B}</b>}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
import { api } from '../lib/api.js';
|
||||||
|
import { FORMAT_LABEL } from '../lib/labels.js';
|
||||||
|
import { useMatch } from '../lib/useMatch.js';
|
||||||
|
import MapImage from '../components/MapImage.jsx';
|
||||||
|
import SideIcon from '../components/SideIcon.jsx';
|
||||||
|
import RoundStrip from '../components/RoundStrip.jsx';
|
||||||
|
import '../stream.css';
|
||||||
|
|
||||||
|
const STAGE_W = 1920;
|
||||||
|
const STAGE_H = 1080;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vollbild-Übersicht für den Stream: /stream/<Zuschauercode>
|
||||||
|
* Optional ?bg=transparent für eine transparente OBS-Browserquelle.
|
||||||
|
*/
|
||||||
|
export default function StreamPage() {
|
||||||
|
const { code } = useParams();
|
||||||
|
const [matchId, setMatchId] = useState(null);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const transparent = new URLSearchParams(window.location.search).get('bg') === 'transparent';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api('/access', { method: 'POST', body: { code } })
|
||||||
|
.then((info) => {
|
||||||
|
if (info.access !== 'spectator') throw new Error('Für die Stream-Ansicht wird der Zuschauercode benötigt.');
|
||||||
|
setMatchId(info.matchId);
|
||||||
|
})
|
||||||
|
.catch((e) => setError(e.message));
|
||||||
|
}, [code]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.documentElement.classList.toggle('stream-transparent', transparent);
|
||||||
|
document.documentElement.classList.add('stream-mode');
|
||||||
|
return () => document.documentElement.classList.remove('stream-mode', 'stream-transparent');
|
||||||
|
}, [transparent]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScaledStage>
|
||||||
|
{error ? <StreamMessage title="Stream-Ansicht nicht verfügbar" text={error} />
|
||||||
|
: matchId ? <StreamContent matchId={matchId} code={code} />
|
||||||
|
: <StreamMessage title="Verbinde …" />}
|
||||||
|
</ScaledStage>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Feste 1920×1080-Bühne, auf die Fenstergröße skaliert. */
|
||||||
|
export function ScaledStage({ children }) {
|
||||||
|
const ref = useRef(null);
|
||||||
|
const [scale, setScale] = useState(1);
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const fit = () => setScale(Math.min(window.innerWidth / STAGE_W, window.innerHeight / STAGE_H));
|
||||||
|
fit();
|
||||||
|
window.addEventListener('resize', fit);
|
||||||
|
return () => window.removeEventListener('resize', fit);
|
||||||
|
}, []);
|
||||||
|
return (
|
||||||
|
<div className="stream-viewport">
|
||||||
|
<div ref={ref} className="stream-stage" style={{ width: STAGE_W, height: STAGE_H, transform: `translate(-50%, -50%) scale(${scale})` }}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StreamMessage({ title, text }) {
|
||||||
|
return (
|
||||||
|
<div className="st-message">
|
||||||
|
<img src="/brand/maxlan-logo.webp" alt="MaxLAN e.V." />
|
||||||
|
<h1>{title}</h1>
|
||||||
|
{text && <p>{text}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useClock() {
|
||||||
|
const [now, setNow] = useState(() => new Date());
|
||||||
|
useEffect(() => {
|
||||||
|
const t = setInterval(() => setNow(new Date()), 1000);
|
||||||
|
return () => clearInterval(t);
|
||||||
|
}, []);
|
||||||
|
return now;
|
||||||
|
}
|
||||||
|
|
||||||
|
function StreamContent({ matchId, code }) {
|
||||||
|
const { state, connected } = useMatch(matchId, code);
|
||||||
|
const now = useClock();
|
||||||
|
if (!state) return <StreamMessage title="Lade Match …" />;
|
||||||
|
|
||||||
|
const { teams } = state;
|
||||||
|
const current = state.series.find((s) => s.number === state.currentMapNumber);
|
||||||
|
const playing = current?.live?.status === 'live' && current.status !== 'done';
|
||||||
|
const phase = state.status === 'lobby' ? 'lobby' : state.status === 'veto' ? 'veto' : 'match';
|
||||||
|
const showSeries = state.series.length > 1;
|
||||||
|
// Im Laufband nur, was Zuschauer interessiert – keine technischen Servermeldungen
|
||||||
|
const tickerEvent = state.events.find((e) => ['veto', 'score', 'series', 'join', 'captain', 'status'].includes(e.type));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`st-root phase-${phase} ${phase !== 'lobby' && state.series.length > 0 ? 'has-maps' : 'no-maps'}`}>
|
||||||
|
<header className="st-top">
|
||||||
|
<img className="st-logo" src="/brand/maxlan-logo.webp" alt="MaxLAN e.V." />
|
||||||
|
<div className="st-meta">
|
||||||
|
<span>Counter-Strike 2</span>
|
||||||
|
<span>{state.modeLabel}</span>
|
||||||
|
<span>{FORMAT_LABEL[state.format]}</span>
|
||||||
|
</div>
|
||||||
|
<div className="st-clock">
|
||||||
|
{playing && <span className="st-live">Live</span>}
|
||||||
|
{!connected && connected !== null && <span className="st-offline">Verbindung …</span>}
|
||||||
|
<time>{now.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}</time>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="st-hero">
|
||||||
|
<div className="st-team st-team-a">
|
||||||
|
<h1>{teams.A.name}</h1>
|
||||||
|
</div>
|
||||||
|
<div className="st-center">
|
||||||
|
<span className="st-slash" aria-hidden="true" />
|
||||||
|
{showSeries && phase === 'match' ? (
|
||||||
|
<div className="st-series" aria-label="Serienstand">
|
||||||
|
<span className="a">{state.seriesScore.A}</span>
|
||||||
|
<span className="sep">–</span>
|
||||||
|
<span className="b">{state.seriesScore.B}</span>
|
||||||
|
<small>Maps</small>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="st-vs">vs</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="st-team st-team-b">
|
||||||
|
<h1>{teams.B.name}</h1>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="st-main">
|
||||||
|
{phase === 'lobby' && <LobbyScene state={state} />}
|
||||||
|
{phase === 'veto' && <VetoScene state={state} />}
|
||||||
|
{phase === 'match' && <MatchScene state={state} current={current} />}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{phase !== 'lobby' && state.series.length > 0 && (
|
||||||
|
<section className="st-maps">
|
||||||
|
{state.series.map((s) => <SeriesCard key={s.number} s={s} state={state} />)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<footer className="st-ticker">
|
||||||
|
<span className="st-ticker-label">{phaseLabel(state)}</span>
|
||||||
|
<span className="st-ticker-text" key={tickerEvent?.at}>{tickerEvent?.message ?? 'Warten auf die Teams'}</span>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function phaseLabel(state) {
|
||||||
|
if (state.status === 'lobby') return 'Lobby';
|
||||||
|
if (state.status === 'veto') return 'Map-Veto';
|
||||||
|
if (state.status === 'finished') return 'Endstand';
|
||||||
|
if (state.status === 'cancelled') return 'Abgebrochen';
|
||||||
|
return 'Match';
|
||||||
|
}
|
||||||
|
|
||||||
|
function LobbyScene({ state }) {
|
||||||
|
return (
|
||||||
|
<div className="st-lobby">
|
||||||
|
{['A', 'B'].map((key) => {
|
||||||
|
const team = state.teams[key];
|
||||||
|
return (
|
||||||
|
<div key={key} className={`st-roster st-roster-${key}`}>
|
||||||
|
<h2>{team.readiness.ready ? 'Bereit' : `${team.readiness.count} / ${team.teamSize} Spieler`}</h2>
|
||||||
|
<ul>
|
||||||
|
{team.players.map((p) => (
|
||||||
|
<li key={p.id} className={p.ready ? 'is-ready' : ''}>
|
||||||
|
<span>{p.nickname}</span>
|
||||||
|
{p.isCaptain && <em>Captain</em>}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<div className="st-lobby-note">Das Map-Veto beginnt gleich</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function VetoScene({ state }) {
|
||||||
|
const { veto, teams } = state;
|
||||||
|
const current = veto?.current;
|
||||||
|
const mapsById = Object.fromEntries(state.maps.map((m) => [m.id, m]));
|
||||||
|
const action = current && ({ ban: 'bannt', pick: 'wählt', side: 'wählt die Startseite auf' })[current.type];
|
||||||
|
return (
|
||||||
|
<div className="st-veto">
|
||||||
|
{current && (
|
||||||
|
<div className={`st-turn st-turn-${current.team}`}>
|
||||||
|
<strong>{teams[current.team].name}</strong> {action}
|
||||||
|
{current.type === 'side' ? ` ${mapsById[current.map]?.name}` : ' eine Map'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="st-veto-grid" style={{ '--count': veto.maps.length }}>
|
||||||
|
{veto.maps.map((m) => (
|
||||||
|
<div key={m.id} className={`st-vmap status-${m.status} ${m.by ? `by-${m.by}` : ''} ${current?.map === m.id ? 'is-side' : ''}`}>
|
||||||
|
<MapImage map={mapsById[m.id]} />
|
||||||
|
<span className="st-vmap-name">{mapsById[m.id]?.name}</span>
|
||||||
|
<span className="st-vmap-status">
|
||||||
|
{m.status === 'banned' && `Bann ${teams[m.by].name}`}
|
||||||
|
{m.status === 'picked' && `Pick ${teams[m.by].name}`}
|
||||||
|
{m.status === 'decider' && 'Decider'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MatchScene({ state, current }) {
|
||||||
|
const { teams } = state;
|
||||||
|
if (!current) return <div className="st-waiting">Server wird vorbereitet</div>;
|
||||||
|
const map = state.maps.find((m) => m.id === current.map);
|
||||||
|
const live = current.live;
|
||||||
|
const sides = live?.sides ?? (current.ctTeam ? { CT: current.ctTeam, T: current.ctTeam === 'A' ? 'B' : 'A' } : null);
|
||||||
|
const sideOf = (team) => (sides ? (sides.CT === team ? 'CT' : 'T') : null);
|
||||||
|
const finished = current.status === 'done';
|
||||||
|
const waiting = !live || live.status === 'warmup';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="st-board" style={{ '--map': map?.color ?? '#556' }}>
|
||||||
|
<MapImage map={map} className="st-board-art" />
|
||||||
|
<div className="st-board-head">
|
||||||
|
<span>Map {current.number}{state.series.length > 1 ? ` von ${state.series.length}` : ''}</span>
|
||||||
|
<strong>{current.mapName}</strong>
|
||||||
|
<span className="st-board-state">{finished ? 'Endstand' : waiting ? 'Aufwärmphase' : `Runde ${(live?.rounds.length ?? 0) + 1}`}</span>
|
||||||
|
</div>
|
||||||
|
<div className="st-score">
|
||||||
|
<div className={`st-score-team a ${current.winner === 'A' ? 'won' : ''}`}>
|
||||||
|
{sideOf('A') && <SideIcon side={sideOf('A')} size={64} />}
|
||||||
|
<span className="num">{current.score.A}</span>
|
||||||
|
</div>
|
||||||
|
<span className="st-score-colon">:</span>
|
||||||
|
<div className={`st-score-team b ${current.winner === 'B' ? 'won' : ''}`}>
|
||||||
|
<span className="num">{current.score.B}</span>
|
||||||
|
{sideOf('B') && <SideIcon side={sideOf('B')} size={64} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{finished && current.winner && <div className="st-winner">{teams[current.winner].name} gewinnt {current.mapName}</div>}
|
||||||
|
<div className="st-rounds">
|
||||||
|
<RoundStrip rounds={live?.rounds ?? []} maxRounds={state.maxRounds} teams={teams} size="lg" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SeriesCard({ s, state }) {
|
||||||
|
const map = state.maps.find((m) => m.id === s.map);
|
||||||
|
const isCurrent = s.number === state.currentMapNumber && state.status === 'live';
|
||||||
|
const playing = isCurrent && s.live?.status === 'live' && s.status !== 'done';
|
||||||
|
return (
|
||||||
|
<div className={`st-mapcard is-${s.status} ${playing ? 'is-playing' : ''}`} style={{ '--map': map?.color ?? '#556' }}>
|
||||||
|
<MapImage map={map} />
|
||||||
|
<span className="st-mapcard-no">Map {s.number}</span>
|
||||||
|
<strong>{s.mapName}</strong>
|
||||||
|
<span className="st-mapcard-foot">
|
||||||
|
{s.status === 'done' || playing ? (
|
||||||
|
<span className="st-mapcard-score">
|
||||||
|
<b className={s.winner === 'A' ? 'won a' : 'a'}>{s.score.A}</b>
|
||||||
|
<i>:</i>
|
||||||
|
<b className={s.winner === 'B' ? 'won b' : 'b'}>{s.score.B}</b>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="st-mapcard-origin">{s.pickedBy ? `Pick ${state.teams[s.pickedBy].name}` : 'Decider'}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
/* ---------- Stream-Übersicht aller Matches (1920×1080) ---------- */
|
||||||
|
.so-root {
|
||||||
|
width: 100%; height: 100%; overflow: hidden;
|
||||||
|
display: grid; grid-template-rows: 104px 1fr 190px; padding: 0 56px;
|
||||||
|
color: var(--text); font-family: var(--body);
|
||||||
|
background:
|
||||||
|
radial-gradient(900px 600px at 0% 20%, rgba(52, 74, 153, .25), transparent 70%),
|
||||||
|
radial-gradient(900px 600px at 100% 20%, rgba(167, 42, 28, .22), transparent 70%),
|
||||||
|
#000;
|
||||||
|
}
|
||||||
|
.stream-transparent .so-root { background: transparent; }
|
||||||
|
|
||||||
|
.so-title { display: flex; align-items: center; gap: 18px; font: 600 30px var(--display); }
|
||||||
|
.so-count { padding: 4px 14px; border-radius: 4px; background: var(--surface-2); color: var(--muted); font-size: 24px; }
|
||||||
|
.so-count-live { background: var(--brand-red); color: #fff; }
|
||||||
|
|
||||||
|
/* Raster je nach Anzahl */
|
||||||
|
.so-grid { display: grid; gap: 24px; padding: 24px 0; min-height: 0; }
|
||||||
|
.so-grid.count-1 { grid-template-columns: 1fr; }
|
||||||
|
.so-grid.count-2 { grid-template-columns: 1fr 1fr; }
|
||||||
|
.so-grid.count-3, .so-grid.count-4 { grid-template-columns: 1fr 1fr; grid-template-rows: 1fr 1fr; }
|
||||||
|
.so-grid.count-5, .so-grid.count-6 { grid-template-columns: repeat(3, 1fr); grid-template-rows: 1fr 1fr; }
|
||||||
|
.so-empty { grid-column: 1 / -1; display: grid; place-content: center; text-align: center; color: var(--muted); }
|
||||||
|
.so-empty h2 { font: 700 64px var(--display); color: var(--text); margin-bottom: 12px; }
|
||||||
|
.so-empty p { font-size: 28px; }
|
||||||
|
|
||||||
|
/* Karte */
|
||||||
|
.so-card {
|
||||||
|
position: relative; overflow: hidden; min-height: 0; border-radius: 10px;
|
||||||
|
display: grid; grid-template-rows: auto auto 1fr; gap: 10px; padding: 22px 28px;
|
||||||
|
background: rgba(17, 20, 30, .82); box-shadow: inset 0 0 0 2px rgba(255, 255, 255, .06);
|
||||||
|
}
|
||||||
|
.so-card.is-playing { box-shadow: inset 0 0 0 3px var(--brand-red), 0 0 40px rgba(167, 42, 28, .35); }
|
||||||
|
.so-card-art { opacity: .3; }
|
||||||
|
.so-card-art::after { background: linear-gradient(180deg, rgba(0, 0, 0, .4), rgba(0, 0, 0, .9)); }
|
||||||
|
.so-card > :not(.so-card-art) { position: relative; z-index: 1; }
|
||||||
|
|
||||||
|
.so-card-head { display: flex; justify-content: space-between; align-items: center; font: 600 22px var(--display); color: var(--muted); }
|
||||||
|
.so-badge { padding: 3px 12px; border-radius: 4px; background: var(--surface-2); color: var(--text); font-size: 20px; }
|
||||||
|
.so-badge.badge-live { background: var(--brand-red); color: #fff; }
|
||||||
|
|
||||||
|
.so-teams { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 24px; }
|
||||||
|
.so-team { font: 700 52px/1 var(--display); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.so-team.a { color: var(--team-a); text-align: right; }
|
||||||
|
.so-team.b { color: var(--team-b); }
|
||||||
|
.so-series { font: 700 56px var(--display); font-variant-numeric: tabular-nums; }
|
||||||
|
.so-series i { font-style: normal; color: var(--muted); margin: 0 .15em; }
|
||||||
|
.so-series .so-vs { font-size: 32px; color: var(--muted); margin: 0; }
|
||||||
|
|
||||||
|
/* Veto */
|
||||||
|
.so-veto { display: grid; grid-template-rows: auto 1fr; gap: 14px; min-height: 0; }
|
||||||
|
.so-turn { display: flex; align-items: baseline; justify-content: center; gap: 14px; font: 600 32px var(--display); }
|
||||||
|
.so-turn .a { color: var(--team-a); }
|
||||||
|
.so-turn .b { color: var(--team-b); }
|
||||||
|
.so-turn small { font: 500 20px var(--body); color: var(--muted); }
|
||||||
|
.so-vmaps { display: grid; grid-template-columns: repeat(var(--count), minmax(0, 1fr)); gap: 10px; min-height: 0; }
|
||||||
|
.so-vmap { --by: var(--line-strong); position: relative; overflow: hidden; border-radius: 6px; display: flex; align-items: flex-end; padding: 10px; min-height: 0; }
|
||||||
|
.so-vmap.by-A { --by: var(--team-a); }
|
||||||
|
.so-vmap.by-B { --by: var(--team-b); }
|
||||||
|
.so-vmap-name { position: relative; z-index: 2; font: 700 24px/1 var(--display); text-shadow: 0 2px 8px #000; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.so-vmap .map-art::after { background: linear-gradient(180deg, transparent 40%, rgba(0, 0, 0, .85)); }
|
||||||
|
.so-vmap.status-banned { filter: grayscale(.95) brightness(.45); }
|
||||||
|
.so-vmap.status-banned::after {
|
||||||
|
content: ''; position: absolute; inset: 0; z-index: 3;
|
||||||
|
background: linear-gradient(to top right, transparent calc(50% - 3px), var(--by) calc(50% - 3px), var(--by) calc(50% + 3px), transparent calc(50% + 3px));
|
||||||
|
}
|
||||||
|
.so-vmap.status-picked::before, .so-vmap.status-decider::before { content: ''; position: absolute; inset: 0; z-index: 3; border-radius: 6px; box-shadow: inset 0 0 0 4px var(--by); }
|
||||||
|
.so-vmap.status-decider { --by: #fff; }
|
||||||
|
|
||||||
|
/* Live */
|
||||||
|
.so-live { display: grid; grid-template-rows: auto 1fr auto; justify-items: center; align-items: center; min-height: 0; }
|
||||||
|
.so-map { display: flex; align-items: baseline; gap: 16px; }
|
||||||
|
.so-map strong { font: 700 40px var(--display); }
|
||||||
|
.so-map span { font: 600 24px var(--display); color: var(--pending); }
|
||||||
|
.so-score { display: flex; align-items: center; gap: 22px; }
|
||||||
|
.so-score b { font: 700 140px/0.9 var(--display); font-variant-numeric: tabular-nums; }
|
||||||
|
.so-score b.a { color: var(--team-a); }
|
||||||
|
.so-score b.b { color: var(--team-b); }
|
||||||
|
.so-score b.won { text-shadow: 0 0 30px currentColor; }
|
||||||
|
.so-score i { font: 700 70px var(--display); font-style: normal; color: var(--muted); }
|
||||||
|
.so-maps { list-style: none; display: flex; gap: 10px; padding: 0; margin: 0; }
|
||||||
|
.so-maps li { display: flex; gap: 10px; align-items: baseline; padding: 6px 14px; border-radius: 4px; background: rgba(0, 0, 0, .55); font: 600 22px var(--display); color: var(--muted); }
|
||||||
|
.so-maps li.is-current { color: var(--text); box-shadow: inset 0 -3px 0 var(--brand-red); }
|
||||||
|
.so-maps li b { color: var(--text); font-variant-numeric: tabular-nums; }
|
||||||
|
.so-setup { align-self: center; justify-self: center; font: 600 32px var(--display); color: var(--muted); }
|
||||||
|
|
||||||
|
/* Dichte: weniger Matches = größere Schrift */
|
||||||
|
.density-1 .so-team { font-size: 96px; }
|
||||||
|
.density-1 .so-series { font-size: 110px; }
|
||||||
|
.density-1 .so-score b { font-size: 260px; }
|
||||||
|
.density-1 .so-map strong { font-size: 60px; }
|
||||||
|
.density-1 .so-vmap-name { font-size: 40px; }
|
||||||
|
.density-1 .so-turn { font-size: 44px; }
|
||||||
|
.density-3 .so-team { font-size: 38px; }
|
||||||
|
.density-3 .so-series { font-size: 42px; }
|
||||||
|
.density-3 .so-score b { font-size: 96px; }
|
||||||
|
.density-3 .so-score .side-icon { width: 32px; height: 32px; }
|
||||||
|
.density-3 .so-map strong { font-size: 30px; }
|
||||||
|
.density-3 .so-vmap-name { font-size: 18px; }
|
||||||
|
.density-3 .so-turn { font-size: 26px; }
|
||||||
|
.density-3 .so-maps li { font-size: 18px; padding: 4px 10px; }
|
||||||
|
|
||||||
|
/* Unten: Lobbys und Ergebnisse */
|
||||||
|
.so-bottom { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; padding-bottom: 28px; min-height: 0; }
|
||||||
|
.so-strip { background: rgba(17, 20, 30, .82); border-radius: 10px; padding: 16px 24px; overflow: hidden; border-top: 4px solid var(--line-strong); }
|
||||||
|
.so-strip:last-child { border-top-color: var(--brand-red); }
|
||||||
|
.so-strip h3 { font: 700 26px var(--display); color: var(--muted); margin: 0 0 10px; }
|
||||||
|
.so-strip ul { list-style: none; padding: 0; margin: 0; display: grid; grid-template-columns: 1fr 1fr; gap: 6px 28px; }
|
||||||
|
.so-strip li { display: flex; align-items: baseline; gap: 10px; font: 600 26px var(--display); white-space: nowrap; overflow: hidden; }
|
||||||
|
.so-strip li .a { color: var(--team-a); }
|
||||||
|
.so-strip li .b { color: var(--team-b); }
|
||||||
|
.so-strip li .vs { color: var(--muted); font-size: 20px; }
|
||||||
|
.so-strip li small { font: 500 18px var(--body); color: var(--muted); }
|
||||||
|
.so-strip li b { font-variant-numeric: tabular-nums; }
|
||||||
|
.so-strip li .won { text-decoration: underline; text-underline-offset: 5px; }
|
||||||
|
.so-none { color: var(--muted); font-size: 22px; margin: 0; }
|
||||||
|
|
||||||
|
/* Klickbare Karten und Listeneinträge führen zur Übersicht des jeweiligen Matches */
|
||||||
|
.so-link { display: block; color: inherit; text-decoration: none; }
|
||||||
|
.so-strip li .so-link { display: flex; align-items: baseline; gap: 10px; color: inherit; text-decoration: none; }
|
||||||
|
.so-link:hover { filter: brightness(1.15); }
|
||||||
|
.so-link:focus-visible { outline: 3px solid var(--brand-red); outline-offset: 4px; }
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
/* ---------- Stream-Übersicht (1920×1080, skaliert) ---------- */
|
||||||
|
html.stream-mode, html.stream-mode body { background: #000; overflow: hidden; height: 100%; }
|
||||||
|
html.stream-mode.stream-transparent, html.stream-mode.stream-transparent body { background: transparent; }
|
||||||
|
|
||||||
|
.stream-viewport { position: fixed; inset: 0; overflow: hidden; }
|
||||||
|
.stream-stage { position: absolute; left: 50%; top: 50%; transform-origin: center center; }
|
||||||
|
|
||||||
|
.st-root {
|
||||||
|
position: relative; width: 100%; height: 100%; overflow: hidden;
|
||||||
|
display: grid; grid-template-rows: 104px 260px 1fr 190px 60px;
|
||||||
|
padding: 0 72px; color: var(--text); font-family: var(--body);
|
||||||
|
background:
|
||||||
|
radial-gradient(900px 600px at 0% 30%, rgba(52, 74, 153, .28), transparent 70%),
|
||||||
|
radial-gradient(900px 600px at 100% 30%, rgba(167, 42, 28, .26), transparent 70%),
|
||||||
|
#000;
|
||||||
|
}
|
||||||
|
.stream-transparent .st-root { background: transparent; }
|
||||||
|
/* Ohne Map-Leiste (Lobby, Veto) bekommt die Hauptfläche den Platz */
|
||||||
|
.st-root.no-maps { grid-template-rows: 104px 260px 1fr 60px; }
|
||||||
|
.phase-veto .st-veto-grid { align-items: stretch; }
|
||||||
|
.phase-veto .st-vmap { min-height: 420px; }
|
||||||
|
|
||||||
|
/* Kopfzeile */
|
||||||
|
.st-top { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; border-bottom: 3px solid var(--brand-red); }
|
||||||
|
.st-logo { height: 58px; width: auto; }
|
||||||
|
.st-meta { display: flex; gap: 28px; font: 600 26px var(--display); color: var(--muted); }
|
||||||
|
.st-meta span + span { padding-left: 28px; border-left: 2px solid var(--line-strong); }
|
||||||
|
.st-clock { justify-self: end; display: flex; align-items: center; gap: 22px; font: 600 30px var(--display); font-variant-numeric: tabular-nums; }
|
||||||
|
.st-live {
|
||||||
|
display: inline-flex; align-items: center; gap: 10px; padding: 4px 16px 4px 12px; border-radius: 4px;
|
||||||
|
background: var(--brand-red); color: #fff; font: 700 24px var(--display);
|
||||||
|
}
|
||||||
|
.st-live::before { content: ''; width: 12px; height: 12px; border-radius: 50%; background: #fff; animation: st-blink 1.4s ease-in-out infinite; }
|
||||||
|
.st-offline { color: var(--pending); font-size: 22px; }
|
||||||
|
@keyframes st-blink { 50% { opacity: .25; } }
|
||||||
|
|
||||||
|
/* Teams und Serienstand */
|
||||||
|
.st-hero { display: grid; grid-template-columns: 1fr 380px 1fr; align-items: center; }
|
||||||
|
.st-team h1 {
|
||||||
|
margin: 0; font: 700 108px/0.95 var(--display); letter-spacing: -.01em;
|
||||||
|
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.st-team-a { text-align: right; }
|
||||||
|
.st-team-a h1 { color: var(--team-a); }
|
||||||
|
.st-team-b h1 { color: var(--team-b); }
|
||||||
|
.st-center { position: relative; display: grid; place-items: center; height: 100%; }
|
||||||
|
/* Das rote X aus dem Logo als diagonaler Schnitt */
|
||||||
|
.st-slash {
|
||||||
|
position: absolute; width: 34px; height: 230px; background: var(--brand-red); transform: rotate(18deg);
|
||||||
|
border-radius: 6px; box-shadow: 0 0 60px rgba(167, 42, 28, .55); opacity: .9;
|
||||||
|
}
|
||||||
|
.st-series { position: relative; display: grid; grid-template-columns: 1fr 60px 1fr; align-items: center; gap: 0 34px; text-shadow: 0 4px 24px #000; }
|
||||||
|
.st-series span { font: 700 150px/1 var(--display); font-variant-numeric: tabular-nums; }
|
||||||
|
.st-series .a { text-align: right; color: var(--team-a); }
|
||||||
|
.st-series .b { color: var(--team-b); }
|
||||||
|
/* Der rote Schrägstrich übernimmt die Trennung zwischen den Ziffern */
|
||||||
|
.st-series .sep { visibility: hidden; }
|
||||||
|
.st-series small { grid-column: 1 / -1; text-align: center; font: 600 24px var(--display); color: var(--muted); margin-top: -6px; }
|
||||||
|
.st-vs { position: relative; font: 700 84px var(--display); color: #fff; text-shadow: 0 4px 30px #000; }
|
||||||
|
|
||||||
|
.st-main { min-height: 0; padding: 18px 0 24px; }
|
||||||
|
|
||||||
|
/* Lobby */
|
||||||
|
.st-lobby { display: grid; grid-template-columns: 1fr 1fr; grid-template-rows: 1fr auto; gap: 24px 80px; height: 100%; }
|
||||||
|
.st-roster { padding: 30px 40px; background: rgba(17, 20, 30, .82); border-top: 6px solid var(--team-a); border-radius: 8px; }
|
||||||
|
.st-roster-B { border-top-color: var(--team-b); }
|
||||||
|
.st-roster h2 { font: 600 36px var(--display); color: var(--muted); margin-bottom: 18px; }
|
||||||
|
.st-roster ul { list-style: none; padding: 0; margin: 0; display: grid; gap: 12px; }
|
||||||
|
.st-roster li { display: flex; align-items: baseline; gap: 16px; font: 600 44px var(--display); color: var(--muted); }
|
||||||
|
.st-roster li.is-ready { color: var(--text); }
|
||||||
|
.st-roster li.is-ready::after { content: 'bereit'; font: 500 24px var(--body); color: var(--ok); }
|
||||||
|
.st-roster em { font: 500 22px var(--body); font-style: normal; color: var(--pending); }
|
||||||
|
.st-lobby-note { grid-column: 1 / -1; text-align: center; font: 600 34px var(--display); color: var(--muted); }
|
||||||
|
|
||||||
|
/* Veto */
|
||||||
|
.st-veto { display: grid; grid-template-rows: auto 1fr; gap: 26px; height: 100%; }
|
||||||
|
.st-turn {
|
||||||
|
justify-self: center; padding: 14px 40px; font: 600 40px var(--display); border-radius: 6px;
|
||||||
|
background: rgba(17, 20, 30, .85); border-left: 8px solid var(--team-a);
|
||||||
|
}
|
||||||
|
.st-turn-B { border-left-color: var(--team-b); }
|
||||||
|
.st-turn strong { color: var(--team-a); }
|
||||||
|
.st-turn-B strong { color: var(--team-b); }
|
||||||
|
.st-veto-grid { display: grid; grid-template-columns: repeat(var(--count), minmax(0, 1fr)); gap: 18px; align-items: stretch; }
|
||||||
|
.st-vmap { --by: var(--line-strong); position: relative; overflow: hidden; border-radius: 6px; display: flex; flex-direction: column; justify-content: flex-end; padding: 18px; min-height: 0; }
|
||||||
|
.st-vmap.by-A { --by: var(--team-a); }
|
||||||
|
.st-vmap.by-B { --by: var(--team-b); }
|
||||||
|
.st-vmap .map-art::after { background: linear-gradient(180deg, transparent 35%, rgba(0, 0, 0, .9)); }
|
||||||
|
.st-vmap-name, .st-vmap-status { position: relative; z-index: 2; }
|
||||||
|
.st-vmap-name { font: 700 46px/1 var(--display); text-shadow: 0 2px 12px #000; }
|
||||||
|
.st-vmap-status { font: 600 24px var(--display); color: var(--by); margin-top: 6px; min-height: 30px; }
|
||||||
|
.st-vmap.status-banned { filter: grayscale(.95) brightness(.5); }
|
||||||
|
.st-vmap.status-banned::after {
|
||||||
|
content: ''; position: absolute; inset: 0; z-index: 3;
|
||||||
|
background: linear-gradient(to top right, transparent calc(50% - 5px), var(--by) calc(50% - 5px), var(--by) calc(50% + 5px), transparent calc(50% + 5px));
|
||||||
|
}
|
||||||
|
.st-vmap.status-picked::before, .st-vmap.status-decider::before, .st-vmap.is-side::before {
|
||||||
|
content: ''; position: absolute; inset: 0; z-index: 3; border-radius: 6px; box-shadow: inset 0 0 0 6px var(--by);
|
||||||
|
}
|
||||||
|
.st-vmap.status-decider { --by: #fff; }
|
||||||
|
.st-vmap.is-side::before { animation: st-side 1.6s ease-in-out infinite; }
|
||||||
|
@keyframes st-side { 50% { box-shadow: inset 0 0 0 6px var(--pending); } }
|
||||||
|
|
||||||
|
/* Spielstand der aktuellen Map */
|
||||||
|
.st-board {
|
||||||
|
position: relative; overflow: hidden; height: 100%; border-radius: 10px;
|
||||||
|
display: grid; grid-template-rows: auto 1fr auto auto; justify-items: center; padding: 26px 48px 30px;
|
||||||
|
background: rgba(17, 20, 30, .7); box-shadow: inset 0 0 0 2px rgba(255, 255, 255, .06);
|
||||||
|
}
|
||||||
|
.st-board-art { opacity: .38; }
|
||||||
|
.st-board-art::after { background: linear-gradient(180deg, rgba(0, 0, 0, .35), rgba(0, 0, 0, .85)); }
|
||||||
|
.st-board > :not(.st-board-art) { position: relative; z-index: 1; }
|
||||||
|
.st-board-head { display: flex; align-items: baseline; gap: 26px; font: 600 30px var(--display); color: var(--muted); }
|
||||||
|
.st-board-head strong { font: 700 56px var(--display); color: var(--text); }
|
||||||
|
.st-board-state { color: var(--pending); }
|
||||||
|
.st-score { display: flex; align-items: center; gap: 40px; }
|
||||||
|
.st-score-team { display: flex; align-items: center; gap: 28px; }
|
||||||
|
.st-score .num { font: 700 230px/0.9 var(--display); font-variant-numeric: tabular-nums; min-width: 2ch; }
|
||||||
|
.st-score-team.a .num { color: var(--team-a); text-align: right; }
|
||||||
|
.st-score-team.b .num { color: var(--team-b); }
|
||||||
|
.st-score-team.won .num { text-shadow: 0 0 40px currentColor; }
|
||||||
|
.st-score-colon { font: 700 120px var(--display); color: var(--muted); }
|
||||||
|
.st-score .side-icon { filter: drop-shadow(0 4px 16px #000); }
|
||||||
|
.st-winner { font: 700 40px var(--display); color: var(--ok); margin-bottom: 8px; }
|
||||||
|
.st-rounds { width: 100%; display: flex; justify-content: center; }
|
||||||
|
.st-waiting { display: grid; place-items: center; height: 100%; font: 600 48px var(--display); color: var(--muted); }
|
||||||
|
|
||||||
|
/* Map-Leiste */
|
||||||
|
.st-maps { display: flex; gap: 18px; padding-bottom: 16px; }
|
||||||
|
.st-mapcard {
|
||||||
|
position: relative; flex: 1 1 0; min-width: 0; overflow: hidden; border-radius: 8px;
|
||||||
|
display: grid; align-content: end; padding: 14px 20px; border-bottom: 6px solid var(--map);
|
||||||
|
}
|
||||||
|
.st-mapcard .map-art::after { background: linear-gradient(180deg, rgba(0, 0, 0, .1), rgba(0, 0, 0, .9)); }
|
||||||
|
.st-mapcard > :not(.map-art) { position: relative; z-index: 1; }
|
||||||
|
.st-mapcard-no { font: 600 22px var(--display); color: var(--muted); }
|
||||||
|
.st-mapcard strong { font: 700 40px/1.05 var(--display); }
|
||||||
|
.st-mapcard-foot { font: 600 26px var(--display); color: var(--muted); min-height: 36px; }
|
||||||
|
.st-mapcard-score { display: inline-flex; gap: 10px; align-items: baseline; font-size: 38px; }
|
||||||
|
.st-mapcard-score b { font-weight: 700; color: var(--muted); }
|
||||||
|
.st-mapcard-score b.won.a { color: var(--team-a); }
|
||||||
|
.st-mapcard-score b.won.b { color: var(--team-b); }
|
||||||
|
.st-mapcard.is-playing .st-mapcard-score b.a { color: var(--team-a); }
|
||||||
|
.st-mapcard.is-playing .st-mapcard-score b.b { color: var(--team-b); }
|
||||||
|
.st-mapcard.is-pending, .st-mapcard.is-next { opacity: .65; }
|
||||||
|
.st-mapcard.is-playing { box-shadow: 0 0 0 4px var(--brand-red), 0 0 40px rgba(167, 42, 28, .5); }
|
||||||
|
|
||||||
|
/* Laufband */
|
||||||
|
.st-ticker { display: flex; align-items: stretch; margin: 0 -72px; background: #05060a; border-top: 2px solid var(--line); }
|
||||||
|
.st-ticker-label { display: flex; align-items: center; padding: 0 30px 0 72px; background: var(--brand-red); color: #fff; font: 700 26px var(--display); }
|
||||||
|
.st-ticker-text { display: flex; align-items: center; padding: 0 30px; font: 500 26px var(--body); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; animation: st-in .5s ease-out; }
|
||||||
|
@keyframes st-in { from { opacity: 0; transform: translateY(8px); } }
|
||||||
|
|
||||||
|
.st-message { display: grid; place-items: center; align-content: center; gap: 24px; height: 100%; text-align: center; background: #000; }
|
||||||
|
.st-message img { height: 90px; }
|
||||||
|
.st-message h1 { font: 700 64px var(--display); margin: 0; }
|
||||||
|
.st-message p { font-size: 28px; color: var(--muted); }
|
||||||
|
|
||||||
|
/* Große Rundenleiste */
|
||||||
|
.round-strip-lg { gap: 6px; }
|
||||||
|
.round-strip-lg .round { width: 50px; height: 50px; border-radius: 5px; }
|
||||||
|
.round-strip-lg .round.break { margin-left: 22px; }
|
||||||
|
.round-strip-lg .reason-icon { width: 24px; height: 24px; }
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.st-live::before, .st-vmap.is-side::before, .st-ticker-text { animation: none; }
|
||||||
|
}
|
||||||
@@ -443,3 +443,149 @@ input:focus, select:focus { border-color: var(--link); outline: none; }
|
|||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
*, *::before, *::after { animation: none !important; transition: none !important; }
|
*, *::before, *::after { animation: none !important; transition: none !important; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- Live-Ergebnisse ---------- */
|
||||||
|
.live-badge {
|
||||||
|
display: inline-flex; align-items: center; gap: 6px; padding: 1px 8px 1px 6px; border-radius: 3px;
|
||||||
|
background: var(--brand-red); color: #fff; font: 700 .8125rem var(--display); vertical-align: middle;
|
||||||
|
}
|
||||||
|
.live-badge::before { content: ''; width: 7px; height: 7px; border-radius: 50%; background: #fff; animation: pulse 1.4s ease-in-out infinite; }
|
||||||
|
.series-total { font-variant-numeric: tabular-nums; color: var(--text); }
|
||||||
|
.series-total .colon, .series-score .colon { color: var(--muted); margin: 0 .12em; }
|
||||||
|
.row-score { font: 700 1.375rem var(--display); color: var(--text); font-variant-numeric: tabular-nums; }
|
||||||
|
.row-live { color: var(--text); }
|
||||||
|
|
||||||
|
.series-score {
|
||||||
|
grid-column: 3; grid-row: 2 / 4; align-self: center; justify-self: end; text-align: right;
|
||||||
|
font: 700 2.25rem/1 var(--display); font-variant-numeric: tabular-nums; text-shadow: 0 2px 10px rgba(0, 0, 0, .8);
|
||||||
|
}
|
||||||
|
.series-score small { display: block; font: 500 .8125rem var(--body); color: var(--muted); margin-top: 4px; }
|
||||||
|
.series-score .is-winner { text-decoration: underline; text-decoration-thickness: 3px; text-underline-offset: 6px; }
|
||||||
|
.series-rounds { grid-column: 1 / -1; margin-top: 10px; overflow-x: auto; }
|
||||||
|
.series-item.is-playing { box-shadow: inset 0 0 0 2px var(--brand-red); }
|
||||||
|
|
||||||
|
.round-strip { list-style: none; display: flex; gap: 3px; padding: 0; margin: 0; }
|
||||||
|
.round {
|
||||||
|
display: grid; place-items: center; flex: none; width: 22px; height: 22px; border-radius: 3px;
|
||||||
|
background: var(--surface-2); color: #fff; box-shadow: inset 0 0 0 1px var(--line);
|
||||||
|
}
|
||||||
|
.round.won-A { background: var(--team-a); box-shadow: none; }
|
||||||
|
.round.won-B { background: var(--team-b); box-shadow: none; }
|
||||||
|
.round.break { margin-left: 10px; }
|
||||||
|
.round-strip-sm .round { width: 18px; height: 18px; }
|
||||||
|
.round-strip-sm .reason-icon { width: 10px; height: 10px; }
|
||||||
|
.round-strip-sm .round.break { margin-left: 8px; }
|
||||||
|
|
||||||
|
.alert-ok { border-color: var(--ok); background: color-mix(in srgb, var(--ok) 9%, var(--surface)); }
|
||||||
|
.live-data { display: flex; align-items: center; gap: 8px; color: var(--muted); }
|
||||||
|
.stream-link { margin-top: 10px; display: grid; gap: 8px; justify-items: start; }
|
||||||
|
.stream-link .copy-field { width: 100%; }
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.series-score { grid-column: 2; grid-row: 2; font-size: 1.75rem; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Admin ---------- */
|
||||||
|
.admin-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; margin-bottom: 20px; }
|
||||||
|
.admin-list { display: grid; gap: 14px; }
|
||||||
|
.admin-card { border-left: 4px solid var(--line-strong); }
|
||||||
|
.admin-card.status-live { border-left-color: var(--brand-red); }
|
||||||
|
.admin-card.status-veto { border-left-color: var(--pending); }
|
||||||
|
.admin-card-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; }
|
||||||
|
.admin-card-head strong { font: 700 1.5rem var(--display); }
|
||||||
|
.admin-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; align-items: center; }
|
||||||
|
.admin-details { margin-top: 16px; border-top: 1px solid var(--line); padding-top: 14px; display: grid; gap: 10px; }
|
||||||
|
.admin-details h3 { margin-top: 6px; }
|
||||||
|
.admin-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 20px; }
|
||||||
|
.admin-players { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
|
||||||
|
.admin-players ul { list-style: none; padding: 0; margin: 4px 0 0; }
|
||||||
|
.admin-players li { display: flex; gap: 10px; align-items: baseline; padding: 4px 0; border-bottom: 1px solid var(--line); }
|
||||||
|
.admin-players em { font-style: normal; color: var(--pending); font-size: .8125rem; }
|
||||||
|
.admin-log { list-style: none; padding: 0; margin: 0; max-height: 260px; overflow-y: auto; font-size: .875rem; }
|
||||||
|
.admin-log li { padding: 4px 0; border-bottom: 1px solid var(--line); }
|
||||||
|
.admin-log time { color: var(--muted); margin-right: 8px; }
|
||||||
|
.admin-login { margin-top: 40px; }
|
||||||
|
.danger { color: var(--error); }
|
||||||
|
@media (max-width: 640px) { .admin-players { grid-template-columns: 1fr; } }
|
||||||
|
|
||||||
|
/* ---------- Rollback (nur Admin) ---------- */
|
||||||
|
.rollback { border: 1px dashed var(--brand-red); border-radius: var(--radius); padding: 12px 14px; }
|
||||||
|
.rollback h3 { margin: 0 0 8px; display: flex; align-items: center; gap: 10px; }
|
||||||
|
.rollback select { min-width: 200px; }
|
||||||
|
.admin-badge { font: 600 .75rem var(--body); color: #fff; background: var(--brand-red); padding: 2px 8px; border-radius: 3px; }
|
||||||
|
|
||||||
|
.admin-settings { border: 1px solid var(--line); background: var(--surface); border-radius: var(--radius); padding: 16px 20px; margin-bottom: 20px; }
|
||||||
|
.admin-settings h2 { margin: 0 0 12px; font-size: 1.1rem; }
|
||||||
|
.admin-settings-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 8px 16px; margin-bottom: 12px; }
|
||||||
|
.admin-check { display: flex; align-items: center; gap: 8px; cursor: pointer; }
|
||||||
|
|
||||||
|
.admin-tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--line); margin-bottom: 20px; }
|
||||||
|
.admin-tab { background: none; border: 0; border-bottom: 2px solid transparent; color: var(--muted); padding: 10px 14px; font: inherit; cursor: pointer; }
|
||||||
|
.admin-tab:hover { color: var(--text); }
|
||||||
|
.admin-tab.is-active { color: var(--text); border-bottom-color: var(--primary); }
|
||||||
|
|
||||||
|
.turn-timer { grid-column: 1 / -1; display: flex; align-items: center; gap: 10px; margin-top: 8px; }
|
||||||
|
.turn-bar { flex: 1; height: 6px; border-radius: 3px; background: color-mix(in srgb, var(--team) 18%, transparent); overflow: hidden; }
|
||||||
|
.turn-bar span { display: block; height: 100%; background: var(--team); transition: width .25s linear; }
|
||||||
|
.turn-seconds { min-width: 3ch; text-align: right; color: var(--muted); font-size: .875rem; font-variant-numeric: tabular-nums; }
|
||||||
|
.turn-timer.is-low .turn-bar span { background: var(--error); }
|
||||||
|
.turn-timer.is-low .turn-seconds { color: var(--error); font-weight: 600; }
|
||||||
|
.admin-live { display: flex; gap: 10px; align-items: baseline; margin-top: 4px; font-size: .9rem; }
|
||||||
|
.admin-live strong { font-variant-numeric: tabular-nums; }
|
||||||
|
.admin-badge { display: inline-block; margin-top: 6px; padding: 2px 10px; border-radius: 999px; font-size: .8rem; font-weight: 600; }
|
||||||
|
.admin-badge.is-paused { background: color-mix(in srgb, var(--pending) 22%, transparent); color: var(--pending); border: 1px solid var(--pending); }
|
||||||
|
.btn-warn { border-color: var(--pending); color: var(--pending); }
|
||||||
|
/* Laufende Matches in der Übersicht */
|
||||||
|
.run-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 16px; }
|
||||||
|
.run-card { position: relative; display: flex; flex-direction: column; justify-content: space-between; gap: 10px; min-height: 190px; padding: 16px 18px; border-radius: var(--radius); border: 1px solid var(--line); background: var(--surface); color: var(--text); text-decoration: none; overflow: hidden; isolation: isolate; transition: transform .15s ease, border-color .15s ease; }
|
||||||
|
.run-card:hover { transform: translateY(-2px); border-color: var(--map); }
|
||||||
|
.run-card.is-live { box-shadow: inset 3px 0 0 var(--brand-red); }
|
||||||
|
.run-art { position: absolute; inset: 0; z-index: -1; opacity: .28; }
|
||||||
|
.run-art::after { background: linear-gradient(180deg, rgba(0,0,0,.2), rgba(0,0,0,.92)); }
|
||||||
|
.run-top { display: flex; justify-content: space-between; align-items: center; gap: 8px; font-size: .8rem; color: var(--muted); }
|
||||||
|
.run-live { animation: run-pulse 1.6s ease-in-out infinite; }
|
||||||
|
@keyframes run-pulse { 50% { opacity: .55; } }
|
||||||
|
.run-status { font-weight: 600; }
|
||||||
|
.run-teams { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 8px; text-align: center; }
|
||||||
|
.run-team { font: 700 1.25rem var(--display); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.run-team.a { color: var(--team-a); text-align: right; }
|
||||||
|
.run-team.b { color: var(--team-b); text-align: left; }
|
||||||
|
.run-score { font: 700 2.25rem var(--display); font-variant-numeric: tabular-nums; }
|
||||||
|
.run-score i { font-style: normal; color: var(--muted); padding: 0 4px; }
|
||||||
|
.run-vs { font-size: 1rem; color: var(--muted); font-style: normal; }
|
||||||
|
.run-foot { display: flex; justify-content: space-between; gap: 8px; font-size: .875rem; color: var(--muted); }
|
||||||
|
.run-series { font-variant-numeric: tabular-nums; }
|
||||||
|
.host-usage { margin-bottom: 20px; }
|
||||||
|
.host-usage h2 { font-size: 1.1rem; margin: 0 0 10px; }
|
||||||
|
.host-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 12px; }
|
||||||
|
.host-card { border: 1px solid var(--line); background: var(--surface); border-radius: var(--radius); padding: 12px 14px; }
|
||||||
|
.host-card.is-full { border-color: var(--pending); }
|
||||||
|
.host-head { display: flex; flex-wrap: wrap; gap: 4px 10px; align-items: baseline; margin-bottom: 8px; }
|
||||||
|
.host-count { margin-left: auto; font-variant-numeric: tabular-nums; font-size: .875rem; }
|
||||||
|
.host-bar { height: 6px; border-radius: 3px; background: var(--line); overflow: hidden; margin-bottom: 8px; }
|
||||||
|
.host-bar span { display: block; height: 100%; background: var(--primary); transition: width .3s ease; }
|
||||||
|
.host-card.is-full .host-bar span { background: var(--pending); }
|
||||||
|
.host-matches { list-style: none; margin: 0; padding: 0; font-size: .9rem; }
|
||||||
|
.host-matches li { padding: 3px 0; border-top: 1px solid var(--line); }
|
||||||
|
.host-disk { font-size: .85rem; color: var(--muted); margin: -2px 0 8px; font-variant-numeric: tabular-nums; }
|
||||||
|
.host-disk.is-low { color: var(--error); font-weight: 600; }
|
||||||
|
.admin-address { display: flex; flex-wrap: wrap; gap: 6px 16px; margin-top: 8px; font-size: .85rem; }
|
||||||
|
.admin-address code { font: 500 .85rem ui-monospace, SFMono-Regular, Consolas, monospace; padding: 1px 6px; border: 1px solid var(--line); border-radius: 4px; background: var(--surface); }
|
||||||
|
.admin-settings h3 { margin: 16px 0 8px; font-size: .95rem; }
|
||||||
|
.setting-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 12px 20px; }
|
||||||
|
.setting-item { display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.setting-field { display: flex; flex-direction: column; gap: 4px; font-size: .9rem; }
|
||||||
|
.setting-field input, .setting-field textarea { width: 100%; box-sizing: border-box; font: inherit; color: var(--text); background: var(--bg, transparent); border: 1px solid var(--line); border-radius: 4px; padding: 6px 8px; }
|
||||||
|
.setting-field textarea { resize: vertical; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: .85rem; }
|
||||||
|
.setting-live { display: inline-block; margin-right: 6px; padding: 0 6px; border-radius: 999px; font-size: .7rem; font-weight: 600; color: var(--primary); border: 1px solid var(--primary); }
|
||||||
|
.host-edit { display: flex; flex-direction: column; gap: 10px; margin-bottom: 12px; }
|
||||||
|
.host-edit-row { display: grid; grid-template-columns: 1fr 2fr 1.4fr .8fr .8fr 1.1fr 1.4fr auto; gap: 8px; align-items: end; padding-bottom: 10px; border-bottom: 1px solid var(--line); }
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.host-edit-row { grid-template-columns: 1fr 1fr; }
|
||||||
|
.host-col-dockerHost { grid-column: 1 / -1; }
|
||||||
|
}
|
||||||
|
.pull-results { list-style: none; margin: 8px 0 0; padding: 0; display: flex; flex-direction: column; gap: 4px; font-size: .9rem; }
|
||||||
|
.pull-results li { display: flex; flex-wrap: wrap; gap: 4px 10px; align-items: baseline; }
|
||||||
|
.pull-results code { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; }
|
||||||
|
.pull-running span { color: var(--pending); }
|
||||||
|
.pull-ok span { color: var(--ok, #22c55e); }
|
||||||
|
.pull-failed span { color: var(--error); }
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# Vorlage für deploy/.env – kopieren und anpassen:
|
||||||
|
# cp deploy/.env.example deploy/.env
|
||||||
|
|
||||||
|
# --- Pflicht ---
|
||||||
|
# IP oder Name des Docker-Hosts im LAN. Spieler verbinden sich dorthin.
|
||||||
|
HOST_IP=192.168.178.111
|
||||||
|
|
||||||
|
# --- Webseite ---
|
||||||
|
# Port der Webseite auf dem Host
|
||||||
|
WEB_PORT=3000
|
||||||
|
# Image: lokal gebaut oder mit "docker load" geladen (siehe docs/container.md)
|
||||||
|
CS2_MAPVETO_IMAGE=cs2-mapveto:latest
|
||||||
|
|
||||||
|
# --- Serververwaltung ---
|
||||||
|
# docker = pro Match ein CS2-Container (Standard)
|
||||||
|
# demo = nur Simulation, startet keine echten Server (zum Ausprobieren)
|
||||||
|
SERVER_ADAPTER=docker
|
||||||
|
# Game Server Login Token (App-ID 730). Leer = LAN-Modus (sv_lan 1).
|
||||||
|
CS2_GSLT=
|
||||||
|
CS2_DOCKER_IMAGE=joedwards32/cs2:latest
|
||||||
|
# Zwei Ports pro Match (Spiel TCP+UDP, CSTV UDP) – auf dem Host freigeben
|
||||||
|
CS2_PORT_RANGE_START=27100
|
||||||
|
CS2_PORT_RANGE_END=27139
|
||||||
|
SERVER_IDLE_TIMEOUT_MINUTES=30
|
||||||
|
|
||||||
|
# --- Matches und Stream ---
|
||||||
|
# Wenn gesetzt, braucht man dieses Passwort zum Anlegen von Matches
|
||||||
|
CREATE_MATCH_PASSWORD=
|
||||||
|
# Wenn gesetzt, braucht man /stream?key=… für die Stream-Übersicht
|
||||||
|
STREAM_KEY=
|
||||||
|
SERIES_AUTO_ADVANCE_SECONDS=30
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# CS2 Mapveto – Start auf einem beliebigen Docker-Host (Linux, z. B. Portainer-Server, NAS, eigener Rechner).
|
||||||
|
#
|
||||||
|
# Voraussetzungen:
|
||||||
|
# 1. deploy/.env anlegen (Vorlage: deploy/.env.example), mindestens HOST_IP setzen
|
||||||
|
# 2. Image cs2-mapveto:latest auf dem Host (gebaut oder mit "docker load" geladen)
|
||||||
|
# 3. Volume cs2-mapveto-gamefiles mit den CS2-Spieldateien (siehe docs/container.md)
|
||||||
|
#
|
||||||
|
# Start: docker compose --env-file deploy/.env -f deploy/compose.yml up -d
|
||||||
|
# Stoppen: docker compose --env-file deploy/.env -f deploy/compose.yml down
|
||||||
|
|
||||||
|
name: cs2-mapveto
|
||||||
|
|
||||||
|
services:
|
||||||
|
cs2-mapveto:
|
||||||
|
image: ${CS2_MAPVETO_IMAGE:-cs2-mapveto:latest}
|
||||||
|
container_name: cs2-mapveto
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "${WEB_PORT:-3000}:3000"
|
||||||
|
environment:
|
||||||
|
PORT: "3000"
|
||||||
|
# Adresse des Docker-Hosts im LAN: Einladungslinks, Spieler-Verbindungen, Live-Logs
|
||||||
|
PUBLIC_URL: "http://${HOST_IP:?HOST_IP in deploy/.env eintragen}:${WEB_PORT:-3000}"
|
||||||
|
LOG_RECEIVER_URL: "http://${HOST_IP}:${WEB_PORT:-3000}"
|
||||||
|
DATABASE_FILE: /data/cs2-mapveto.db
|
||||||
|
SERVER_ADAPTER: ${SERVER_ADAPTER:-docker}
|
||||||
|
CS2_PUBLIC_HOST: ${HOST_IP}
|
||||||
|
CS2_RCON_HOST: ${HOST_IP}
|
||||||
|
CS2_GSLT: ${CS2_GSLT:-}
|
||||||
|
CS2_DOCKER_VOLUME: cs2-mapveto-gamefiles
|
||||||
|
CS2_DOCKER_IMAGE: ${CS2_DOCKER_IMAGE:-joedwards32/cs2:latest}
|
||||||
|
CS2_PORT_RANGE_START: ${CS2_PORT_RANGE_START:-27100}
|
||||||
|
CS2_PORT_RANGE_END: ${CS2_PORT_RANGE_END:-27139}
|
||||||
|
CREATE_MATCH_PASSWORD: ${CREATE_MATCH_PASSWORD:-}
|
||||||
|
STREAM_KEY: ${STREAM_KEY:-}
|
||||||
|
SERIES_AUTO_ADVANCE_SECONDS: ${SERIES_AUTO_ADVANCE_SECONDS:-30}
|
||||||
|
SERVER_IDLE_TIMEOUT_MINUTES: ${SERVER_IDLE_TIMEOUT_MINUTES:-30}
|
||||||
|
volumes:
|
||||||
|
# Datenbank (Matches, Ergebnisse) überdauert Updates
|
||||||
|
- cs2-mapveto-data:/data
|
||||||
|
# Zugriff auf Docker, um Match-Container zu starten und zu entfernen
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
cs2-mapveto-data:
|
||||||
|
name: cs2-mapveto-data
|
||||||
@@ -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.
@@ -0,0 +1,172 @@
|
|||||||
|
# CS2 Mapveto als Container betreiben
|
||||||
|
|
||||||
|
CS2 Mapveto läuft als **ein** Container: Webseite, Backend, Datenbank (SQLite) und ein Docker-Client.
|
||||||
|
Er steht auf demselben Docker-Host wie die CS2-Server und startet pro Match über `/var/run/docker.sock`
|
||||||
|
einen eigenen CS2-Container. Der Host kann jeder Linux-Rechner mit Docker sein.
|
||||||
|
|
||||||
|
```
|
||||||
|
Docker-Host (beliebig, Linux, Adresse z. B. 192.168.178.111)
|
||||||
|
├── cs2-mapveto ← diese Anwendung, Port 3000
|
||||||
|
├── cs2mv-<match> ← CS2-Server pro Match (startet/entfernt CS2 Mapveto)
|
||||||
|
├── Volume cs2-mapveto-gamefiles CS2-Spieldateien (~73 GB)
|
||||||
|
└── Volume cs2-mapveto-data Datenbank der Anwendung
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Nur eine Instanz pro Docker-Host.** Läuft CS2 Mapveto zusätzlich anderswo mit demselben Host, streiten
|
||||||
|
> sich beide um Ports und Containernamen. Die andere Instanz vorher beenden.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Was du brauchst
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| Docker + Compose | `docker compose version` muss funktionieren |
|
||||||
|
| Speicher | ~80 GB frei für Spieldateien, Images und Updates |
|
||||||
|
| CPU / RAM | pro gleichzeitigem Match etwa 1–2 Kerne und 2–3 GB RAM |
|
||||||
|
| Netz | Ports TCP/UDP 27100–27139 (Spiel, RCON) und UDP 27101… (CSTV) sowie TCP 3000 im LAN freigeben |
|
||||||
|
| Dateien | das Image `cs2-mapveto` (siehe Schritt 1) und die CS2-Spieldateien (Schritt 2) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Schritt 1: Image auf den neuen Host bringen
|
||||||
|
|
||||||
|
Du hast zwei Wege.
|
||||||
|
|
||||||
|
### Weg A: Image-Datei kopieren (ohne Build, schnell)
|
||||||
|
|
||||||
|
Auf dem Rechner, auf dem das Image gebaut wurde, liegt die Datei `dist/cs2-mapveto-image.tar` (294 MB). Sie
|
||||||
|
auf den neuen Host kopieren und laden:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scp cs2-mapveto-image.tar USER@NEUERHOST:/tmp/
|
||||||
|
ssh USER@NEUERHOST "docker load -i /tmp/cs2-mapveto-image.tar && rm /tmp/cs2-mapveto-image.tar"
|
||||||
|
```
|
||||||
|
|
||||||
|
Prüfen mit `docker image ls cs2-mapveto`.
|
||||||
|
|
||||||
|
### Weg B: aus dem Repository bauen
|
||||||
|
|
||||||
|
Auf dem neuen Host ist Git nötig. Das Repository liegt auf dem Gitea (`http://192.168.178.45:3002/marcel/Cs2-Mapveto.git`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone http://192.168.178.45:3002/marcel/Cs2-Mapveto.git
|
||||||
|
cd Cs2-Mapveto
|
||||||
|
docker build -t cs2-mapveto:latest .
|
||||||
|
```
|
||||||
|
|
||||||
|
Beim Bauen laufen alle Tests mit. Schlägt einer fehl, bricht der Build ab.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Schritt 2: CS2-Spieldateien auf den neuen Host bringen
|
||||||
|
|
||||||
|
Das Volume `cs2-mapveto-gamefiles` muss vor dem ersten Match vorhanden und befüllt sein. Sonst lädt jeder Match-Start
|
||||||
|
die ~60 GB neu. Zwei Wege:
|
||||||
|
|
||||||
|
**a) Vom bisherigen Host kopieren** (im LAN, kein Internet nötig, dauert je nach Netz Stunden):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh ALTERHOST "docker run --rm -v cs2-mapveto-gamefiles:/from alpine tar -C /from -cf - ." \
|
||||||
|
| ssh NEUERHOST "docker volume create cs2-mapveto-gamefiles >/dev/null && docker run --rm -i -v cs2-mapveto-gamefiles:/to alpine tar -C /to -xf -"
|
||||||
|
```
|
||||||
|
|
||||||
|
**b) Neu laden** (braucht Internet, ~60 GB). Der Container lädt die Dateien und stoppt danach von selbst:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker volume create cs2-mapveto-gamefiles
|
||||||
|
docker run -d --name cs2-updater -e STEAMAPPVALIDATE=0 -e CS2_RCONPW=updater1 -v cs2-mapveto-gamefiles:/home/steam/cs2-dedicated/ joedwards32/cs2:latest
|
||||||
|
docker logs -f cs2-updater # warten auf "Success! App '730' fully installed"
|
||||||
|
docker rm -f cs2-updater
|
||||||
|
```
|
||||||
|
|
||||||
|
Danach liegen unter `docker system df -v` rund 73 GB im Volume.
|
||||||
|
|
||||||
|
Das CS2-Image selbst (`joedwards32/cs2:latest`, ~3,7 GB) holt Docker beim ersten Match automatisch aus dem
|
||||||
|
Docker Hub. Ohne Internet muss es vorher ebenfalls geladen werden (`docker save`/`docker load` wie bei Schritt 1).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Schritt 3: Konfiguration
|
||||||
|
|
||||||
|
Im Ordner `deploy/` die Vorlage kopieren und anpassen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp deploy/.env.example deploy/.env
|
||||||
|
```
|
||||||
|
|
||||||
|
Das Wichtigste:
|
||||||
|
|
||||||
|
| Variable | Bedeutung |
|
||||||
|
|---|---|
|
||||||
|
| `HOST_IP` | **Pflicht.** IP des neuen Hosts im LAN. Spieler verbinden sich dorthin, Einladungslinks zeigen darauf |
|
||||||
|
| `WEB_PORT` | Port der Webseite (Standard 3000) |
|
||||||
|
| `SERVER_ADAPTER` | `docker` für echte Server, `demo` nur zum Ausprobieren ohne CS2 |
|
||||||
|
| `CS2_GSLT` | leer = LAN-Modus (`sv_lan 1`), kein Token nötig |
|
||||||
|
| `CS2_PORT_RANGE_START` / `_END` | Ports für Match-Server, zwei pro Match |
|
||||||
|
| `CREATE_MATCH_PASSWORD` | Passwort zum Anlegen von Matches (empfohlen) |
|
||||||
|
| `STREAM_KEY` | Schlüssel für `/stream?key=…` (optional) |
|
||||||
|
|
||||||
|
Passwörter nur aus Buchstaben und Ziffern wählen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Schritt 4: Starten
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose --env-file deploy/.env -f deploy/compose.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Prüfen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose --env-file deploy/.env -f deploy/compose.yml ps # Status "healthy" nach ~20 s
|
||||||
|
curl http://HOST_IP:3000/api/config # JSON mit "adapter": "docker"
|
||||||
|
```
|
||||||
|
|
||||||
|
Danach ist die Webseite unter `http://HOST_IP:3000` erreichbar, die Stream-Übersicht unter `/stream`.
|
||||||
|
|
||||||
|
Stoppen und neu starten:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose --env-file deploy/.env -f deploy/compose.yml down
|
||||||
|
docker compose --env-file deploy/.env -f deploy/compose.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Die Datenbank (Volume `cs2-mapveto-data`) bleibt dabei erhalten.
|
||||||
|
|
||||||
|
### Mit Portainer statt Kommandozeile
|
||||||
|
|
||||||
|
Portainer → Stacks → **Add stack** → Web editor: den Inhalt von `deploy/compose.yml` einfügen. Die Variablen aus
|
||||||
|
`deploy/.env` als *Environment variables* eintragen (mindestens `HOST_IP`). Danach **Deploy the stack**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Aktualisieren
|
||||||
|
|
||||||
|
1. Neues Image holen (Weg A oder B aus Schritt 1).
|
||||||
|
2. `docker compose … up -d` erneut ausführen. Compose ersetzt den Container, die Datenbank bleibt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sicherheit
|
||||||
|
|
||||||
|
Der Container hat über `/var/run/docker.sock` volle Kontrolle über Docker auf dem Host. Das ist nötig, um
|
||||||
|
Match-Container zu starten, entspricht aber Root-Rechten auf dem Host. Deshalb:
|
||||||
|
|
||||||
|
- Port 3000 nur im LAN anbieten, nicht ins Internet weiterleiten.
|
||||||
|
- `CREATE_MATCH_PASSWORD` setzen, wenn nicht jeder im LAN Matches anlegen soll.
|
||||||
|
- `deploy/.env` enthält Passwörter. Nicht ins Repository einchecken (ist in `.gitignore`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fehlersuche
|
||||||
|
|
||||||
|
| Symptom | Ursache / Lösung |
|
||||||
|
|---|---|
|
||||||
|
| `HOST_IP in deploy/.env eintragen` beim Start | `deploy/.env` fehlt oder `HOST_IP` ist leer |
|
||||||
|
| „Server wird angefordert“ bleibt stehen | Log des Containers prüfen: `docker logs cs2-mapveto` |
|
||||||
|
| `permission denied … docker.sock` im Log | Rootless-Docker oder anderer Socket-Pfad. Dann den Pfad im `volumes:`-Eintrag anpassen |
|
||||||
|
| Match-Server startet, Spieler finden ihn nicht | Ports 27100–27139 im Host-Netz freigeben; `HOST_IP` muss die LAN-Adresse des Hosts sein |
|
||||||
|
| Keine Live-Ergebnisse | `HOST_IP` falsch oder Port 3000 vom Match-Server aus nicht erreichbar |
|
||||||
|
| Ein Match-Start dauert sehr lange | Spieldateien fehlen im Volume und werden neu geladen (Schritt 2) |
|
||||||
+51
-12
@@ -83,12 +83,16 @@ volumes:
|
|||||||
|
|
||||||
5. Darunter bei **Environment variables** auf *+ Add an environment variable* klicken und anlegen:
|
5. Darunter bei **Environment variables** auf *+ Add an environment variable* klicken und anlegen:
|
||||||
|
|
||||||
| Name | Wert |
|
| Name | Beispielwert | Bedeutung |
|
||||||
|---|---|
|
|---|---|---|
|
||||||
| `SRCDS_TOKEN` | dein GSLT |
|
| `SRCDS_TOKEN` | `A1B2C3D4E5F6…` | dein GSLT; leer lassen für LAN-Modus |
|
||||||
| `CS2_RCONPW` | langes, zufälliges Passwort (nur Buchstaben und Ziffern) |
|
| `CS2_RCONPW` | `Kx7fQ2mPz9Lw4tNb` | RCON-Passwort, lang und zufällig |
|
||||||
| `CS2_PW` | beliebiges Serverpasswort |
|
| `CS2_PW` | `Lan2026` | Serverpasswort |
|
||||||
| `TV_PW` | Passwort für CSTV |
|
| `TV_PW` | `Cstv2026` | Passwort für CSTV |
|
||||||
|
|
||||||
|
> **Nur Buchstaben und Ziffern verwenden.** Das Startskript des Images setzt die Werte ungeschützt in einen
|
||||||
|
> Shell-Befehl ein. Leerzeichen, Klammern, Kommas, Anführungszeichen oder `$` führen dazu, dass der Server
|
||||||
|
> nicht startet (`syntax error near unexpected token`) und Docker ihn endlos neu startet.
|
||||||
|
|
||||||
So stehen die Zugangsdaten nicht im Stack-Text selbst.
|
So stehen die Zugangsdaten nicht im Stack-Text selbst.
|
||||||
|
|
||||||
@@ -208,19 +212,54 @@ CS2_STATIC_SERVERS=192.168.178.50:27015:PASSWORT:27020,192.168.178.50:27025:PASS
|
|||||||
|
|
||||||
Statt dauerhaft laufender Server kann CS2 Mapveto pro Match einen eigenen Container starten und danach wieder entfernen (`SERVER_ADAPTER=docker`). Damit auch dabei nichts neu geladen wird, nutzt der Adapter das Volume **`cs2-mapveto-gamefiles`**. Das ist genau das Volume, das der Stack oben angelegt und befüllt hat.
|
Statt dauerhaft laufender Server kann CS2 Mapveto pro Match einen eigenen Container starten und danach wieder entfernen (`SERVER_ADAPTER=docker`). Damit auch dabei nichts neu geladen wird, nutzt der Adapter das Volume **`cs2-mapveto-gamefiles`**. Das ist genau das Volume, das der Stack oben angelegt und befüllt hat.
|
||||||
|
|
||||||
|
### 1. Docker-Zugriff von CS2 Mapveto auf den Docker-Host
|
||||||
|
|
||||||
|
Läuft CS2 Mapveto auf einem anderen Rechner als Docker (z. B. Windows-PC → Docker-Host im LAN), steuert es Docker per SSH:
|
||||||
|
|
||||||
|
1. Auf dem Docker-Host den Benutzer in die Docker-Gruppe aufnehmen (einmalig, danach neu anmelden):
|
||||||
|
```bash
|
||||||
|
sudo usermod -aG docker server
|
||||||
|
```
|
||||||
|
2. Auf dem Rechner mit CS2 Mapveto einen SSH-Eintrag anlegen (Windows: `C:\Users\<Name>\.ssh\config`) und die Anmeldung per Schlüssel einrichten. `DOCKER-HOST-IP` und `USER` durch deine Werte ersetzen:
|
||||||
|
```
|
||||||
|
Host docker-host
|
||||||
|
HostName DOCKER-HOST-IP
|
||||||
|
User USER
|
||||||
|
```
|
||||||
|
3. Testen, **auf dem Rechner mit CS2 Mapveto**, nicht auf dem Docker-Host:
|
||||||
|
```powershell
|
||||||
|
docker -H ssh://docker-host ps
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. `.env` von CS2 Mapveto
|
||||||
|
|
||||||
```env
|
```env
|
||||||
SERVER_ADAPTER=docker
|
SERVER_ADAPTER=docker
|
||||||
CS2_GSLT=dein_gslt
|
DOCKER_HOST=ssh://docker-host
|
||||||
CS2_PUBLIC_HOST=192.168.178.50
|
# leer = LAN-Modus (sv_lan 1), sonst ein Game Server Login Token
|
||||||
|
CS2_GSLT=
|
||||||
|
# Adresse, unter der Spieler den Docker-Host erreichen
|
||||||
|
CS2_PUBLIC_HOST=192.168.178.111
|
||||||
|
# Adresse, unter der CS2 Mapveto RCON erreicht – bei getrennten Rechnern ebenfalls der Docker-Host
|
||||||
|
CS2_RCON_HOST=192.168.178.111
|
||||||
CS2_DOCKER_VOLUME=cs2-mapveto-gamefiles
|
CS2_DOCKER_VOLUME=cs2-mapveto-gamefiles
|
||||||
CS2_DOCKER_IMAGE=joedwards32/cs2:latest
|
CS2_DOCKER_IMAGE=joedwards32/cs2:latest
|
||||||
|
# zwei Ports pro Match (Spiel TCP+UDP, CSTV UDP), in der Firewall freigeben
|
||||||
|
CS2_PORT_RANGE_START=27100
|
||||||
|
CS2_PORT_RANGE_END=27139
|
||||||
|
# Container müssen CS2 Mapveto für die Live-Ergebnisse erreichen
|
||||||
|
LOG_RECEIVER_URL=http://192.168.178.31:3000
|
||||||
```
|
```
|
||||||
|
|
||||||
Wichtig dabei:
|
Wichtig dabei:
|
||||||
|
|
||||||
- **Den Stack-Server vorher stoppen**, damit nicht zwei Container gleichzeitig dasselbe Volume aktualisieren. Der Stack dient dann nur zum einmaligen Befüllen und für Updates.
|
- **Den Stack-Server stoppen**, sobald die Spieldateien geladen sind. Der Stack dient nur zum einmaligen Befüllen und für Updates. Das Volume bleibt beim Stoppen und Löschen des Stacks erhalten.
|
||||||
- Für gleichzeitige Matches gilt dasselbe Problem: Alle Match-Container teilen sich das Volume und prüfen beim Start auf Updates. Das ist unkritisch, solange kein Valve-Update ansteht. Kurz nach einem CS2-Patch nur **ein** Match auf einmal starten.
|
- **Starts laufen nacheinander:** Jeder Match-Container prüft beim Start per SteamCMD auf Updates. Damit nie zwei Container gleichzeitig das Volume aktualisieren, startet CS2 Mapveto den nächsten erst, wenn der vorige seinen Update-Check abgeschlossen hat. Nach einem CS2-Patch dauert der erste Start dadurch länger, die übrigen warten kurz.
|
||||||
- Das Backend braucht Zugriff auf den Docker-Daemon (es läuft auf demselben Host bzw. hat `/var/run/docker.sock`).
|
- **Ein GSLT gilt nur für einen laufenden Server.** Ohne Token (LAN-Modus) gibt es diese Grenze nicht.
|
||||||
|
- **Hardware:** Ein CS2-Server braucht etwa 1–2 CPU-Kerne und 2–3 GB RAM. 6 Kerne und 16 GB reichen für etwa 2–3 gleichzeitige Matches.
|
||||||
|
- **Plätze:** CSTV belegt selbst einen Spielerplatz. Der Adapter startet Container deshalb mit 6 (Wingman) bzw. 12 (5 gegen 5) Plätzen: alle Spieler, CSTV und eine Reserve. Bei festen Servern (Stack) `maxplayers` genauso hoch setzen, sonst kommt der letzte Spieler nicht drauf.
|
||||||
|
- **Serverliste:** Match-Container tauchen im LAN-Tab von CS2 nicht auf. Der Client sucht dort nur auf den Ports 27015–27020, und Broadcasts erreichen Docker-Container kaum. Spieler verbinden sich über den Befehl bzw. den Knopf „In CS2 verbinden“ auf der Match-Seite. Jedes Match hat ohnehin ein eigenes Passwort.
|
||||||
|
- **Bots zum Testen:** Ohne menschliche Spieler entfernt CS2 Bots zum Matchstart wieder (`bot_join_after_player 1`). Für Tests `CS2_EXTRA_COMMANDS=bot_join_after_player 0;bot_quota 4;bot_quota_mode fill` setzen.
|
||||||
|
|
||||||
Für eine LAN-Party mit festen Servern ist der **Static-Adapter mit Stack aus Schritt 1** die einfachere und robustere Wahl.
|
Für eine LAN-Party mit festen Servern ist der **Static-Adapter mit Stack aus Schritt 1** die einfachere und robustere Wahl.
|
||||||
|
|
||||||
@@ -228,7 +267,7 @@ Für eine LAN-Party mit festen Servern ist der **Static-Adapter mit Stack aus Sc
|
|||||||
|
|
||||||
## LAN-Betrieb ohne GSLT
|
## LAN-Betrieb ohne GSLT
|
||||||
|
|
||||||
Für reine LAN-Veranstaltungen kann `CS2_LAN=1` gesetzt werden (`sv_lan 1`). Der Server ist dann nur im lokalen Netz erreichbar und braucht kein Token. Spieler müssen im selben Netz sein. Für Zuschauer über das Internet oder externe Teams ist ein GSLT nötig.
|
Für reine LAN-Veranstaltungen kann `CS2_LAN=1` gesetzt werden (`sv_lan 1`). Beim Docker-Adapter genügt es, `CS2_GSLT` leer zu lassen. Der Server ist dann nur im lokalen Netz erreichbar und braucht kein Token. Spieler müssen im selben Netz sein. Für Zuschauer über das Internet oder externe Teams ist ein GSLT nötig.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -7,7 +7,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "node --watch src/index.js",
|
"dev": "node --watch src/index.js",
|
||||||
"start": "node src/index.js",
|
"start": "node src/index.js",
|
||||||
"test": "node --test test/"
|
"test": "node --test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"better-sqlite3": "^11.10.0",
|
"better-sqlite3": "^11.10.0",
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import { formatPortList } from './config.js';
|
||||||
|
import { HttpError } from './matchService.js';
|
||||||
|
import { createRateLimiter, randomToken, safeEqual } from './security.js';
|
||||||
|
|
||||||
|
const SESSION_HOURS = 12;
|
||||||
|
|
||||||
|
function bearer(req) {
|
||||||
|
const header = req.get('authorization') ?? '';
|
||||||
|
return header.startsWith('Bearer ') ? header.slice(7).trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin-Modus unter /api/admin. Anmeldung mit ADMIN_PASSWORD, danach ein zufälliger Sitzungs-Token
|
||||||
|
* (nur im Speicher, verfällt nach 12 Stunden oder beim Neustart). Admin-Aktionen nutzen die bestehenden
|
||||||
|
* Organisator-Funktionen mit einem Admin-Zugang, die Berechtigungen bleiben also an einer Stelle.
|
||||||
|
*/
|
||||||
|
export function createAdminRouter({ service, manager, config, settings }) {
|
||||||
|
const router = express.Router();
|
||||||
|
const sessions = new Map(); // Token -> Ablaufzeit (ms)
|
||||||
|
const loginLimit = createRateLimiter({ windowMs: 60_000, max: 5 });
|
||||||
|
|
||||||
|
const wrap = (fn) => async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const result = await fn(req, res);
|
||||||
|
res.json(result ?? { ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const requireEnabled = (req, res, next) => {
|
||||||
|
if (config.adminPassword) return next();
|
||||||
|
return next(new HttpError(404, 'Admin-Modus ist nicht eingerichtet (ADMIN_PASSWORD fehlt)', 'admin_disabled'));
|
||||||
|
};
|
||||||
|
|
||||||
|
const requireAdmin = (req, res, next) => {
|
||||||
|
const token = bearer(req);
|
||||||
|
const expires = token && sessions.get(token);
|
||||||
|
if (expires && expires > Date.now()) return next();
|
||||||
|
if (token) sessions.delete(token);
|
||||||
|
return next(new HttpError(401, 'Admin-Anmeldung erforderlich', 'unauthorized'));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Admin-Zugang für eine Match-Aktion: wie ein Organisator, bei Bedarf als Captain eines Teams
|
||||||
|
const adminViewer = (matchId, extra = {}) => ({
|
||||||
|
matchId,
|
||||||
|
role: 'organizer',
|
||||||
|
isOrganizer: true,
|
||||||
|
isCaptain: false,
|
||||||
|
isSpectator: false,
|
||||||
|
playerId: null,
|
||||||
|
team: null,
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
|
||||||
|
router.use(requireEnabled);
|
||||||
|
|
||||||
|
router.post('/login', (req, res, next) => {
|
||||||
|
if (!loginLimit(`admin:${req.ip}`)) {
|
||||||
|
return next(new HttpError(429, 'Zu viele Anmeldeversuche – bitte eine Minute warten', 'rate_limited'));
|
||||||
|
}
|
||||||
|
if (!safeEqual(String(req.body?.password ?? ''), config.adminPassword)) {
|
||||||
|
return next(new HttpError(401, 'Falsches Passwort', 'unauthorized'));
|
||||||
|
}
|
||||||
|
const token = randomToken();
|
||||||
|
const expiresAt = Date.now() + SESSION_HOURS * 3_600_000;
|
||||||
|
sessions.set(token, expiresAt);
|
||||||
|
return res.json({ token, expiresAt });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/logout', (req, res) => {
|
||||||
|
const token = bearer(req);
|
||||||
|
if (token) sessions.delete(token);
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/session', requireAdmin, (req, res) => res.json({ ok: true }));
|
||||||
|
|
||||||
|
// Discord-Meldungen: welche Ereignisse gemeldet werden
|
||||||
|
// Gameserver-Einstellungen: Vorgaben aus der .env, vom Admin überschreibbar
|
||||||
|
router.get('/server-settings', requireAdmin, wrap(() => ({ fields: settings.view() })));
|
||||||
|
router.put('/server-settings', requireAdmin, wrap(async (req) => {
|
||||||
|
const changed = settings.update(req.body?.values ?? {});
|
||||||
|
if (changed.includes('vetoStepSeconds')) service.startVetoTimer();
|
||||||
|
const live = changed.length ? await manager.applyLiveSettings(changed) : { updated: 0, failed: [] };
|
||||||
|
return { fields: settings.view(), changed, live };
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Docker-Hosts: Liste wie CS2_DOCKER_HOSTS, gespeichert in der Datenbank
|
||||||
|
const hostView = () => ({
|
||||||
|
hosts: (manager.adapter.hosts ?? []).map((h) => ({
|
||||||
|
name: h.name,
|
||||||
|
dockerHost: h.dockerHost,
|
||||||
|
publicHost: h.publicHost,
|
||||||
|
rconHost: h.rconHost,
|
||||||
|
portStart: h.portRangeStart,
|
||||||
|
portEnd: h.portRangeEnd,
|
||||||
|
volume: h.volume,
|
||||||
|
lanPorts: formatPortList(h.lanPorts),
|
||||||
|
})),
|
||||||
|
overridden: settings.hostsOverridden(),
|
||||||
|
editable: !!manager.adapter.setHosts,
|
||||||
|
image: config.docker.image,
|
||||||
|
pull: manager.adapter.pullStatus?.() ?? null,
|
||||||
|
});
|
||||||
|
router.get('/docker-hosts', requireAdmin, wrap(() => hostView()));
|
||||||
|
router.put('/docker-hosts', requireAdmin, wrap((req) => {
|
||||||
|
const list = req.body?.hosts;
|
||||||
|
const hosts = settings.parseHosts(list);
|
||||||
|
manager.setDockerHosts(hosts);
|
||||||
|
settings.saveHosts(list, hosts);
|
||||||
|
return hostView();
|
||||||
|
}));
|
||||||
|
router.delete('/docker-hosts', requireAdmin, wrap(() => {
|
||||||
|
manager.setDockerHosts(settings.defaultHosts);
|
||||||
|
settings.resetHosts();
|
||||||
|
return hostView();
|
||||||
|
}));
|
||||||
|
// Image auf allen Hosts neu laden. Läuft im Hintergrund, Stand über GET /docker-hosts.
|
||||||
|
router.post('/docker-hosts/pull', requireAdmin, wrap(() => {
|
||||||
|
if (!manager.adapter.pullImage) throw new HttpError(409, 'Nur mit dem Docker-Adapter möglich', 'unsupported');
|
||||||
|
if (manager.adapter.pullStatus().running) throw new HttpError(409, 'Das Image wird bereits aktualisiert', 'busy');
|
||||||
|
manager.adapter.pullImage().catch(() => {});
|
||||||
|
return hostView();
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.get('/notifications', requireAdmin, wrap(() => service.notificationSettings()));
|
||||||
|
router.put('/notifications', requireAdmin, wrap((req) => service.setNotificationEvents(req.body?.events)));
|
||||||
|
router.post('/notifications/test', requireAdmin, wrap(async () => {
|
||||||
|
if (!service.notifier.enabled) throw new HttpError(409, 'Kein Discord-Webhook eingerichtet (DISCORD_WEBHOOK_URL)', 'not_configured');
|
||||||
|
const test = { embeds: [{ title: '🧪 Testmeldung', description: 'Die Verbindung zu CS2 Mapveto funktioniert.', color: 0x3b82f6 }] };
|
||||||
|
if (!(await service.notifier.send(test))) {
|
||||||
|
throw new HttpError(502, 'Discord hat die Meldung nicht angenommen (siehe Log)', 'send_failed');
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Alle Matches mit allen Details (Codes, Verbindungsdaten, Spieler, Veto, Serie, Ereignisse)
|
||||||
|
router.get('/matches', requireAdmin, wrap(async () => {
|
||||||
|
const ids = service.db.prepare('SELECT id FROM matches ORDER BY created_at DESC LIMIT 100').all();
|
||||||
|
return { matches: ids.map(({ id }) => service.snapshot(id, adminViewer(id))), hosts: await manager.hostUsage() };
|
||||||
|
}));
|
||||||
|
|
||||||
|
const action = (path, fn) => router.post(`/matches/:id/${path}`, requireAdmin, wrap((req) => {
|
||||||
|
service.getMatch(req.params.id);
|
||||||
|
return fn(adminViewer(req.params.id), req.body ?? {}, req);
|
||||||
|
}));
|
||||||
|
|
||||||
|
action('finish', (v) => service.close(v, 'finished'));
|
||||||
|
action('cancel', (v) => service.close(v, 'cancelled'));
|
||||||
|
action('server/stop', (v) => manager.stopByOrganizer(v));
|
||||||
|
action('server/retry', (v) => manager.retry(v));
|
||||||
|
action('server/warmup-end', (v) => manager.endWarmup(v));
|
||||||
|
action('server/pause', (v) => manager.setPaused(v, true));
|
||||||
|
action('server/resume', (v) => manager.setPaused(v, false));
|
||||||
|
action('series/next', (v) => manager.loadNextMap(v));
|
||||||
|
action('veto/start', (v) => service.startVeto(v));
|
||||||
|
action('veto/action', (v, b) => service.vetoAction(
|
||||||
|
adminViewer(v.matchId, { team: b.team, isCaptain: true, role: 'captain' }),
|
||||||
|
{ stepIndex: b.stepIndex, map: b.map, side: b.side },
|
||||||
|
));
|
||||||
|
action('kick', (v, b) => service.kick(v, b.playerId));
|
||||||
|
action('codes/regenerate', (v, b) => service.regenerateCode(v, b.target));
|
||||||
|
|
||||||
|
// Rollback: nur für Admins. Diese Routen gibt es für Organisatoren nicht.
|
||||||
|
router.get('/matches/:id/rollback-points', requireAdmin, wrap(async (req) => {
|
||||||
|
service.getMatch(req.params.id);
|
||||||
|
return { points: await manager.rollbackPoints(adminViewer(req.params.id)) };
|
||||||
|
}));
|
||||||
|
action('rollback', (v, b) => manager.rollback(v, b.file));
|
||||||
|
|
||||||
|
// Match endgültig löschen (Server wird vorher freigegeben)
|
||||||
|
router.delete('/matches/:id', requireAdmin, wrap(async (req) => {
|
||||||
|
await manager.deleteMatch(req.params.id);
|
||||||
|
return { ok: true };
|
||||||
|
}));
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
+16
-3
@@ -5,22 +5,31 @@ import express from 'express';
|
|||||||
import { REPO_ROOT } from './config.js';
|
import { REPO_ROOT } from './config.js';
|
||||||
import { openDatabase } from './db.js';
|
import { openDatabase } from './db.js';
|
||||||
import { createApiRouter } from './http.js';
|
import { createApiRouter } from './http.js';
|
||||||
|
import { createDiscordNotifier } from './discord.js';
|
||||||
import { loadMapPools } from './mapPools.js';
|
import { loadMapPools } from './mapPools.js';
|
||||||
import { MatchService } from './matchService.js';
|
import { MatchService } from './matchService.js';
|
||||||
import { attachRealtime } from './realtime.js';
|
import { attachRealtime } from './realtime.js';
|
||||||
import { createServerAdapter } from './servers/adapter.js';
|
import { createServerAdapter } from './servers/adapter.js';
|
||||||
import { ServerManager } from './servers/serverManager.js';
|
import { ServerManager } from './servers/serverManager.js';
|
||||||
|
import { ServerSettings } from './serverSettings.js';
|
||||||
|
|
||||||
/** Baut die komplette Anwendung zusammen (auch für Tests nutzbar). */
|
/** Baut die komplette Anwendung zusammen (auch für Tests nutzbar). */
|
||||||
export function createApp(config, { adapter: adapterOverride, logger = console } = {}) {
|
export function createApp(config, { adapter: adapterOverride, logger = console } = {}) {
|
||||||
const db = openDatabase(config.databaseFile);
|
const db = openDatabase(config.databaseFile);
|
||||||
|
// Vom Admin geänderte Gameserver-Einstellungen auf die Konfiguration legen, bevor der Adapter entsteht
|
||||||
|
const settings = new ServerSettings({ db, config });
|
||||||
|
settings.load();
|
||||||
const pools = loadMapPools(config.mapPoolFile);
|
const pools = loadMapPools(config.mapPoolFile);
|
||||||
const service = new MatchService({ db, pools, config });
|
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();
|
||||||
|
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 +39,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 }));
|
||||||
|
|
||||||
// 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');
|
||||||
@@ -52,6 +61,10 @@ export function createApp(config, { adapter: adapterOverride, logger = console }
|
|||||||
},
|
},
|
||||||
async close() {
|
async close() {
|
||||||
manager.stopLoops();
|
manager.stopLoops();
|
||||||
|
service.stopVetoTimer();
|
||||||
|
// Laufende Server-Aktionen (Start, Konfiguration) kurz abwarten, bevor die Datenbank schließt
|
||||||
|
await manager.idle(5000);
|
||||||
|
io.disposeRealtime();
|
||||||
io.close();
|
io.close();
|
||||||
await new Promise((resolve) => server.close(() => resolve()));
|
await new Promise((resolve) => server.close(() => resolve()));
|
||||||
db.close();
|
db.close();
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -31,9 +86,19 @@ export function loadConfig(overrides = {}) {
|
|||||||
const config = {
|
const config = {
|
||||||
port: int('PORT', 3000),
|
port: int('PORT', 3000),
|
||||||
publicUrl: str('PUBLIC_URL', 'http://localhost:5173').replace(/\/$/, ''),
|
publicUrl: str('PUBLIC_URL', 'http://localhost:5173').replace(/\/$/, ''),
|
||||||
|
// Adresse, unter der der CS2-Server dieses Backend für Live-Logs erreicht (Standard: PUBLIC_URL)
|
||||||
|
logReceiverUrl: str('LOG_RECEIVER_URL', str('PUBLIC_URL', 'http://localhost:3000')).replace(/\/$/, ''),
|
||||||
|
logDebug: bool('LOG_DEBUG', false),
|
||||||
|
// Sekunden nach Map-Ende bis zur nächsten Map bzw. zum Abschluss der Serie; -1 = nur manuell
|
||||||
|
autoAdvanceSeconds: int('SERIES_AUTO_ADVANCE_SECONDS', 30),
|
||||||
databaseFile: path.resolve(REPO_ROOT, str('DATABASE_FILE', 'data/cs2-mapveto.db')),
|
databaseFile: path.resolve(REPO_ROOT, str('DATABASE_FILE', 'data/cs2-mapveto.db')),
|
||||||
mapPoolFile: path.resolve(REPO_ROOT, str('MAP_POOL_FILE', 'server/config/map-pools.json')),
|
mapPoolFile: path.resolve(REPO_ROOT, str('MAP_POOL_FILE', 'server/config/map-pools.json')),
|
||||||
createMatchPassword: str('CREATE_MATCH_PASSWORD'),
|
createMatchPassword: str('CREATE_MATCH_PASSWORD'),
|
||||||
|
// Optionaler Schlüssel für die Stream-Übersicht aller Matches (/stream?key=…); leer = offen
|
||||||
|
streamKey: str('STREAM_KEY'),
|
||||||
|
discordWebhookUrl: str('DISCORD_WEBHOOK_URL'),
|
||||||
|
// Passwort für den Admin-Modus (/admin). Leer = Admin-Modus ist aus.
|
||||||
|
adminPassword: str('ADMIN_PASSWORD'),
|
||||||
requireFullTeams: bool('REQUIRE_FULL_TEAMS', false),
|
requireFullTeams: bool('REQUIRE_FULL_TEAMS', false),
|
||||||
trustProxy: bool('TRUST_PROXY', false),
|
trustProxy: bool('TRUST_PROXY', false),
|
||||||
|
|
||||||
@@ -42,6 +107,8 @@ export function loadConfig(overrides = {}) {
|
|||||||
maxLifetimeHours: int('SERVER_MAX_LIFETIME_HOURS', 8),
|
maxLifetimeHours: int('SERVER_MAX_LIFETIME_HOURS', 8),
|
||||||
statusPollSeconds: int('SERVER_STATUS_POLL_SECONDS', 5),
|
statusPollSeconds: int('SERVER_STATUS_POLL_SECONDS', 5),
|
||||||
reaperIntervalSeconds: int('SERVER_REAPER_INTERVAL_SECONDS', 60),
|
reaperIntervalSeconds: int('SERVER_REAPER_INTERVAL_SECONDS', 60),
|
||||||
|
// Frist pro Veto-Schritt in Sekunden, danach führt das Backend den Schritt automatisch aus. 0 = keine Frist
|
||||||
|
vetoStepSeconds: int('VETO_STEP_SECONDS', 30),
|
||||||
|
|
||||||
match: {
|
match: {
|
||||||
tvDelaySeconds: int('CS2_TV_DELAY', 90),
|
tvDelaySeconds: int('CS2_TV_DELAY', 90),
|
||||||
@@ -58,6 +125,8 @@ export function loadConfig(overrides = {}) {
|
|||||||
demo: {
|
demo: {
|
||||||
startSeconds: int('DEMO_START_SECONDS', 20),
|
startSeconds: int('DEMO_START_SECONDS', 20),
|
||||||
failFirstConfigure: bool('DEMO_FAIL_FIRST_CONFIGURE', false),
|
failFirstConfigure: bool('DEMO_FAIL_FIRST_CONFIGURE', false),
|
||||||
|
// Sekunden pro simulierter Runde nach "Aufwärmphase beenden"
|
||||||
|
roundSeconds: int('DEMO_ROUND_SECONDS', 4),
|
||||||
},
|
},
|
||||||
|
|
||||||
docker: {
|
docker: {
|
||||||
@@ -71,6 +140,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,10 @@ CREATE TABLE IF NOT EXISTS events (
|
|||||||
created_at INTEGER NOT NULL
|
created_at INTEGER NOT NULL
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS events_match ON events(match_id, id);
|
CREATE INDEX IF NOT EXISTS events_match ON events(match_id, id);
|
||||||
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL
|
||||||
|
);
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export function openDatabase(file) {
|
export function openDatabase(file) {
|
||||||
@@ -105,5 +109,25 @@ export function openDatabase(file) {
|
|||||||
db.pragma('journal_mode = WAL');
|
db.pragma('journal_mode = WAL');
|
||||||
db.pragma('foreign_keys = ON');
|
db.pragma('foreign_keys = ON');
|
||||||
db.exec(SCHEMA);
|
db.exec(SCHEMA);
|
||||||
|
migrate(db);
|
||||||
return db;
|
return db;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Ergänzt Spalten, die in älteren Datenbanken noch fehlen. */
|
||||||
|
function migrate(db) {
|
||||||
|
const columns = (table) => new Set(db.prepare(`PRAGMA table_info(${table})`).all().map((c) => c.name));
|
||||||
|
const seriesColumns = columns('series_maps');
|
||||||
|
const add = [
|
||||||
|
['score_a', 'INTEGER NOT NULL DEFAULT 0'],
|
||||||
|
['score_b', 'INTEGER NOT NULL DEFAULT 0'],
|
||||||
|
['winner', 'TEXT'],
|
||||||
|
// Live-Zustand aus den Serverlogs (JSON, siehe liveScore.js)
|
||||||
|
['live', 'TEXT'],
|
||||||
|
['live_updated_at', 'INTEGER'],
|
||||||
|
];
|
||||||
|
for (const [name, type] of add) {
|
||||||
|
if (!seriesColumns.has(name)) db.exec(`ALTER TABLE series_maps ADD COLUMN ${name} ${type}`);
|
||||||
|
}
|
||||||
|
const serverColumns = columns('servers');
|
||||||
|
if (!serverColumns.has('paused')) db.exec('ALTER TABLE servers ADD COLUMN paused INTEGER NOT NULL DEFAULT 0');
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
+26
-1
@@ -2,6 +2,7 @@ import express from 'express';
|
|||||||
import { HttpError } from './matchService.js';
|
import { HttpError } from './matchService.js';
|
||||||
import { publicPools } from './mapPools.js';
|
import { publicPools } from './mapPools.js';
|
||||||
import { createRateLimiter, safeEqual } from './security.js';
|
import { createRateLimiter, safeEqual } from './security.js';
|
||||||
|
import { createAdminRouter } from './adminRoutes.js';
|
||||||
|
|
||||||
function bearer(req) {
|
function bearer(req) {
|
||||||
const header = req.get('authorization') ?? '';
|
const header = req.get('authorization') ?? '';
|
||||||
@@ -13,9 +14,25 @@ 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 }) {
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Live-Logs vom CS2-Server (logaddress_add_http). Vor dem JSON-Parser, weil CS2 Klartext sendet.
|
||||||
|
// Der Schlüssel in der URL ist pro Server und Match zufällig und nur im Backend bekannt.
|
||||||
|
router.post('/logs/:id/:key', express.text({ type: () => true, limit: '2mb' }), (req, res) => {
|
||||||
|
try {
|
||||||
|
const body = typeof req.body === 'string' ? req.body : '';
|
||||||
|
// LOG_DEBUG=true: empfangene Zeilen ausgeben, z. B. um das Logformat eines Servers zu prüfen
|
||||||
|
if (config.logDebug) console.log(`[logs ${req.params.id}] ${body.trim().replace(/\n/g, `\n[logs ${req.params.id}] `)}`);
|
||||||
|
service.ingestLogs(req.params.id, req.params.key, body);
|
||||||
|
res.status(200).end();
|
||||||
|
} catch (err) {
|
||||||
|
res.status(err.status ?? 500).end();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
router.use(express.json({ limit: '32kb' }));
|
router.use(express.json({ limit: '32kb' }));
|
||||||
|
router.use('/admin', createAdminRouter({ service, manager, config, settings }));
|
||||||
|
|
||||||
const limiters = {
|
const limiters = {
|
||||||
create: createRateLimiter({ windowMs: 60_000, max: 10 }),
|
create: createRateLimiter({ windowMs: 60_000, max: 10 }),
|
||||||
@@ -54,6 +71,14 @@ export function createApiRouter({ service, manager, config, pools, adapter }) {
|
|||||||
|
|
||||||
router.get('/matches', wrap(() => ({ matches: service.listMatches() })));
|
router.get('/matches', wrap(() => ({ matches: service.listMatches() })));
|
||||||
|
|
||||||
|
// Stream-Übersicht aller Matches; mit STREAM_KEY nur mit passendem ?key=
|
||||||
|
router.get('/stream/overview', wrap((req) => {
|
||||||
|
if (config.streamKey && !safeEqual(String(req.query.key ?? ''), config.streamKey)) {
|
||||||
|
throw new HttpError(403, 'Für die Stream-Übersicht wird der Stream-Schlüssel benötigt', 'forbidden');
|
||||||
|
}
|
||||||
|
return service.streamOverview();
|
||||||
|
}));
|
||||||
|
|
||||||
router.post('/matches', limit('create'), wrap((req) => {
|
router.post('/matches', limit('create'), wrap((req) => {
|
||||||
const { teamA, teamB, mode, format, vetoStart, password } = req.body ?? {};
|
const { teamA, teamB, mode, format, vetoStart, password } = req.body ?? {};
|
||||||
if (config.createMatchPassword && !safeEqual(password ?? '', config.createMatchPassword)) {
|
if (config.createMatchPassword && !safeEqual(password ?? '', config.createMatchPassword)) {
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
// Reine Auswertung von CS2-Serverlogs (logaddress_add_http) zu Live-Spielständen.
|
||||||
|
// Kein Datenbank- oder Netzwerkzugriff, damit sich die Logik mit Beispielzeilen testen lässt.
|
||||||
|
//
|
||||||
|
// Ausgewertete Zeilen (Präfix mit Datum/Uhrzeit wird ignoriert):
|
||||||
|
// World triggered "Match_Start" on "de_mirage"
|
||||||
|
// MatchStatus: Team playing "CT": MaxLAN Blau
|
||||||
|
// Team "TERRORIST" triggered "SFUI_Notice_Terrorists_Win" (CT "3") (T "5")
|
||||||
|
// World triggered "Warmup_Start"
|
||||||
|
// Game Over: competitive mg_active de_mirage score 13:9 after 35 min
|
||||||
|
// Game Over: scrimcomp2v2 de_inferno score 1:3 after 3 min (Wingman: leere Map-Gruppe, zwei Leerzeichen)
|
||||||
|
|
||||||
|
const RE = {
|
||||||
|
matchStart: /World triggered "Match_Start" on "([^"]+)"/,
|
||||||
|
warmupStart: /World triggered "Warmup_Start"/,
|
||||||
|
worldRoundEnd: /World triggered "Round_End"/,
|
||||||
|
teamPlaying: /Team playing "(CT|TERRORIST)": (.+?)\s*$/,
|
||||||
|
roundEnd: /Team "(CT|TERRORIST)" triggered "SFUI_Notice_(\w+)" \(CT "(\d+)"\) \(T "(\d+)"\)/,
|
||||||
|
// Das Wort direkt vor "score" ist die Map – Modus und Map-Gruppe davor sind beliebig bzw. leer.
|
||||||
|
gameOver: /Game Over:.*?(\S+)\s+score\s+(\d+):(\d+)/,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Gründe für ein Rundenende aus den SFUI-Hinweisen
|
||||||
|
const REASONS = {
|
||||||
|
Target_Bombed: 'bomb',
|
||||||
|
Bomb_Defused: 'defuse',
|
||||||
|
Target_Saved: 'time',
|
||||||
|
CTs_Win: 'elimination',
|
||||||
|
Terrorists_Win: 'elimination',
|
||||||
|
Terrorists_Surrender: 'surrender',
|
||||||
|
CTs_Surrender: 'surrender',
|
||||||
|
};
|
||||||
|
|
||||||
|
const other = (team) => (team === 'A' ? 'B' : 'A');
|
||||||
|
const normalizeName = (name) => String(name ?? '').trim().toLowerCase();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Neuer Live-Zustand einer Map. ctTeam = Team, das laut Veto auf CT startet.
|
||||||
|
* warmupEnded: erst danach zählt ein Match_Start (CS2 sendet ihn auch beim Laden der Map, vor der Aufwärmphase).
|
||||||
|
*/
|
||||||
|
export function initialLiveState({ map, ctTeam, warmupEnded = false }) {
|
||||||
|
const startCt = ctTeam ?? 'A';
|
||||||
|
return {
|
||||||
|
map,
|
||||||
|
status: 'warmup',
|
||||||
|
warmupEnded,
|
||||||
|
startCt,
|
||||||
|
sides: { CT: startCt, T: other(startCt) },
|
||||||
|
sidesFromLog: false,
|
||||||
|
score: { A: 0, B: 0 },
|
||||||
|
rounds: [],
|
||||||
|
winner: null,
|
||||||
|
pendingRound: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wendet Logzeilen auf den Zustand an.
|
||||||
|
* @param {object} state Zustand aus initialLiveState bzw. vorherigem Aufruf
|
||||||
|
* @param {string[]} lines Logzeilen
|
||||||
|
* @param {object} ctx { teamNames: {A, B}, maxRounds, overtimeRounds }
|
||||||
|
* @returns {{ state, events }} events: [{ type: 'match_start'|'round'|'map_end', ... }]
|
||||||
|
*/
|
||||||
|
export function applyLogLines(state, lines, ctx) {
|
||||||
|
const s = structuredClone(state);
|
||||||
|
const events = [];
|
||||||
|
const names = { A: normalizeName(ctx.teamNames.A), B: normalizeName(ctx.teamNames.B) };
|
||||||
|
|
||||||
|
// Ein Rundenende wird erst abgeschlossen, wenn die Seitenzuordnung der gerade gespielten Runde feststeht:
|
||||||
|
// CS2 schickt sie direkt nach dem Rundenende ("MatchStatus: Team playing ..."). Die Zeilen zu Beginn der
|
||||||
|
// nächsten Runde sind nach der Halbzeit noch veraltet und werden deshalb ignoriert.
|
||||||
|
const finalize = () => {
|
||||||
|
const p = s.pendingRound;
|
||||||
|
if (!p) return;
|
||||||
|
s.pendingRound = null;
|
||||||
|
if (p.number <= s.rounds.length) return; // doppelt gesendete Zeile
|
||||||
|
const winner = s.sides[p.side];
|
||||||
|
s.rounds.push({ number: p.number, winner, side: p.side, reason: p.reason });
|
||||||
|
// Stand aus den Seitenwerten übernehmen – korrigiert sich bei verlorenen Zeilen selbst.
|
||||||
|
s.score = { [s.sides.CT]: p.sideScore.CT, [s.sides.T]: p.sideScore.T };
|
||||||
|
events.push({ type: 'round', number: p.number, winner, score: { ...s.score } });
|
||||||
|
switchSidesByRules(s, p.number, ctx);
|
||||||
|
};
|
||||||
|
|
||||||
|
const reset = (status) => {
|
||||||
|
Object.assign(s, { status, score: { A: 0, B: 0 }, rounds: [], winner: null, pendingRound: null });
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
let m;
|
||||||
|
if ((m = RE.matchStart.exec(line))) {
|
||||||
|
// Echter Matchbeginn (nach Aufwärmphase oder mp_restartgame): Zählung beginnt neu.
|
||||||
|
// Nach Spielende startet CS2 von selbst neu – das Ergebnis bleibt dann stehen.
|
||||||
|
if (m[1] !== s.map || s.status === 'finished') continue;
|
||||||
|
reset(s.warmupEnded ? 'live' : 'warmup');
|
||||||
|
// Seitenzuordnung aus dem Log hat Vorrang vor der Annahme aus dem Veto.
|
||||||
|
if (!s.sidesFromLog) s.sides = { CT: s.startCt, T: other(s.startCt) };
|
||||||
|
if (s.warmupEnded) events.push({ type: 'match_start', map: s.map });
|
||||||
|
} else if (RE.warmupStart.test(line)) {
|
||||||
|
// Neue Aufwärmphase (Mapstart oder Neustart): bisherige Zählung verwerfen, Ergebnis einer beendeten Map bleibt.
|
||||||
|
if (s.status !== 'finished') reset('warmup');
|
||||||
|
} else if ((m = RE.teamPlaying.exec(line))) {
|
||||||
|
// Nur vor der ersten Runde und direkt nach einem Rundenende verlässlich.
|
||||||
|
if (!s.pendingRound && s.rounds.length > 0) continue;
|
||||||
|
const side = m[1] === 'CT' ? 'CT' : 'T';
|
||||||
|
const name = normalizeName(m[2]);
|
||||||
|
const team = name === names.A ? 'A' : name === names.B ? 'B' : null;
|
||||||
|
if (team) {
|
||||||
|
s.sides[side] = team;
|
||||||
|
s.sides[side === 'CT' ? 'T' : 'CT'] = other(team);
|
||||||
|
s.sidesFromLog = true;
|
||||||
|
}
|
||||||
|
} else if ((m = RE.roundEnd.exec(line))) {
|
||||||
|
if (s.status === 'finished') continue;
|
||||||
|
finalize();
|
||||||
|
if (s.status === 'warmup') s.status = 'live';
|
||||||
|
const sideScore = { CT: Number(m[3]), T: Number(m[4]) };
|
||||||
|
s.pendingRound = {
|
||||||
|
number: sideScore.CT + sideScore.T,
|
||||||
|
side: m[1] === 'CT' ? 'CT' : 'T',
|
||||||
|
reason: REASONS[m[2]] ?? 'other',
|
||||||
|
sideScore,
|
||||||
|
};
|
||||||
|
} else if (RE.worldRoundEnd.test(line)) {
|
||||||
|
finalize();
|
||||||
|
} else if ((m = RE.gameOver.exec(line))) {
|
||||||
|
if (m[1] !== s.map || s.status === 'finished') continue;
|
||||||
|
finalize();
|
||||||
|
s.status = 'finished';
|
||||||
|
s.winner = s.score.A === s.score.B ? null : s.score.A > s.score.B ? 'A' : 'B';
|
||||||
|
events.push({ type: 'map_end', winner: s.winner, score: { ...s.score } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Ende des Pakets: offenes Rundenende mit der bekannten Zuordnung abschließen, damit die Anzeige nicht hängt.
|
||||||
|
finalize();
|
||||||
|
return { state: s, events };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seitenwechsel nach Regelwerk, damit die Anzeige auch während einer Runde stimmt:
|
||||||
|
* Halbzeit nach maxRounds/2, in der Verlängerung nach jeder halben Verlängerung.
|
||||||
|
* Die Zeilen nach dem nächsten Rundenende korrigieren die Zuordnung, falls ein Server anders wechselt.
|
||||||
|
*/
|
||||||
|
function switchSidesByRules(s, roundsPlayed, { maxRounds, overtimeRounds = 6 }) {
|
||||||
|
const half = maxRounds / 2;
|
||||||
|
const otHalf = overtimeRounds / 2;
|
||||||
|
const regulationSwitch = roundsPlayed === half;
|
||||||
|
const overtimeSwitch = roundsPlayed > maxRounds && (roundsPlayed - maxRounds) % otHalf === 0
|
||||||
|
&& ((roundsPlayed - maxRounds) / otHalf) % 2 === 1;
|
||||||
|
if (regulationSwitch || overtimeSwitch) s.sides = { CT: s.sides.T, T: s.sides.CT };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Teilt einen HTTP-Body in Logzeilen. */
|
||||||
|
export function splitLogBody(body) {
|
||||||
|
return String(body ?? '').split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Serienstand aus den Ergebnissen der einzelnen Maps. */
|
||||||
|
export function seriesScore(maps) {
|
||||||
|
const score = { A: 0, B: 0 };
|
||||||
|
for (const m of maps) if (m.winner) score[m.winner] += 1;
|
||||||
|
return score;
|
||||||
|
}
|
||||||
+363
-15
@@ -1,6 +1,10 @@
|
|||||||
import { EventEmitter } from 'node:events';
|
import { EventEmitter } from 'node:events';
|
||||||
import { BEST_OF, computeVetoState, resolveAction, VetoError } from './veto.js';
|
import { BEST_OF, computeVetoState, resolveAction, VetoError } from './veto.js';
|
||||||
import { formatCode, hashToken, normalizeCode, randomCode, randomId, randomToken, safeEqual } from './security.js';
|
import { formatCode, hashToken, normalizeCode, randomCode, randomId, randomToken, safeEqual } from './security.js';
|
||||||
|
import { applyLogLines, initialLiveState, seriesScore, splitLogBody } from './liveScore.js';
|
||||||
|
import { trimLiveState } from './rounds.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') {
|
||||||
@@ -38,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();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -336,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;
|
||||||
@@ -414,9 +477,14 @@ export class MatchService extends EventEmitter {
|
|||||||
|
|
||||||
advanceSeries(viewer) {
|
advanceSeries(viewer) {
|
||||||
this.require(viewer, 'organizer');
|
this.require(viewer, 'organizer');
|
||||||
|
return this.advanceSeriesFor(viewer.matchId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nächste Map der Serie vormerken (Organisator oder automatisch nach Map-Ende). */
|
||||||
|
advanceSeriesFor(matchId) {
|
||||||
let nextNumber;
|
let nextNumber;
|
||||||
this.db.transaction(() => {
|
this.db.transaction(() => {
|
||||||
const match = this.getMatch(viewer.matchId);
|
const match = this.getMatch(matchId);
|
||||||
if (match.status !== 'live') throw new HttpError(409, 'Die nächste Map kann erst geladen werden, wenn das Match läuft', 'wrong_phase');
|
if (match.status !== 'live') throw new HttpError(409, 'Die nächste Map kann erst geladen werden, wenn das Match läuft', 'wrong_phase');
|
||||||
const maps = this.seriesMaps(match.id);
|
const maps = this.seriesMaps(match.id);
|
||||||
nextNumber = match.current_map_number + 1;
|
nextNumber = match.current_map_number + 1;
|
||||||
@@ -428,10 +496,128 @@ export class MatchService extends EventEmitter {
|
|||||||
.run(nextNumber, this.now(), match.id);
|
.run(nextNumber, this.now(), match.id);
|
||||||
this.log(match.id, 'series', `Map ${nextNumber} der Serie wird geladen`);
|
this.log(match.id, 'series', `Map ${nextNumber} der Serie wird geladen`);
|
||||||
})();
|
})();
|
||||||
this.changed(viewer.matchId);
|
this.changed(matchId);
|
||||||
return nextNumber;
|
return nextNumber;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- Live-Ergebnisse ----------
|
||||||
|
|
||||||
|
/** Startet die Zählung einer Map neu (beim Laden der Map auf dem Server). */
|
||||||
|
resetLiveScore(matchId, number) {
|
||||||
|
this.db
|
||||||
|
.prepare('UPDATE series_maps SET score_a = 0, score_b = 0, winner = NULL, live = NULL, live_updated_at = NULL WHERE match_id = ? AND number = ?')
|
||||||
|
.run(matchId, number);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stand auf den Zeitpunkt nach einer Runde zurückstellen (nach Rollback). */
|
||||||
|
rewindLiveScore(matchId, roundNumber, file) {
|
||||||
|
const match = this.getMatch(matchId);
|
||||||
|
const row = this.seriesMaps(matchId).find((m) => m.number === match.current_map_number);
|
||||||
|
if (!row) return;
|
||||||
|
const live = row.live ? trimLiveState(JSON.parse(row.live), roundNumber) : null;
|
||||||
|
const score = live ? live.score : { A: 0, B: 0 };
|
||||||
|
this.db
|
||||||
|
.prepare("UPDATE series_maps SET score_a = ?, score_b = ?, winner = NULL, status = 'live', live = ?, live_updated_at = ? WHERE match_id = ? AND number = ?")
|
||||||
|
.run(score.A, score.B, live ? JSON.stringify(live) : null, this.now(), matchId, row.number);
|
||||||
|
this.log(matchId, 'rollback', `Map ${row.number} zurückgespielt auf Runde ${roundNumber} (${file})`);
|
||||||
|
this.changed(matchId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Organisator hat die Aufwärmphase beendet: ab dem nächsten Match_Start wird gezählt. */
|
||||||
|
markWarmupEnded(matchId, ended = true) {
|
||||||
|
const match = this.getMatch(matchId);
|
||||||
|
const row = this.seriesMaps(matchId).find((m) => m.number === match.current_map_number);
|
||||||
|
if (!row) return;
|
||||||
|
const state = row.live ? JSON.parse(row.live) : initialLiveState({ map: row.map, ctTeam: row.ct_team });
|
||||||
|
state.warmupEnded = ended;
|
||||||
|
this.db
|
||||||
|
.prepare('UPDATE series_maps SET live = ? WHERE match_id = ? AND number = ?')
|
||||||
|
.run(JSON.stringify(state), matchId, row.number);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Logzeilen vom CS2-Server (logaddress_add_http). Der Schlüssel in der URL identifiziert den Server. */
|
||||||
|
ingestLogs(matchId, key, body) {
|
||||||
|
const server = this.serverRow(matchId);
|
||||||
|
if (!server || !safeEqual(String(key ?? ''), server.config_key)) {
|
||||||
|
throw new HttpError(403, 'Unbekannter Log-Schlüssel', 'forbidden');
|
||||||
|
}
|
||||||
|
this.applyLiveLog(matchId, splitLogBody(body));
|
||||||
|
}
|
||||||
|
|
||||||
|
applyLiveLog(matchId, lines) {
|
||||||
|
if (!lines.length) return;
|
||||||
|
let ended = null;
|
||||||
|
this.db.transaction(() => {
|
||||||
|
const match = this.getMatch(matchId);
|
||||||
|
if (CLOSED_STATUSES.includes(match.status)) return;
|
||||||
|
const row = this.seriesMaps(matchId).find((m) => m.number === match.current_map_number);
|
||||||
|
if (!row) return;
|
||||||
|
const before = row.live ? JSON.parse(row.live) : initialLiveState({ map: row.map, ctTeam: row.ct_team });
|
||||||
|
const { state, events } = applyLogLines(before, lines, {
|
||||||
|
teamNames: { A: sanitizeConsoleText(match.team_a_name), B: sanitizeConsoleText(match.team_b_name) },
|
||||||
|
maxRounds: match.mode === 'wingman' ? this.config.match.maxRoundsWingman : this.config.match.maxRoundsCompetitive,
|
||||||
|
overtimeRounds: 6,
|
||||||
|
});
|
||||||
|
if (!events.length && JSON.stringify(before) === JSON.stringify(state)) {
|
||||||
|
// Nichts Relevantes dabei, aber der Server liefert: Empfang trotzdem vermerken (Anzeige "Live-Daten empfangen")
|
||||||
|
this.db.prepare('UPDATE series_maps SET live_updated_at = ? WHERE match_id = ? AND number = ?').run(this.now(), matchId, row.number);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const finished = events.find((e) => e.type === 'map_end');
|
||||||
|
this.db
|
||||||
|
.prepare(
|
||||||
|
`UPDATE series_maps SET score_a = ?, score_b = ?, winner = ?, live = ?, live_updated_at = ?
|
||||||
|
${finished ? ", status = 'done'" : ''} WHERE match_id = ? AND number = ?`,
|
||||||
|
)
|
||||||
|
.run(state.score.A, state.score.B, state.winner, JSON.stringify(state), this.now(), matchId, row.number);
|
||||||
|
if (events.some((e) => e.type === 'match_start')) this.log(matchId, 'score', `Map ${row.number} (${this.mapName(match, row.map)}) live – Zählung beginnt`);
|
||||||
|
if (finished) {
|
||||||
|
ended = { row, state, match };
|
||||||
|
const winner = state.winner ? this.teamName(match, state.winner) : 'Unentschieden';
|
||||||
|
this.log(matchId, 'score',
|
||||||
|
`Map ${row.number} (${this.mapName(match, row.map)}) beendet: ${match.team_a_name} ${state.score.A}:${state.score.B} ${match.team_b_name} – Sieger: ${winner}`);
|
||||||
|
this.notify('map_end', matchId, {
|
||||||
|
title: '🗺️ Map beendet',
|
||||||
|
description: `**${this.matchTitle(matchId)}**`,
|
||||||
|
color: 0xf59e0b,
|
||||||
|
fields: [
|
||||||
|
{ name: `Map ${row.number}`, value: this.mapName(match, row.map), inline: true },
|
||||||
|
{ name: 'Stand', value: `${state.score.A} : ${state.score.B}`, inline: true },
|
||||||
|
{ name: 'Sieger', value: winner, inline: true },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
if (ended) {
|
||||||
|
const maps = this.seriesMaps(matchId);
|
||||||
|
const score = seriesScore(maps);
|
||||||
|
const needed = Math.floor(BEST_OF[ended.match.format] / 2) + 1;
|
||||||
|
const decided = score.A >= needed ? 'A' : score.B >= needed ? 'B' : null;
|
||||||
|
if (decided) this.log(matchId, 'score', `Serie entschieden: ${this.teamName(ended.match, decided)} gewinnt ${score[decided]}:${score[decided === 'A' ? 'B' : 'A']}`);
|
||||||
|
if (decided) {
|
||||||
|
const loser = decided === 'A' ? 'B' : 'A';
|
||||||
|
this.notify('series_end', matchId, {
|
||||||
|
title: '🏆 Serie entschieden',
|
||||||
|
description: `**${this.matchTitle(matchId)}**`,
|
||||||
|
color: 0x22c55e,
|
||||||
|
fields: [
|
||||||
|
{ name: 'Sieger', value: this.teamName(ended.match, decided), inline: true },
|
||||||
|
{ name: 'Serienstand', value: `${score[decided]} : ${score[loser]}`, inline: true },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const hasNext = maps.some((m) => m.number === ended.row.number + 1);
|
||||||
|
this.changed(matchId);
|
||||||
|
this.emit('mapEnded', { matchId, number: ended.row.number, decided, hasNext });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.changed(matchId);
|
||||||
|
}
|
||||||
|
|
||||||
|
mapName(match, mapId) {
|
||||||
|
return JSON.parse(match.maps).find((m) => m.id === mapId)?.name ?? mapId;
|
||||||
|
}
|
||||||
|
|
||||||
markCurrentMapLive(matchId) {
|
markCurrentMapLive(matchId) {
|
||||||
const match = this.getMatch(matchId);
|
const match = this.getMatch(matchId);
|
||||||
this.db
|
this.db
|
||||||
@@ -441,12 +627,22 @@ export class MatchService extends EventEmitter {
|
|||||||
|
|
||||||
close(viewer, status) {
|
close(viewer, status) {
|
||||||
this.require(viewer, 'organizer');
|
this.require(viewer, 'organizer');
|
||||||
const match = this.getMatch(viewer.matchId);
|
this.closeMatch(viewer.matchId, status);
|
||||||
|
}
|
||||||
|
|
||||||
|
closeMatch(matchId, status, message) {
|
||||||
|
const match = this.getMatch(matchId);
|
||||||
if (CLOSED_STATUSES.includes(match.status)) throw new HttpError(409, 'Das Match ist bereits beendet', 'match_closed');
|
if (CLOSED_STATUSES.includes(match.status)) throw new HttpError(409, 'Das Match ist bereits beendet', 'match_closed');
|
||||||
if (status === 'finished') {
|
if (status === 'finished') {
|
||||||
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', 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);
|
||||||
}
|
}
|
||||||
@@ -461,6 +657,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 (?, ?, ?, ?)')
|
||||||
@@ -472,6 +709,76 @@ export class MatchService extends EventEmitter {
|
|||||||
this.emit('changed', matchId);
|
this.emit('changed', matchId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Übersicht aller aktiven Matches für die Stream-Seite: laufende Vetos und Matches mit Details,
|
||||||
|
* Lobbys und kürzlich beendete Matches knapp. Enthält nur, was Zuschauer sehen dürfen –
|
||||||
|
* keine Codes, keine Verbindungsdaten.
|
||||||
|
*/
|
||||||
|
streamOverview({ resultsWithinMs = 3 * 3_600_000 } = {}) {
|
||||||
|
const rows = this.db.prepare('SELECT * FROM matches ORDER BY created_at DESC LIMIT 200').all();
|
||||||
|
const now = this.now();
|
||||||
|
const active = [];
|
||||||
|
const upcoming = [];
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
for (const m of rows) {
|
||||||
|
const pool = this.pools[m.mode];
|
||||||
|
const maps = JSON.parse(m.maps);
|
||||||
|
const mapInfo = (id) => {
|
||||||
|
const map = maps.find((x) => x.id === id);
|
||||||
|
return { id, name: map?.name ?? id, image: map?.image ?? null, color: map?.color ?? null };
|
||||||
|
};
|
||||||
|
const base = {
|
||||||
|
id: m.id,
|
||||||
|
teams: { A: m.team_a_name, B: m.team_b_name },
|
||||||
|
mode: m.mode,
|
||||||
|
modeLabel: pool?.label ?? m.mode,
|
||||||
|
format: m.format,
|
||||||
|
status: m.status,
|
||||||
|
statusLabel: MATCH_STATUS[m.status],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (m.status === 'lobby') {
|
||||||
|
const readiness = this.teamReadiness(m);
|
||||||
|
upcoming.push({ ...base, ready: { A: readiness.A.ready, B: readiness.B.ready } });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const seriesRows = this.seriesMaps(m.id);
|
||||||
|
const series = seriesRows.map((s) => {
|
||||||
|
const live = s.live ? JSON.parse(s.live) : null;
|
||||||
|
return {
|
||||||
|
number: s.number,
|
||||||
|
...mapInfo(s.map),
|
||||||
|
pickedBy: s.picked_by,
|
||||||
|
status: s.status,
|
||||||
|
score: { A: s.score_a, B: s.score_b },
|
||||||
|
winner: s.winner,
|
||||||
|
live: live && { status: live.status, sides: live.sides, roundsPlayed: live.rounds.length },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const item = { ...base, seriesScore: seriesScore(seriesRows), currentMapNumber: m.current_map_number, series };
|
||||||
|
|
||||||
|
if (CLOSED_STATUSES.includes(m.status)) {
|
||||||
|
if (m.status === 'finished' && now - (m.finished_at ?? 0) < resultsWithinMs) results.push(item);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (m.status === 'veto') {
|
||||||
|
const veto = this.vetoState(m);
|
||||||
|
item.veto = {
|
||||||
|
current: veto.current,
|
||||||
|
maps: veto.maps.map((x) => ({ ...mapInfo(x.id), status: x.status, by: x.by })),
|
||||||
|
stepsDone: veto.steps.filter((x) => x.done).length,
|
||||||
|
stepsTotal: veto.steps.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
active.push(item);
|
||||||
|
}
|
||||||
|
// Laufende Matches zuerst, dann Vetos, dann Serverstart – jeweils älteste zuerst
|
||||||
|
const order = { live: 0, veto: 1, server_setup: 2 };
|
||||||
|
active.sort((a, b) => (order[a.status] - order[b.status]) || a.id.localeCompare(b.id));
|
||||||
|
return { active, upcoming: upcoming.slice(0, 8), results: results.slice(0, 8), updatedAt: now };
|
||||||
|
}
|
||||||
|
|
||||||
listMatches() {
|
listMatches() {
|
||||||
const rows = this.db.prepare('SELECT * FROM matches ORDER BY created_at DESC LIMIT 200').all();
|
const rows = this.db.prepare('SELECT * FROM matches ORDER BY created_at DESC LIMIT 200').all();
|
||||||
const counts = this.db.prepare('SELECT team, COUNT(*) AS n FROM players WHERE match_id = ? AND active = 1 GROUP BY team');
|
const counts = this.db.prepare('SELECT team, COUNT(*) AS n FROM players WHERE match_id = ? AND active = 1 GROUP BY team');
|
||||||
@@ -479,7 +786,20 @@ export class MatchService extends EventEmitter {
|
|||||||
const byTeam = Object.fromEntries(counts.all(m.id).map((r) => [r.team, r.n]));
|
const byTeam = Object.fromEntries(counts.all(m.id).map((r) => [r.team, r.n]));
|
||||||
const server = this.serverRow(m.id);
|
const server = this.serverRow(m.id);
|
||||||
const pool = this.pools[m.mode];
|
const pool = this.pools[m.mode];
|
||||||
|
const maps = this.seriesMaps(m.id);
|
||||||
|
const current = maps.find((s) => s.number === m.current_map_number);
|
||||||
return {
|
return {
|
||||||
|
seriesScore: maps.length ? seriesScore(maps) : null,
|
||||||
|
currentMap: current && (current.status === 'live' || current.status === 'done')
|
||||||
|
? {
|
||||||
|
number: current.number,
|
||||||
|
name: this.mapName(m, current.map),
|
||||||
|
image: JSON.parse(m.maps).find((x) => x.id === current.map)?.image ?? null,
|
||||||
|
color: JSON.parse(m.maps).find((x) => x.id === current.map)?.color ?? null,
|
||||||
|
score: { A: current.score_a, B: current.score_b },
|
||||||
|
finished: current.status === 'done',
|
||||||
|
}
|
||||||
|
: null,
|
||||||
id: m.id,
|
id: m.id,
|
||||||
teamA: m.team_a_name,
|
teamA: m.team_a_name,
|
||||||
teamB: m.team_b_name,
|
teamB: m.team_b_name,
|
||||||
@@ -528,15 +848,28 @@ export class MatchService extends EventEmitter {
|
|||||||
|
|
||||||
const veto = this.vetoState(match);
|
const veto = this.vetoState(match);
|
||||||
const actions = this.vetoActions(matchId);
|
const actions = this.vetoActions(matchId);
|
||||||
const series = this.seriesMaps(matchId).map((s) => ({
|
const seriesRows = this.seriesMaps(matchId);
|
||||||
number: s.number,
|
const series = seriesRows.map((s) => {
|
||||||
map: s.map,
|
const live = s.live ? JSON.parse(s.live) : null;
|
||||||
mapName: mapNames[s.map] ?? s.map,
|
return {
|
||||||
pickedBy: s.picked_by,
|
number: s.number,
|
||||||
sideChosenBy: s.side_chosen_by,
|
map: s.map,
|
||||||
ctTeam: s.ct_team,
|
mapName: mapNames[s.map] ?? s.map,
|
||||||
status: s.status,
|
pickedBy: s.picked_by,
|
||||||
}));
|
sideChosenBy: s.side_chosen_by,
|
||||||
|
ctTeam: s.ct_team,
|
||||||
|
status: s.status,
|
||||||
|
score: { A: s.score_a, B: s.score_b },
|
||||||
|
winner: s.winner,
|
||||||
|
// Auch ohne Spielstand (Aufwärmphase) mitschicken, sobald der Server Logs liefert
|
||||||
|
live: (live || s.live_updated_at) && {
|
||||||
|
status: live?.status ?? 'warmup',
|
||||||
|
sides: live?.sides ?? null,
|
||||||
|
rounds: live?.rounds ?? [],
|
||||||
|
updatedAt: s.live_updated_at,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
const snapshot = {
|
const snapshot = {
|
||||||
id: match.id,
|
id: match.id,
|
||||||
@@ -557,8 +890,9 @@ export class MatchService extends EventEmitter {
|
|||||||
},
|
},
|
||||||
canStartVeto: match.status === 'lobby' && readiness.A.ready && readiness.B.ready,
|
canStartVeto: match.status === 'lobby' && readiness.A.ready && readiness.B.ready,
|
||||||
requireFullTeams: this.config.requireFullTeams,
|
requireFullTeams: this.config.requireFullTeams,
|
||||||
|
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',
|
||||||
@@ -566,6 +900,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,
|
||||||
@@ -578,6 +914,8 @@ export class MatchService extends EventEmitter {
|
|||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
series,
|
series,
|
||||||
|
seriesScore: seriesScore(seriesRows),
|
||||||
|
maxRounds: match.mode === 'wingman' ? this.config.match.maxRoundsWingman : this.config.match.maxRoundsCompetitive,
|
||||||
server: this.serverView(match, viewer),
|
server: this.serverView(match, viewer),
|
||||||
events: publicView
|
events: publicView
|
||||||
? []
|
? []
|
||||||
@@ -594,6 +932,10 @@ export class MatchService extends EventEmitter {
|
|||||||
A: `${base}/join/${formatCode(match.code_a)}`,
|
A: `${base}/join/${formatCode(match.code_a)}`,
|
||||||
B: `${base}/join/${formatCode(match.code_b)}`,
|
B: `${base}/join/${formatCode(match.code_b)}`,
|
||||||
spectator: `${base}/watch/${formatCode(match.code_spectator)}`,
|
spectator: `${base}/watch/${formatCode(match.code_spectator)}`,
|
||||||
|
// Vollbild-Übersicht für den Stream (OBS-Browserquelle), nutzt den Zuschauercode
|
||||||
|
stream: `${base}/stream/${formatCode(match.code_spectator)}`,
|
||||||
|
// Stream-Übersicht nur für dieses Match, ohne Zuschauercode (Schlüssel nur wenn gesetzt)
|
||||||
|
overview: `${base}/stream/match/${match.id}${this.config.streamKey ? `?key=${encodeURIComponent(this.config.streamKey)}` : ''}`,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -609,10 +951,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;
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Server } from 'socket.io';
|
import { Server } from 'socket.io';
|
||||||
|
import { safeEqual } from './security.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Echtzeit-Kanal. Clients senden nie Aktionen über den Socket – alle Änderungen laufen über die
|
* Echtzeit-Kanal. Clients senden nie Aktionen über den Socket – alle Änderungen laufen über die
|
||||||
@@ -50,9 +51,16 @@ export function attachRealtime(httpServer, { service, config }) {
|
|||||||
}, 50));
|
}, 50));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let streamTimer = null;
|
||||||
|
const broadcastStream = () => {
|
||||||
|
clearTimeout(streamTimer);
|
||||||
|
streamTimer = setTimeout(() => io.to('stream-overview').emit('stream:overview', service.streamOverview()), 200);
|
||||||
|
};
|
||||||
|
|
||||||
service.on('changed', (matchId) => {
|
service.on('changed', (matchId) => {
|
||||||
broadcastMatch(matchId);
|
broadcastMatch(matchId);
|
||||||
broadcastOverview();
|
broadcastOverview();
|
||||||
|
broadcastStream();
|
||||||
});
|
});
|
||||||
|
|
||||||
io.on('connection', (socket) => {
|
io.on('connection', (socket) => {
|
||||||
@@ -61,6 +69,15 @@ export function attachRealtime(httpServer, { service, config }) {
|
|||||||
socket.emit('overview', { matches: service.listMatches() });
|
socket.emit('overview', { matches: service.listMatches() });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
socket.on('stream:subscribe', ({ key } = {}) => {
|
||||||
|
if (config.streamKey && !safeEqual(String(key ?? ''), config.streamKey)) {
|
||||||
|
socket.emit('stream:error', { error: 'Für die Stream-Übersicht wird der Stream-Schlüssel benötigt' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
socket.join('stream-overview');
|
||||||
|
socket.emit('stream:overview', service.streamOverview());
|
||||||
|
});
|
||||||
|
|
||||||
socket.on('match:subscribe', ({ matchId, token, playerToken } = {}) => {
|
socket.on('match:subscribe', ({ matchId, token, playerToken } = {}) => {
|
||||||
if (typeof matchId !== 'string') return;
|
if (typeof matchId !== 'string') return;
|
||||||
for (const room of socket.rooms) if (room.startsWith('match:')) leave(room.slice(6));
|
for (const room of socket.rooms) if (room.startsWith('match:')) leave(room.slice(6));
|
||||||
@@ -94,5 +111,14 @@ export function attachRealtime(httpServer, { service, config }) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Beim Herunterfahren: ausstehende Sammel-Updates verwerfen, damit nichts mehr auf die Datenbank zugreift
|
||||||
|
io.disposeRealtime = () => {
|
||||||
|
clearTimeout(overviewTimer);
|
||||||
|
clearTimeout(streamTimer);
|
||||||
|
for (const t of pending.values()) clearTimeout(t);
|
||||||
|
pending.clear();
|
||||||
|
service.removeAllListeners('changed');
|
||||||
|
};
|
||||||
|
|
||||||
return io;
|
return io;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
// Rundensicherung und Zurückspielen (Rollback).
|
||||||
|
//
|
||||||
|
// CS2 speichert nach jeder Runde eine Sicherungsdatei des Spielzustands auf dem Server (mp_backup_round_auto).
|
||||||
|
// Diese Datei lässt sich später wieder laden (mp_backup_restore_load_file). Dieses Modul kennt die Namen,
|
||||||
|
// wertet die Liste aus und kürzt den gespeicherten Live-Stand passend zur Rückkehr.
|
||||||
|
|
||||||
|
// Präfix und Muster wie bei TMT2: der Präfix enthält die Match-ID, das Muster Map, Runde und Stand.
|
||||||
|
export const backupPrefix = (matchId) => `round_backup_${matchId}`;
|
||||||
|
export const BACKUP_PATTERN = '%prefix%_%date%_%time%_%map%_round%round%_score_%score1%_%score2%.txt';
|
||||||
|
|
||||||
|
// Endung: _<map>_round<N>_score_<a>_<b>.txt – die Map beginnt mit zwei Kleinbuchstaben und Unterstrich (de_, cs_, ar_ …)
|
||||||
|
const ROUND_RE = /_([a-z]{2}_[a-z0-9_]+?)_round(\d+)_score_(\d+)_(\d+)\.txt$/;
|
||||||
|
|
||||||
|
/** Ist der Dateiname eine Sicherung genau dieses Matches? Schützt vor Pfaden wie "../" und fremden Dateien. */
|
||||||
|
export function isValidBackupFile(file, matchId) {
|
||||||
|
if (typeof file !== 'string' || file.length > 200) return false;
|
||||||
|
if (!file.startsWith(backupPrefix(matchId))) return false;
|
||||||
|
return /^[A-Za-z0-9_.-]+\.txt$/.test(file) && !file.includes('..') && ROUND_RE.test(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Findet alle Sicherungsdateien dieses Matches in einer RCON-Antwort (Format der Antwort ist nicht festgelegt). */
|
||||||
|
export function parseBackupFiles(text, matchId) {
|
||||||
|
const prefix = backupPrefix(matchId);
|
||||||
|
const found = new Set();
|
||||||
|
const re = new RegExp(`${prefix}_[A-Za-z0-9_.-]+\\.txt`, 'g');
|
||||||
|
for (const m of String(text ?? '').matchAll(re)) found.add(m[0]);
|
||||||
|
return [...found].filter((f) => isValidBackupFile(f, matchId)).map((file) => describeBackup(file, matchId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zerlegt einen Dateinamen in Map, Rundennummer und Stand. Mit matchId wird vorher der Präfix entfernt,
|
||||||
|
* damit Teile der Match-ID nicht mit einem Map-Namen verwechselt werden.
|
||||||
|
*/
|
||||||
|
export function describeBackup(file, matchId = null) {
|
||||||
|
const rest = matchId && file.startsWith(backupPrefix(matchId)) ? file.slice(backupPrefix(matchId).length) : file;
|
||||||
|
const m = ROUND_RE.exec(rest);
|
||||||
|
if (!m) return { file, map: null, round: null, score: null };
|
||||||
|
return { file, map: m[1], round: Number(m[2]), score: { s1: Number(m[3]), s2: Number(m[4]) } };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nur die Sicherungen der gerade gespielten Map, neueste Runde zuerst. */
|
||||||
|
export function pointsForMap(points, map) {
|
||||||
|
return points.filter((p) => p.map === map).sort((a, b) => b.round - a.round);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kürzt den Live-Stand auf die Runden bis einschließlich roundNumber und rechnet den Stand neu.
|
||||||
|
* Danach zählt die Anzeige weiter, sobald CS2 wieder Rundenmeldungen schickt.
|
||||||
|
*/
|
||||||
|
export function trimLiveState(state, roundNumber) {
|
||||||
|
const rounds = (state.rounds ?? []).filter((r) => r.number <= roundNumber);
|
||||||
|
const score = { A: 0, B: 0 };
|
||||||
|
for (const r of rounds) if (r.winner) score[r.winner] += 1;
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
status: 'live',
|
||||||
|
rounds,
|
||||||
|
score,
|
||||||
|
winner: null,
|
||||||
|
pendingRound: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
// Gameserver-Einstellungen, die der Admin zur Laufzeit ändern kann. Die Werte aus der .env sind die
|
||||||
|
// Vorgabe; Änderungen landen in der Tabelle settings und überschreiben die Vorgabe auch nach einem Neustart.
|
||||||
|
|
||||||
|
import { HttpError } from './matchService.js';
|
||||||
|
import { parseDockerHosts } from './config.js';
|
||||||
|
|
||||||
|
const SETTINGS_KEY = 'server_settings';
|
||||||
|
const HOSTS_KEY = 'docker_hosts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Änderbare Felder. `get`/`set` greifen auf das gemeinsame config-Objekt zu, das Match-Service,
|
||||||
|
* Server-Manager und Docker-Adapter lesen. `live` = wird an laufende Server geschickt.
|
||||||
|
*/
|
||||||
|
export const SERVER_SETTING_FIELDS = [
|
||||||
|
{ key: 'maxRoundsCompetitive', group: 'match', label: 'Runden Competitive (mp_maxrounds)', type: 'int', min: 2, max: 60, live: true,
|
||||||
|
get: (c) => c.match.maxRoundsCompetitive, set: (c, v) => { c.match.maxRoundsCompetitive = v; } },
|
||||||
|
{ key: 'maxRoundsWingman', group: 'match', label: 'Runden Wingman (mp_maxrounds)', type: 'int', min: 2, max: 60, live: true,
|
||||||
|
get: (c) => c.match.maxRoundsWingman, set: (c, v) => { c.match.maxRoundsWingman = v; } },
|
||||||
|
{ key: 'overtime', group: 'match', label: 'Verlängerung (mp_overtime_enable)', type: 'bool', live: true,
|
||||||
|
get: (c) => c.match.overtime, set: (c, v) => { c.match.overtime = v; } },
|
||||||
|
{ key: 'extraCommands', group: 'match', label: 'Zusätzliche Konsolenbefehle (durch ; getrennt)', type: 'commands', live: true,
|
||||||
|
get: (c) => c.match.extraCommands, set: (c, v) => { c.match.extraCommands = v; } },
|
||||||
|
{ key: 'tvEnabled', group: 'tv', label: 'CSTV aktiv (wirkt ab dem nächsten Mapwechsel)', type: 'bool', live: false,
|
||||||
|
get: (c) => c.match.tvEnabled, set: (c, v) => { c.match.tvEnabled = v; } },
|
||||||
|
{ key: 'tvDelaySeconds', group: 'tv', label: 'CSTV-Verzögerung in Sekunden (tv_delay)', type: 'int', min: 0, max: 300, live: true,
|
||||||
|
get: (c) => c.match.tvDelaySeconds, set: (c, v) => { c.match.tvDelaySeconds = v; } },
|
||||||
|
{ key: 'vetoStepSeconds', group: 'times', label: 'Zeit pro Veto-Schritt in Sekunden (0 = ohne Frist)', type: 'int', min: 0, max: 600, live: false,
|
||||||
|
get: (c) => c.vetoStepSeconds, set: (c, v) => { c.vetoStepSeconds = v; } },
|
||||||
|
{ key: 'idleTimeoutMinutes', group: 'times', label: 'Leerlauf bis zum Abschalten in Minuten', type: 'int', min: 1, max: 1440, live: false,
|
||||||
|
get: (c) => c.idleTimeoutMinutes, set: (c, v) => { c.idleTimeoutMinutes = v; } },
|
||||||
|
{ key: 'maxLifetimeHours', group: 'times', label: 'Maximale Laufzeit eines Servers in Stunden', type: 'int', min: 1, max: 72, live: false,
|
||||||
|
get: (c) => c.maxLifetimeHours, set: (c, v) => { c.maxLifetimeHours = v; } },
|
||||||
|
{ key: 'startTimeoutMinutes', group: 'times', label: 'Startzeit eines Servers in Minuten (inkl. Download)', type: 'int', min: 5, max: 240, live: false,
|
||||||
|
get: (c) => c.docker.startTimeoutMinutes, set: (c, v) => { c.docker.startTimeoutMinutes = v; } },
|
||||||
|
];
|
||||||
|
|
||||||
|
const fieldFor = (key) => SERVER_SETTING_FIELDS.find((f) => f.key === key);
|
||||||
|
|
||||||
|
/** Konsolenbefehle aus Text: getrennt durch ; oder Zeilenumbruch. Befehle, die Zugänge ändern oder den Server beenden, sind gesperrt. */
|
||||||
|
export function parseCommands(value) {
|
||||||
|
const list = (Array.isArray(value) ? value : String(value ?? '').split(/[;\r\n]+/))
|
||||||
|
.map((c) => String(c).replace(/[\r\n;]/g, ' ').trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
if (list.length > 30) throw new HttpError(400, 'Höchstens 30 zusätzliche Befehle', 'invalid');
|
||||||
|
for (const c of list) {
|
||||||
|
if (c.length > 120) throw new HttpError(400, `Befehl zu lang: ${c.slice(0, 30)} …`, 'invalid');
|
||||||
|
if (/^(rcon_password|sv_password|tv_password|rcon|quit|exit|_restart|logaddress_\w*)\b/i.test(c)) {
|
||||||
|
throw new HttpError(400, `Befehl nicht erlaubt: ${c.split(' ')[0]}`, 'invalid');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseValue(field, raw) {
|
||||||
|
if (field.type === 'bool') {
|
||||||
|
if (typeof raw !== 'boolean') throw new HttpError(400, `${field.label}: an oder aus erwartet`, 'invalid');
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
if (field.type === 'int') {
|
||||||
|
const value = Number(raw);
|
||||||
|
if (raw === '' || raw === null || !Number.isInteger(value) || value < field.min || value > field.max) {
|
||||||
|
throw new HttpError(400, `${field.label}: ganze Zahl von ${field.min} bis ${field.max} erwartet`, 'invalid');
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return parseCommands(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
||||||
|
|
||||||
|
export class ServerSettings {
|
||||||
|
constructor({ db, config }) {
|
||||||
|
this.db = db;
|
||||||
|
this.config = config;
|
||||||
|
// Vorgaben aus der .env merken, damit der Admin zurück auf die Vorgabe kann
|
||||||
|
this.defaults = Object.fromEntries(SERVER_SETTING_FIELDS.map((f) => [f.key, structuredClone(f.get(config))]));
|
||||||
|
this.defaultHosts = config.docker.hosts;
|
||||||
|
}
|
||||||
|
|
||||||
|
readJson(key) {
|
||||||
|
const row = this.db.prepare('SELECT value FROM settings WHERE key = ?').get(key);
|
||||||
|
if (!row) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(row.value);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJson(key, value) {
|
||||||
|
this.db.prepare('INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value')
|
||||||
|
.run(key, JSON.stringify(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Beim Start: gespeicherte Werte auf das config-Objekt legen (vor dem Anlegen des Docker-Adapters). */
|
||||||
|
load() {
|
||||||
|
const saved = this.readJson(SETTINGS_KEY) ?? {};
|
||||||
|
for (const field of SERVER_SETTING_FIELDS) {
|
||||||
|
if (!(field.key in saved)) continue;
|
||||||
|
try {
|
||||||
|
field.set(this.config, parseValue(field, saved[field.key]));
|
||||||
|
} catch {
|
||||||
|
// Ungültiger gespeicherter Wert: Vorgabe aus der .env behalten
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const hosts = this.readJson(HOSTS_KEY);
|
||||||
|
if (hosts) {
|
||||||
|
try {
|
||||||
|
this.config.docker.hosts = parseDockerHosts(JSON.stringify(hosts));
|
||||||
|
} catch {
|
||||||
|
// Kaputte Host-Liste ignorieren, die .env gilt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
view() {
|
||||||
|
const saved = this.readJson(SETTINGS_KEY) ?? {};
|
||||||
|
return SERVER_SETTING_FIELDS.map(({ key, group, label, type, min, max, live, get }) => ({
|
||||||
|
key, group, label, type, min, max, live,
|
||||||
|
value: get(this.config),
|
||||||
|
default: this.defaults[key],
|
||||||
|
overridden: key in saved,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Übernimmt geänderte Werte. Liefert die Schlüssel, die sich wirklich geändert haben. */
|
||||||
|
update(input = {}) {
|
||||||
|
const parsed = Object.entries(input).map(([key, raw]) => {
|
||||||
|
const field = fieldFor(key);
|
||||||
|
if (!field) throw new HttpError(400, `Unbekannte Einstellung: ${key}`, 'invalid');
|
||||||
|
return [field, parseValue(field, raw)];
|
||||||
|
});
|
||||||
|
const saved = this.readJson(SETTINGS_KEY) ?? {};
|
||||||
|
const changed = [];
|
||||||
|
for (const [field, value] of parsed) {
|
||||||
|
if (!same(field.get(this.config), value)) changed.push(field.key);
|
||||||
|
field.set(this.config, value);
|
||||||
|
if (same(value, this.defaults[field.key])) delete saved[field.key];
|
||||||
|
else saved[field.key] = value;
|
||||||
|
}
|
||||||
|
this.writeJson(SETTINGS_KEY, saved);
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Prüft und speichert eine neue Host-Liste (Format wie CS2_DOCKER_HOSTS). */
|
||||||
|
parseHosts(list) {
|
||||||
|
if (!Array.isArray(list)) throw new HttpError(400, 'Host-Liste erwartet', 'invalid');
|
||||||
|
let hosts;
|
||||||
|
try {
|
||||||
|
hosts = parseDockerHosts(JSON.stringify(list));
|
||||||
|
} catch (err) {
|
||||||
|
throw new HttpError(400, err.message, 'invalid');
|
||||||
|
}
|
||||||
|
for (const h of hosts) {
|
||||||
|
if (h.dockerHost && !/^(ssh:\/\/[\w.@-]+(:\d+)?|tcp:\/\/[\w.-]+:\d+)$/.test(h.dockerHost)) {
|
||||||
|
throw new HttpError(400, `Ungültiges Docker-Ziel für ${h.name}: ${h.dockerHost}`, 'invalid');
|
||||||
|
}
|
||||||
|
if (!/^[\w.-]+$/.test(h.name)) throw new HttpError(400, `Ungültiger Name: ${h.name}`, 'invalid');
|
||||||
|
if (!/^[\w.-]+$/.test(h.publicHost) || !/^[\w.-]+$/.test(h.rconHost)) {
|
||||||
|
throw new HttpError(400, `Ungültige Adresse für ${h.name}`, 'invalid');
|
||||||
|
}
|
||||||
|
if (h.volume && !/^[\w.-]+$/.test(h.volume)) throw new HttpError(400, `Ungültiger Volume-Name für ${h.name}`, 'invalid');
|
||||||
|
const 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
// Erzeugt die Konsolenbefehle, mit denen ein laufender CS2-Server für eine Map der Serie konfiguriert wird.
|
// Erzeugt die Konsolenbefehle, mit denen ein laufender CS2-Server für eine Map der Serie konfiguriert wird.
|
||||||
// Alle Werte werden so bereinigt, dass sie keine weiteren Befehle einschleusen können.
|
// Alle Werte werden so bereinigt, dass sie keine weiteren Befehle einschleusen können.
|
||||||
|
|
||||||
|
import { BACKUP_PATTERN } from '../rounds.js';
|
||||||
|
|
||||||
export function sanitizeConsoleText(value, maxLength = 32) {
|
export function sanitizeConsoleText(value, maxLength = 32) {
|
||||||
return String(value ?? '')
|
return String(value ?? '')
|
||||||
.replace(/["';\\\r\n\t]/g, '')
|
.replace(/["';\\\r\n\t]/g, '')
|
||||||
@@ -23,7 +25,7 @@ export function sanitizeConsoleText(value, maxLength = 32) {
|
|||||||
* @param {object} p.secrets { serverPassword, tvPassword }
|
* @param {object} p.secrets { serverPassword, tvPassword }
|
||||||
* @param {object} p.settings config.match
|
* @param {object} p.settings config.match
|
||||||
*/
|
*/
|
||||||
export function buildMapCommands({ match, pool, seriesMap, secrets, settings }) {
|
export function buildMapCommands({ match, pool, seriesMap, secrets, settings, logUrl, backupPrefix }) {
|
||||||
const isWingman = pool.mode === 'wingman';
|
const isWingman = pool.mode === 'wingman';
|
||||||
const ctTeam = seriesMap.ctTeam ?? 'A';
|
const ctTeam = seriesMap.ctTeam ?? 'A';
|
||||||
const tTeam = ctTeam === 'A' ? 'B' : 'A';
|
const tTeam = ctTeam === 'A' ? 'B' : 'A';
|
||||||
@@ -36,6 +38,17 @@ export function buildMapCommands({ match, pool, seriesMap, secrets, settings })
|
|||||||
`tv_enable ${settings.tvEnabled ? 1 : 0}`,
|
`tv_enable ${settings.tvEnabled ? 1 : 0}`,
|
||||||
`tv_delay ${Number(settings.tvDelaySeconds)}`,
|
`tv_delay ${Number(settings.tvDelaySeconds)}`,
|
||||||
`tv_password "${secrets.tvPassword}"`,
|
`tv_password "${secrets.tvPassword}"`,
|
||||||
|
// Live-Ergebnisse: Server schickt seine Logzeilen per HTTP an das Backend
|
||||||
|
...(logUrl && /^https?:\/\/[\w.:/-]+$/.test(logUrl)
|
||||||
|
? ['log on', 'logaddress_delall_http', `logaddress_add_http "${logUrl}"`]
|
||||||
|
: []),
|
||||||
|
// Rundensicherung: CS2 speichert nach jeder Runde den Spielzustand, damit sich Runden zurückspielen lassen
|
||||||
|
...(backupPrefix ? [
|
||||||
|
`mp_backup_round_file "${backupPrefix}"`,
|
||||||
|
`mp_backup_round_file_pattern "${BACKUP_PATTERN}"`,
|
||||||
|
'mp_backup_round_auto 1',
|
||||||
|
'mp_backup_restore_load_autopause 1',
|
||||||
|
] : []),
|
||||||
`changelevel ${seriesMap.map}`,
|
`changelevel ${seriesMap.map}`,
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -50,6 +63,13 @@ export function buildMapCommands({ match, pool, seriesMap, secrets, settings })
|
|||||||
'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,6 +2,8 @@
|
|||||||
// Er simuliert nur Startdauer und Konfiguration, damit der Ablauf ohne Hosting getestet werden kann.
|
// Er simuliert nur Startdauer und Konfiguration, damit der Ablauf ohne Hosting getestet werden kann.
|
||||||
// Das Flag simulated=true sorgt dafür, dass das Frontend den Server ausdrücklich als Simulation kennzeichnet.
|
// Das Flag simulated=true sorgt dafür, dass das Frontend den Server ausdrücklich als Simulation kennzeichnet.
|
||||||
|
|
||||||
|
import { backupPrefix, parseBackupFiles } from '../rounds.js';
|
||||||
|
|
||||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
export class DemoServerAdapter {
|
export class DemoServerAdapter {
|
||||||
@@ -41,6 +43,14 @@ export class DemoServerAdapter {
|
|||||||
handle.map = plan.map;
|
handle.map = plan.map;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Simulierte Sicherungen: eine Datei je Runde der aktuellen Map, damit die Oberfläche testbar ist
|
||||||
|
async listRoundBackups(handle, matchId, map = 'de_demo') {
|
||||||
|
const files = [1, 2, 3].map((r) => `${backupPrefix(matchId)}_01_01_${map}_round${r}_score_${r}_0.txt`);
|
||||||
|
return parseBackupFiles(files.join('\n'), matchId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadRoundBackup() {}
|
||||||
|
|
||||||
async sendCommands(handle, commands) {
|
async sendCommands(handle, commands) {
|
||||||
return commands.map(() => '(Demo – nicht ausgeführt)');
|
return commands.map(() => '(Demo – nicht ausgeführt)');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
// DEMO: erzeugt einen zufälligen Matchverlauf als CS2-Logzeilen.
|
||||||
|
// Die Zeilen laufen durch denselben Parser wie echte Serverlogs, so lässt sich die
|
||||||
|
// Live-Anzeige ohne CS2-Server testen. Ergebnisse sind frei erfunden.
|
||||||
|
|
||||||
|
const REASONS = {
|
||||||
|
CT: ['CTs_Win', 'Bomb_Defused', 'Target_Saved'],
|
||||||
|
T: ['Terrorists_Win', 'Target_Bombed'],
|
||||||
|
};
|
||||||
|
|
||||||
|
const stamp = () => {
|
||||||
|
const d = new Date();
|
||||||
|
const p = (n, l = 2) => String(n).padStart(l, '0');
|
||||||
|
return `${p(d.getMonth() + 1)}/${p(d.getDate())}/${d.getFullYear()} - ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}.${p(d.getMilliseconds(), 3)} - `;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class DemoMatchSimulator {
|
||||||
|
constructor({ random = Math.random } = {}) {
|
||||||
|
this.runs = new Map();
|
||||||
|
this.random = random;
|
||||||
|
}
|
||||||
|
|
||||||
|
start(matchId, { map, teamNames, ctTeam, maxRounds, roundMs, onLines }) {
|
||||||
|
this.stop(matchId);
|
||||||
|
const other = (t) => (t === 'A' ? 'B' : 'A');
|
||||||
|
const run = { sides: { CT: ctTeam, T: other(ctTeam) }, score: { A: 0, B: 0 }, played: 0 };
|
||||||
|
const toWin = maxRounds / 2 + 1;
|
||||||
|
// Leichte Stärke-Unterschiede, damit nicht jedes Demo-Match gleich aussieht
|
||||||
|
const strengthA = 0.4 + this.random() * 0.2;
|
||||||
|
|
||||||
|
const teamLines = () => [
|
||||||
|
`${stamp()}MatchStatus: Team playing "CT": ${teamNames[run.sides.CT]}`,
|
||||||
|
`${stamp()}MatchStatus: Team playing "TERRORIST": ${teamNames[run.sides.T]}`,
|
||||||
|
];
|
||||||
|
onLines([...teamLines(), `${stamp()}World triggered "Match_Start" on "${map}"`]);
|
||||||
|
|
||||||
|
run.timer = setInterval(() => {
|
||||||
|
const winner = this.random() < strengthA ? 'A' : 'B';
|
||||||
|
const side = run.sides.CT === winner ? 'CT' : 'T';
|
||||||
|
run.score[winner] += 1;
|
||||||
|
run.played += 1;
|
||||||
|
const notice = REASONS[side][Math.floor(this.random() * REASONS[side].length)];
|
||||||
|
// Reihenfolge wie bei einem echten CS2-Server: Rundenende, Seitenzuordnung der gespielten Runde, Round_End
|
||||||
|
const lines = [
|
||||||
|
`${stamp()}Team "${side === 'CT' ? 'CT' : 'TERRORIST'}" triggered "SFUI_Notice_${notice}" (CT "${run.score[run.sides.CT]}") (T "${run.score[run.sides.T]}")`,
|
||||||
|
...teamLines(),
|
||||||
|
`${stamp()}World triggered "Round_End"`,
|
||||||
|
];
|
||||||
|
if (run.score[winner] >= toWin) {
|
||||||
|
lines.push(`${stamp()}Game Over: competitive mg_active ${map} score ${run.score[run.sides.CT]}:${run.score[run.sides.T]} after 1 min`);
|
||||||
|
onLines(lines);
|
||||||
|
this.stop(matchId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Halbzeit: Seiten wechseln, die nächsten Zeilen nennen die neue Zuordnung
|
||||||
|
if (run.played === maxRounds / 2) run.sides = { CT: run.sides.T, T: run.sides.CT };
|
||||||
|
onLines(lines);
|
||||||
|
}, roundMs);
|
||||||
|
this.runs.set(matchId, run);
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(matchId) {
|
||||||
|
const run = this.runs.get(matchId);
|
||||||
|
if (run) clearInterval(run.timer);
|
||||||
|
this.runs.delete(matchId);
|
||||||
|
}
|
||||||
|
|
||||||
|
stopAll() {
|
||||||
|
for (const id of [...this.runs.keys()]) this.stop(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,55 +1,189 @@
|
|||||||
import { execFile } from 'node:child_process';
|
import { execFile } from 'node:child_process';
|
||||||
import { promisify } from 'node:util';
|
import { promisify } from 'node:util';
|
||||||
import { configureViaRcon, rconStatus } from './rconConfigure.js';
|
import { configureViaRcon, listRoundBackups, loadRoundBackup, rconStatus } from './rconConfigure.js';
|
||||||
import { runRcon } from './rcon.js';
|
import { 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));
|
||||||
|
|
||||||
|
// Diese Zeile schreibt das Image, sobald SteamCMD fertig ist und der Spielserver startet.
|
||||||
|
const UPDATE_DONE_MARKER = 'Starting CS2 Dedicated Server';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Startet pro Match einen eigenen CS2-Container (Standard-Image: joedwards32/cs2, Nachfolger von cm2network/cs2).
|
* Startet pro Match einen eigenen CS2-Container (Standard-Image: joedwards32/cs2, Nachfolger von cm2network/cs2).
|
||||||
* Benötigt Docker auf dem Backend-Host, ein Game Server Login Token (CS2_GSLT)
|
* Benötigt Docker (lokal oder per DOCKER_HOST, z. B. ssh://…) und die Adresse, unter der Spieler
|
||||||
* und eine öffentlich erreichbare Adresse (CS2_PUBLIC_HOST).
|
* den Docker-Host erreichen (CS2_PUBLIC_HOST).
|
||||||
|
*
|
||||||
|
* Ohne CS2_GSLT laufen die Server im LAN-Modus (sv_lan 1): nur aus dem lokalen Netz erreichbar.
|
||||||
*
|
*
|
||||||
* Pro Match werden zwei aufeinanderfolgende Ports belegt: Spielport (TCP+UDP, auch RCON)
|
* Pro Match werden zwei aufeinanderfolgende Ports belegt: Spielport (TCP+UDP, auch RCON)
|
||||||
* und CSTV-Port (UDP).
|
* und CSTV-Port (UDP).
|
||||||
|
*
|
||||||
|
* Alle Container teilen sich das Volume mit den Spieldateien. Jeder prüft beim Start per SteamCMD
|
||||||
|
* auf Updates; damit nie zwei gleichzeitig schreiben, starten Container nacheinander: der nächste
|
||||||
|
* erst, wenn der vorige seinen Update-Check abgeschlossen hat.
|
||||||
*/
|
*/
|
||||||
export class DockerServerAdapter {
|
export class DockerServerAdapter {
|
||||||
constructor(options, { getUsedPorts }) {
|
constructor(options, { getUsedPorts, runDocker } = {}) {
|
||||||
this.name = 'docker';
|
this.name = 'docker';
|
||||||
this.simulated = false;
|
this.simulated = false;
|
||||||
this.options = options;
|
this.options = options;
|
||||||
this.getUsedPorts = getUsedPorts;
|
this.getUsedPorts = getUsedPorts;
|
||||||
|
this.runDocker = runDocker; // nur für Tests
|
||||||
|
this.lanMode = !options.gslt;
|
||||||
|
this.startQueue = Promise.resolve();
|
||||||
if (!options.host) throw new Error('SERVER_ADAPTER=docker benötigt CS2_PUBLIC_HOST');
|
if (!options.host) throw new Error('SERVER_ADAPTER=docker benötigt CS2_PUBLIC_HOST');
|
||||||
if (!options.gslt) throw new Error('SERVER_ADAPTER=docker benötigt CS2_GSLT (Game Server Login Token)');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async docker(args, timeout = 60_000) {
|
async docker(args, timeout = 60_000) {
|
||||||
const { stdout } = await exec(this.options.bin, args, { timeout, maxBuffer: 4 * 1024 * 1024 });
|
if (this.runDocker) return this.runDocker(args, this.options.name);
|
||||||
|
const env = this.options.dockerHost ? { ...process.env, DOCKER_HOST: this.options.dockerHost } : process.env;
|
||||||
|
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');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async start({ matchId, mode, secrets }) {
|
/** Servername im Steam-Serverbrowser, z. B. "MaxLAN | Team A vs. Team B". */
|
||||||
const port = this.allocatePort();
|
serverName(matchId, teamNames) {
|
||||||
const tvPort = port + 1;
|
const prefix = this.options.serverNamePrefix ? sanitizeConsoleText(this.options.serverNamePrefix, 24) : '';
|
||||||
|
if (!teamNames) return `${prefix} ${matchId}`.trim();
|
||||||
|
const title = `${sanitizeConsoleText(teamNames.A)} vs. ${sanitizeConsoleText(teamNames.B)}`;
|
||||||
|
return (prefix ? `${prefix} | ${title}` : title).slice(0, 63);
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(args) {
|
||||||
|
// In die Warteschlange: erst starten, wenn der vorige Container seinen Update-Check hinter sich hat.
|
||||||
|
const previous = this.startQueue;
|
||||||
|
let release;
|
||||||
|
this.startQueue = new Promise((resolve) => { release = resolve; });
|
||||||
|
await previous;
|
||||||
|
let result;
|
||||||
|
try {
|
||||||
|
result = await this.launch(args);
|
||||||
|
} catch (err) {
|
||||||
|
release();
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
this.waitForUpdateCheck(result.handle).finally(release);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wartet, bis der Container SteamCMD abgeschlossen hat, beendet ist oder die Startzeit abläuft.
|
||||||
|
* Fertig ist er, sobald der Spielserver per RCON antwortet (RCON gibt es erst nach SteamCMD) oder
|
||||||
|
* die Startzeile im Log steht. Nur auf das Log zu schauen reicht nicht: Der Server schreibt danach
|
||||||
|
* so viel, dass die Zeile schnell aus den letzten Zeilen herausfällt.
|
||||||
|
*/
|
||||||
|
async waitForUpdateCheck(handle, { pollMs = 3000 } = {}) {
|
||||||
|
const deadline = Date.now() + this.options.startTimeoutMinutes * 60_000;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
try {
|
||||||
|
const state = await this.docker(['inspect', '-f', '{{.State.Status}}', handle.container], 15_000);
|
||||||
|
if (state !== 'running' && state !== 'created') return;
|
||||||
|
if (await this.isReachable(handle)) return;
|
||||||
|
const logs = await this.docker(['logs', '--tail', '2000', handle.container], 15_000);
|
||||||
|
if (logs.includes(UPDATE_DONE_MARKER)) return;
|
||||||
|
} catch {
|
||||||
|
return; // Container weg – nicht ewig blockieren
|
||||||
|
}
|
||||||
|
await sleep(pollMs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async isReachable(handle) {
|
||||||
|
try {
|
||||||
|
await rconStatus(this.target(handle));
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Im Host-Netz meldet Docker keinen Portkonflikt, CS2 startet dann einfach nicht. Deshalb vorher per SSH
|
||||||
|
* nachsehen, ob ein fremder Dienst den Port belegt. Ohne SSH-Ziel (oder in Tests) gilt der Port als frei.
|
||||||
|
*/
|
||||||
|
async hostPortsFree(ports) {
|
||||||
|
if (this.runDocker || !this.options.dockerHost?.startsWith('ssh://')) return true;
|
||||||
|
try {
|
||||||
|
const filter = ports.map((p) => `sport = :${Number(p)}`).join(' or ');
|
||||||
|
const { stdout } = await exec('ssh', [
|
||||||
|
'-o', 'BatchMode=yes', '-o', 'ConnectTimeout=5', this.options.dockerHost.slice('ssh://'.length),
|
||||||
|
`ss -Hlnut '( ${filter} )'`,
|
||||||
|
], { timeout: 10_000 });
|
||||||
|
return stdout.trim() === '';
|
||||||
|
} catch {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async launch({ matchId, mode, secrets, teamNames }, 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 }, 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 = {
|
||||||
SRCDS_TOKEN: this.options.gslt,
|
...(this.lanMode ? { CS2_LAN: '1' } : { SRCDS_TOKEN: this.options.gslt, CS2_LAN: '0' }),
|
||||||
CS2_SERVERNAME: `CS2 Mapveto ${matchId}`,
|
CS2_SERVERNAME: 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,
|
||||||
CS2_MAXPLAYERS: mode === 'wingman' ? '4' : '10',
|
// Spielerplätze + 1 für CSTV (belegt selbst einen Platz) + 1 Reserve (Admin, Ersatz)
|
||||||
|
CS2_MAXPLAYERS: mode === 'wingman' ? '6' : '12',
|
||||||
CS2_GAMETYPE: '0',
|
CS2_GAMETYPE: '0',
|
||||||
CS2_GAMEMODE: mode === 'wingman' ? '2' : '1',
|
CS2_GAMEMODE: mode === 'wingman' ? '2' : '1',
|
||||||
CS2_SERVER_HIBERNATE: '0',
|
CS2_SERVER_HIBERNATE: '0',
|
||||||
|
// Ohne diesen Startparameter legt CS2 keine Rundensicherungen an (Rollback)
|
||||||
|
CS2_ADDITIONAL_ARGS: '-allow_round_backup_files',
|
||||||
TV_ENABLE: '1',
|
TV_ENABLE: '1',
|
||||||
TV_PORT: String(tvPort),
|
TV_PORT: String(tvPort),
|
||||||
TV_PW: secrets.tvPassword,
|
TV_PW: secrets.tvPassword,
|
||||||
@@ -58,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}`);
|
||||||
args.push(
|
if (lan) {
|
||||||
'-p', `${port}:${port}/tcp`,
|
// Der LAN-Reiter von Steam sucht per Broadcast. Die Port-Weiterleitung von Docker reicht Broadcasts
|
||||||
'-p', `${port}:${port}/udp`,
|
// nicht an den Container weiter, deshalb laufen Server auf LAN-Ports direkt im Netz des Hosts.
|
||||||
'-p', `${tvPort}:${tvPort}/udp`,
|
args.push('--network', 'host');
|
||||||
'-v', `${this.options.volume}:/home/steam/cs2-dedicated`,
|
} else {
|
||||||
);
|
args.push(
|
||||||
|
'-p', `${port}:${port}/tcp`,
|
||||||
|
'-p', `${port}:${port}/udp`,
|
||||||
|
'-p', `${tvPort}:${tvPort}/udp`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
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.
|
||||||
await this.docker(args, 15 * 60_000);
|
try {
|
||||||
|
await this.docker(args, 15 * 60_000);
|
||||||
|
} catch (err) {
|
||||||
|
// Port von einem fremden Dienst belegt (die App kennt nur ihre eigenen Server): mit anderen Ports erneut versuchen
|
||||||
|
if (!PORT_BUSY.test(`${err.message} ${err.stderr ?? ''}`) || blocked.size >= 10) throw err;
|
||||||
|
await this.docker(['rm', '-f', container]).catch(() => {});
|
||||||
|
return this.launch({ matchId, mode, secrets, teamNames }, 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,
|
||||||
@@ -104,7 +258,7 @@ export class DockerServerAdapter {
|
|||||||
if (minutes >= this.options.startTimeoutMinutes) {
|
if (minutes >= this.options.startTimeoutMinutes) {
|
||||||
return { state: 'failed', message: `Server nach ${minutes} Minuten nicht per RCON erreichbar` };
|
return { state: 'failed', message: `Server nach ${minutes} Minuten nicht per RCON erreichbar` };
|
||||||
}
|
}
|
||||||
return { state: 'starting', message: 'Container läuft, Spielserver lädt (Download/Update möglich)' };
|
return { state: 'starting', message: 'Container läuft, Spielserver lädt (Update-Prüfung oder Start)' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,6 +266,14 @@ export class DockerServerAdapter {
|
|||||||
await configureViaRcon(this.target(handle), plan);
|
await configureViaRcon(this.target(handle), plan);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async listRoundBackups(handle, matchId) {
|
||||||
|
return listRoundBackups(this.target(handle), matchId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadRoundBackup(handle, file) {
|
||||||
|
await loadRoundBackup(this.target(handle), file);
|
||||||
|
}
|
||||||
|
|
||||||
async sendCommands(handle, commands) {
|
async sendCommands(handle, commands) {
|
||||||
return runRcon(this.target(handle), commands);
|
return runRcon(this.target(handle), commands);
|
||||||
}
|
}
|
||||||
@@ -120,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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { parseBackupFiles } from '../rounds.js';
|
||||||
import { parseStatus, runRcon } from './rcon.js';
|
import { parseStatus, runRcon } from './rcon.js';
|
||||||
|
|
||||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
@@ -42,3 +43,19 @@ export async function rconStatus(target) {
|
|||||||
const [output] = await runRcon(target, ['status'], { timeoutMs: 4000 });
|
const [output] = await runRcon(target, ['status'], { timeoutMs: 4000 });
|
||||||
return parseStatus(output);
|
return parseStatus(output);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Sicherungsdateien dieses Matches vom Server holen. */
|
||||||
|
export async function listRoundBackups(target, matchId) {
|
||||||
|
const [out] = await runRcon(target, ['mp_backup_restore_list_files 60'], { timeoutMs: 8000, quietMs: 500 });
|
||||||
|
return parseBackupFiles(out, matchId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sicherung laden. Der Dateiname muss vorher mit isValidBackupFile geprüft sein. */
|
||||||
|
export async function loadRoundBackup(target, file) {
|
||||||
|
// mp_backup_restore_load_autopause wirkt in CS2 nicht zuverlässig, deshalb pausieren wir ausdrücklich
|
||||||
|
await runRcon(target, [
|
||||||
|
'mp_backup_restore_load_autopause 1',
|
||||||
|
`mp_backup_restore_load_file "${file}"`,
|
||||||
|
'mp_pause_match',
|
||||||
|
], { timeoutMs: 15000, quietMs: 800 });
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
import { buildMapCommands } from './cs2Config.js';
|
import { buildMapCommands, sanitizeConsoleText } from './cs2Config.js';
|
||||||
|
import { DemoMatchSimulator } from './demoMatchSimulator.js';
|
||||||
|
import { backupPrefix, describeBackup, isValidBackupFile, pointsForMap } from '../rounds.js';
|
||||||
import { HttpError, CLOSED_STATUSES } from '../matchService.js';
|
import { HttpError, CLOSED_STATUSES } from '../matchService.js';
|
||||||
import { randomPassword } from '../security.js';
|
import { randomPassword } from '../security.js';
|
||||||
|
|
||||||
@@ -24,6 +26,8 @@ export class ServerManager {
|
|||||||
constructor({ db, service, adapter, config, logger = console }) {
|
constructor({ db, service, adapter, config, logger = console }) {
|
||||||
Object.assign(this, { db, service, adapter, config, logger });
|
Object.assign(this, { db, service, adapter, config, logger });
|
||||||
this.busy = new Set();
|
this.busy = new Set();
|
||||||
|
this.simulator = new DemoMatchSimulator();
|
||||||
|
this.autoAdvance = new Map();
|
||||||
this.lastPoll = new Map();
|
this.lastPoll = new Map();
|
||||||
this.timers = [];
|
this.timers = [];
|
||||||
}
|
}
|
||||||
@@ -32,6 +36,37 @@ export class ServerManager {
|
|||||||
this.service.on('created', (id) => this.provision(id).catch((err) => this.logger.error(err)));
|
this.service.on('created', (id) => this.provision(id).catch((err) => this.logger.error(err)));
|
||||||
this.service.on('vetoFinished', (id) => this.maybeConfigure(id));
|
this.service.on('vetoFinished', (id) => this.maybeConfigure(id));
|
||||||
this.service.on('closed', (id) => this.stop(id, 'Match beendet').catch((err) => this.logger.error(err)));
|
this.service.on('closed', (id) => this.stop(id, 'Match beendet').catch((err) => this.logger.error(err)));
|
||||||
|
this.service.on('mapEnded', (e) => this.onMapEnded(e));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nach Map-Ende: ist die Serie entschieden, wird das Match abgeschlossen, sonst die nächste Map geladen.
|
||||||
|
* Die Wartezeit lässt Spielern und Stream Zeit für den Endstand. Lädt der Organisator vorher selbst, entfällt es.
|
||||||
|
*/
|
||||||
|
onMapEnded({ matchId, number, decided, hasNext }) {
|
||||||
|
const seconds = this.config.autoAdvanceSeconds;
|
||||||
|
if (seconds < 0) return;
|
||||||
|
clearTimeout(this.autoAdvance.get(matchId));
|
||||||
|
const finish = Boolean(decided) || !hasNext;
|
||||||
|
this.service.log(matchId, 'series', finish
|
||||||
|
? `Serie entschieden – das Match wird in ${seconds} s automatisch abgeschlossen`
|
||||||
|
: `Map ${number + 1} wird in ${seconds} s automatisch geladen`);
|
||||||
|
this.service.changed(matchId);
|
||||||
|
this.autoAdvance.set(matchId, setTimeout(() => {
|
||||||
|
this.autoAdvance.delete(matchId);
|
||||||
|
const match = this.service.getMatch(matchId);
|
||||||
|
if (CLOSED_STATUSES.includes(match.status) || match.current_map_number !== number) return;
|
||||||
|
try {
|
||||||
|
if (finish) {
|
||||||
|
this.service.closeMatch(matchId, 'finished', 'Match automatisch abgeschlossen – Serie entschieden');
|
||||||
|
} else if (this.row(matchId)?.status === 'configured') {
|
||||||
|
this.service.advanceSeriesFor(matchId);
|
||||||
|
this.configure(matchId).catch((err) => this.logger.error(err));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`[serie] Automatischer Wechsel für ${matchId} fehlgeschlagen: ${err.message}`);
|
||||||
|
}
|
||||||
|
}, seconds * 1000));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Nach einem Neustart des Backends: unterbrochene Vorgänge aufräumen und fortsetzen. */
|
/** Nach einem Neustart des Backends: unterbrochene Vorgänge aufräumen und fortsetzen. */
|
||||||
@@ -52,7 +87,64 @@ export class ServerManager {
|
|||||||
this.timers.push(setInterval(() => this.reap().catch((e) => this.logger.error(e)), this.config.reaperIntervalSeconds * 1000));
|
this.timers.push(setInterval(() => this.reap().catch((e) => this.logger.error(e)), this.config.reaperIntervalSeconds * 1000));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Admin: Rücksprungpunkte der aktuellen Map, neueste Runde zuerst. */
|
||||||
|
async rollbackPoints(viewer) {
|
||||||
|
this.service.require(viewer, 'organizer');
|
||||||
|
const { match, seriesMap, handle } = this.rollbackContext(viewer.matchId);
|
||||||
|
const all = await this.adapter.listRoundBackups(handle, match.id, seriesMap.map);
|
||||||
|
return pointsForMap(all, seriesMap.map);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Admin: Match auf den Stand nach einer Runde zurückspielen. */
|
||||||
|
async rollback(viewer, file) {
|
||||||
|
this.service.require(viewer, 'organizer');
|
||||||
|
const { match, handle, seriesMap } = this.rollbackContext(viewer.matchId);
|
||||||
|
if (!isValidBackupFile(file, match.id)) throw new HttpError(400, 'Ungültige Sicherungsdatei', 'invalid_input');
|
||||||
|
const point = describeBackup(file, match.id);
|
||||||
|
if (point.map !== seriesMap.map) throw new HttpError(409, 'Die Sicherung gehört zu einer anderen Map', 'wrong_map');
|
||||||
|
if (this.busy.has(match.id)) throw new HttpError(409, 'Für diesen Server läuft bereits eine Aktion', 'busy');
|
||||||
|
this.busy.add(match.id);
|
||||||
|
try {
|
||||||
|
await this.adapter.loadRoundBackup(handle, file);
|
||||||
|
} catch (err) {
|
||||||
|
throw new HttpError(502, `Zurückspielen fehlgeschlagen: ${err.message}`, 'server_error');
|
||||||
|
} finally {
|
||||||
|
this.busy.delete(match.id);
|
||||||
|
}
|
||||||
|
this.service.rewindLiveScore(match.id, point.round, file);
|
||||||
|
}
|
||||||
|
|
||||||
|
rollbackContext(matchId) {
|
||||||
|
const match = this.service.getMatch(matchId);
|
||||||
|
const row = this.row(matchId);
|
||||||
|
if (!row?.handle || row.status !== 'configured' || match.status !== 'live') {
|
||||||
|
throw new HttpError(409, 'Zurückspielen ist nur bei einem laufenden Match mit bereitem Server möglich', 'wrong_state');
|
||||||
|
}
|
||||||
|
const seriesMap = this.service.seriesMaps(matchId).find((m) => m.number === match.current_map_number);
|
||||||
|
return { match, row, seriesMap, handle: JSON.parse(row.handle) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Löscht ein Match samt Server-Datensatz. Ein laufender Server wird vorher freigegeben. */
|
||||||
|
async deleteMatch(matchId) {
|
||||||
|
this.service.getMatch(matchId);
|
||||||
|
clearTimeout(this.autoAdvance.get(matchId));
|
||||||
|
this.autoAdvance.delete(matchId);
|
||||||
|
this.simulator.stop(matchId);
|
||||||
|
await this.stop(matchId, 'Match vom Admin gelöscht');
|
||||||
|
this.db.prepare('DELETE FROM matches WHERE id = ?').run(matchId);
|
||||||
|
this.service.emit('changed', matchId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wartet, bis keine Server-Aktion mehr läuft (höchstens timeoutMs). */
|
||||||
|
async idle(timeoutMs = 5000) {
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
while (this.busy.size > 0 && Date.now() < deadline) await new Promise((r) => setTimeout(r, 50));
|
||||||
|
}
|
||||||
|
|
||||||
stopLoops() {
|
stopLoops() {
|
||||||
|
this.simulator.stopAll();
|
||||||
|
for (const t of this.autoAdvance.values()) clearTimeout(t);
|
||||||
|
this.autoAdvance.clear();
|
||||||
this.timers.forEach(clearInterval);
|
this.timers.forEach(clearInterval);
|
||||||
this.timers = [];
|
this.timers = [];
|
||||||
}
|
}
|
||||||
@@ -68,6 +160,107 @@ export class ServerManager {
|
|||||||
this.service.changed(matchId);
|
this.service.changed(matchId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Auslastung der Docker-Hosts mit den Matches, die dort laufen. Leer, wenn der Adapter keine Hosts kennt. */
|
||||||
|
async hostUsage() {
|
||||||
|
if (!this.adapter.hostStats) return [];
|
||||||
|
const rows = this.db.prepare(`
|
||||||
|
SELECT s.host, s.status, m.id AS match_id, m.team_a_name, m.team_b_name
|
||||||
|
FROM servers s JOIN matches m ON m.id = s.match_id
|
||||||
|
WHERE s.status != 'stopped' AND s.host IS NOT NULL
|
||||||
|
`).all();
|
||||||
|
const stats = this.adapter.hostStats();
|
||||||
|
const disks = await Promise.all(stats.map((h) => this.adapter.diskUsage?.(h.name) ?? null));
|
||||||
|
return stats.map((h, i) => ({
|
||||||
|
...h,
|
||||||
|
disk: disks[i],
|
||||||
|
matches: rows
|
||||||
|
.filter((r) => r.host === h.publicHost)
|
||||||
|
.map((r) => ({ id: r.match_id, title: `${r.team_a_name} vs. ${r.team_b_name}`, status: r.status })),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Server, die gerade laufen oder starten, mit dem Docker-Host aus ihrem Handle. */
|
||||||
|
activeServers() {
|
||||||
|
return this.db.prepare(`
|
||||||
|
SELECT s.match_id, s.status, s.host, s.handle, m.mode
|
||||||
|
FROM servers s JOIN matches m ON m.id = s.match_id
|
||||||
|
WHERE s.status != 'stopped' AND s.handle IS NOT NULL
|
||||||
|
`).all().map((r) => {
|
||||||
|
let handle = null;
|
||||||
|
try {
|
||||||
|
handle = JSON.parse(r.handle);
|
||||||
|
} catch {
|
||||||
|
// kaputter Handle: Server zählt trotzdem als aktiv
|
||||||
|
}
|
||||||
|
return { ...r, handle, hostName: handle?.hostName ?? null };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Übernimmt eine neue Liste von Docker-Hosts. Hosts mit laufenden Servern dürfen nicht wegfallen
|
||||||
|
* und ihre Adresse nicht ändern, sonst verliert die App den Kontakt zu diesen Servern.
|
||||||
|
*/
|
||||||
|
setDockerHosts(hosts) {
|
||||||
|
if (!this.adapter.setHosts) throw new HttpError(409, 'Nur mit dem Docker-Adapter möglich', 'unsupported');
|
||||||
|
const before = new Map(this.adapter.hosts.map((h) => [h.name, h]));
|
||||||
|
// Ohne Liste gilt der eine Host aus DOCKER_HOST/CS2_PUBLIC_HOST
|
||||||
|
const nextList = hosts ?? [{ name: 'default', publicHost: this.config.docker.host, dockerHost: process.env.DOCKER_HOST || null }];
|
||||||
|
for (const server of this.activeServers()) {
|
||||||
|
const name = server.hostName ?? this.adapter.hosts[0]?.name;
|
||||||
|
const old = before.get(name);
|
||||||
|
const next = nextList.find((h) => h.name === name);
|
||||||
|
if (!next) {
|
||||||
|
throw new HttpError(409, `Auf ${name} läuft noch ein Server. Erst stoppen, dann den Host entfernen.`, 'host_busy');
|
||||||
|
}
|
||||||
|
if (old && (next.publicHost !== old.publicHost || (next.dockerHost ?? null) !== (old.dockerHost ?? null))) {
|
||||||
|
throw new HttpError(409, `Auf ${name} läuft noch ein Server. Adresse und Docker-Ziel erst danach ändern.`, 'host_busy');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.adapter.setHosts(hosts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schickt geänderte Match-Einstellungen an alle bereiten Server. Liefert, wie viele Server
|
||||||
|
* aktualisiert wurden und welche nicht erreichbar waren.
|
||||||
|
*/
|
||||||
|
async applyLiveSettings(changed) {
|
||||||
|
const keys = new Set(changed);
|
||||||
|
const rows = this.activeServers().filter((r) => r.status === 'configured');
|
||||||
|
if (!this.adapter.sendCommands || rows.length === 0) return { updated: 0, failed: [] };
|
||||||
|
const m = this.config.match;
|
||||||
|
const result = { updated: 0, failed: [] };
|
||||||
|
for (const row of rows) {
|
||||||
|
const wingman = row.mode === 'wingman';
|
||||||
|
const commands = [];
|
||||||
|
if (keys.has(wingman ? 'maxRoundsWingman' : 'maxRoundsCompetitive')) {
|
||||||
|
commands.push(`mp_maxrounds ${wingman ? m.maxRoundsWingman : m.maxRoundsCompetitive}`);
|
||||||
|
}
|
||||||
|
if (keys.has('overtime')) commands.push(`mp_overtime_enable ${m.overtime ? 1 : 0}`);
|
||||||
|
if (keys.has('tvDelaySeconds')) commands.push(`tv_delay ${m.tvDelaySeconds}`);
|
||||||
|
if (keys.has('extraCommands')) commands.push(...m.extraCommands);
|
||||||
|
if (commands.length === 0) continue;
|
||||||
|
try {
|
||||||
|
await this.adapter.sendCommands(row.handle, commands);
|
||||||
|
result.updated += 1;
|
||||||
|
this.service.log(row.match_id, 'server', `Einstellungen übernommen (Admin): ${commands.join('; ')}`);
|
||||||
|
} catch (err) {
|
||||||
|
result.failed.push(row.match_id);
|
||||||
|
this.logger.warn?.(`Einstellungen für ${row.match_id} nicht übernommen: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ports, die auf einem bestimmten Docker-Host belegt sind. */
|
||||||
|
usedPortsOn(host) {
|
||||||
|
const used = new Set();
|
||||||
|
for (const r of this.db.prepare(`SELECT port, tv_port FROM servers WHERE host = ? AND status != 'stopped' AND port IS NOT NULL`).all(host)) {
|
||||||
|
used.add(r.port);
|
||||||
|
used.add(r.tv_port);
|
||||||
|
}
|
||||||
|
return used;
|
||||||
|
}
|
||||||
|
|
||||||
usedPorts() {
|
usedPorts() {
|
||||||
const used = new Set();
|
const used = new Set();
|
||||||
for (const r of this.db.prepare(`SELECT host, port, tv_port FROM servers WHERE status != 'stopped' AND port IS NOT NULL`).all()) {
|
for (const r of this.db.prepare(`SELECT host, port, tv_port FROM servers WHERE status != 'stopped' AND port IS NOT NULL`).all()) {
|
||||||
@@ -121,7 +314,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,
|
||||||
@@ -173,6 +371,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');
|
||||||
@@ -189,7 +392,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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -211,7 +415,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({
|
||||||
@@ -220,7 +425,11 @@ export class ServerManager {
|
|||||||
seriesMap: { map: seriesMap.map, ctTeam: seriesMap.ct_team },
|
seriesMap: { map: seriesMap.map, ctTeam: seriesMap.ct_team },
|
||||||
secrets: { serverPassword: row.server_password, tvPassword: row.tv_password },
|
secrets: { serverPassword: row.server_password, tvPassword: row.tv_password },
|
||||||
settings: this.config.match,
|
settings: this.config.match,
|
||||||
|
logUrl: `${this.config.logReceiverUrl}/api/logs/${matchId}/${row.config_key}`,
|
||||||
|
backupPrefix: backupPrefix(matchId),
|
||||||
});
|
});
|
||||||
|
this.simulator.stop(matchId);
|
||||||
|
this.service.resetLiveScore(matchId, seriesMap.number);
|
||||||
try {
|
try {
|
||||||
await this.adapter.configure(JSON.parse(row.handle), { map: seriesMap.map, ...plan });
|
await this.adapter.configure(JSON.parse(row.handle), { map: seriesMap.map, ...plan });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -259,7 +468,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;
|
||||||
@@ -271,6 +480,8 @@ export class ServerManager {
|
|||||||
this.service.require(viewer, 'organizer');
|
this.service.require(viewer, 'organizer');
|
||||||
const row = this.row(viewer.matchId);
|
const row = this.row(viewer.matchId);
|
||||||
if (row?.status !== 'configured') throw new HttpError(409, 'Der Server ist nicht bereit für die nächste Map', 'wrong_state');
|
if (row?.status !== 'configured') throw new HttpError(409, 'Der Server ist nicht bereit für die nächste Map', 'wrong_state');
|
||||||
|
clearTimeout(this.autoAdvance.get(viewer.matchId));
|
||||||
|
this.autoAdvance.delete(viewer.matchId);
|
||||||
this.service.advanceSeries(viewer);
|
this.service.advanceSeries(viewer);
|
||||||
this.configure(viewer.matchId).catch((err) => this.logger.error(err));
|
this.configure(viewer.matchId).catch((err) => this.logger.error(err));
|
||||||
}
|
}
|
||||||
@@ -280,15 +491,68 @@ export class ServerManager {
|
|||||||
const row = this.row(viewer.matchId);
|
const row = this.row(viewer.matchId);
|
||||||
if (row?.status !== 'configured') throw new HttpError(409, 'Der Server ist noch nicht konfiguriert', 'wrong_state');
|
if (row?.status !== 'configured') throw new HttpError(409, 'Der Server ist noch nicht konfiguriert', 'wrong_state');
|
||||||
if (!this.adapter.sendCommands) throw new HttpError(501, 'Dieser Adapter unterstützt keine Befehle', 'unsupported');
|
if (!this.adapter.sendCommands) throw new HttpError(501, 'Dieser Adapter unterstützt keine Befehle', 'unsupported');
|
||||||
|
// Vor dem Befehl markieren: CS2 schickt Match_Start sofort nach mp_warmup_end, oft bevor RCON antwortet.
|
||||||
|
this.service.markWarmupEnded(viewer.matchId, true);
|
||||||
try {
|
try {
|
||||||
await this.adapter.sendCommands(JSON.parse(row.handle), ['mp_warmup_pausetimer 0', 'mp_warmup_end']);
|
await this.adapter.sendCommands(JSON.parse(row.handle), ['mp_warmup_pausetimer 0', 'mp_warmup_end']);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
this.service.markWarmupEnded(viewer.matchId, false);
|
||||||
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);
|
||||||
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. */
|
||||||
|
startDemoSimulation(matchId) {
|
||||||
|
const match = this.service.getMatch(matchId);
|
||||||
|
const seriesMap = this.service.seriesMaps(matchId).find((m) => m.number === match.current_map_number);
|
||||||
|
if (!seriesMap) return;
|
||||||
|
this.service.log(matchId, 'server', 'Demo: simulierter Spielverlauf startet (keine echten Ergebnisse)');
|
||||||
|
this.simulator.start(matchId, {
|
||||||
|
map: seriesMap.map,
|
||||||
|
teamNames: { A: sanitizeConsoleText(match.team_a_name), B: sanitizeConsoleText(match.team_b_name) },
|
||||||
|
ctTeam: seriesMap.ct_team ?? 'A',
|
||||||
|
maxRounds: match.mode === 'wingman' ? this.config.match.maxRoundsWingman : this.config.match.maxRoundsCompetitive,
|
||||||
|
roundMs: this.config.demo.roundSeconds * 1000,
|
||||||
|
onLines: (lines) => {
|
||||||
|
try {
|
||||||
|
this.service.applyLiveLog(matchId, lines);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`[demo] Simulation für ${matchId}: ${err.message}`);
|
||||||
|
this.simulator.stop(matchId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async stopByOrganizer(viewer) {
|
async stopByOrganizer(viewer) {
|
||||||
this.service.require(viewer, 'organizer');
|
this.service.require(viewer, 'organizer');
|
||||||
await this.stop(viewer.matchId, 'Vom Organisator gestoppt');
|
await this.stop(viewer.matchId, 'Vom Organisator gestoppt');
|
||||||
@@ -303,10 +567,11 @@ export class ServerManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async stop(matchId, reason) {
|
async stop(matchId, reason) {
|
||||||
|
this.simulator.stop(matchId);
|
||||||
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}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -327,11 +592,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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { configureViaRcon, rconStatus } from './rconConfigure.js';
|
import { configureViaRcon, listRoundBackups, loadRoundBackup, rconStatus } from './rconConfigure.js';
|
||||||
import { runRcon } from './rcon.js';
|
import { runRcon } from './rcon.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -45,6 +45,14 @@ export class StaticPoolServerAdapter {
|
|||||||
await configureViaRcon(this.target(handle), plan);
|
await configureViaRcon(this.target(handle), plan);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async listRoundBackups(handle, matchId) {
|
||||||
|
return listRoundBackups(this.target(handle), matchId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadRoundBackup(handle, file) {
|
||||||
|
await loadRoundBackup(this.target(handle), file);
|
||||||
|
}
|
||||||
|
|
||||||
async sendCommands(handle, commands) {
|
async sendCommands(handle, commands) {
|
||||||
return runRcon(this.target(handle), commands);
|
return runRcon(this.target(handle), commands);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,6 +107,9 @@ test('kompletter Ablauf: Lobby, Berechtigungen, Veto, Server-Konfiguration', asy
|
|||||||
const orgView = (await call('GET', `/matches/${id}`, { token: org })).body;
|
const orgView = (await call('GET', `/matches/${id}`, { token: org })).body;
|
||||||
assert.equal(orgView.viewer.role, 'organizer');
|
assert.equal(orgView.viewer.role, 'organizer');
|
||||||
const { codes } = orgView.organizer;
|
const { codes } = orgView.organizer;
|
||||||
|
// Match-Übersicht nur für dieses Match, ohne Zuschauercode
|
||||||
|
assert.match(orgView.organizer.links.overview, new RegExp(`/stream/match/${id}$`));
|
||||||
|
assert.ok(!orgView.organizer.links.overview.includes(codes.spectator));
|
||||||
|
|
||||||
// Zuschauer- und fremde Codes erlauben keinen Teambeitritt
|
// Zuschauer- und fremde Codes erlauben keinen Teambeitritt
|
||||||
assert.equal((await call('POST', `/matches/${id}/join`, { body: { code: codes.spectator, nickname: 'spy' } })).status, 403);
|
assert.equal((await call('POST', `/matches/${id}/join`, { body: { code: codes.spectator, nickname: 'spy' } })).status, 403);
|
||||||
@@ -253,6 +256,211 @@ test('Organisator spielt im selben Browser selbst mit (zwei Zugänge)', async ()
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Live-Ergebnisse aus Serverlogs und Demo-Simulation', async () => {
|
||||||
|
const { app, call, close } = await setup({ demo: { startSeconds: 0, failFirstConfigure: false, roundSeconds: 0 } });
|
||||||
|
try {
|
||||||
|
const { id, org, tokens } = await readyWingmanMatch(call, 'bo3');
|
||||||
|
await call('POST', `/matches/${id}/veto/start`, { token: org });
|
||||||
|
await playVeto(call, id, { A: tokens.a1, B: tokens.b1 });
|
||||||
|
await app.manager.poll();
|
||||||
|
await waitFor(() => app.service.getMatch(id).status === 'live', { label: 'live' });
|
||||||
|
|
||||||
|
const { config_key: key } = app.service.serverRow(id);
|
||||||
|
const map1 = app.service.seriesMaps(id)[0];
|
||||||
|
const base = `http://127.0.0.1:${app.server.address().port}/api/logs/${id}`;
|
||||||
|
const post = (k, text) => fetch(`${base}/${k}`, { method: 'POST', headers: { 'content-type': 'text/plain' }, body: text });
|
||||||
|
|
||||||
|
assert.equal((await post('falsch', 'x')).status, 403);
|
||||||
|
|
||||||
|
const ct = map1.ct_team === 'A' ? 'Alpha' : 'Bravo';
|
||||||
|
const t = map1.ct_team === 'A' ? 'Bravo' : 'Alpha';
|
||||||
|
const res = await post(key, [
|
||||||
|
`L 10/25/2026 - 18:00:00: MatchStatus: Team playing "CT": ${ct}`,
|
||||||
|
`L 10/25/2026 - 18:00:00: MatchStatus: Team playing "TERRORIST": ${t}`,
|
||||||
|
`L 10/25/2026 - 18:00:01: World triggered "Match_Start" on "${map1.map}"`,
|
||||||
|
'L 10/25/2026 - 18:01:00: Team "CT" triggered "SFUI_Notice_CTs_Win" (CT "1") (T "0")',
|
||||||
|
'L 10/25/2026 - 18:02:00: Team "TERRORIST" triggered "SFUI_Notice_Target_Bombed" (CT "1") (T "1")',
|
||||||
|
].join('\n'));
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
|
||||||
|
const snap = (await call('GET', `/matches/${id}`, { token: org })).body;
|
||||||
|
const ctTeam = map1.ct_team;
|
||||||
|
assert.equal(snap.series[0].score[ctTeam], 1);
|
||||||
|
assert.equal(snap.series[0].live.rounds.length, 2);
|
||||||
|
assert.equal(snap.series[0].live.rounds[1].reason, 'bomb');
|
||||||
|
|
||||||
|
await post(key, `Game Over: wingman mg_active ${map1.map} score 1:1 after 3 min`);
|
||||||
|
// Gleichstand beendet die Map ohne Sieger; Serienstand bleibt 0:0
|
||||||
|
const after = (await call('GET', `/matches/${id}`, { token: org })).body;
|
||||||
|
assert.equal(after.series[0].status, 'done');
|
||||||
|
assert.deepEqual(after.seriesScore, { A: 0, B: 0 });
|
||||||
|
|
||||||
|
// Map 2 laden und den Demo-Verlauf bis zum Ende laufen lassen
|
||||||
|
await call('POST', `/matches/${id}/series/next`, { token: org });
|
||||||
|
await waitFor(() => app.service.serverRow(id).status === 'configured' && app.service.getMatch(id).current_map_number === 2, { label: 'Map 2' });
|
||||||
|
assert.equal((await call('POST', `/matches/${id}/server/warmup-end`, { token: org })).status, 200);
|
||||||
|
await waitFor(() => app.service.seriesMaps(id)[1].status === 'done', { label: 'Demo-Map beendet', timeoutMs: 15000 });
|
||||||
|
const final = (await call('GET', `/matches/${id}`, { token: org })).body;
|
||||||
|
const winner = final.series[1].winner;
|
||||||
|
assert.ok(['A', 'B'].includes(winner));
|
||||||
|
assert.equal(final.series[1].score[winner], 9, 'Wingman: 9 Runden zum Sieg');
|
||||||
|
assert.equal(final.seriesScore[winner], 1);
|
||||||
|
const list = (await call('GET', '/matches')).body.matches.find((m) => m.id === id);
|
||||||
|
assert.equal(list.seriesScore[winner], 1);
|
||||||
|
} finally {
|
||||||
|
await close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Nach Map-Ende lädt die nächste Map automatisch, entschiedene Serie wird abgeschlossen', async () => {
|
||||||
|
const { app, call, close } = await setup({ autoAdvanceSeconds: 0 });
|
||||||
|
try {
|
||||||
|
const { id, org, tokens } = await readyWingmanMatch(call, 'bo3');
|
||||||
|
await call('POST', `/matches/${id}/veto/start`, { token: org });
|
||||||
|
await playVeto(call, id, { A: tokens.a1, B: tokens.b1 });
|
||||||
|
await app.manager.poll();
|
||||||
|
await waitFor(() => app.service.getMatch(id).status === 'live', { label: 'live' });
|
||||||
|
|
||||||
|
const key = app.service.serverRow(id).config_key;
|
||||||
|
const winMapForAlpha = async (map) => {
|
||||||
|
const body = [
|
||||||
|
'Team playing "CT": Alpha',
|
||||||
|
'Team playing "TERRORIST": Bravo',
|
||||||
|
'Team "CT" triggered "SFUI_Notice_CTs_Win" (CT "1") (T "0")',
|
||||||
|
'World triggered "Round_End"',
|
||||||
|
`Game Over: scrimcomp2v2 ${map} score 1:0 after 1 min`,
|
||||||
|
].join('\n');
|
||||||
|
const res = await fetch(`http://127.0.0.1:${app.server.address().port}/api/logs/${id}/${key}`, { method: 'POST', body });
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
};
|
||||||
|
|
||||||
|
await winMapForAlpha(app.service.seriesMaps(id)[0].map);
|
||||||
|
// Serie 1:0, nicht entschieden -> Map 2 wird automatisch geladen
|
||||||
|
await waitFor(() => app.service.getMatch(id).current_map_number === 2 && app.service.serverRow(id).status === 'configured', { label: 'Map 2 automatisch' });
|
||||||
|
assert.equal(app.service.getMatch(id).status, 'live');
|
||||||
|
|
||||||
|
await winMapForAlpha(app.service.seriesMaps(id)[1].map);
|
||||||
|
// 2:0 im Best of 3 -> Match wird automatisch abgeschlossen, Map 3 nicht gespielt
|
||||||
|
await waitFor(() => app.service.getMatch(id).status === 'finished', { label: 'Serie abgeschlossen' });
|
||||||
|
await waitFor(() => app.service.serverRow(id).status === 'stopped', { label: 'Server freigegeben' });
|
||||||
|
const snap = (await call('GET', `/matches/${id}`, { token: org })).body;
|
||||||
|
assert.deepEqual(snap.seriesScore, { A: 2, B: 0 });
|
||||||
|
assert.equal(snap.series[2].winner, null);
|
||||||
|
} finally {
|
||||||
|
await close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Stream-Übersicht aller Matches: Inhalte, keine Geheimnisse, optionaler Schlüssel', async () => {
|
||||||
|
const { app, call, close } = await setup({ streamKey: 'geheim123' });
|
||||||
|
try {
|
||||||
|
const lobby = (await call('POST', '/matches', { body: { teamA: 'Lobby A', teamB: 'Lobby B', mode: 'wingman', format: 'bo1' } })).body;
|
||||||
|
const { id, org, tokens } = await readyWingmanMatch(call, 'bo3');
|
||||||
|
await call('POST', `/matches/${id}/veto/start`, { token: org });
|
||||||
|
await call('POST', `/matches/${id}/veto/action`, { token: tokens.a1, body: { stepIndex: 0, map: 'de_inferno' } });
|
||||||
|
|
||||||
|
assert.equal((await call('GET', '/stream/overview')).status, 403);
|
||||||
|
assert.equal((await call('GET', '/stream/overview?key=falsch')).status, 403);
|
||||||
|
const res = await call('GET', '/stream/overview?key=geheim123');
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
const { active, upcoming } = res.body;
|
||||||
|
assert.equal(active.length, 1);
|
||||||
|
assert.equal(active[0].status, 'veto');
|
||||||
|
assert.equal(active[0].veto.maps.find((m) => m.id === 'de_inferno').status, 'banned');
|
||||||
|
assert.equal(active[0].veto.current.team, 'B');
|
||||||
|
assert.ok(upcoming.some((m) => m.id === lobby.id));
|
||||||
|
|
||||||
|
const json = JSON.stringify(res.body);
|
||||||
|
const row = app.service.getMatch(id);
|
||||||
|
for (const secret of [row.code_a, row.code_b, row.code_spectator, app.service.serverRow(id).rcon_password, app.service.serverRow(id).server_password]) {
|
||||||
|
assert.ok(!json.includes(secret), 'keine Codes oder Passwörter in der Stream-Übersicht');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Admin-Modus: Anmeldung, Verwaltung aller Matches, Sperre ohne Anmeldung', async () => {
|
||||||
|
const { app, call, close } = await setup({ adminPassword: 'Admin123' });
|
||||||
|
try {
|
||||||
|
const { id } = (await call('POST', '/matches', {
|
||||||
|
body: { teamA: 'Alpha', teamB: 'Bravo', mode: 'wingman', format: 'bo1' },
|
||||||
|
})).body;
|
||||||
|
const adminListed = (await call('GET', '/admin/matches')).body; // ohne Anmeldung
|
||||||
|
assert.equal(adminListed.error, 'Admin-Anmeldung erforderlich');
|
||||||
|
|
||||||
|
assert.equal((await call('POST', '/admin/login', { body: { password: 'falsch' } })).status, 401);
|
||||||
|
const login = (await call('POST', '/admin/login', { body: { password: 'Admin123' } })).body;
|
||||||
|
const admin = { token: login.token };
|
||||||
|
assert.ok(login.token);
|
||||||
|
|
||||||
|
const list = (await call('GET', '/admin/matches', admin)).body.matches;
|
||||||
|
const mine = list.find((m) => m.id === id);
|
||||||
|
assert.ok(mine, 'alle Matches sichtbar, nicht nur die eigenen');
|
||||||
|
assert.ok(mine.organizer.codes.A, 'Codes sichtbar');
|
||||||
|
|
||||||
|
// Ein Match abbrechen wie ein Admin
|
||||||
|
assert.equal((await call('POST', `/admin/matches/${id}/cancel`, { ...admin, body: {} })).status, 200);
|
||||||
|
assert.equal((await call('POST', `/admin/matches/${id}/server/pause`, { ...admin, body: {} })).status, 409, 'ohne Server keine Pause');
|
||||||
|
assert.equal(app.service.getMatch(id).status, 'cancelled');
|
||||||
|
|
||||||
|
// Endgültig löschen
|
||||||
|
assert.equal((await call('DELETE', `/admin/matches/${id}`, admin)).status, 200);
|
||||||
|
assert.equal((await call('GET', `/matches/${id}`)).status, 404);
|
||||||
|
|
||||||
|
// Abmelden: Token ist danach ungültig
|
||||||
|
await call('POST', '/admin/logout', admin);
|
||||||
|
assert.equal((await call('GET', '/admin/session', admin)).status, 401);
|
||||||
|
} finally {
|
||||||
|
await close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Rollback: nur mit Admin-Anmeldung, nur für die laufende Map, Dateiname wird geprüft', async () => {
|
||||||
|
const { app, call, close } = await setup({ adminPassword: 'Admin123', demo: { startSeconds: 0, failFirstConfigure: false, roundSeconds: 600 } });
|
||||||
|
try {
|
||||||
|
const { id, org, tokens } = await readyWingmanMatch(call, 'bo3');
|
||||||
|
await call('POST', `/matches/${id}/veto/start`, { token: org });
|
||||||
|
await playVeto(call, id, { A: tokens.a1, B: tokens.b1 });
|
||||||
|
await app.manager.poll();
|
||||||
|
await waitFor(() => app.service.getMatch(id).status === 'live', { label: 'live' });
|
||||||
|
await call('POST', `/matches/${id}/server/warmup-end`, { token: org });
|
||||||
|
|
||||||
|
// Organisator sieht keine Rollback-Funktion
|
||||||
|
assert.equal((await call('GET', `/admin/matches/${id}/rollback-points`, { token: org })).status, 401);
|
||||||
|
assert.equal((await call('POST', `/matches/${id}/rollback`, { token: org, body: { file: 'x.txt' } })).status, 404);
|
||||||
|
|
||||||
|
const login = (await call('POST', '/admin/login', { body: { password: 'Admin123' } })).body;
|
||||||
|
const admin = { token: login.token };
|
||||||
|
const points = (await call('GET', `/admin/matches/${id}/rollback-points`, admin)).body.points;
|
||||||
|
assert.equal(points.length, 3, 'Demo liefert drei Sicherungen der aktuellen Map');
|
||||||
|
const map = app.service.seriesMaps(id)[0].map;
|
||||||
|
assert.ok(points.every((p) => p.map === map));
|
||||||
|
|
||||||
|
// Dateien, die nicht zu diesem Match gehören, werden abgelehnt
|
||||||
|
assert.equal((await call('POST', `/admin/matches/${id}/rollback`, { ...admin, body: { file: '../../x.txt' } })).status, 400);
|
||||||
|
|
||||||
|
const ok = await call('POST', `/admin/matches/${id}/rollback`, { ...admin, body: { file: points[0].file } });
|
||||||
|
assert.equal(ok.status, 200);
|
||||||
|
const log = app.service.db.prepare("SELECT message FROM events WHERE match_id = ? AND type = 'rollback'").all(id);
|
||||||
|
assert.equal(log.length, 1);
|
||||||
|
assert.match(log[0].message, /zurückgespielt/);
|
||||||
|
} finally {
|
||||||
|
await close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Admin-Modus ohne ADMIN_PASSWORD ist komplett aus', async () => {
|
||||||
|
const { call, close } = await setup({ adminPassword: '' });
|
||||||
|
try {
|
||||||
|
const res = await call('POST', '/admin/login', { body: { password: 'x' } });
|
||||||
|
assert.equal(res.status, 404);
|
||||||
|
assert.equal(res.body.code, 'admin_disabled');
|
||||||
|
} finally {
|
||||||
|
await close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('Spieler verlassen die Lobby, Captain geht über', async () => {
|
test('Spieler verlassen die Lobby, Captain geht über', async () => {
|
||||||
const { call, close } = await setup();
|
const { call, close } = await setup();
|
||||||
try {
|
try {
|
||||||
@@ -269,3 +477,34 @@ 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();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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')));
|
||||||
|
});
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { DockerServerAdapter } from '../src/servers/dockerAdapter.js';
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
bin: 'docker', image: 'joedwards32/cs2:latest', host: '192.168.178.111', rconHost: '192.168.178.111',
|
||||||
|
portRangeStart: 27100, portRangeEnd: 27139, gslt: '', volume: 'cs2-mapveto-gamefiles', extraArgs: '', startTimeoutMinutes: 1,
|
||||||
|
};
|
||||||
|
const secrets = { rconPassword: 'Rcon123', serverPassword: 'Pw123', tvPassword: 'Tv123' };
|
||||||
|
|
||||||
|
/** Simuliertes Docker: merkt sich Aufrufe; Container gelten als "Update fertig", sobald freigegeben. */
|
||||||
|
function fakeDocker() {
|
||||||
|
const calls = [];
|
||||||
|
const updated = new Set();
|
||||||
|
const run = async (args) => {
|
||||||
|
calls.push(args);
|
||||||
|
const [cmd] = args;
|
||||||
|
const name = args.at(-1);
|
||||||
|
if (cmd === 'inspect') return 'running';
|
||||||
|
if (cmd === 'logs') return updated.has(name) ? 'Success! App \'730\' already up to date.\nStarting CS2 Dedicated Server' : 'Checking for available updates...';
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
return { run, calls, finishUpdate: (name) => updated.add(name) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const envOf = (args) => Object.fromEntries(args.flatMap((a, i) => (args[i - 1] === '-e' ? [a.split(/=(.*)/s).slice(0, 2)] : [])));
|
||||||
|
|
||||||
|
test('Ohne GSLT startet der Container im LAN-Modus, mit GSLT mit Token', async () => {
|
||||||
|
const lan = fakeDocker();
|
||||||
|
const a = new DockerServerAdapter(options, { getUsedPorts: () => new Set(), runDocker: lan.run });
|
||||||
|
a.isReachable = async () => false;
|
||||||
|
const r = await a.start({ matchId: 'abc_DEF-1', mode: 'wingman', secrets });
|
||||||
|
lan.finishUpdate(r.handle.container);
|
||||||
|
const runArgs = lan.calls.find((c) => c[0] === 'run');
|
||||||
|
const env = envOf(runArgs);
|
||||||
|
assert.equal(env.CS2_LAN, '1');
|
||||||
|
assert.equal(env.SRCDS_TOKEN, undefined);
|
||||||
|
assert.equal(env.CS2_GAMEMODE, '2');
|
||||||
|
assert.equal(env.CS2_MAXPLAYERS, '6', '4 Spieler + CSTV + Reserve');
|
||||||
|
assert.equal(env.CS2_ADDITIONAL_ARGS, '-allow_round_backup_files', 'Rundensicherung für Rollback');
|
||||||
|
assert.equal(env.CS2_PORT, '27100');
|
||||||
|
assert.equal(env.TV_PORT, '27200');
|
||||||
|
assert.ok(runArgs.includes('cs2-mapveto-gamefiles:/home/steam/cs2-dedicated'));
|
||||||
|
assert.equal(r.host, '192.168.178.111');
|
||||||
|
|
||||||
|
const tok = fakeDocker();
|
||||||
|
const b = new DockerServerAdapter({ ...options, gslt: 'TOKEN1' }, { getUsedPorts: () => new Set(), runDocker: tok.run });
|
||||||
|
b.isReachable = async () => false;
|
||||||
|
const r2 = await b.start({ matchId: 'x', mode: 'competitive', secrets });
|
||||||
|
tok.finishUpdate(r2.handle.container);
|
||||||
|
const env2 = envOf(tok.calls.find((c) => c[0] === 'run'));
|
||||||
|
assert.equal(env2.SRCDS_TOKEN, 'TOKEN1');
|
||||||
|
assert.equal(env2.CS2_LAN, '0');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Zweiter Container startet erst, wenn der erste seinen Update-Check abgeschlossen hat', async () => {
|
||||||
|
const d = fakeDocker();
|
||||||
|
const used = new Set();
|
||||||
|
const a = new DockerServerAdapter(options, { getUsedPorts: () => used, runDocker: d.run });
|
||||||
|
// Wartezeit zwischen Prüfungen für den Test verkürzen
|
||||||
|
const original = a.waitForUpdateCheck.bind(a);
|
||||||
|
a.waitForUpdateCheck = (handle) => original(handle, { pollMs: 10 });
|
||||||
|
a.isReachable = async () => false;
|
||||||
|
|
||||||
|
const first = await a.start({ matchId: 'm1', mode: 'competitive', secrets });
|
||||||
|
used.add(first.port).add(first.tvPort);
|
||||||
|
let secondDone = false;
|
||||||
|
const second = a.start({ matchId: 'm2', mode: 'competitive', secrets }).then((r) => { secondDone = true; return r; });
|
||||||
|
|
||||||
|
await new Promise((r) => setTimeout(r, 60));
|
||||||
|
assert.equal(secondDone, false, 'zweiter Start wartet noch');
|
||||||
|
assert.equal(d.calls.filter((c) => c[0] === 'run').length, 1);
|
||||||
|
|
||||||
|
d.finishUpdate(first.handle.container);
|
||||||
|
const r2 = await second;
|
||||||
|
assert.equal(d.calls.filter((c) => c[0] === 'run').length, 2);
|
||||||
|
assert.equal(r2.port, 27101, 'zweiter Container bekommt den nächsten Port');
|
||||||
|
d.finishUpdate(r2.handle.container);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Warteschlange gibt frei, sobald der Server per RCON antwortet – auch ohne Startzeile im Log', async () => {
|
||||||
|
const d = fakeDocker();
|
||||||
|
const used = new Set();
|
||||||
|
const a = new DockerServerAdapter(options, { getUsedPorts: () => used, runDocker: d.run });
|
||||||
|
const original = a.waitForUpdateCheck.bind(a);
|
||||||
|
a.waitForUpdateCheck = (handle) => original(handle, { pollMs: 10 });
|
||||||
|
const reachable = new Set();
|
||||||
|
a.isReachable = async (handle) => reachable.has(handle.container);
|
||||||
|
|
||||||
|
const first = await a.start({ matchId: 'r1', mode: 'wingman', secrets });
|
||||||
|
used.add(first.port).add(first.tvPort);
|
||||||
|
const second = a.start({ matchId: 'r2', mode: 'wingman', secrets });
|
||||||
|
await new Promise((r) => setTimeout(r, 50));
|
||||||
|
assert.equal(d.calls.filter((c) => c[0] === 'run').length, 1, 'wartet noch');
|
||||||
|
reachable.add(first.handle.container); // Log ohne Startzeile, aber RCON antwortet
|
||||||
|
const r2 = await second;
|
||||||
|
assert.equal(d.calls.filter((c) => c[0] === 'run').length, 2);
|
||||||
|
reachable.add(r2.handle.container);
|
||||||
|
});
|
||||||
@@ -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,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);
|
||||||
|
});
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { applyLogLines, initialLiveState, seriesScore, splitLogBody } from '../src/liveScore.js';
|
||||||
|
|
||||||
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
const ctx = { teamNames: { A: 'MaxLAN Blau', B: 'MaxLAN Rot' }, maxRounds: 24, overtimeRounds: 6 };
|
||||||
|
const ts = '10/25/2026 - 18:00:00.000 - ';
|
||||||
|
|
||||||
|
function round(winnerSide, ct, t, notice) {
|
||||||
|
const n = notice ?? (winnerSide === 'CT' ? 'CTs_Win' : 'Terrorists_Win');
|
||||||
|
const side = winnerSide === 'CT' ? 'CT' : 'TERRORIST';
|
||||||
|
return `${ts}Team "${side}" triggered "SFUI_Notice_${n}" (CT "${ct}") (T "${t}")`;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Logzeilen ergeben Spielstand, Rundenverlauf und Gründe', () => {
|
||||||
|
let state = initialLiveState({ map: 'de_mirage', ctTeam: 'A', warmupEnded: true });
|
||||||
|
const lines = [
|
||||||
|
`${ts}World triggered "Match_Start" on "de_mirage"`,
|
||||||
|
round('T', 0, 1, 'Target_Bombed'),
|
||||||
|
round('CT', 1, 1, 'Bomb_Defused'),
|
||||||
|
round('CT', 2, 1, 'Target_Saved'),
|
||||||
|
];
|
||||||
|
const { state: next, events } = applyLogLines(state, lines, ctx);
|
||||||
|
state = next;
|
||||||
|
assert.equal(state.status, 'live');
|
||||||
|
assert.deepEqual(state.score, { A: 2, B: 1 });
|
||||||
|
assert.deepEqual(state.rounds.map((r) => [r.winner, r.reason]), [['B', 'bomb'], ['A', 'defuse'], ['A', 'time']]);
|
||||||
|
assert.equal(events.filter((e) => e.type === 'round').length, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Doppelt gesendete Zeilen werden nur einmal gezählt', () => {
|
||||||
|
const state = initialLiveState({ map: 'de_nuke', ctTeam: 'A', warmupEnded: true });
|
||||||
|
const line = round('CT', 1, 0);
|
||||||
|
const { state: s } = applyLogLines(state, [line, line, line], ctx);
|
||||||
|
assert.equal(s.rounds.length, 1);
|
||||||
|
assert.deepEqual(s.score, { A: 1, B: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Seitenzuordnung nach dem Rundenende hat Vorrang, veraltete Zeilen zu Rundenbeginn zählen nicht', () => {
|
||||||
|
let s = initialLiveState({ map: 'de_nuke', ctTeam: 'B', warmupEnded: true });
|
||||||
|
const playing = (ct, t) => [`${ts}MatchStatus: Team playing "CT": ${ct}`, `${ts}MatchStatus: Team playing "TERRORIST": ${t}`];
|
||||||
|
({ state: s } = applyLogLines(s, [
|
||||||
|
// Veto sagt Rot auf CT, tatsächlich steht Blau auf CT – das Log gewinnt
|
||||||
|
`${ts}World triggered "Match_Start" on "de_nuke"`,
|
||||||
|
...playing('MaxLAN Blau', 'MaxLAN Rot'),
|
||||||
|
round('CT', 1, 0),
|
||||||
|
...playing('MaxLAN Blau', 'MaxLAN Rot'),
|
||||||
|
`${ts}World triggered "Round_End"`,
|
||||||
|
// veraltete Zeile zu Beginn der nächsten Runde (darf nichts ändern)
|
||||||
|
...playing('MaxLAN Rot', 'MaxLAN Blau'),
|
||||||
|
round('T', 1, 1),
|
||||||
|
...playing('MaxLAN Blau', 'MaxLAN Rot'),
|
||||||
|
`${ts}World triggered "Round_End"`,
|
||||||
|
], ctx));
|
||||||
|
assert.deepEqual(s.score, { A: 1, B: 1 });
|
||||||
|
assert.deepEqual(s.rounds.map((r) => r.winner), ['A', 'B']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Echte, anonymisierte Logzeilen eines CS2-Servers (Wingman, 4 Runden, Halbzeit nach 2)', () => {
|
||||||
|
const lines = fs.readFileSync(path.join(here, 'fixtures', 'wingman-anonym.log'), 'utf8').split(/\r?\n/).filter(Boolean);
|
||||||
|
const warmupEnd = lines.findIndex((l) => l.includes('command "mp_warmup_end"'));
|
||||||
|
const ctx = { teamNames: { A: 'MaxLAN Blau', B: 'MaxLAN Rot' }, maxRounds: 4, overtimeRounds: 6 };
|
||||||
|
|
||||||
|
const check = (s) => {
|
||||||
|
assert.equal(s.status, 'finished');
|
||||||
|
assert.equal(s.winner, 'A');
|
||||||
|
assert.deepEqual(s.score, { A: 3, B: 1 });
|
||||||
|
assert.deepEqual(s.rounds.map((r) => r.winner), ['B', 'A', 'A', 'A']);
|
||||||
|
assert.ok(s.rounds.every((r) => r.reason === 'elimination'));
|
||||||
|
};
|
||||||
|
|
||||||
|
// a) wie vom Server in Paketen geschickt
|
||||||
|
let s = initialLiveState({ map: 'de_inferno', ctTeam: 'A' });
|
||||||
|
({ state: s } = applyLogLines(s, lines.slice(0, warmupEnd), ctx));
|
||||||
|
assert.equal(s.status, 'warmup');
|
||||||
|
s.warmupEnded = true; // Organisator hat "Aufwärmphase beenden" geklickt
|
||||||
|
({ state: s } = applyLogLines(s, lines.slice(warmupEnd), ctx));
|
||||||
|
check(s);
|
||||||
|
|
||||||
|
// b) jede Zeile einzeln (ungünstigste Paketgrenzen)
|
||||||
|
let t = initialLiveState({ map: 'de_inferno', ctTeam: 'A', warmupEnded: true });
|
||||||
|
for (const line of lines.slice(warmupEnd)) ({ state: t } = applyLogLines(t, [line], ctx));
|
||||||
|
check(t);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Ohne "Team playing": Halbzeitwechsel nach Regelwerk', () => {
|
||||||
|
let s = initialLiveState({ map: 'de_inferno', ctTeam: 'B', warmupEnded: true });
|
||||||
|
const small = { ...ctx, maxRounds: 4 };
|
||||||
|
({ state: s } = applyLogLines(s, [
|
||||||
|
`${ts}World triggered "Match_Start" on "de_inferno"`,
|
||||||
|
round('CT', 1, 0), // B gewinnt als CT
|
||||||
|
round('CT', 2, 0), // B gewinnt als CT -> Halbzeit, B ist jetzt T
|
||||||
|
round('T', 0, 3), // B gewinnt als T: CT-Seite (A) hat 0, T-Seite (B) hat 3
|
||||||
|
], small));
|
||||||
|
assert.deepEqual(s.score, { A: 0, B: 3 });
|
||||||
|
assert.deepEqual(s.rounds.map((r) => r.winner), ['B', 'B', 'B']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Game Over beendet die Map, Aufwärmrunden vor Match_Start zählen nicht', () => {
|
||||||
|
let s = initialLiveState({ map: 'de_dust2', ctTeam: 'A', warmupEnded: true });
|
||||||
|
({ state: s } = applyLogLines(s, [
|
||||||
|
round('T', 0, 1), // Aufwärm-/Messerrunde
|
||||||
|
`${ts}World triggered "Match_Start" on "de_dust2"`,
|
||||||
|
round('CT', 1, 0),
|
||||||
|
`${ts}Game Over: competitive mg_active de_dust2 score 1:0 after 2 min`,
|
||||||
|
round('T', 1, 1), // nach Spielende ignoriert
|
||||||
|
], ctx));
|
||||||
|
assert.equal(s.status, 'finished');
|
||||||
|
assert.equal(s.winner, 'A');
|
||||||
|
assert.deepEqual(s.score, { A: 1, B: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Zeilen einer anderen Map werden ignoriert, Serienstand wird gezählt', () => {
|
||||||
|
const s = initialLiveState({ map: 'de_dust2', ctTeam: 'A', warmupEnded: true });
|
||||||
|
const { state } = applyLogLines(s, [`${ts}Game Over: competitive mg_active de_nuke score 13:2 after 30 min`], ctx);
|
||||||
|
assert.equal(state.status, 'warmup');
|
||||||
|
assert.deepEqual(seriesScore([{ winner: 'A' }, { winner: 'B' }, { winner: 'A' }, { winner: null }]), { A: 2, B: 1 });
|
||||||
|
assert.deepEqual(splitLogBody('a\r\n\r\nb\n'), ['a', 'b']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Match_Start vor dem Ende der Aufwärmphase startet keine Zählung', () => {
|
||||||
|
let s = initialLiveState({ map: 'de_inferno', ctTeam: 'A' });
|
||||||
|
const start = `${ts}World triggered "Match_Start" on "de_inferno"`;
|
||||||
|
({ state: s } = applyLogLines(s, [start], ctx));
|
||||||
|
assert.equal(s.status, 'warmup');
|
||||||
|
s.warmupEnded = true;
|
||||||
|
const { state, events } = applyLogLines(s, [start], ctx);
|
||||||
|
assert.equal(state.status, 'live');
|
||||||
|
assert.equal(events[0].type, 'match_start');
|
||||||
|
});
|
||||||
@@ -64,4 +64,21 @@ 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', () => {
|
||||||
|
const base = {
|
||||||
|
match: { team_a_name: 'A', team_b_name: 'B' },
|
||||||
|
pool: { mode: 'competitive', gameType: 0, gameMode: 1 },
|
||||||
|
seriesMap: { map: 'de_nuke', ctTeam: 'A' },
|
||||||
|
secrets: { serverPassword: 'abc', tvPassword: 'def' },
|
||||||
|
settings: { tvEnabled: true, tvDelaySeconds: 90, maxRoundsWingman: 16, maxRoundsCompetitive: 24, overtime: true, extraCommands: [] },
|
||||||
|
};
|
||||||
|
const ok = buildMapCommands({ ...base, logUrl: 'http://192.168.178.31:3000/api/logs/abc_D-1/KEY123' }).beforeMapChange;
|
||||||
|
assert.ok(ok.includes('logaddress_add_http "http://192.168.178.31:3000/api/logs/abc_D-1/KEY123"'));
|
||||||
|
assert.ok(ok.indexOf('log on') < ok.indexOf('changelevel de_nuke'));
|
||||||
|
const bad = buildMapCommands({ ...base, logUrl: 'http://x"; quit' }).beforeMapChange;
|
||||||
|
assert.ok(!bad.some((c) => c.startsWith('logaddress')));
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { backupPrefix, describeBackup, isValidBackupFile, parseBackupFiles, pointsForMap, trimLiveState } from '../src/rounds.js';
|
||||||
|
|
||||||
|
const id = 'Ab_9-xY';
|
||||||
|
const file = (map, round, s1, s2) => `${backupPrefix(id)}_2026-10-25_18-30-00_${map}_round${round}_score_${s1}_${s2}.txt`;
|
||||||
|
|
||||||
|
test('Gültige Sicherungsdateien nur für dieses Match', () => {
|
||||||
|
assert.equal(isValidBackupFile(file('de_inferno', 3, 2, 1), id), true);
|
||||||
|
assert.equal(isValidBackupFile(file('de_inferno', 3, 2, 1), 'anderes'), false, 'fremdes Match');
|
||||||
|
assert.equal(isValidBackupFile(`${backupPrefix(id)}_../../etc/passwd.txt`, id), false, 'Pfad');
|
||||||
|
assert.equal(isValidBackupFile(`${backupPrefix(id)}_x;quit_round1_score_0_0.txt`, id), false, 'Befehl');
|
||||||
|
assert.equal(isValidBackupFile(`${backupPrefix(id)}_de_inferno.txt`, id), false, 'ohne Runde');
|
||||||
|
assert.equal(isValidBackupFile(undefined, id), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Liste aus einer RCON-Antwort auslesen, fremde Dateien ignorieren', () => {
|
||||||
|
const text = [
|
||||||
|
'Backups:',
|
||||||
|
file('de_inferno', 1, 1, 0),
|
||||||
|
file('de_inferno', 2, 1, 1),
|
||||||
|
'round_backup_fremd_2026_de_nuke_round4_score_3_1.txt',
|
||||||
|
file('de_nuke', 1, 1, 0),
|
||||||
|
].join('\n');
|
||||||
|
const found = parseBackupFiles(text, id);
|
||||||
|
assert.equal(found.length, 3);
|
||||||
|
assert.deepEqual(found[0], { file: file('de_inferno', 1, 1, 0), map: 'de_inferno', round: 1, score: { s1: 1, s2: 0 } });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Nur die Map der laufenden Serie, neueste Runde zuerst', () => {
|
||||||
|
const all = parseBackupFiles([file('de_inferno', 1, 1, 0), file('de_inferno', 3, 2, 1), file('de_nuke', 2, 1, 1)].join('\n'), id);
|
||||||
|
const points = pointsForMap(all, 'de_inferno');
|
||||||
|
assert.deepEqual(points.map((p) => p.round), [3, 1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Stand nach Rollback: Runden bis zur Sicherung, Punkte neu gerechnet', () => {
|
||||||
|
const state = {
|
||||||
|
map: 'de_inferno', status: 'live', warmupEnded: true, score: { A: 3, B: 2 }, winner: 'A', pendingRound: null,
|
||||||
|
rounds: [
|
||||||
|
{ number: 1, winner: 'A' }, { number: 2, winner: 'B' }, { number: 3, winner: 'A' },
|
||||||
|
{ number: 4, winner: 'B' }, { number: 5, winner: 'A' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const back = trimLiveState(state, 3);
|
||||||
|
assert.deepEqual(back.rounds.map((r) => r.number), [1, 2, 3]);
|
||||||
|
assert.deepEqual(back.score, { A: 2, B: 1 });
|
||||||
|
assert.equal(back.winner, null);
|
||||||
|
assert.equal(back.status, 'live');
|
||||||
|
assert.equal(back.warmupEnded, true, 'Zustand bleibt erhalten');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Beschreibung eines Dateinamens', () => {
|
||||||
|
assert.deepEqual(describeBackup('nichts.txt'), { file: 'nichts.txt', map: null, round: null, score: null });
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user