From f7520a6ca7974e9315ca759aa15f929c53924972 Mon Sep 17 00:00:00 2001 From: MrEgosar Date: Sun, 25 Oct 2026 17:02:18 +0100 Subject: [PATCH] Frontend, README und Konfigurationsvorlage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit React-Oberfläche mit Übersicht, Match-Erstellung, Beitritt über Team- und Zuschauercodes, Teamkarten, visuellem Map-Veto, Serie und Serverpanel. README beschreibt Einrichtung, Umgebungsvariablen, Rollen, Veto-Abläufe, Serveradapter und CSTV-Zuschauerzugang. --- .env.example | 60 ++++ .gitignore | 1 + README.md | 327 ++++++++++++++++++++ client/index.html | 20 ++ client/src/App.jsx | 50 ++++ client/src/components/CopyField.jsx | 31 ++ client/src/components/EventLog.jsx | 19 ++ client/src/components/OrganizerPanel.jsx | 37 +++ client/src/components/SeriesList.jsx | 30 ++ client/src/components/ServerPanel.jsx | 110 +++++++ client/src/components/TeamCard.jsx | 64 ++++ client/src/components/VetoBoard.jsx | 117 ++++++++ client/src/lib/api.js | 26 ++ client/src/lib/labels.js | 51 ++++ client/src/lib/session.js | 34 +++ client/src/lib/useMatch.js | 80 +++++ client/src/main.jsx | 13 + client/src/pages/AccessPages.jsx | 92 ++++++ client/src/pages/CreateMatch.jsx | 124 ++++++++ client/src/pages/MatchPage.jsx | 174 +++++++++++ client/src/pages/Overview.jsx | 97 ++++++ client/src/styles.css | 366 +++++++++++++++++++++++ client/vite.config.js | 15 + server/src/config.js | 1 - 24 files changed, 1938 insertions(+), 1 deletion(-) create mode 100644 .env.example create mode 100644 README.md create mode 100644 client/index.html create mode 100644 client/src/App.jsx create mode 100644 client/src/components/CopyField.jsx create mode 100644 client/src/components/EventLog.jsx create mode 100644 client/src/components/OrganizerPanel.jsx create mode 100644 client/src/components/SeriesList.jsx create mode 100644 client/src/components/ServerPanel.jsx create mode 100644 client/src/components/TeamCard.jsx create mode 100644 client/src/components/VetoBoard.jsx create mode 100644 client/src/lib/api.js create mode 100644 client/src/lib/labels.js create mode 100644 client/src/lib/session.js create mode 100644 client/src/lib/useMatch.js create mode 100644 client/src/main.jsx create mode 100644 client/src/pages/AccessPages.jsx create mode 100644 client/src/pages/CreateMatch.jsx create mode 100644 client/src/pages/MatchPage.jsx create mode 100644 client/src/pages/Overview.jsx create mode 100644 client/src/styles.css create mode 100644 client/vite.config.js diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..03c94b3 --- /dev/null +++ b/.env.example @@ -0,0 +1,60 @@ +# Kopieren nach .env und anpassen. Alle Werte sind optional, außer beim jeweiligen Serveradapter angegeben. + +# --- Webanwendung --- +PORT=3000 +# Öffentliche Adresse der Webseite, wird für Einladungslinks verwendet. +# Entwicklung mit Vite: http://localhost:5173, Produktion: z. B. https://veto.example.de +PUBLIC_URL=http://localhost:5173 +DATABASE_FILE=data/cs2-mapveto.db +MAP_POOL_FILE=server/config/map-pools.json +# Wenn gesetzt, ist dieses Passwort zum Erstellen von Matches nötig. +CREATE_MATCH_PASSWORD= +# true: Veto erst, wenn beide Teams vollständig (2 bzw. 5 Spieler) und bereit sind. +REQUIRE_FULL_TEAMS=true +# true, wenn die App hinter einem Reverse-Proxy läuft (für korrekte Client-IP beim Rate-Limit). +TRUST_PROXY=false + +# --- Serververwaltung --- +# demo = simuliert, startet KEINE echten Server (Standard) +# docker = startet pro Match einen CS2-Container +# static = nutzt vorhandene, selbst betriebene CS2-Server per RCON +SERVER_ADAPTER=demo +SERVER_IDLE_TIMEOUT_MINUTES=30 +SERVER_MAX_LIFETIME_HOURS=8 +SERVER_STATUS_POLL_SECONDS=5 +SERVER_REAPER_INTERVAL_SECONDS=60 + +# --- Match-Einstellungen auf dem CS2-Server --- +CS2_TV_ENABLE=true +CS2_TV_DELAY=90 +CS2_MAXROUNDS_COMPETITIVE=24 +CS2_MAXROUNDS_WINGMAN=16 +CS2_OVERTIME=true +# Zusätzliche Konsolenbefehle nach dem Mapwechsel, durch ; getrennt +CS2_EXTRA_COMMANDS= + +# --- Demo-Adapter --- +DEMO_START_SECONDS=20 +# true: erste Konfiguration schlägt absichtlich fehl (zum Testen von "Erneut versuchen") +DEMO_FAIL_FIRST_CONFIGURE=false + +# --- Docker-Adapter (SERVER_ADAPTER=docker) --- +# Pflicht: Game Server Login Token für App-ID 730, https://steamcommunity.com/dev/managegameservers +CS2_GSLT= +# Pflicht: Adresse, unter der Spieler den Docker-Host erreichen (IP oder DNS-Name) +CS2_PUBLIC_HOST= +# Adresse, unter der das Backend die RCON-Ports der Container erreicht +CS2_RCON_HOST=127.0.0.1 +CS2_DOCKER_IMAGE=cm2network/cs2:latest +# Pro Match werden zwei Ports belegt (Spiel TCP+UDP, CSTV UDP) +CS2_PORT_RANGE_START=27015 +CS2_PORT_RANGE_END=27115 +# Gemeinsames Volume für die Spieldateien (~60 GB), damit nicht jedes Match neu herunterlädt +CS2_DOCKER_VOLUME=cs2-mapveto-gamefiles +CS2_DOCKER_EXTRA_ARGS= +CS2_START_TIMEOUT_MINUTES=20 +DOCKER_BIN=docker + +# --- Static-Pool-Adapter (SERVER_ADAPTER=static) --- +# host:port:rconPasswort[:tvPort], mehrere durch Komma getrennt +CS2_STATIC_SERVERS= diff --git a/.gitignore b/.gitignore index 5aa41f9..100cf3c 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ data/ .env .env.local npm-debug.log* +.claude/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..0bdfdf3 --- /dev/null +++ b/README.md @@ -0,0 +1,327 @@ +# 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 + +```bash +npm install +cp .env.example .env # optional, Standardwerte funktionieren für die Demo +npm run dev # Backend auf :3000, Frontend auf :5173 +``` + +Dann ö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/` + - Team B: `/join/` + - Zuschauer: `/watch/` 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, wenn es vollständig ist (abschaltbar mit `REQUIRE_FULL_TEAMS=false`), einen Captain hat und alle bereit sind. +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`), die nächste Map laden, den Server stoppen und das Match abschließen oder abbrechen. +8. **Fehlerfälle:** Startet der Server nicht, schlägt die Konfiguration fehl oder fällt der Server aus, zeigt die Seite den Grund. Der Organisator bekommt einen Knopf für den erneuten Versuch: „Server neu starten“ bzw. „Konfiguration wiederholen“. + +### Rollen und Berechtigungen + +| Rolle | Zugang über | Darf | +|---|---|---| +| Organisator | Token beim Erstellen | Veto starten, Spieler entfernen, Captain setzen, Codes erneuern, Server neu starten/stoppen/konfigurieren, nächste Map, Match beenden; sieht Einladungen, Spieler- und CSTV-Verbindung | +| 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 | + +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. + +> 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 + +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 `. + 2. Warten, bis `status` die Map meldet (max. 2 Minuten), dann die Match-Einstellungen (`mp_teamname_1/2`, `mp_maxrounds`, `mp_overtime_enable`, `mp_match_can_clinch`, `mp_warmup_pausetimer 1`, `CS2_EXTRA_COMMANDS`). + + Die zweite Phase kommt erst nach dem Mapwechsel, weil die `gamemode_*.cfg` des Spiels diese Werte beim Laden der Map sonst überschreibt. +4. **`stop(handle)`**: Server beenden bzw. freigeben. + +### Option A: Docker (`SERVER_ADAPTER=docker`) + +Startet pro Match einen Container mit dem Community-Image [`cm2network/cs2`](https://github.com/CM2Walki/CS2) (SteamCMD, CS2-Dedicated-Server App 730). + +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 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-` 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 ; connect : +``` + +Zusätzlich bietet die Seite einen `steam://connect/:/`-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 ; connect :`. 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 [cm2network/cs2](https://github.com/CM2Walki/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. +- Ergebnisse werden nicht ausgelesen. Der Organisator lädt die nächste Map manuell. + +--- + +## 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` | `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` | – | **Pflicht bei Docker:** Game Server Login Token | +| `CS2_PUBLIC_HOST` | – | **Pflicht bei Docker:** öffentliche Adresse | +| `CS2_STATIC_SERVERS` | – | **Pflicht bei Static:** Serverliste | +| `DEMO_FAIL_FIRST_CONFIGURE` | `false` | Demo: Konfigurationsfehler simulieren | + +Benötigte Zugangsdaten für den Echtbetrieb: + +- **Docker:** GSLT, Zugriff auf den Docker-Daemon, öffentliche IP bzw. Portfreigaben +- **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 `. 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`, `…/series/next`, `…/finish`, `…/cancel` | Organisator | + +Socket.IO: `match:subscribe {matchId, token}` liefert `match:state` bei jeder Änderung; `overview:subscribe` liefert `overview`. diff --git a/client/index.html b/client/index.html new file mode 100644 index 0000000..95d5713 --- /dev/null +++ b/client/index.html @@ -0,0 +1,20 @@ + + + + + + + CS2 Mapveto + + + + + + +
+ + + diff --git a/client/src/App.jsx b/client/src/App.jsx new file mode 100644 index 0000000..6ec8227 --- /dev/null +++ b/client/src/App.jsx @@ -0,0 +1,50 @@ +import { useEffect, useState } from 'react'; +import { Link, Route, Routes } from 'react-router-dom'; +import { api } from './lib/api.js'; +import Overview from './pages/Overview.jsx'; +import CreateMatch from './pages/CreateMatch.jsx'; +import MatchPage from './pages/MatchPage.jsx'; +import { JoinPage, WatchPage } from './pages/AccessPages.jsx'; + +export default function App() { + const [config, setConfig] = useState(null); + const [configError, setConfigError] = useState(null); + + useEffect(() => { + api('/config').then(setConfig).catch((e) => setConfigError(e.message)); + }, []); + + return ( +
+
+ + + CS2 Mapveto + + {config?.server.simulated && ( + + Demo-Modus: keine echten Server + + )} + +
+
+ {configError &&
Backend nicht erreichbar: {configError}
} + + } /> + } /> + } /> + } /> + } /> +

Seite nicht gefunden

Zur Übersicht
} /> + + + + ); +} diff --git a/client/src/components/CopyField.jsx b/client/src/components/CopyField.jsx new file mode 100644 index 0000000..7716b2d --- /dev/null +++ b/client/src/components/CopyField.jsx @@ -0,0 +1,31 @@ +import { useState } from 'react'; + +export default function CopyField({ label, value, secret = false }) { + const [copied, setCopied] = useState(false); + const [revealed, setRevealed] = useState(!secret); + const copy = async () => { + try { + await navigator.clipboard.writeText(value); + } catch { + // Fallback für Browser ohne Clipboard-API (z. B. HTTP ohne localhost) + const el = document.createElement('textarea'); + el.value = value; + document.body.appendChild(el); + el.select(); + document.execCommand('copy'); + el.remove(); + } + setCopied(true); + setTimeout(() => setCopied(false), 1800); + }; + return ( +
+ {label && {label}} +
+ setRevealed(true)}>{value} + {secret && !revealed && } + +
+
+ ); +} diff --git a/client/src/components/EventLog.jsx b/client/src/components/EventLog.jsx new file mode 100644 index 0000000..2ecc359 --- /dev/null +++ b/client/src/components/EventLog.jsx @@ -0,0 +1,19 @@ +import { clock } from '../lib/labels.js'; + +export default function EventLog({ events }) { + return ( +
+

Verlauf

+ {events.length === 0 ?

Noch keine Ereignisse.

: ( +
    + {events.map((e, i) => ( +
  1. + + {e.message} +
  2. + ))} +
+ )} +
+ ); +} diff --git a/client/src/components/OrganizerPanel.jsx b/client/src/components/OrganizerPanel.jsx new file mode 100644 index 0000000..4099eaa --- /dev/null +++ b/client/src/components/OrganizerPanel.jsx @@ -0,0 +1,37 @@ +import CopyField from './CopyField.jsx'; + +export default function OrganizerPanel({ state, act, busy, matchId, token }) { + const { codes, links } = state.organizer; + const open = !['finished', 'cancelled'].includes(state.status); + const orgLink = `${window.location.origin}/m/${matchId}#org=${token}`; + + const regen = (target, label) => + window.confirm(`Neuen Code für ${label} erzeugen? Der alte Link funktioniert dann nicht mehr. Bereits beigetretene Spieler bleiben im Team.`) && + act('codes/regenerate', { target }, `Neuer Code für ${label}`); + + return ( +
+

Einladungen

+

Jeder Link führt nur in das jeweilige Team. Gib ihn ausschließlich an dieses Team weiter.

+ {[ + ['A', state.teams.A.name, 'team-A'], + ['B', state.teams.B.name, 'team-B'], + ['spectator', 'Zuschauer (Stream, Kommentatoren)', 'spectator'], + ].map(([key, label, cls]) => ( +
+
+ {label} + {codes[key]} + {open && } +
+ +
+ ))} +
+ Organisator-Zugang auf einem anderen Gerät öffnen +

Dieser Link gibt volle Kontrolle über das Match. Nicht weitergeben.

+ +
+
+ ); +} diff --git a/client/src/components/SeriesList.jsx b/client/src/components/SeriesList.jsx new file mode 100644 index 0000000..2ad698c --- /dev/null +++ b/client/src/components/SeriesList.jsx @@ -0,0 +1,30 @@ +const STATUS = { pending: 'Ausstehend', next: 'Wird eingerichtet', live: 'Aktuell', done: 'Gespielt' }; + +export default function SeriesList({ state }) { + const { teams } = state; + return ( +
+

Map-Reihenfolge

+
    + {state.series.map((s) => { + const map = state.maps.find((m) => m.id === s.map); + const ct = s.ctTeam ? teams[s.ctTeam].name : null; + const t = s.ctTeam ? teams[s.ctTeam === 'A' ? 'B' : 'A'].name : null; + return ( +
  1. + Map {s.number} + {s.mapName} + + {s.pickedBy ? `Pick von ${teams[s.pickedBy].name}` : 'Entscheidungsmap'} + + + {ct ? <>CT: {ct} T: {t} : 'Seiten im Spiel festlegen'} + + {STATUS[s.status]} +
  2. + ); + })} +
+
+ ); +} diff --git a/client/src/components/ServerPanel.jsx b/client/src/components/ServerPanel.jsx new file mode 100644 index 0000000..081fc2f --- /dev/null +++ b/client/src/components/ServerPanel.jsx @@ -0,0 +1,110 @@ +import { serverLabel } from '../lib/labels.js'; +import CopyField from './CopyField.jsx'; + +const RETRY = { + start_failed: 'Server neu starten', + failed: 'Server neu starten', + stopped: 'Server neu starten', + config_failed: 'Konfiguration wiederholen', + ready: 'Konfiguration starten', +}; +const ACTIVE = ['starting', 'ready', 'configuring', 'configured', 'config_failed', 'failed']; + +export default function ServerPanel({ state, isOrg, act, busy }) { + const { server } = state; + const label = serverLabel(server); + const closed = ['finished', 'cancelled'].includes(state.status); + const sim = server?.simulated; + const canRetry = server && !closed && RETRY[server.status] && (server.status !== 'ready' || state.status === 'server_setup'); + const hasNext = state.series.some((s) => s.number === (state.currentMapNumber ?? 0) + 1); + + return ( +
+

Spielserver

+
+ + {label.text} +
+ {server?.message &&

{server.message}

} + + {sim && ( +
+ Demo-Adapter aktiv: Es läuft kein echter CS2-Server. Start, Konfiguration und Verbindungsdaten sind simuliert. + Wie die echte Anbindung eingerichtet wird, steht in der README. +
+ )} + + {!server &&

Für dieses Match wurde noch kein Server angefordert.

} + {server && ['starting', 'ready'].includes(server.status) && state.status !== 'server_setup' && ( +

+ {sim ? 'Der Start wird während des Vetos simuliert.' : 'Der Server fährt während des Vetos hoch.'} Die Verbindungsdaten erscheinen nach dem Veto. +

+ )} + + {server?.connect && ( +
+

{sim ? 'Spieler-Verbindung (Demo-Beispiel, nicht verbindbar)' : 'Verbindung für Spieler'}

+ + {!sim && In CS2 verbinden} +

Server {server.connect.host}:{server.connect.port}. Das Passwort ist nur für Spieler dieses Matches sichtbar.

+
+ )} + + {server?.gotv && ( +
+

{sim ? 'CSTV/GOTV (Demo-Beispiel, nicht verbindbar)' : 'Zuschauen über CSTV (GOTV)'}

+ +

+ Übertragung mit {server.gotv.delaySeconds} Sekunden Verzögerung gegen Ghosting. Zuschauer belegen keinen Spielerplatz. +

+
+ )} + {state.viewer.role === 'spectator' && server?.status === 'configured' && !server.gotv && ( +

CSTV ist auf diesem Server deaktiviert.

+ )} + + {isOrg && server && !closed && ( +
+ {canRetry && ( + + )} + {server.status === 'configured' && ( + + )} + {server.status === 'configured' && state.status === 'live' && hasNext && ( + + )} + {ACTIVE.includes(server.status) && ( + + )} +
+ )} + {isOrg && !closed && ( +
+ {['live', 'server_setup'].includes(state.status) && ( + + )} + +
+ )} + {server && ACTIVE.includes(server.status) && ( +

Ungenutzte Server werden nach {server.idleTimeoutMinutes} Minuten automatisch heruntergefahren.

+ )} +
+ ); +} diff --git a/client/src/components/TeamCard.jsx b/client/src/components/TeamCard.jsx new file mode 100644 index 0000000..24041bd --- /dev/null +++ b/client/src/components/TeamCard.jsx @@ -0,0 +1,64 @@ +export default function TeamCard({ team, side, state, viewer, isOrg, act, busy }) { + const isOwnTeam = viewer.team === side; + const inLobby = state.status === 'lobby'; + const me = team.players.find((p) => p.isYou); + const emptySlots = Math.max(0, team.teamSize - team.players.length); + const canManageCaptain = isOrg || (viewer.role === 'captain' && isOwnTeam); + const { readiness } = team; + + return ( +
+
+

{team.name}

+ + {readiness.ready ? 'Bereit' : `${readiness.count}/${team.teamSize}`} + +
+ +
    + {team.players.map((p) => ( +
  • + + + {p.nickname} + {p.isYou && (du)} + + {p.isCaptain && Captain} + {inLobby && {p.ready ? 'bereit' : 'wartet'}} + {(canManageCaptain && !p.isCaptain) || (isOrg && inLobby) ? ( + + {canManageCaptain && !p.isCaptain && ( + + )} + {isOrg && inLobby && ( + + )} + + ) : null} +
  • + ))} + {Array.from({ length: emptySlots }, (_, i) => ( +
  • Freier Platz
  • + ))} +
+ + {me && inLobby && ( +
+ + +
+ )} + {inLobby && !readiness.enough &&

Es fehlen noch {team.teamSize - readiness.count} Spieler.

} +
+ ); +} diff --git a/client/src/components/VetoBoard.jsx b/client/src/components/VetoBoard.jsx new file mode 100644 index 0000000..7903bc3 --- /dev/null +++ b/client/src/components/VetoBoard.jsx @@ -0,0 +1,117 @@ +import { useEffect, useState } from 'react'; +import { clock } from '../lib/labels.js'; + +const STEP_SHORT = { ban: 'Ban', pick: 'Pick', side: 'Seite', decider: 'Decider' }; + +export default function VetoBoard({ state, viewer, act, busy, compact = false }) { + const { veto, teams } = state; + const { current } = veto; + const [selected, setSelected] = useState(null); + useEffect(() => setSelected(null), [current?.index]); + + const mapsById = Object.fromEntries(state.maps.map((m) => [m.id, m])); + const yourTurn = !!current && viewer.role === 'captain' && viewer.team === current.team; + const choosingMap = yourTurn && (current.type === 'ban' || current.type === 'pick'); + const sideMap = current?.type === 'side' ? mapsById[current.map] : null; + + const submitMap = () => act('veto/action', { stepIndex: current.index, map: selected }); + const submitSide = (side) => act('veto/action', { stepIndex: current.index, side }); + + return ( +
+ {current && ( +
+ {teams[current.team].name} + + {current.type === 'ban' && 'bannt eine Map'} + {current.type === 'pick' && 'wählt eine Map'} + {current.type === 'side' && `wählt die Startseite auf ${sideMap?.name}`} + + + {yourTurn + ? 'Du bist am Zug.' + : viewer.team === current.team + ? 'Dein Captain entscheidet.' + : viewer.role === 'spectator' || viewer.role === 'organizer' + ? 'Live-Ansicht – nur Captains können wählen.' + : 'Das andere Team ist am Zug.'} + +
+ )} + {!current && veto.done && !compact &&
Veto abgeschlossen
} + +
+ {veto.maps.map((m) => { + const meta = mapsById[m.id]; + const selectable = choosingMap && m.status === 'available'; + const isSel = selected === m.id; + return ( + + ); + })} +
+ + {choosingMap && ( +
+ +
+ )} + + {yourTurn && current.type === 'side' && ( +
+

Auf welcher Seite startet {teams[viewer.team].name} auf {sideMap?.name}?

+
+ + +
+
+ )} + +
    + {veto.steps.map((s) => ( +
  1. + {STEP_SHORT[s.type]} +
  2. + ))} +
+ + {veto.history.length > 0 && !compact && ( +
    + {veto.history.map((h) => ( +
  1. + {h.text} +
  2. + ))} +
+ )} +
+ ); +} + +function statusText(m, teams) { + switch (m.status) { + case 'banned': return `Gebannt von ${teams[m.by].name}`; + case 'picked': return `Gewählt von ${teams[m.by].name}`; + case 'decider': return 'Entscheidungsmap'; + default: return 'Verfügbar'; + } +} diff --git a/client/src/lib/api.js b/client/src/lib/api.js new file mode 100644 index 0000000..f98f725 --- /dev/null +++ b/client/src/lib/api.js @@ -0,0 +1,26 @@ +export class ApiError extends Error { + constructor(message, status, code) { + super(message); + this.status = status; + this.code = code; + } +} + +export async function api(path, { method = 'GET', body, token } = {}) { + let res; + try { + res = await fetch(`/api${path}`, { + method, + headers: { + ...(body ? { 'content-type': 'application/json' } : {}), + ...(token ? { authorization: `Bearer ${token}` } : {}), + }, + body: body ? JSON.stringify(body) : undefined, + }); + } catch { + throw new ApiError('Keine Verbindung zum Server. Prüfe deine Internetverbindung.', 0, 'network'); + } + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new ApiError(data.error ?? `Fehler ${res.status}`, res.status, data.code); + return data; +} diff --git a/client/src/lib/labels.js b/client/src/lib/labels.js new file mode 100644 index 0000000..0324f1b --- /dev/null +++ b/client/src/lib/labels.js @@ -0,0 +1,51 @@ +export const FORMAT_LABEL = { bo1: 'Best of 1', bo3: 'Best of 3', bo5: 'Best of 5' }; + +export const ROLE_LABEL = { + organizer: 'Organisator', + captain: 'Captain', + player: 'Spieler', + spectator: 'Zuschauer', + public: 'Gast', +}; + +/** Serverstatus in Worte fassen. Simulierte Server werden nie als laufend bezeichnet. */ +export function serverLabel(server) { + if (!server) return { text: 'Kein Server', tone: 'muted' }; + const sim = server.simulated; + switch (server.status) { + case 'starting': + return { text: sim ? 'Demo: Start wird simuliert' : 'Server startet', tone: 'pending' }; + case 'ready': + return { text: sim ? 'Demo: simuliert bereit' : 'Server bereit', tone: sim ? 'demo' : 'ok' }; + case 'configuring': + return { text: sim ? 'Demo: Konfiguration simuliert' : 'Server wird konfiguriert', tone: 'pending' }; + case 'configured': + return { text: sim ? 'Demo: simuliert konfiguriert' : 'Server läuft', tone: sim ? 'demo' : 'ok' }; + case 'start_failed': + return { text: 'Serverstart fehlgeschlagen', tone: 'error' }; + case 'config_failed': + return { text: 'Konfiguration fehlgeschlagen', tone: 'error' }; + case 'failed': + return { text: 'Server ausgefallen', tone: 'error' }; + case 'stopped': + return { text: 'Server gestoppt', tone: 'muted' }; + default: + return { text: server.status, tone: 'muted' }; + } +} + +export const STEP_VERB = { ban: 'bannt eine Map', pick: 'wählt eine Map', side: 'wählt die Startseite', decider: 'Entscheidungsmap' }; + +export function timeAgo(ts) { + const s = Math.round((Date.now() - ts) / 1000); + if (s < 60) return 'gerade eben'; + const m = Math.round(s / 60); + if (m < 60) return `vor ${m} Min.`; + const h = Math.round(m / 60); + if (h < 24) return `vor ${h} Std.`; + return new Date(ts).toLocaleDateString('de-DE'); +} + +export function clock(ts) { + return new Date(ts).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', second: '2-digit' }); +} diff --git a/client/src/lib/session.js b/client/src/lib/session.js new file mode 100644 index 0000000..f1d1bbf --- /dev/null +++ b/client/src/lib/session.js @@ -0,0 +1,34 @@ +// Zugangsdaten pro Match im Browser. Nach Neuladen oder Verbindungsabbruch +// meldet sich der Client damit automatisch wieder an. +const KEY = (matchId) => `cs2mv:match:${matchId}`; + +export function getAccess(matchId) { + try { + return JSON.parse(localStorage.getItem(KEY(matchId))) ?? null; + } catch { + return null; + } +} + +export function setAccess(matchId, access) { + try { + localStorage.setItem(KEY(matchId), JSON.stringify(access)); + } catch { + /* privater Modus – Zugang gilt dann nur für diese Sitzung */ + } + memory.set(matchId, access); +} + +export function clearAccess(matchId) { + try { + localStorage.removeItem(KEY(matchId)); + } catch { + /* ignorieren */ + } + memory.delete(matchId); +} + +const memory = new Map(); +export function getToken(matchId) { + return (getAccess(matchId) ?? memory.get(matchId))?.token ?? null; +} diff --git a/client/src/lib/useMatch.js b/client/src/lib/useMatch.js new file mode 100644 index 0000000..b4ea7a8 --- /dev/null +++ b/client/src/lib/useMatch.js @@ -0,0 +1,80 @@ +import { useEffect, useRef, useState } from 'react'; +import { io } from 'socket.io-client'; +import { api } from './api.js'; + +let shared = null; +export function getSocket() { + if (!shared) shared = io({ path: '/socket.io', transports: ['websocket', 'polling'] }); + return shared; +} + +/** Abonniert den Live-Zustand eines Matches. Meldet sich nach jedem Reconnect neu an. */ +export function useMatch(matchId, token) { + const [state, setState] = useState(null); + const [error, setError] = useState(null); + const [connected, setConnected] = useState(false); + const tokenRef = useRef(token); + tokenRef.current = token; + + useEffect(() => { + const socket = getSocket(); + const subscribe = () => socket.emit('match:subscribe', { matchId, token: tokenRef.current }); + const onState = (s) => { + if (s.id !== matchId) return; + setState(s); + setError(null); + }; + const onError = (e) => setError(e); + const onConnect = () => { + setConnected(true); + subscribe(); + }; + const onDisconnect = () => setConnected(false); + + socket.on('match:state', onState); + socket.on('match:error', onError); + socket.on('connect', onConnect); + socket.on('disconnect', onDisconnect); + if (socket.connected) onConnect(); + + // Erster Stand per REST, falls der Socket noch verbindet; neuere Socket-Stände haben Vorrang. + let active = true; + api(`/matches/${matchId}`, { token }) + .then((s) => active && setState((prev) => (prev && prev.updatedAt >= s.updatedAt ? prev : s))) + .catch((e) => active && setError({ error: e.message, code: e.code })); + + return () => { + active = false; + socket.off('match:state', onState); + socket.off('match:error', onError); + socket.off('connect', onConnect); + socket.off('disconnect', onDisconnect); + }; + }, [matchId, token]); + + return { state, error, connected }; +} + +export function useOverview() { + const [matches, setMatches] = useState(null); + const [connected, setConnected] = useState(false); + useEffect(() => { + const socket = getSocket(); + const onConnect = () => { + setConnected(true); + socket.emit('overview:subscribe'); + }; + const onOverview = (d) => setMatches(d.matches); + const onDisconnect = () => setConnected(false); + socket.on('overview', onOverview); + socket.on('connect', onConnect); + socket.on('disconnect', onDisconnect); + if (socket.connected) onConnect(); + return () => { + socket.off('overview', onOverview); + socket.off('connect', onConnect); + socket.off('disconnect', onDisconnect); + }; + }, []); + return { matches, connected }; +} diff --git a/client/src/main.jsx b/client/src/main.jsx new file mode 100644 index 0000000..1387b77 --- /dev/null +++ b/client/src/main.jsx @@ -0,0 +1,13 @@ +import React from 'react'; +import { createRoot } from 'react-dom/client'; +import { BrowserRouter } from 'react-router-dom'; +import App from './App.jsx'; +import './styles.css'; + +createRoot(document.getElementById('root')).render( + + + + + , +); diff --git a/client/src/pages/AccessPages.jsx b/client/src/pages/AccessPages.jsx new file mode 100644 index 0000000..cdac953 --- /dev/null +++ b/client/src/pages/AccessPages.jsx @@ -0,0 +1,92 @@ +import { useEffect, useState } from 'react'; +import { Link, useNavigate, useParams } from 'react-router-dom'; +import { api } from '../lib/api.js'; +import { getAccess, setAccess } from '../lib/session.js'; + +function useCode() { + const { code } = useParams(); + const [info, setInfo] = useState(null); + const [error, setError] = useState(null); + useEffect(() => { + api('/access', { method: 'POST', body: { code } }).then(setInfo).catch((e) => setError(e.message)); + }, [code]); + return { code, info, error }; +} + +export function JoinPage() { + const { code, info, error } = useCode(); + const navigate = useNavigate(); + const [nickname, setNickname] = useState(''); + const [steamId, setSteamId] = useState(''); + const [submitError, setSubmitError] = useState(null); + const [busy, setBusy] = useState(false); + + useEffect(() => { + if (!info) return; + if (info.access === 'spectator') navigate(`/watch/${code}`, { replace: true }); + // Schon beigetreten? Dann direkt zur Lobby. + else if (getAccess(info.matchId)) navigate(`/m/${info.matchId}`, { replace: true }); + }, [info, code, navigate]); + + if (error) return ; + if (!info) return

Prüfe Code …

; + + const submit = async (e) => { + e.preventDefault(); + setBusy(true); + setSubmitError(null); + try { + const res = await api(`/matches/${info.matchId}/join`, { method: 'POST', body: { code, nickname, steamId } }); + setAccess(info.matchId, { token: res.playerToken, kind: 'player' }); + navigate(`/m/${info.matchId}`); + } catch (err) { + setSubmitError(err.message); + setBusy(false); + } + }; + + return ( +
+

{info.title}

+

Beitreten als {info.teamName}

+ + + {submitError &&
{submitError}
} + +
+ ); +} + +export function WatchPage() { + const { code, info, error } = useCode(); + const navigate = useNavigate(); + useEffect(() => { + if (!info) return; + if (info.access !== 'spectator') { + navigate(`/join/${code}`, { replace: true }); + return; + } + // Spieler oder Organisator behalten ihren stärkeren Zugang. + if (!getAccess(info.matchId)) setAccess(info.matchId, { token: code, kind: 'spectator' }); + navigate(`/m/${info.matchId}`, { replace: true }); + }, [info, code, navigate]); + if (error) return ; + return

Öffne Zuschauerzugang …

; +} + +function AccessError({ message }) { + return ( +
+

Code nicht gültig

+

{message}. Prüfe den Link oder frag den Organisator nach einem neuen Code.

+ Zur Übersicht +
+ ); +} diff --git a/client/src/pages/CreateMatch.jsx b/client/src/pages/CreateMatch.jsx new file mode 100644 index 0000000..bb10937 --- /dev/null +++ b/client/src/pages/CreateMatch.jsx @@ -0,0 +1,124 @@ +import { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { api } from '../lib/api.js'; +import { FORMAT_LABEL } from '../lib/labels.js'; +import { setAccess } from '../lib/session.js'; + +export default function CreateMatch({ config }) { + const navigate = useNavigate(); + const [form, setForm] = useState({ teamA: '', teamB: '', mode: 'competitive', format: 'bo3', vetoStart: 'coin', password: '' }); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + const pool = config?.pools.find((p) => p.mode === form.mode); + const set = (key) => (e) => setForm((f) => ({ ...f, [key]: e.target.value })); + + // Wird ein Format für den neuen Modus nicht unterstützt, auf das erste unterstützte wechseln. + useEffect(() => { + if (pool && !pool.formats[form.format]?.supported) { + const first = Object.keys(pool.formats).find((f) => pool.formats[f].supported); + if (first) setForm((f) => ({ ...f, format: first })); + } + }, [pool, form.format]); + + const submit = async (e) => { + e.preventDefault(); + setBusy(true); + setError(null); + try { + const res = await api('/matches', { method: 'POST', body: form }); + setAccess(res.id, { token: res.organizerToken, kind: 'organizer' }); + navigate(`/m/${res.id}`); + } catch (err) { + setError(err.message); + setBusy(false); + } + }; + + if (!config) return

Lade Einstellungen …

; + + return ( +
+

Match erstellen

+

Mit dem Erstellen der Lobby wird sofort ein Server angefordert, damit er während des Vetos hochfahren kann.

+ +
+ + vs. + +
+ +
+ Spielmodus +
+ {config.pools.map((p) => ( + + ))} +
+
+ +
+ Serie +
+ {Object.entries(pool.formats).map(([f, info]) => ( + + ))} +
+ {pool.formats[form.format]?.flow && } +
+ + + + {config.createRequiresPassword && ( + + )} + + {error &&
{error}
} + +
+ ); +} + +const STEP_TEXT = { ban: 'Ban', pick: 'Pick', side: 'Seite', decider: 'Entscheidungsmap' }; + +function FlowPreview({ flow }) { + return ( +
    + {flow.map((raw, i) => { + const [type, actor] = raw.split(':'); + return ( +
  1. + {STEP_TEXT[type]} + {actor && {actor === 'X' ? 'Erstes Team' : 'Zweites Team'}} +
  2. + ); + })} +
+ ); +} diff --git a/client/src/pages/MatchPage.jsx b/client/src/pages/MatchPage.jsx new file mode 100644 index 0000000..5a3fde2 --- /dev/null +++ b/client/src/pages/MatchPage.jsx @@ -0,0 +1,174 @@ +import { useCallback, useEffect, useState } from 'react'; +import { Link, useParams } from 'react-router-dom'; +import { api } from '../lib/api.js'; +import { FORMAT_LABEL, ROLE_LABEL, serverLabel } from '../lib/labels.js'; +import { clearAccess, getToken, setAccess } from '../lib/session.js'; +import { useMatch } from '../lib/useMatch.js'; +import TeamCard from '../components/TeamCard.jsx'; +import VetoBoard from '../components/VetoBoard.jsx'; +import ServerPanel from '../components/ServerPanel.jsx'; +import OrganizerPanel from '../components/OrganizerPanel.jsx'; +import SeriesList from '../components/SeriesList.jsx'; +import EventLog from '../components/EventLog.jsx'; + +export default function MatchPage() { + const { id } = useParams(); + const [token, setToken] = useState(() => { + // Organisator-Link für ein weiteres Gerät: /m/#org= + const hash = new URLSearchParams(window.location.hash.slice(1)); + const org = hash.get('org'); + if (org) { + setAccess(id, { token: org, kind: 'organizer' }); + window.history.replaceState(null, '', window.location.pathname); + } + return getToken(id); + }); + const { state, error, connected } = useMatch(id, token); + const [busy, setBusy] = useState(null); + const [toast, setToast] = useState(null); + + // Gespeicherter Zugang ist ungültig geworden (z. B. aus dem Team entfernt) + const lostAccess = state && token && state.viewer.role === 'public'; + useEffect(() => { + if (lostAccess) { + clearAccess(id); + setToken(null); + setToast({ tone: 'error', text: 'Dein gespeicherter Zugang ist nicht mehr gültig. Tritt mit deinem Teamcode erneut bei.' }); + } + }, [lostAccess, id]); + + useEffect(() => { + if (!toast) return undefined; + const t = setTimeout(() => setToast(null), 6000); + return () => clearTimeout(t); + }, [toast]); + + /** Führt eine Aktion aus; verhindert Doppelklicks, solange sie läuft. */ + const act = useCallback(async (path, body, success) => { + if (busy) return; + setBusy(path); + try { + await api(`/matches/${id}/${path}`, { method: 'POST', body: body ?? {}, token }); + if (success) setToast({ tone: 'ok', text: success }); + } catch (err) { + setToast({ tone: 'error', text: err.message }); + } finally { + setBusy(null); + } + }, [busy, id, token]); + + if (error && !state) { + return ( +
+

{error.code === 'not_found' ? 'Match nicht gefunden' : 'Fehler'}

+

{error.error}

+ Zur Übersicht +
+ ); + } + if (!state) return

Verbinde mit Lobby …

; + + const { viewer } = state; + const isOrg = viewer.role === 'organizer'; + const server = serverLabel(state.server); + const ctx = { state, act, busy, isOrg, viewer }; + + return ( +
+ {!connected &&
Verbindung unterbrochen – stelle wieder her …
} + {toast &&
{toast.text}
} + +
+
+

+ {state.teams.A.name} + vs. + {state.teams.B.name} +

+
+
+
Modus
{state.modeLabel}
+
Serie
{FORMAT_LABEL[state.format]}
+
Status
{state.statusLabel}
+
Server
{server.text}
+
Du bist
{ROLE_LABEL[viewer.role]}{viewer.team && `, ${state.teams[viewer.team].name}`}
+
+
+ + {viewer.role === 'public' && } + +
+ +
+ +
+ +
+ + {viewer.role !== 'public' && ( +
+ + {isOrg && } + +
+ )} +
+ ); +} + +function Stage(ctx) { + const { state, isOrg, act, busy } = ctx; + if (state.viewer.role === 'public') { + return

{state.statusLabel}

Details sehen nur Teilnehmer.

; + } + if (state.status === 'lobby') { + const { A, B } = state.teams; + const missing = [A, B].filter((t) => !t.readiness.ready); + return ( +
+

Warten auf beide Teams

+

+ {missing.length === 0 + ? 'Beide Teams sind bereit.' + : missing.map((t) => `${t.name}: ${t.readiness.count}/${t.teamSize} Spieler, ${t.readiness.allReady ? 'alle bereit' : 'noch nicht alle bereit'}`).join(' – ')} +

+ {isOrg ? ( + + ) : ( +

Der Organisator startet das Veto, sobald beide Teams bereit sind.

+ )} + +
+ ); + } + if (state.status === 'veto') return ; + return ( + <> + + + + ); +} + +function MapPoolPreview({ maps }) { + return ( +
+ {maps.map((m) => ( + {m.name} + ))} +
+ ); +} + +function PublicNotice() { + return ( +
+ Du siehst diese Lobby ohne Zugang. Spieler treten über den Einladungslink ihres Teams bei, + Zuschauer über den Zuschauerlink. Beides bekommst du vom Organisator. +
+ ); +} + diff --git a/client/src/pages/Overview.jsx b/client/src/pages/Overview.jsx new file mode 100644 index 0000000..78c7769 --- /dev/null +++ b/client/src/pages/Overview.jsx @@ -0,0 +1,97 @@ +import { useState } from 'react'; +import { Link, useNavigate } from 'react-router-dom'; +import { api } from '../lib/api.js'; +import { FORMAT_LABEL, serverLabel, timeAgo } from '../lib/labels.js'; +import { getAccess } from '../lib/session.js'; +import { useOverview } from '../lib/useMatch.js'; + +const GROUPS = [ + { key: 'open', title: 'Offen', empty: 'Keine offenen Lobbys. Erstelle ein Match, um zu starten.' }, + { key: 'running', title: 'Laufend', empty: 'Gerade läuft kein Match.' }, + { key: 'closed', title: 'Abgeschlossen', empty: 'Noch keine abgeschlossenen Matches.' }, +]; + +export default function Overview() { + const { matches } = useOverview(); + return ( +
+
+
+

Match-Lobbys

+

Teams treten mit ihrem Einladungscode bei, die Captains führen das Map-Veto, der Server wird automatisch eingerichtet.

+
+ +
+ + {matches === null ? ( +

Lade Matches …

+ ) : ( +
+ {GROUPS.map((g) => { + const list = matches.filter((m) => m.group === g.key); + return ( +
+

{g.title} {list.length}

+ {list.length === 0 ?

{g.empty}

: ( +
    + {list.map((m) => )} +
+ )} +
+ ); + })} +
+ )} +
+ ); +} + +function MatchRow({ match: m }) { + const access = getAccess(m.id); + const server = serverLabel(m.server); + return ( +
  • + +
    + {m.teamA} + vs. + {m.teamB} +
    +
    + {m.modeLabel} + {FORMAT_LABEL[m.format]} + {m.group === 'open' && {m.players.A + m.players.B}/{m.players.teamSize * 2} Spieler} + {m.statusLabel} + {access && Dein Zugang} + {timeAgo(m.createdAt)} +
    + +
  • + ); +} + +function CodeEntry() { + const [code, setCode] = useState(''); + const [error, setError] = useState(null); + const navigate = useNavigate(); + const submit = async (e) => { + e.preventDefault(); + setError(null); + try { + const res = await api('/access', { method: 'POST', body: { code } }); + navigate(res.access === 'spectator' ? `/watch/${code.trim()}` : `/join/${code.trim()}`); + } catch (err) { + setError(err.message); + } + }; + return ( +
    + +
    + setCode(e.target.value)} placeholder="ABCD-EFGH" autoComplete="off" spellCheck="false" /> + +
    + {error &&

    {error}

    } +
    + ); +} diff --git a/client/src/styles.css b/client/src/styles.css new file mode 100644 index 0000000..5bb1b98 --- /dev/null +++ b/client/src/styles.css @@ -0,0 +1,366 @@ +/* ---------- Tokens ---------- */ +:root { + --bg: #10151f; + --surface: #161d2a; + --surface-2: #1d2636; + --line: #2a3448; + --line-strong: #3a4760; + --text: #e6ebf2; + --muted: #8a96ab; + + --team-a: #3cc6c0; + --team-b: #e86fa0; + --ct: #6ca8e8; + --t: #dda84a; + + --ok: #49d48f; + --pending: #e8c25a; + --error: #f0605d; + --demo: #b39bff; + + --display: 'Barlow Condensed', 'Arial Narrow', sans-serif; + --body: 'Barlow', system-ui, sans-serif; + + --radius: 6px; + --chamfer: 14px; + color-scheme: dark; +} + +* { box-sizing: border-box; } +html, body { margin: 0; } +body { + background: var(--bg); + color: var(--text); + font: 400 16px/1.5 var(--body); + -webkit-font-smoothing: antialiased; +} +h1, h2, h3 { font-family: var(--display); font-weight: 600; line-height: 1.1; margin: 0 0 .5em; letter-spacing: .01em; } +h1 { font-size: clamp(2rem, 4vw, 2.75rem); } +h2 { font-size: 1.375rem; } +h3 { font-size: 1.0625rem; } +p { margin: 0 0 .75em; } +a { color: var(--ct); } +code { font-family: ui-monospace, 'Cascadia Code', Consolas, monospace; font-size: .875em; } +.muted { color: var(--muted); } +.small { font-size: .875rem; } +.lead { color: var(--muted); max-width: 60ch; font-size: 1.0625rem; } +:focus-visible { outline: 2px solid var(--ct); outline-offset: 2px; } + +.tone-ok { color: var(--ok); } +.tone-pending { color: var(--pending); } +.tone-error { color: var(--error); } +.tone-demo { color: var(--demo); } +.tone-muted { color: var(--muted); } +.team-a { color: var(--team-a); } +.team-b { color: var(--team-b); } + +/* ---------- Layout ---------- */ +.topbar { + display: flex; align-items: center; gap: 16px; + padding: 12px clamp(16px, 4vw, 40px); + border-bottom: 1px solid var(--line); + background: var(--surface); + position: sticky; top: 0; z-index: 10; +} +.brand { display: flex; align-items: center; gap: 10px; color: var(--text); text-decoration: none; font: 700 1.25rem var(--display); } +.topbar nav { margin-left: auto; display: flex; align-items: center; gap: 16px; } +.nav-link { color: var(--muted); text-decoration: none; } +.nav-link:hover { color: var(--text); } +.demo-flag { + font-size: .8125rem; color: var(--demo); border: 1px solid color-mix(in srgb, var(--demo) 50%, transparent); + padding: 2px 10px; border-radius: 999px; +} +.content { max-width: 1320px; margin: 0 auto; padding: 32px clamp(16px, 4vw, 40px) 80px; } + +/* ---------- Buttons & Felder ---------- */ +.btn { + --btn: var(--surface-2); + display: inline-flex; align-items: center; justify-content: center; gap: 8px; + font: 600 .9375rem var(--body); + color: var(--text); background: var(--btn); + border: 1px solid var(--line-strong); border-radius: var(--radius); + padding: 9px 16px; cursor: pointer; text-decoration: none; + transition: background .15s, border-color .15s; +} +.btn:hover:not(:disabled) { border-color: var(--muted); } +.btn:disabled { opacity: .45; cursor: not-allowed; } +.btn-primary { background: var(--team-a); border-color: var(--team-a); color: #06201f; } +.btn-primary:hover:not(:disabled) { background: #5ad6d0; border-color: #5ad6d0; } +.btn-danger { background: var(--error); border-color: var(--error); color: #2a0606; } +.btn-ct { background: color-mix(in srgb, var(--ct) 22%, var(--surface)); border-color: var(--ct); } +.btn-t { background: color-mix(in srgb, var(--t) 22%, var(--surface)); border-color: var(--t); } +.btn-ghost { background: transparent; } +.btn-sm { padding: 5px 12px; font-size: .875rem; } +.btn-lg { padding: 13px 22px; font-size: 1.0625rem; } +.btn.danger, .link-btn.danger { color: var(--error); } +.link-btn { background: none; border: 0; padding: 0; color: var(--ct); font: inherit; font-size: .875rem; cursor: pointer; } +.link-btn:disabled { opacity: .5; cursor: not-allowed; } + +.field { display: flex; flex-direction: column; gap: 6px; margin-bottom: 18px; } +.field > span { font-weight: 600; font-size: .9375rem; } +input, select { + font: inherit; color: var(--text); background: var(--bg); + border: 1px solid var(--line-strong); border-radius: var(--radius); padding: 10px 12px; min-width: 0; +} +input:focus, select:focus { border-color: var(--ct); outline: none; } +.hint { color: var(--muted); font-size: .8125rem; } +.field-error { color: var(--error); font-size: .875rem; margin-top: 6px; } +.row { display: flex; gap: 10px; flex-wrap: wrap; } + +.alert { border: 1px solid var(--line-strong); background: var(--surface); padding: 12px 16px; border-radius: var(--radius); margin-bottom: 16px; } +.alert-error { border-color: var(--error); color: #ffc9c7; } +.alert-demo { border-color: color-mix(in srgb, var(--demo) 55%, transparent); background: color-mix(in srgb, var(--demo) 9%, var(--surface)); color: #ded3ff; font-size: .875rem; } + +.empty { text-align: center; padding: 80px 16px; } + +/* ---------- Übersicht ---------- */ +.overview-head { display: flex; gap: 32px; justify-content: space-between; align-items: flex-end; flex-wrap: wrap; margin-bottom: 40px; } +.code-entry { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); padding: 16px; width: min(100%, 380px); } +.code-entry label { display: block; font-weight: 600; margin-bottom: 8px; } +.code-entry input { flex: 1; text-transform: uppercase; letter-spacing: .08em; } +.groups { display: grid; gap: 40px; } +.group h2 { display: flex; align-items: baseline; gap: 10px; } +.count { font: 500 .9375rem var(--body); color: var(--muted); } +.match-list { list-style: none; margin: 0; padding: 0; border-top: 1px solid var(--line); } +.match-row { + display: flex; flex-wrap: wrap; gap: 8px 24px; align-items: center; justify-content: space-between; + padding: 14px 4px; border-bottom: 1px solid var(--line); color: var(--text); text-decoration: none; +} +.match-row:hover { background: var(--surface); } +.match-row-teams { font: 600 1.375rem var(--display); display: flex; gap: 10px; align-items: baseline; } +.vs { color: var(--muted); font: 500 .9375rem var(--body); } +.vs.big { font-size: 1.25rem; align-self: center; } +.match-row-meta { display: flex; flex-wrap: wrap; gap: 6px 16px; font-size: .875rem; color: var(--muted); } +.mine { color: var(--team-a); } +.group-closed .match-row-teams { opacity: .7; } + +/* ---------- Match erstellen ---------- */ +.create, .join-card { max-width: 720px; } +.create-teams { display: grid; grid-template-columns: 1fr auto 1fr; gap: 16px; align-items: end; } +.team-field-a input { border-left: 4px solid var(--team-a); } +.team-field-b input { border-left: 4px solid var(--team-b); } +.choice { border: 0; padding: 0; margin: 8px 0 22px; } +.choice legend { font-weight: 600; margin-bottom: 8px; } +.choice-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 10px; } +.choice-card { + position: relative; display: flex; flex-direction: column; gap: 2px; + padding: 14px 16px; border: 1px solid var(--line-strong); border-radius: var(--radius); background: var(--surface); cursor: pointer; +} +.choice-card input { position: absolute; opacity: 0; pointer-events: none; } +.choice-card strong { font: 600 1.375rem var(--display); } +.choice-card span { color: var(--muted); font-size: .875rem; } +.choice-card small { color: var(--muted); } +.choice-card.is-on { border-color: var(--team-a); box-shadow: inset 0 0 0 1px var(--team-a); } +.choice-card.is-off { opacity: .45; cursor: not-allowed; } +.choice-card:has(input:focus-visible) { outline: 2px solid var(--ct); outline-offset: 2px; } +.flow-preview { list-style: none; display: flex; flex-wrap: wrap; gap: 4px; padding: 0; margin: 12px 0 0; } +.flow-step { font-size: .75rem; padding: 3px 8px; border-radius: 3px; background: var(--surface-2); display: flex; flex-direction: column; line-height: 1.2; } +.flow-step small { color: var(--muted); font-size: .6875rem; } +.flow-ban { box-shadow: inset 0 -2px 0 var(--error); } +.flow-pick { box-shadow: inset 0 -2px 0 var(--ok); } +.flow-decider { box-shadow: inset 0 -2px 0 var(--text); } + +.join-card { margin: 0 auto; background: var(--surface); border: 1px solid var(--line); border-top: 4px solid var(--team-a); padding: 28px; border-radius: var(--radius); } +.join-card.team-B { border-top-color: var(--team-b); } + +/* ---------- Match-Seite ---------- */ +.match-head { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 16px 40px; align-items: flex-end; margin-bottom: 28px; } +.versus h1 { display: flex; flex-wrap: wrap; align-items: baseline; gap: .1em .4em; font-size: clamp(2.25rem, 5.5vw, 4rem); font-weight: 700; margin: 0; } +.match-facts { display: flex; flex-wrap: wrap; gap: 12px 28px; margin: 0; } +.match-facts dt { color: var(--muted); font-size: .8125rem; } +.match-facts dd { margin: 0; font-weight: 600; } + +.match-grid { display: grid; grid-template-columns: minmax(220px, 280px) minmax(0, 1fr) minmax(220px, 280px); gap: 20px; align-items: start; } +.stage { min-width: 0; } +.stage-note { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); padding: 24px; } +.stage-note .btn { margin: 8px 0 20px; } +.pool-preview { display: flex; flex-wrap: wrap; gap: 6px; } +.pool-chip { padding: 4px 10px; border-left: 3px solid var(--map); background: var(--surface-2); font-size: .875rem; border-radius: 3px; } + +.team-card { --team: var(--team-a); background: var(--surface); border: 1px solid var(--line); border-top: 4px solid var(--team); border-radius: var(--radius); padding: 16px; } +.team-card.team-B { --team: var(--team-b); } +.team-card header { display: flex; justify-content: space-between; align-items: baseline; gap: 8px; } +.team-card h2 { color: var(--team); font-size: 1.625rem; margin: 0 0 10px; overflow-wrap: anywhere; } +.ready-state { font-size: .8125rem; color: var(--muted); white-space: nowrap; } +.ready-state.on { color: var(--ok); font-weight: 600; } +.roster { list-style: none; padding: 0; margin: 0 0 12px; } +.roster li { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 8px; padding: 8px 0; border-bottom: 1px solid var(--line); } +.roster li.is-you .nick { color: var(--team); } +.nick { font-weight: 600; flex: 1; min-width: 0; overflow-wrap: anywhere; } +.nick small { font-weight: 400; color: var(--muted); } +.presence { width: 8px; height: 8px; border-radius: 50%; background: var(--line-strong); flex: none; } +.presence.on { background: var(--ok); } +.captain { font-size: .75rem; padding: 1px 6px; border: 1px solid var(--team); color: var(--team); border-radius: 3px; } +.ready-dot { font-size: .75rem; color: var(--muted); } +.ready-dot.on { color: var(--ok); } +.row-actions { flex-basis: 100%; display: flex; gap: 14px; padding-left: 16px; } +.slot-empty { color: var(--muted); font-style: italic; font-size: .9375rem; border-bottom-style: dashed !important; } +.team-actions { display: flex; flex-direction: column; gap: 10px; align-items: flex-start; } +.team-card.is-ready { box-shadow: 0 0 0 1px color-mix(in srgb, var(--team) 35%, transparent); } + +/* ---------- Veto ---------- */ +.veto { display: grid; gap: 16px; } +.turn { + --team: var(--team-a); + display: grid; grid-template-columns: auto 1fr; gap: 2px 12px; align-items: baseline; + padding: 14px 18px; background: color-mix(in srgb, var(--team) 10%, var(--surface)); + border-left: 4px solid var(--team); border-radius: var(--radius); +} +.turn-B { --team: var(--team-b); } +.turn-team { font: 700 1.75rem var(--display); color: var(--team); } +.turn-action { font: 600 1.375rem var(--display); } +.turn-hint { grid-column: 1 / -1; color: var(--muted); font-size: .9375rem; } +.turn.is-you { box-shadow: 0 0 0 1px var(--team); } +.turn.is-you .turn-hint { color: var(--text); font-weight: 600; } +.turn-done { font: 600 1.375rem var(--display); --team: var(--ok); } + +.map-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 12px; } +.map-tile { + --by: var(--line-strong); + position: relative; display: flex; flex-direction: column; justify-content: flex-end; text-align: left; + aspect-ratio: 4 / 3; padding: 12px; border: 0; color: var(--text); font: inherit; cursor: default; + background: var(--surface-2); + clip-path: polygon(0 0, calc(100% - var(--chamfer)) 0, 100% var(--chamfer), 100% 100%, var(--chamfer) 100%, 0 calc(100% - var(--chamfer))); + transition: transform .15s, filter .25s; +} +.map-tile.by-A { --by: var(--team-a); } +.map-tile.by-B { --by: var(--team-b); } +.map-art { + position: absolute; inset: 0; z-index: 0; + background: + repeating-linear-gradient(135deg, transparent 0 9px, color-mix(in srgb, var(--map) 16%, transparent) 9px 10px), + radial-gradient(120% 90% at 85% 0%, color-mix(in srgb, var(--map) 70%, transparent), transparent 60%), + linear-gradient(180deg, color-mix(in srgb, var(--map) 35%, var(--surface-2)), var(--surface) 85%); +} +.map-name, .map-status { position: relative; z-index: 1; } +.map-name { font: 700 1.625rem/1 var(--display); text-shadow: 0 1px 8px rgba(0, 0, 0, .5); } +.map-status { font-size: .8125rem; color: color-mix(in srgb, var(--text) 75%, transparent); margin-top: 4px; } +.map-tile:not(:disabled) { cursor: pointer; } +.map-tile:not(:disabled):hover { transform: translateY(-2px); } +.map-tile:focus-visible { outline: none; } +.map-tile:focus-visible::after { content: ''; position: absolute; inset: 0; z-index: 3; box-shadow: inset 0 0 0 3px var(--ct); } + +/* Gebannt: entsättigt, mit diagonalem Strich in der Farbe des bannenden Teams */ +.map-tile.status-banned { filter: grayscale(.9) brightness(.6); } +.map-tile.status-banned::after { + content: ''; position: absolute; inset: 0; z-index: 2; pointer-events: none; + background: linear-gradient(to top right, transparent calc(50% - 3px), var(--by) calc(50% - 3px), var(--by) calc(50% + 3px), transparent calc(50% + 3px)); +} +.map-tile.status-banned .map-status { color: var(--text); } +/* Gewählt, Entscheidungsmap, Auswahl: Rahmen als Overlay über dem Map-Bild */ +.map-tile.status-picked::after, .map-tile.status-decider::after, .map-tile.is-selected::after, .map-tile.is-side::after { + content: ''; position: absolute; inset: 0; z-index: 2; pointer-events: none; + box-shadow: inset 0 0 0 3px var(--frame, var(--by)); +} +.map-tile.status-picked .map-status { color: var(--by); font-weight: 600; } +.map-tile.status-decider { --by: var(--text); } +.map-tile.status-decider .map-status { font-weight: 600; } +.map-tile.is-selected { transform: translateY(-2px); } +.map-tile.is-selected.sel-ban { --frame: var(--error); } +.map-tile.is-selected.sel-pick { --frame: var(--ok); } +.map-tile.is-side::after { animation: side-pulse 1.6s ease-in-out infinite; } +@keyframes side-pulse { 50% { box-shadow: inset 0 0 0 3px var(--pending); } } + +.veto-confirm .btn { width: 100%; } +.side-choice { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); padding: 16px; } +.side-choice .btn { flex: 1; } + +.step-track { list-style: none; display: flex; flex-wrap: wrap; gap: 4px; padding: 0; margin: 0; } +.step { + --team: var(--muted); + font-size: .75rem; padding: 4px 8px; border-radius: 3px; background: var(--surface); + border-bottom: 2px solid color-mix(in srgb, var(--team) 45%, transparent); color: var(--muted); +} +.step-A { --team: var(--team-a); } +.step-B { --team: var(--team-b); } +.step.done { color: var(--text); background: var(--surface-2); border-bottom-color: var(--team); } +.step.now { color: var(--bg); background: var(--team); border-bottom-color: var(--team); font-weight: 600; } +.step-decider.now, .step-decider.done { --team: var(--text); } + +.veto-history { list-style: none; padding: 0; margin: 0; font-size: .9375rem; border-top: 1px solid var(--line); } +.veto-history li { padding: 6px 0 6px 12px; border-bottom: 1px solid var(--line); border-left: 3px solid transparent; } +.veto-history li.hist-A { border-left-color: var(--team-a); } +.veto-history li.hist-B { border-left-color: var(--team-b); } +.veto-history time, .event-log time { color: var(--muted); font-size: .8125rem; margin-right: 8px; font-variant-numeric: tabular-nums; } +.veto-compact .map-grid { grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); gap: 8px; } +.veto-compact .map-tile { aspect-ratio: 16 / 9; padding: 8px; --chamfer: 10px; } +.veto-compact .map-name { font-size: 1.125rem; } + +/* ---------- Serie ---------- */ +.series { margin-bottom: 20px; } +.series-list { list-style: none; padding: 0; margin: 0; display: grid; gap: 8px; } +.series-item { + display: grid; grid-template-columns: auto 1fr auto; gap: 2px 16px; align-items: baseline; + padding: 14px 16px; background: var(--surface); border-left: 4px solid var(--map); border-radius: var(--radius); +} +.series-no { color: var(--muted); font-size: .875rem; } +.series-map { font: 700 1.625rem var(--display); } +.series-status { font-size: .875rem; color: var(--muted); text-align: right; grid-column: 3; grid-row: 1; } +.series-origin, .series-sides { grid-column: 2 / -1; font-size: .875rem; color: var(--muted); } +.series-item.is-live { box-shadow: inset 0 0 0 1px var(--map); } +.series-item.is-live .series-status, .series-item.is-next .series-status { color: var(--pending); font-weight: 600; } +.series-item.is-done { opacity: .6; } +.side-ct { color: var(--ct); } +.side-t { color: var(--t); margin-left: 12px; } + +/* ---------- Panels unten ---------- */ +.match-lower { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 20px; margin-top: 28px; align-items: start; } +.panel { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); padding: 18px; min-width: 0; } +.server-status { display: flex; align-items: center; gap: 10px; margin-bottom: 6px; font-size: 1.0625rem; } +.status-dot { width: 10px; height: 10px; border-radius: 50%; background: currentColor; flex: none; } +.status-dot.pulse { animation: pulse 1.2s ease-in-out infinite; } +@keyframes pulse { 50% { opacity: .3; } } +.tone-border-error { border-color: color-mix(in srgb, var(--error) 60%, transparent); } +.connect { border-top: 1px solid var(--line); padding-top: 14px; margin-top: 14px; display: grid; grid-template-columns: minmax(0, 1fr); gap: 10px; justify-items: start; } +.connect .copy-field { width: 100%; } +.org-controls { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 14px; } + +.copy-field { display: grid; grid-template-columns: minmax(0, 1fr); gap: 4px; } +.copy-label { font-size: .8125rem; color: var(--muted); } +.copy-row { display: flex; gap: 8px; align-items: center; } +.copy-row code { flex: 1; min-width: 0; background: var(--bg); border: 1px solid var(--line); border-radius: var(--radius); padding: 8px 10px; overflow-x: auto; white-space: nowrap; } +.copy-row code.blurred { filter: blur(5px); cursor: pointer; user-select: none; } + +.invite { padding: 12px 0; border-top: 1px solid var(--line); border-left: 3px solid transparent; padding-left: 12px; } +.invite.team-A { border-left-color: var(--team-a); } +.invite.team-B { border-left-color: var(--team-b); } +.invite.spectator { border-left-color: var(--muted); } +.invite-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 6px 12px; margin-bottom: 6px; } +.invite-code { font: 600 1.125rem var(--display); letter-spacing: .1em; } +.invite-head .link-btn { margin-left: auto; } +.org-link { margin-top: 12px; } +.org-link summary { cursor: pointer; color: var(--muted); font-size: .875rem; } + +.event-log ol { list-style: none; padding: 0; margin: 0; max-height: 360px; overflow-y: auto; font-size: .875rem; } +.event-log li { display: flex; gap: 4px; padding: 5px 0; border-bottom: 1px solid var(--line); } +.event-log li.ev-server span { color: color-mix(in srgb, var(--text) 80%, var(--ct)); } + +.toast { + position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%); z-index: 50; + padding: 12px 18px; border-radius: var(--radius); background: var(--surface-2); border: 1px solid var(--line-strong); + max-width: min(560px, calc(100vw - 32px)); box-shadow: 0 8px 30px rgba(0, 0, 0, .45); +} +.toast-error { border-color: var(--error); } +.toast-ok { border-color: var(--ok); } +.reconnect { background: var(--pending); color: #2a2000; padding: 8px 14px; border-radius: var(--radius); margin-bottom: 16px; font-weight: 600; } + +/* ---------- Responsiv ---------- */ +@media (max-width: 1040px) { + .match-grid { grid-template-columns: 1fr 1fr; } + .stage { grid-column: 1 / -1; grid-row: 1; } +} +@media (max-width: 640px) { + .match-grid { grid-template-columns: 1fr; } + .topbar { flex-wrap: wrap; gap: 8px 16px; } + .demo-flag { order: 3; } + .create-teams { grid-template-columns: 1fr; } + .vs.big { justify-self: center; } + .map-grid { grid-template-columns: repeat(2, 1fr); } + .series-item { grid-template-columns: 1fr auto; } + .series-no { grid-column: 1 / -1; } + .series-status { grid-column: 2; grid-row: 2; } + .series-origin, .series-sides { grid-column: 1 / -1; } +} +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { animation: none !important; transition: none !important; } +} diff --git a/client/vite.config.js b/client/vite.config.js new file mode 100644 index 0000000..9ae2b2e --- /dev/null +++ b/client/vite.config.js @@ -0,0 +1,15 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +const backend = process.env.BACKEND_URL ?? 'http://localhost:3000'; + +export default defineConfig({ + plugins: [react()], + server: { + port: 5173, + proxy: { + '/api': backend, + '/socket.io': { target: backend, ws: true }, + }, + }, +}); diff --git a/server/src/config.js b/server/src/config.js index 428592e..35a9e6e 100644 --- a/server/src/config.js +++ b/server/src/config.js @@ -53,7 +53,6 @@ export function loadConfig(overrides = {}) { .split(';') .map((c) => c.trim()) .filter(Boolean), - matchzy: bool('MATCHZY_ENABLED', false), }, demo: {