Die Testdatei enthielt Spielernamen und SteamID eines echten Accounts. Sie ist jetzt durch eine Kopie mit Platzhaltern ersetzt, der Parser-Test prüft weiterhin echte CS2-Logzeilen.
471 lines
31 KiB
Markdown
471 lines
31 KiB
Markdown
# CS2 Mapveto
|
||
|
||
Webanwendung zur Organisation von Counter-Strike-2-Matches: Match-Lobbys mit Team-Einladungen, abwechselndes Map-Veto der Captains, Zuschauerzugang und automatische Verwaltung eines CS2-Servers pro Match.
|
||
|
||
- **Frontend:** React 18 + Vite, deutschsprachig, responsives dunkles Design
|
||
- **Backend:** Node.js (≥ 18.18), Express, Socket.IO
|
||
- **Datenbank:** SQLite (better-sqlite3), Datei unter `data/`
|
||
- **Echtzeit:** Socket.IO; jede Änderung schickt allen Teilnehmern ihren rollenabhängigen Match-Zustand
|
||
- **Serververwaltung:** austauschbare Adapter hinter einer Schnittstelle (`start`, `status`, `configure`, `stop`)
|
||
|
||
> **Standardmäßig läuft der Demo-Adapter.** Er startet **keine** echten CS2-Server. Oberfläche, Statusanzeigen und Verbindungsdaten sind dann überall als „Demo“ bzw. „simuliert“ gekennzeichnet. Wie die echte Anbindung eingerichtet wird, steht im Abschnitt [Echte Server anbinden](#echte-server-anbinden).
|
||
|
||
---
|
||
|
||
## Schnellstart
|
||
|
||
**Als Container (empfohlen für den Betrieb):** ein Container mit Webseite, Backend und Datenbank, der die
|
||
CS2-Server-Container auf demselben Docker-Host startet. Siehe [docs/container.md](docs/container.md):
|
||
|
||
```powershell
|
||
docker -H ssh://USER@DOCKER-HOST-IP build -t cs2-mapveto:latest .
|
||
```
|
||
|
||
…dann den Stack aus der Anleitung in Portainer anlegen.
|
||
|
||
**Zum Entwickeln:**
|
||
|
||
```bash
|
||
npm install
|
||
cp .env.example .env # optional, Standardwerte funktionieren für die Demo
|
||
npm run dev # Backend auf :3000, Frontend auf :5173
|
||
```
|
||
|
||
Dann <http://localhost:5173> öffnen.
|
||
|
||
**Produktion (ein Prozess, Backend liefert das gebaute Frontend aus):**
|
||
|
||
```bash
|
||
npm install
|
||
npm run build
|
||
PUBLIC_URL=https://veto.example.de npm start # lauscht auf PORT (Standard 3000)
|
||
```
|
||
|
||
**Tests:**
|
||
|
||
```bash
|
||
npm test
|
||
```
|
||
|
||
Die Tests decken Veto-Engine und Ablaufvalidierung, RCON-Protokoll (gegen einen lokalen Test-RCON-Server), Befehls-Bereinigung und den kompletten API-Ablauf ab. Dazu gehören Berechtigungen, doppelte Aktionen, Server-Konfiguration mit Wiederholung, nächste Map, automatisches Herunterfahren und dass keine RCON-Daten an Clients gelangen.
|
||
|
||
---
|
||
|
||
## Ablauf: von der Match-Erstellung bis zum Serverbeitritt
|
||
|
||
1. **Match erstellen** (`/neu`): Der Organisator trägt beide Teamnamen ein und wählt Spielmodus (Wingman 2 gegen 2 oder Competitive 5 gegen 5), Serie (Best of 1/3/5) und wer das Veto beginnt (Münzwurf oder festes Team). Formate, für die der Map-Pool keinen gültigen Veto-Ablauf hat, sind ausgegraut und werden auch vom Backend abgelehnt.
|
||
Der Browser des Organisators erhält einen geheimen Organisator-Token. Er wird lokal gespeichert und lässt sich über „Organisator-Zugang auf einem anderen Gerät öffnen“ übertragen.
|
||
2. **Server vorbereiten:** Direkt beim Anlegen der Lobby fordert das Backend im Hintergrund einen Server an. Er fährt also schon während Lobby und Veto hoch. Der Status ist auf der Match-Seite live sichtbar.
|
||
3. **Einladungen:** Das Match hat drei Zugangscodes (Format `ABCD-EFGH`) mit Links:
|
||
- Team A: `/join/<code>`
|
||
- Team B: `/join/<code>`
|
||
- Zuschauer: `/watch/<code>` für Stream und Kommentatoren
|
||
|
||
Diese Codes gelten nur für die Webseite. Der CS2-Serverzugang wird getrennt bereitgestellt (Schritt 6). Der Organisator kann jeden Code jederzeit neu erzeugen; der alte Link wird damit ungültig.
|
||
4. **Spieler treten bei:** Über den Teamlink gibt ein Spieler seinen Namen ein (optional die SteamID64) und landet in genau diesem Team. Pro Team sind maximal 2 bzw. 5 Spieler erlaubt.
|
||
- Der erste Spieler eines Teams wird Captain. Organisator oder Captain können den Captain wechseln.
|
||
- Jeder Spieler setzt sich auf „bereit“. Ein Team ist bereit, sobald mindestens ein Spieler beigetreten ist, es einen Captain hat und alle beigetretenen Spieler bereit sind. Mit `REQUIRE_FULL_TEAMS=true` muss das Team zusätzlich vollständig sein (2 bzw. 5 Spieler).
|
||
5. **Map-Veto:** Sind beide Teams bereit, startet der Organisator das Veto. Die Captains bannen und wählen abwechselnd nach dem festen Ablauf (siehe unten). Die Seite zeigt jederzeit:
|
||
- welches Team am Zug ist
|
||
- welche Aktion ansteht
|
||
- den Zustand jeder Map (verfügbar, gebannt, gewählt, Entscheidungsmap)
|
||
- den gesamten Verlauf mit Uhrzeit
|
||
|
||
Die letzte verbleibende Map wird automatisch als Entscheidungsmap übernommen. Zuschauer und Organisator sehen alles live, können aber nicht wählen.
|
||
6. **Server konfigurieren:** Nach dem Veto wird der bereits laufende Server automatisch eingerichtet: Spielmodus, erste Map, Teamnamen und Startseiten, Match-Einstellungen, Server- und CSTV-Passwort. Die restliche Map-Reihenfolge ist gespeichert.
|
||
- Spieler sehen danach den kopierbaren Konsolenbefehl `password …; connect host:port` und einen `steam://connect`-Link.
|
||
- Zuschauer sehen den CSTV-Zugang (eigener Port, eigenes Passwort, Verzögerung).
|
||
7. **Während der Serie:** Der Organisator kann die Aufwärmphase beenden (`mp_warmup_end`), das laufende Match pausieren und fortsetzen (`mp_pause_match`, `mp_unpause_match`), die nächste Map laden, den Server stoppen und das Match abschließen oder abbrechen.
|
||
8. **Fehlerfälle:** Startet der Server nicht, schlägt die Konfiguration fehl oder fällt der Server aus, zeigt die Seite den Grund. Der Organisator bekommt einen Knopf für den erneuten Versuch: „Server neu starten“ bzw. „Konfiguration wiederholen“.
|
||
|
||
### Rollen und Berechtigungen
|
||
|
||
| Rolle | Zugang über | Darf |
|
||
|---|---|---|
|
||
| Organisator | Token beim Erstellen | Veto starten, Spieler entfernen, Captain setzen, Codes erneuern, Server neu starten/stoppen/konfigurieren, pausieren/fortsetzen, nächste Map, Match beenden; sieht Einladungen, Spieler- und CSTV-Verbindung, die Match-Übersicht |
|
||
| Captain | Teamcode (erster Spieler oder ernannt) | alles wie Spieler, dazu Veto-Aktionen seines Teams und Captain-Übergabe |
|
||
| Spieler | Teamcode | bereit melden, Team verlassen; sieht die Spieler-Verbindungsdaten nach dem Veto |
|
||
| Zuschauer | Zuschauercode | alles live verfolgen; sieht nur den CSTV-Zugang, nicht das Spielerpasswort |
|
||
| Gast | ohne Code | nur Teamnamen und Status |
|
||
|
||
**Organisator spielt selbst mit:** In der Lobby kann der Organisator unter „Selbst mitspielen?“ direkt einem Team beitreten, oder er öffnet einen Teamlink im selben Browser. Er bleibt Organisator und ist zusätzlich Spieler bzw. Captain. Ein zweiter Browser ist nicht nötig. Technisch speichert der Browser dafür beide Zugänge und schickt den Spieler-Token im Header `X-Player-Token` mit. Pro Aufrufer zählt höchstens ein Spieler.
|
||
|
||
Alle Prüfungen passieren im Backend. Die Oberfläche blendet nur zusätzlich aus, was die Rolle nicht darf.
|
||
|
||
### Schutzmaßnahmen
|
||
|
||
- **Fremde Teambeitritte:** Jeder Code gehört zu genau einem Team eines Matches. Zuschauercodes erlauben keinen Beitritt. Volle Teams, doppelte Namen und doppelte Steam-IDs werden abgelehnt. Codebasierte Endpunkte sind pro IP begrenzt (20 Versuche pro Minute).
|
||
- **Doppelte Aktionen:** Jede Veto-Aktion trägt die Nummer des Schritts. Sie wird in einer SQLite-Transaktion geprüft und gespeichert; `(match_id, idx)` ist Primärschlüssel. Ein doppelter Klick oder ein veralteter Stand liefert „Diese Aktion ist veraltet“. Serveraktionen sind pro Match gesperrt, solange eine läuft.
|
||
- **Unberechtigte Befehle:** Veto nur durch den Captain des Teams am Zug, Server- und Match-Befehle nur durch den Organisator.
|
||
- **Tokens:** Organisator- und Spieler-Tokens sind 256 Bit zufällig und werden nur als SHA-256-Hash gespeichert.
|
||
- **Server-Zugangsdaten:** RCON-Passwort, Container-Handle und GSLT liegen ausschließlich in der Datenbank bzw. den Umgebungsvariablen des Backends. Die Snapshot-Funktion gibt sie nie aus (per Test abgesichert). Server- und CSTV-Passwort sind als Beitrittsdaten nötig und werden nur an die jeweils berechtigte Rolle geliefert.
|
||
- **Befehls-Injection:** Teamnamen werden vor der Übergabe an die Serverkonsole von Anführungszeichen, `;` und Steuerzeichen bereinigt. Passwörter sind rein alphanumerisch.
|
||
|
||
### Wiederherstellung nach Neuladen oder Verbindungsabbruch
|
||
|
||
Der gesamte Zustand liegt in SQLite. Der Browser speichert pro Match nur seinen Zugangstoken (`localStorage`).
|
||
|
||
- **Neuladen der Seite:** Die Seite lädt den Stand per REST und abonniert danach den Socket.
|
||
- **Abgebrochene Socket-Verbindung:** Nach dem Reconnect meldet sich der Client automatisch neu an und erhält den vollständigen aktuellen Stand. Socket-Nachrichten enthalten immer den kompletten Zustand und keine Deltas, deshalb geht nichts verloren.
|
||
- **Backend-Neustart:** Unterbrochene Serverstarts werden als fehlgeschlagen markiert (mit „Server neu starten“). Unterbrochene Konfigurationen werden automatisch wiederholt.
|
||
|
||
### Automatisches Herunterfahren
|
||
|
||
Alle `SERVER_REAPER_INTERVAL_SECONDS` prüft das Backend alle aktiven Server:
|
||
|
||
- Ein Match ist **abgeschlossen oder abgebrochen**: Server wird gestoppt.
|
||
- **Lobby, Veto oder Server ohne Spielerzahl:** Ist niemand mit der Match-Seite verbunden und gab es länger als `SERVER_IDLE_TIMEOUT_MINUTES` keine Aktivität, wird der Server gestoppt.
|
||
- **Konfigurierter echter Server:** Gestoppt wird, wenn länger als die Frist keine Spieler auf dem Server waren. Die Spielerzahl kommt per RCON aus `status`.
|
||
- **Unabhängig davon:** Nach `SERVER_MAX_LIFETIME_HOURS` wird jeder Server gestoppt.
|
||
|
||
Ein gestoppter Server lässt sich vom Organisator neu starten.
|
||
|
||
---
|
||
|
||
## Map-Pools und Veto-Abläufe
|
||
|
||
Konfiguration in [`server/config/map-pools.json`](server/config/map-pools.json) (Pfad änderbar über `MAP_POOL_FILE`). Jeder Pool hat:
|
||
|
||
- eine eigene Map-Liste mit ID, Anzeigename und Kartenfarbe
|
||
- Teamgröße und `game_type`/`game_mode`
|
||
- je Format einen Ablauf
|
||
|
||
Schritte im Ablauf:
|
||
|
||
| Schritt | Bedeutung |
|
||
|---|---|
|
||
| `ban:X` / `ban:Y` | Team X bzw. Y bannt eine Map |
|
||
| `pick:X` / `pick:Y` | Team X bzw. Y wählt eine Map für die Serie |
|
||
| `side:X` / `side:Y` | Team wählt die Startseite (CT/T) auf der zuletzt gewählten Map bzw. der Entscheidungsmap |
|
||
| `decider` | die letzte verbleibende Map wird automatisch übernommen |
|
||
|
||
**X** ist das Team, das das Veto beginnt (Münzwurf oder vom Organisator festgelegt), **Y** das andere.
|
||
|
||
Beim Start wird jeder Ablauf geprüft:
|
||
|
||
- Anzahl Picks = Maps der Serie − 1
|
||
- Bans + Picks + 1 = Pool-Größe
|
||
- genau eine Entscheidungsmap
|
||
- Seitenwahl nur direkt nach einem Pick oder der Entscheidungsmap
|
||
|
||
Ungültige oder fehlende Abläufe machen nur dieses Format unauswählbar (mit Begründung in der Oberfläche).
|
||
|
||
### Mitgelieferte Abläufe
|
||
|
||
**Competitive (7 Maps):** Ancient, Anubis, Dust II, Inferno, Mirage, Nuke, Train
|
||
|
||
| Format | Ablauf |
|
||
|---|---|
|
||
| BO1 | X bannt, Y bannt, X bannt, Y bannt, X bannt, Y bannt → Entscheidungsmap, X wählt Seite |
|
||
| BO3 | X bannt, Y bannt, X pickt (Y wählt Seite), Y pickt (X wählt Seite), X bannt, Y bannt → Entscheidungsmap, X wählt Seite |
|
||
| BO5 | X bannt, Y bannt, X pickt (Y Seite), Y pickt (X Seite), X pickt (Y Seite), Y pickt (X Seite) → Entscheidungsmap, Y wählt Seite |
|
||
|
||
**Wingman (4 Maps):** Inferno, Nuke, Overpass, Vertigo
|
||
|
||
| Format | Ablauf |
|
||
|---|---|
|
||
| BO1 | X bannt, Y bannt, X bannt → Entscheidungsmap, Y wählt Seite |
|
||
| BO3 | X bannt, Y pickt (X Seite), X pickt (Y Seite) → Entscheidungsmap, X wählt Seite |
|
||
| BO5 | **nicht unterstützt:** Mit 4 Maps sind keine 5 Karten möglich. Mit einem Pool von mindestens 5 Maps und einem `bo5`-Ablauf wird es automatisch auswählbar. |
|
||
|
||
### Regel für Startseiten
|
||
|
||
- Auf einer **gewählten Map** wählt immer das **Gegnerteam des wählenden Teams** die Startseite.
|
||
- Auf der **Entscheidungsmap** wählt das Team, das im Ablauf dafür vorgesehen ist. In BO1 ist das in Competitive Team X, weil Y den letzten Ban hatte, und in Wingman Team Y. Ohne `side`-Schritt werden die Seiten im Spiel festgelegt.
|
||
|
||
Die Wahl wird als `mp_teamname_1` (Team mit CT-Start) und `mp_teamname_2` (T-Start) an den Server übergeben.
|
||
|
||
### Bilder für Maps und Seitenwahl
|
||
|
||
- **Map-Bilder** liegen lokal unter `client/public/maps/<map>.webp` (960 px breit, je ca. 50–90 KB). `"image"` in `map-pools.json` verweist darauf. Neue oder fehlende Bilder lädt `npm run images` von [ghostcap-gaming/cs2-map-images](https://github.com/ghostcap-gaming/cs2-map-images), verkleinert sie und trägt den Pfad ein (`npm run images -- --force` lädt alle neu). Die Bilder stammen aus dem Spiel; die Rechte liegen bei Valve. Ohne Bild zeigt die Kachel ein Muster in der Map-Farbe.
|
||
- **Seiten-Embleme** für CT und T liegen unter `client/public/sides/ct.png` und `client/public/sides/t.png` (quadratisch, z. B. 256×256). Fehlt eine Datei, erscheint ein schlichtes Ersatzsymbol.
|
||
- **Logo und Farben:** Das MaxLAN-e.V.-Logo liegt freigestellt unter `client/public/brand/maxlan-logo.webp`. Die Farben stehen als Variablen am Anfang von `client/src/styles.css`: Logo-Blau `#344A99` für Team A und Hauptbuttons, Logo-Rot `#A72A1C` für Team B und den Abschluss der Kopfzeile, jeweils aufgehellt für die Lesbarkeit.
|
||
- Bestehende Matches behalten die Map-Liste, mit der sie erstellt wurden. Neue Bilder erscheinen in neu erstellten Matches.
|
||
|
||
> Die mitgelieferten Map-Pools entsprechen dem Stand der Entwicklung. Valve ändert den aktiven Map-Pool regelmäßig. Map-IDs bitte gegen die eigene Serverinstallation prüfen. Eine Map, die nicht existiert, führt zu „Konfiguration fehlgeschlagen“ mit Meldung.
|
||
|
||
---
|
||
|
||
## Echte Server anbinden
|
||
|
||
> **Schritt-für-Schritt mit Portainer:** [docs/portainer-cs2.md](docs/portainer-cs2.md) beschreibt, wie du einen CS2-Server als Portainer-Stack aufsetzt, dessen Spieldateien nur einmal heruntergeladen werden, und ihn mit CS2 Mapveto verbindest.
|
||
|
||
Die Serververwaltung ist hinter [`server/src/servers/adapter.js`](server/src/servers/adapter.js) gekapselt. Auswahl über `SERVER_ADAPTER`.
|
||
|
||
### Ablauf, den jeder Adapter bedient
|
||
|
||
1. **`start({ matchId, mode, secrets })`**: Server starten bzw. reservieren.
|
||
- Rückgabe: `{ handle, host, port, tvPort }`.
|
||
- `secrets` (RCON-, Server- und CSTV-Passwort) erzeugt das Backend pro Match.
|
||
2. **`status(handle)`**: liefert `starting | running | stopped | failed`, optional mit Spielerzahl.
|
||
- Wird alle `SERVER_STATUS_POLL_SECONDS` abgefragt, solange der Server startet, danach alle 30 s.
|
||
3. **`configure(handle, plan)`**: in zwei Phasen.
|
||
1. `game_type`, `game_mode`, `sv_password`, `tv_enable`, `tv_delay`, `tv_password`, dann `changelevel <map>`.
|
||
2. Warten, bis `status` die Map meldet (max. 2 Minuten), dann die Match-Einstellungen (`mp_teamname_1/2`, `mp_maxrounds`, `mp_overtime_enable`, `mp_match_can_clinch`, `mp_warmup_pausetimer 1`, `CS2_EXTRA_COMMANDS`) und die Turnierregeln (siehe unten).
|
||
|
||
Die zweite Phase kommt erst nach dem Mapwechsel, weil die `gamemode_*.cfg` des Spiels diese Werte beim Laden der Map sonst überschreibt.
|
||
|
||
**Turnierregeln:** Jeder konfigurierte Server setzt außerdem diese Werte, die sich an FACEIT- und ESL-Richtwerten orientieren:
|
||
|
||
| Befehl | Wert | Zweck |
|
||
|---|---|---|
|
||
| `sv_pausable` | `1` | erlaubt Pausen (`mp_pause_match`) |
|
||
| `mp_halftime` | `1` | Seitenwechsel zur Halbzeit |
|
||
| `mp_freezetime` | `20` | Freezetime in Sekunden |
|
||
| `mp_buytime` | `20` | Kaufzeit in Sekunden |
|
||
| `mp_startmoney` | `800` | Startgeld |
|
||
| `mp_roundtime` | `1.92` | Rundenzeit in Minuten (1:55) |
|
||
|
||
Die Werte stammen nicht aus einer Regel eines konkreten Turniers. Vor einem echten Turnier bitte mit dem Regelwerk des Veranstalters abgleichen und in `server/src/servers/cs2Config.js` anpassen. Ob `sv_pausable` und die Pause-Befehle am echten Server wirken, ist noch nicht getestet.
|
||
4. **`stop(handle)`**: Server beenden bzw. freigeben.
|
||
|
||
### Option A: Docker (`SERVER_ADAPTER=docker`)
|
||
|
||
Startet pro Match einen Container mit dem Community-Image [`joedwards32/cs2`](https://github.com/joedwards32/CS2) (SteamCMD, CS2-Dedicated-Server App 730; gepflegter Nachfolger von `cm2network/cs2`).
|
||
|
||
Voraussetzungen:
|
||
|
||
1. Docker auf dem Backend-Host. Das Backend ruft die Docker-CLI auf und braucht dafür Zugriff auf den Docker-Daemon.
|
||
2. **Game Server Login Token** für App-ID 730 unter <https://steamcommunity.com/dev/managegameservers> erstellen und als `CS2_GSLT` eintragen. Ohne GSLT sind Server nicht öffentlich erreichbar. Pro gleichzeitig laufendem Server ist ein eigenes Token empfehlenswert.
|
||
3. `CS2_PUBLIC_HOST`: IP oder DNS-Name, unter dem Spieler den Host erreichen.
|
||
4. Ports `CS2_PORT_RANGE_START`–`CS2_PORT_RANGE_END` in Firewall bzw. Router freigeben:
|
||
- pro Match zwei aufeinanderfolgende Ports
|
||
- Spielport per UDP und TCP (TCP wird für RCON genutzt, nur für das Backend freigeben)
|
||
- CSTV-Port per UDP
|
||
5. Etwa 60 GB Speicher für die Spieldateien im Volume `CS2_DOCKER_VOLUME`. Das Volume wird von allen Match-Containern geteilt. Der erste Start lädt das Spiel herunter und kann deutlich länger als 10 Minuten dauern (`CS2_START_TIMEOUT_MINUTES`). Spieldateien am besten vorab mit einem einzelnen Container laden, bevor mehrere Matches gleichzeitig starten.
|
||
|
||
Container heißen `cs2mv-<matchId>` und tragen das Label `cs2-mapveto=1`. Stoppen bedeutet `docker rm -f`.
|
||
|
||
### Option B: Vorhandene Server (`SERVER_ADAPTER=static`)
|
||
|
||
Für selbst betriebene, dauerhaft laufende CS2-Server:
|
||
|
||
```env
|
||
SERVER_ADAPTER=static
|
||
CS2_STATIC_SERVERS=203.0.113.10:27015:rconPasswort1:27020,203.0.113.10:27025:rconPasswort2:27030
|
||
```
|
||
|
||
Auf jedem Server müssen gesetzt sein:
|
||
|
||
- `rcon_password`
|
||
- CSTV-Port `tv_port`, falls abweichend von Spielport + 5 (dieser Wert wird angenommen, wenn kein vierter Teil angegeben ist)
|
||
- ein GSLT (`sv_setsteamaccount`)
|
||
|
||
Bei diesem Adapter bedeutet „Start“: einen freien Server reservieren und per RCON prüfen. „Stopp“ gibt ihn frei und setzt ein neues zufälliges `sv_password`. Der Serverprozess selbst wird nicht beendet.
|
||
|
||
### Eigener Adapter (z. B. Hosting-API)
|
||
|
||
1. Klasse mit `name`, `simulated = false` und den vier Methoden anlegen. Vorlage: `dockerAdapter.js`. Für die Konfiguration per RCON gibt es `configureViaRcon` aus `rconConfigure.js`.
|
||
2. In `createServerAdapter` in `adapter.js` registrieren.
|
||
3. Zugangsdaten der Hosting-API ausschließlich über Umgebungsvariablen in `config.js` einlesen.
|
||
|
||
### Verbindung für Spieler
|
||
|
||
```
|
||
password <serverpasswort>; connect <host>:<port>
|
||
```
|
||
|
||
Zusätzlich bietet die Seite einen `steam://connect/<host>:<port>/<passwort>`-Link an.
|
||
|
||
### Zuschauer: CSTV (früher GOTV)
|
||
|
||
CS2 bringt **CSTV** mit (in der Konsole weiterhin `tv_*`-Befehle; früher GOTV). Die Anwendung setzt:
|
||
|
||
- `tv_enable 1` (greift erst nach dem Mapwechsel, deshalb vor `changelevel`)
|
||
- `tv_delay` (Standard 90 s) als Verzögerung gegen Ghosting
|
||
- `tv_password` als eigenes Passwort, getrennt vom Spielerpasswort
|
||
|
||
Zuschauer verbinden sich mit `password <tvpasswort>; connect <host>:<tvPort>`. Sie belegen keinen Spielerplatz und können die Spieler nicht beeinflussen. Für größere Zuschauerzahlen unterstützt CS2 CSTV-Relays (`tv_relay`) und HTTP-Broadcast (`tv_broadcast_url`). Beides ist nicht eingebaut und kann über `CS2_EXTRA_COMMANDS` bzw. die Serverkonfiguration ergänzt werden.
|
||
|
||
### Prüfstand der CS2-Angaben
|
||
|
||
Die offizielle Seite [Counter-Strike 2/Dedicated Servers](https://developer.valvesoftware.com/wiki/Counter-Strike_2/Dedicated_Servers) im Valve Developer Wiki war während der Entwicklung wegen eines Bot-Schutzes nicht automatisiert abrufbar. Grundlage waren:
|
||
|
||
- das [Source-RCON-Protokoll](https://developer.valvesoftware.com/wiki/Source_RCON_Protocol)
|
||
- die Dokumentation des Images [joedwards32/cs2](https://github.com/joedwards32/CS2) (vormals cm2network/cs2): Umgebungsvariablen, Ports, `TV_*`
|
||
- Hoster-Anleitungen zu CSTV, z. B. [ZAP-Hosting](https://zap-hosting.com/guides/de/docs/cs2-cstv)
|
||
- [FACEIT zu CSTV-Zuschauern](https://support.faceit.com/hc/en-us/articles/17050697184668)
|
||
|
||
Bekannte Werte: `game_type 0` + `game_mode 1` = Competitive, `game_mode 2` = Wingman; CSTV-Standardport 27020.
|
||
|
||
**Nicht gegen einen echten CS2-Server getestet:** Docker- und Static-Adapter sowie die Auswertung der `status`-Ausgabe (Map-Name, Spielerzahl). Das RCON-Protokoll ist gegen einen Test-Server geprüft. Vor dem ersten echten Einsatz bitte ein Testmatch durchspielen und bei Bedarf `parseStatus` in `server/src/servers/rcon.js` an die tatsächliche Ausgabe anpassen.
|
||
|
||
### 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.
|
||
- 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.
|
||
|
||
---
|
||
|
||
## Umgebungsvariablen
|
||
|
||
Alle Variablen mit Standardwerten stehen in [`.env.example`](.env.example). Die wichtigsten:
|
||
|
||
| Variable | Standard | Zweck |
|
||
|---|---|---|
|
||
| `PORT` | `3000` | Port des Backends |
|
||
| `PUBLIC_URL` | `http://localhost:5173` | Basis für Einladungslinks |
|
||
| `DATABASE_FILE` | `data/cs2-mapveto.db` | SQLite-Datei |
|
||
| `CREATE_MATCH_PASSWORD` | leer | Passwort zum Erstellen von Matches |
|
||
| `REQUIRE_FULL_TEAMS` | `false` | `true`: Veto nur mit vollständigen Teams |
|
||
| `SERVER_ADAPTER` | `demo` | `demo`, `docker` oder `static` |
|
||
| `SERVER_IDLE_TIMEOUT_MINUTES` | `30` | Frist bis zum automatischen Herunterfahren |
|
||
| `SERVER_MAX_LIFETIME_HOURS` | `8` | harte Obergrenze pro Server |
|
||
| `CS2_TV_DELAY` | `90` | CSTV-Verzögerung in Sekunden |
|
||
| `CS2_GSLT` | leer | Docker: Game Server Login Token; leer = LAN-Modus (`sv_lan 1`) |
|
||
| `CS2_PUBLIC_HOST` | – | **Pflicht bei Docker:** öffentliche Adresse |
|
||
| `CS2_STATIC_SERVERS` | – | **Pflicht bei Static:** Serverliste |
|
||
| `DEMO_FAIL_FIRST_CONFIGURE` | `false` | Demo: Konfigurationsfehler simulieren |
|
||
| `DEMO_ROUND_SECONDS` | `4` | Demo: Sekunden pro simulierter Runde |
|
||
| `LOG_RECEIVER_URL` | `PUBLIC_URL` | Adresse, unter der der CS2-Server das Backend für Live-Logs erreicht |
|
||
| `LOG_DEBUG` | `false` | empfangene Logzeilen im Terminal ausgeben |
|
||
| `STREAM_KEY` | leer | Schlüssel für die Stream-Übersicht aller Matches (`/stream?key=…`); leer = offen |
|
||
| `DISCORD_WEBHOOK_URL` | leer | Discord-Webhook für Meldungen zu Veto, Match-Start, Map- und Serienende sowie Serverfehlern; leer = aus |
|
||
| `TRUST_PROXY` | `false` | `true`, wenn die Anwendung hinter einem Reverse-Proxy läuft (echte Client-IP für die Begrenzung der Anmeldeversuche) |
|
||
| `SERIES_AUTO_ADVANCE_SECONDS` | `30` | Wartezeit nach Map-Ende bis zur nächsten Map bzw. zum Abschluss; `-1` = manuell |
|
||
|
||
Benötigte Zugangsdaten für den Echtbetrieb:
|
||
|
||
- **Docker:** Zugriff auf den Docker-Daemon (lokal oder per `DOCKER_HOST=ssh://…`), Portfreigaben, für Internet-Betrieb ein GSLT (im LAN nicht nötig)
|
||
- **Static:** RCON-Passwörter der Server
|
||
|
||
RCON-Passwörter für Docker-Container erzeugt das Backend selbst.
|
||
|
||
---
|
||
|
||
## Projektstruktur
|
||
|
||
```
|
||
client/ React-Frontend (Vite)
|
||
src/pages/ Übersicht, Match erstellen, Match-Seite, Beitritt
|
||
src/components/ Teamkarten, Veto-Board, Serverpanel, Einladungen, Verlauf
|
||
src/lib/ API, Socket-Hook, lokale Zugangsdaten
|
||
server/
|
||
config/map-pools.json Map-Pools und Veto-Abläufe
|
||
src/veto.js reine Veto-Logik und Ablaufvalidierung
|
||
src/matchService.js Lobby, Rollen, Veto, rollenabhängige Snapshots
|
||
src/servers/ Adapter-Schnittstelle, Demo/Docker/Static, RCON, ServerManager
|
||
src/http.js REST-API
|
||
src/realtime.js Socket.IO
|
||
test/ node:test-Tests
|
||
```
|
||
|
||
### REST-API (Auszug)
|
||
|
||
Alle Aktionen laufen per `POST` mit `Authorization: Bearer <token>`. Der Token ist der Organisator- oder Spieler-Token bzw. der Zuschauercode.
|
||
|
||
| Endpunkt | Wer |
|
||
|---|---|
|
||
| `GET /api/config`, `GET /api/matches` | alle |
|
||
| `POST /api/matches` | alle (ggf. mit `CREATE_MATCH_PASSWORD`) |
|
||
| `POST /api/access` `{code}` | prüft einen Code |
|
||
| `GET /api/matches/:id` | rollenabhängiger Zustand |
|
||
| `POST /api/matches/:id/join` `{code, nickname, steamId?}` | mit Teamcode |
|
||
| `…/ready`, `…/leave` | Spieler |
|
||
| `…/veto/action` `{stepIndex, map?, side?}` | Captain am Zug |
|
||
| `…/captain` `{playerId}` | Organisator, Captain des Teams |
|
||
| `…/veto/start`, `…/kick`, `…/codes/regenerate`, `…/server/retry`, `…/server/stop`, `…/server/warmup-end`, `…/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`.
|