Compare commits
60
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1243e9c852 | ||
|
|
844da4713d | ||
|
|
91f979945e | ||
|
|
c1c9250126 | ||
|
|
eda15bc585 | ||
|
|
1a304cb398 | ||
|
|
cd7f7e0468 | ||
|
|
e1ee009063 | ||
|
|
f90351dd8d | ||
|
|
d2625d23ab | ||
|
|
8b6abdb8b2 | ||
|
|
eb80c4845c | ||
|
|
90996d1b0a | ||
|
|
da986d44c8 | ||
|
|
5383008c8e | ||
|
|
11ccbf01a1 | ||
|
|
5012e8b16d | ||
|
|
1744ff3f73 | ||
|
|
d560c15fef | ||
|
|
364c0b57c2 | ||
|
|
9f2e810fa1 | ||
|
|
e9efe38b76 | ||
|
|
dea0618b69 | ||
|
|
0197cd05ce | ||
|
|
9af83cb47c | ||
|
|
5a332655b2 | ||
|
|
c4bf4f19ae | ||
|
|
2c0f86dccc | ||
|
|
91022e307c | ||
|
|
fe251a13ef | ||
|
|
3e682d7fbc | ||
|
|
a6e7d7d287 | ||
|
|
d7677f3afe | ||
|
|
0341921655 | ||
|
|
fde3baafb5 | ||
|
|
82239c8447 | ||
|
|
b9dafbc6ab | ||
|
|
a4218c47d1 | ||
|
|
22996cc4e4 | ||
|
|
330048acfc | ||
|
|
726974fe82 | ||
|
|
413df03237 | ||
|
|
d78f399612 | ||
|
|
6ee2de5a46 | ||
|
|
7c74c6c4eb | ||
|
|
59d6dc7177 | ||
|
|
8996294d7e | ||
|
|
54bfe198d8 | ||
|
|
43dc5b837a | ||
|
|
5fade769e7 | ||
|
|
5ea5638115 | ||
|
|
4152f9c0a6 | ||
|
|
470b12f232 | ||
|
|
8a0a1309a2 | ||
|
|
9d63799c48 | ||
|
|
54168ae763 | ||
|
|
3a790ce0b1 | ||
|
|
3999c4b111 | ||
|
|
568ccf9b09 | ||
|
|
104aa1a67b |
No files matched your search
@@ -52,6 +52,9 @@ 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=
|
||||
# Optional: mehrere Docker-Hosts als JSON-Liste (eine Zeile). Leer = nur CS2_PUBLIC_HOST/DOCKER_HOST.
|
||||
# Einrichtung pro Host: bash deploy/docker-host-einrichten.sh <ssh-ziel>
|
||||
# CS2_DOCKER_HOSTS=[{"name":"host1","dockerHost":"ssh://cs2@192.168.178.111","publicHost":"192.168.178.111","portStart":27100,"portEnd":27139}]
|
||||
CS2_START_TIMEOUT_MINUTES=20
|
||||
DOCKER_BIN=docker
|
||||
|
||||
@@ -68,3 +71,5 @@ LOG_DEBUG=false
|
||||
SERIES_AUTO_ADVANCE_SECONDS=30
|
||||
# Schlüssel für die Stream-Übersicht aller Matches (/stream?key=...); leer = offen
|
||||
STREAM_KEY=
|
||||
# Discord-Webhook für Meldungen (Veto, Match-Start, Map- und Serienende, Fehler). Leer = aus
|
||||
DISCORD_WEBHOOK_URL=
|
||||
@@ -75,14 +75,14 @@ Die Tests decken Veto-Engine und Ablaufvalidierung, RCON-Protokoll (gegen einen
|
||||
6. **Server konfigurieren:** Nach dem Veto wird der bereits laufende Server automatisch eingerichtet: Spielmodus, erste Map, Teamnamen und Startseiten, Match-Einstellungen, Server- und CSTV-Passwort. Die restliche Map-Reihenfolge ist gespeichert.
|
||||
- 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.
|
||||
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, nächste Map, Match beenden; sieht Einladungen, Spieler- und CSTV-Verbindung |
|
||||
| Organisator | Token beim Erstellen | Veto starten, Spieler entfernen, Captain setzen, Codes erneuern, Server neu starten/stoppen/konfigurieren, pausieren/fortsetzen, nächste Map, Match beenden; sieht Einladungen, Spieler- und CSTV-Verbindung, die Match-Übersicht |
|
||||
| Captain | Teamcode (erster Spieler oder ernannt) | alles wie Spieler, dazu Veto-Aktionen seines Teams und Captain-Übergabe |
|
||||
| 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 |
|
||||
@@ -201,9 +201,22 @@ Die Serververwaltung ist hinter [`server/src/servers/adapter.js`](server/src/ser
|
||||
- Wird alle `SERVER_STATUS_POLL_SECONDS` abgefragt, solange der Server startet, danach alle 30 s.
|
||||
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`).
|
||||
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`)
|
||||
@@ -223,6 +236,24 @@ Voraussetzungen:
|
||||
|
||||
Container heißen `cs2mv-<matchId>` und tragen das Label `cs2-mapveto=1`. Stoppen bedeutet `docker rm -f`.
|
||||
|
||||
### Mehrere Docker-Hosts
|
||||
|
||||
Mit `CS2_DOCKER_HOSTS` verteilt die Anwendung Matches auf mehrere Docker-Hosts. Jedes neue Match läuft auf dem Host mit den meisten freien Ports. Der Host bleibt im Match gespeichert, Status, Konfiguration und Stopp gehen immer an denselben Host.
|
||||
|
||||
1. Jeden Host einrichten (SSH-Schlüssel, Docker, Volume, Image):
|
||||
|
||||
```bash
|
||||
bash deploy/docker-host-einrichten.sh cs2@192.168.178.111
|
||||
```
|
||||
|
||||
2. Die Hosts in der `.env` eintragen, als JSON-Liste in einer Zeile:
|
||||
|
||||
```
|
||||
CS2_DOCKER_HOSTS=[{"name":"host1","dockerHost":"ssh://cs2@192.168.178.111","publicHost":"192.168.178.111","portStart":27100,"portEnd":27139},{"name":"host2","dockerHost":"ssh://cs2@192.168.178.112","publicHost":"192.168.178.112","portStart":27100,"portEnd":27139}]
|
||||
```
|
||||
|
||||
Jeder Host braucht eigene Spieldateien (rund 60 bis 90 GB). Die Ports müssen auf jedem Host frei sein.
|
||||
|
||||
### Option B: Vorhandene Server (`SERVER_ADAPTER=static`)
|
||||
|
||||
Für selbst betriebene, dauerhaft laufende CS2-Server:
|
||||
@@ -298,7 +329,7 @@ Beim Konfigurieren setzt CS2 Mapveto auf dem Server `log on` und `logaddress_add
|
||||
- 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), siehe `server/test/fixtures/echte-logs-wingman.log`.
|
||||
- **Geprüft:** mit echten Logzeilen eines CS2-Servers (Wingman), anonymisiert in `server/test/fixtures/wingman-anonym.log`.
|
||||
|
||||
Zwei Eigenheiten von CS2 berücksichtigt der Parser:
|
||||
- CS2 sendet `Match_Start` schon beim Laden der Map. Gezählt wird erst, nachdem der Organisator „Aufwärmphase beenden“ geklickt hat.
|
||||
@@ -314,17 +345,75 @@ Unter `/stream` (in der Kopfzeile verlinkt) zeigt eine 1920×1080-Seite alle lau
|
||||
- **Raster:** passt sich der Anzahl an (1, 2, 4 oder 6 Karten). Bei mehr als 6 Matches blättert die Seite alle 12 Sekunden weiter.
|
||||
- **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
|
||||
@@ -350,6 +439,8 @@ Alle Variablen mit Standardwerten stehen in [`.env.example`](.env.example). Die
|
||||
| `LOG_RECEIVER_URL` | `PUBLIC_URL` | Adresse, unter der der CS2-Server das Backend für Live-Logs erreicht |
|
||||
| `LOG_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:
|
||||
@@ -392,6 +483,6 @@ Alle Aktionen laufen per `POST` mit `Authorization: Bearer <token>`. Der Token i
|
||||
| `…/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 |
|
||||
| `…/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`.
|
||||
@@ -6,6 +6,7 @@ import CreateMatch from './pages/CreateMatch.jsx';
|
||||
import MatchPage from './pages/MatchPage.jsx';
|
||||
import StreamPage from './pages/StreamPage.jsx';
|
||||
import StreamOverviewPage from './pages/StreamOverviewPage.jsx';
|
||||
import AdminPage from './pages/AdminPage.jsx';
|
||||
import { JoinPage, WatchPage } from './pages/AccessPages.jsx';
|
||||
|
||||
export default function App() {
|
||||
@@ -13,6 +14,7 @@ export default function App() {
|
||||
<Routes>
|
||||
{/* Stream-Übersicht ohne Kopfzeile, als Vollbild bzw. OBS-Browserquelle */}
|
||||
<Route path="/stream/:code" element={<StreamPage />} />
|
||||
<Route path="/stream/match/:matchId" element={<StreamOverviewPage />} />
|
||||
<Route path="/stream" element={<StreamOverviewPage />} />
|
||||
<Route path="*" element={<MainLayout />} />
|
||||
</Routes>
|
||||
@@ -42,6 +44,7 @@ function MainLayout() {
|
||||
<nav>
|
||||
<Link to="/" className="nav-link">Übersicht</Link>
|
||||
<a href="/stream" target="_blank" rel="noreferrer" className="nav-link">Stream-Übersicht</a>
|
||||
<Link to="/admin" className="nav-link">Admin</Link>
|
||||
<Link to="/neu" className="btn btn-primary btn-sm">Match erstellen</Link>
|
||||
</nav>
|
||||
</header>
|
||||
@@ -50,6 +53,7 @@ function MainLayout() {
|
||||
<Routes>
|
||||
<Route path="/" element={<Overview />} />
|
||||
<Route path="/neu" element={<CreateMatch config={config} />} />
|
||||
<Route path="/admin" element={<AdminPage />} />
|
||||
<Route path="/m/:id" element={<MatchPage />} />
|
||||
<Route path="/join/:code" element={<JoinPage config={config} />} />
|
||||
<Route path="/watch/:code" element={<WatchPage />} />
|
||||
|
||||
@@ -29,6 +29,8 @@ export default function OrganizerPanel({ state, act, busy, matchId, token }) {
|
||||
<div className="stream-link">
|
||||
<CopyField label="Stream-Übersicht (als Browserquelle in OBS, 1920×1080)" value={links.stream} />
|
||||
<a className="btn btn-sm" href={links.stream} target="_blank" rel="noreferrer">Stream-Ansicht öffnen</a>
|
||||
<CopyField label="Match-Übersicht (nur dieses Match, ohne Zuschauercode)" value={links.overview} />
|
||||
<a className="btn btn-sm" href={links.overview} target="_blank" rel="noreferrer">Match-Übersicht öffnen</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,7 @@ export default function VetoBoard({ state, viewer, act, busy, compact = false })
|
||||
const { current } = veto;
|
||||
const [selected, setSelected] = useState(null);
|
||||
useEffect(() => setSelected(null), [current?.index]);
|
||||
const left = useCountdown(veto.remainingMs);
|
||||
|
||||
const mapsById = Object.fromEntries(state.maps.map((m) => [m.id, m]));
|
||||
const yourTurn = !!current && viewer.isCaptain && viewer.team === current.team;
|
||||
@@ -38,6 +39,7 @@ export default function VetoBoard({ state, viewer, act, busy, compact = false })
|
||||
? 'Live-Ansicht – nur Captains können wählen.'
|
||||
: 'Das andere Team ist am Zug.'}
|
||||
</span>
|
||||
{left !== null && <TimerBar left={left} totalMs={(veto.stepSeconds || 30) * 1000} />}
|
||||
</div>
|
||||
)}
|
||||
{!current && veto.done && !compact && <div className="turn turn-done">Veto abgeschlossen</div>}
|
||||
@@ -129,3 +131,28 @@ function statusText(m, teams) {
|
||||
default: return 'Verfügbar';
|
||||
}
|
||||
}
|
||||
|
||||
/** Leiste für die verbleibende Zeit des Veto-Schritts. In den letzten 10 Sekunden wird sie rot. */
|
||||
function TimerBar({ left, totalMs }) {
|
||||
const seconds = Math.ceil(left / 1000);
|
||||
const percent = Math.max(0, Math.min(100, (left / totalMs) * 100));
|
||||
return (
|
||||
<div className={`turn-timer ${left <= 10_000 ? 'is-low' : ''}`} role="timer" aria-label={`Noch ${seconds} Sekunden`}>
|
||||
<div className="turn-bar"><span style={{ width: `${percent}%` }} /></div>
|
||||
<span className="turn-seconds">{seconds} s</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Verbleibende Zeit des Veto-Schritts: der Server setzt sie bei jeder Änderung, danach zählt die Anzeige lokal herunter. */
|
||||
function useCountdown(ms) {
|
||||
const [left, setLeft] = useState(ms ?? null);
|
||||
useEffect(() => {
|
||||
setLeft(ms ?? null);
|
||||
if (ms == null) return undefined;
|
||||
const started = Date.now();
|
||||
const t = setInterval(() => setLeft(Math.max(0, ms - (Date.now() - started))), 250);
|
||||
return () => clearInterval(t);
|
||||
}, [ms]);
|
||||
return left;
|
||||
}
|
||||
@@ -0,0 +1,432 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { api } from '../lib/api.js';
|
||||
import { FORMAT_LABEL, serverLabel } from '../lib/labels.js';
|
||||
import CopyField from '../components/CopyField.jsx';
|
||||
|
||||
const KEY = 'cs2mv:admin';
|
||||
const REFRESH_MS = 3000;
|
||||
|
||||
const getToken = () => {
|
||||
try {
|
||||
return sessionStorage.getItem(KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
const storeToken = (t) => {
|
||||
try {
|
||||
if (t) sessionStorage.setItem(KEY, t);
|
||||
else sessionStorage.removeItem(KEY);
|
||||
} catch {
|
||||
/* ignorieren */
|
||||
}
|
||||
};
|
||||
|
||||
export default function AdminPage() {
|
||||
const [token, setToken] = useState(getToken);
|
||||
const [matches, setMatches] = useState(null);
|
||||
const [hosts, setHosts] = useState([]);
|
||||
const [error, setError] = useState(null);
|
||||
const [notice, setNotice] = useState(null);
|
||||
const [busy, setBusy] = useState(null);
|
||||
const [tab, setTab] = useState('matches');
|
||||
|
||||
const logout = useCallback(() => {
|
||||
api('/admin/logout', { method: 'POST', token }).catch(() => {});
|
||||
storeToken(null);
|
||||
setToken(null);
|
||||
setMatches(null);
|
||||
}, [token]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const res = await api('/admin/matches', { token });
|
||||
setMatches(res.matches);
|
||||
setHosts(res.hosts ?? []);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
if (err.status === 401) {
|
||||
storeToken(null);
|
||||
setToken(null);
|
||||
} else {
|
||||
setError(err.message);
|
||||
}
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return undefined;
|
||||
load();
|
||||
const t = setInterval(load, REFRESH_MS);
|
||||
return () => clearInterval(t);
|
||||
}, [token, load]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!notice) return undefined;
|
||||
const t = setTimeout(() => setNotice(null), 5000);
|
||||
return () => clearTimeout(t);
|
||||
}, [notice]);
|
||||
|
||||
if (!token) return <AdminLogin onLogin={(t) => { storeToken(t); setToken(t); }} />;
|
||||
|
||||
/** Aktion ausführen; destruktive Aktionen müssen bestätigt werden. */
|
||||
const run = async ({ method = 'POST', path, body, confirm: question, success }) => {
|
||||
if (question && !window.confirm(question)) return;
|
||||
setBusy(path);
|
||||
try {
|
||||
await api(`/admin${path}`, { method, body, token });
|
||||
setNotice({ tone: 'ok', text: success ?? 'Erledigt' });
|
||||
load();
|
||||
} catch (err) {
|
||||
setNotice({ tone: 'error', text: err.message });
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="admin">
|
||||
<header className="admin-head">
|
||||
<div>
|
||||
<h1>Admin</h1>
|
||||
<p className="muted small">Alle Matches, Server und Teams. Aktualisiert sich alle 3 Sekunden.</p>
|
||||
</div>
|
||||
<button className="btn btn-sm" onClick={logout}>Abmelden</button>
|
||||
</header>
|
||||
|
||||
<nav className="admin-tabs" aria-label="Admin-Bereiche">
|
||||
<button className={`admin-tab ${tab === 'matches' ? 'is-active' : ''}`} onClick={() => setTab('matches')}>Matches</button>
|
||||
<button className={`admin-tab ${tab === 'settings' ? 'is-active' : ''}`} onClick={() => setTab('settings')}>Einstellungen</button>
|
||||
</nav>
|
||||
|
||||
{tab === 'settings' && token && <NotificationSettings token={token} setNotice={setNotice} />}
|
||||
{notice && <div className={`toast toast-${notice.tone}`} role="status">{notice.text}</div>}
|
||||
{error && <div className="alert alert-error">{error}</div>}
|
||||
{tab === 'matches' && <HostUsage hosts={hosts} />}
|
||||
{tab === 'matches' && matches === null && !error && <p className="muted">Lade Matches …</p>}
|
||||
{tab === 'matches' && matches && matches.length === 0 && <p className="muted">Noch keine Matches.</p>}
|
||||
|
||||
{tab === 'matches' && <div className="admin-list">
|
||||
{matches?.map((m) => (
|
||||
<MatchAdminCard key={m.id} m={m} run={run} busy={busy} token={token} reload={load} setNotice={setNotice} />
|
||||
))}
|
||||
</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Auswahl, welche Ereignisse Discord erhalten. Die Werte werden erst beim Speichern übernommen. */
|
||||
function NotificationSettings({ token, setNotice }) {
|
||||
const [settings, setSettings] = useState(null);
|
||||
const [events, setEvents] = useState({});
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
api('/admin/notifications', { token })
|
||||
.then((res) => { setSettings(res); setEvents(res.events); })
|
||||
.catch((err) => setNotice({ tone: 'error', text: err.message }));
|
||||
}, [token, setNotice]);
|
||||
|
||||
const save = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await api('/admin/notifications', { method: 'PUT', body: { events }, token });
|
||||
setSettings(res);
|
||||
setEvents(res.events);
|
||||
setNotice({ tone: 'ok', text: 'Meldungen gespeichert' });
|
||||
} catch (err) {
|
||||
setNotice({ tone: 'error', text: err.message });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const test = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await api('/admin/notifications/test', { method: 'POST', token });
|
||||
setNotice({ tone: 'ok', text: 'Testmeldung gesendet' });
|
||||
} catch (err) {
|
||||
setNotice({ tone: 'error', text: err.message });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!settings) return null;
|
||||
const dirty = settings.catalog.some(({ key }) => !!events[key] !== settings.events[key]);
|
||||
|
||||
return (
|
||||
<section className="admin-settings">
|
||||
<h2>Discord-Meldungen</h2>
|
||||
{!settings.enabled && (
|
||||
<p className="alert alert-error">Kein Webhook eingerichtet. Setze DISCORD_WEBHOOK_URL und starte die Anwendung neu.</p>
|
||||
)}
|
||||
<div className="admin-settings-grid">
|
||||
{settings.catalog.map(({ key, label }) => (
|
||||
<label key={key} className="admin-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!events[key]}
|
||||
onChange={(e) => setEvents({ ...events, [key]: e.target.checked })}
|
||||
/>
|
||||
{label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<div className="admin-actions">
|
||||
<button className="btn btn-sm btn-primary" onClick={save} disabled={busy || !dirty}>Speichern</button>
|
||||
<button className="btn btn-sm" onClick={test} disabled={busy || !settings.enabled}>Testmeldung senden</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function AdminLogin({ onLogin }) {
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const submit = async (e) => {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await api('/admin/login', { method: 'POST', body: { password } });
|
||||
onLogin(res.token);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<form className="create admin-login" onSubmit={submit}>
|
||||
<h1>Admin-Anmeldung</h1>
|
||||
<p className="lead">Das Passwort ist in der Konfiguration unter <code>ADMIN_PASSWORD</code> hinterlegt.</p>
|
||||
<label className="field">
|
||||
<span>Passwort</span>
|
||||
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} autoFocus autoComplete="current-password" required />
|
||||
</label>
|
||||
{error && <div className="alert alert-error">{error}</div>}
|
||||
<button className="btn btn-primary btn-lg" disabled={busy}>{busy ? 'Melde an …' : 'Anmelden'}</button>
|
||||
<p className="small muted"><Link to="/">Zur Übersicht</Link></p>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
/** Rollback: nur auf der Admin-Seite. Lädt die Sicherungen der aktuellen Map und spielt eine davon zurück. */
|
||||
function RollbackPanel({ matchId, token, reload, setNotice }) {
|
||||
const [points, setPoints] = useState(null);
|
||||
const [file, setFile] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api(`/admin/matches/${matchId}/rollback-points`, { token });
|
||||
setPoints(res.points);
|
||||
setFile(res.points[0]?.file ?? '');
|
||||
} catch (err) {
|
||||
setNotice({ tone: 'error', text: err.message });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const rollback = async () => {
|
||||
const p = points.find((x) => x.file === file);
|
||||
if (!p || !window.confirm(`Match auf den Stand nach Runde ${p.round} zurückspielen? Spätere Runden dieser Map gehen verloren.`)) return;
|
||||
try {
|
||||
await api(`/admin/matches/${matchId}/rollback`, { method: 'POST', body: { file }, token });
|
||||
setNotice({ tone: 'ok', text: `Zurückgespielt auf Runde ${p.round}` });
|
||||
setPoints(null);
|
||||
reload();
|
||||
} catch (err) {
|
||||
setNotice({ tone: 'error', text: err.message });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rollback">
|
||||
<h3>Runde zurückspielen <span className="admin-badge">nur Admin</span></h3>
|
||||
{!points && <button className="btn btn-sm" onClick={load} disabled={loading}>{loading ? 'Lade …' : 'Sicherungen laden'}</button>}
|
||||
{points && points.length === 0 && <p className="small muted">Keine Sicherungen für diese Map gefunden.</p>}
|
||||
{points && points.length > 0 && (
|
||||
<div className="row">
|
||||
<select value={file} onChange={(e) => setFile(e.target.value)}>
|
||||
{points.map((p) => <option key={p.file} value={p.file}>Nach Runde {p.round}</option>)}
|
||||
</select>
|
||||
<button className="btn btn-sm btn-danger" onClick={rollback}>Zurückspielen</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MatchAdminCard({ m, run, busy, token, reload, setNotice }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const server = serverLabel(m.server);
|
||||
const closed = ['finished', 'cancelled'].includes(m.status);
|
||||
const id = m.id;
|
||||
const btn = (label, cfg, cls = '') => (
|
||||
<button className={`btn btn-sm ${cls}`} disabled={!!busy} onClick={() => run({ ...cfg, path: `/matches/${id}${cfg.path}` })}>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
const hasNext = m.series.some((s) => s.number === m.currentMapNumber + 1);
|
||||
const current = m.series.find((s) => s.number === m.currentMapNumber);
|
||||
const configured = m.server?.status === 'configured';
|
||||
const paused = !!m.server?.paused;
|
||||
|
||||
return (
|
||||
<section className={`panel admin-card status-${m.status}`}>
|
||||
<div className="admin-card-head">
|
||||
<div>
|
||||
<strong className="team-a">{m.teams.A.name}</strong>
|
||||
<span className="vs"> vs. </span>
|
||||
<strong className="team-b">{m.teams.B.name}</strong>
|
||||
<div className="small muted">
|
||||
{m.modeLabel}, {FORMAT_LABEL[m.format]} · {m.statusLabel} · <span className={`tone-${server.tone}`}>{server.text}</span>
|
||||
{m.format !== 'bo1' && <> · Serie {m.seriesScore.A}:{m.seriesScore.B}</>}
|
||||
</div>
|
||||
{m.status === 'live' && current && (
|
||||
<div className="admin-live">
|
||||
<span>Map {current.number}: {current.name}</span>
|
||||
<strong>{current.score.A} : {current.score.B}</strong>
|
||||
{current.live?.roundsPlayed != null && <span className="muted">Runde {current.live.roundsPlayed + 1}</span>}
|
||||
</div>
|
||||
)}
|
||||
{paused && <span className="admin-badge is-paused">Pausiert</span>}
|
||||
</div>
|
||||
<button className="link-btn" onClick={() => setOpen(!open)}>{open ? 'Weniger' : 'Details'}</button>
|
||||
</div>
|
||||
|
||||
<div className="admin-actions">
|
||||
<Link className="btn btn-sm" to={`/m/${id}`}>Ansehen</Link>
|
||||
{m.status === 'lobby' && btn('Veto starten', { path: '/veto/start' }, 'btn-primary')}
|
||||
{(m.server?.status === 'configured') && btn('Aufwärmphase beenden', { path: '/server/warmup-end' })}
|
||||
{configured && !paused && btn('Pausieren', { path: '/server/pause', confirm: 'Match pausieren?' }, 'btn-warn')}
|
||||
{configured && paused && btn('Fortsetzen', { path: '/server/resume' }, 'btn-primary')}
|
||||
{(m.server?.status === 'configured') && hasNext && btn('Nächste Map', { path: '/series/next' })}
|
||||
{m.server && ['start_failed', 'failed', 'stopped', 'config_failed', 'ready'].includes(m.server.status) && !closed &&
|
||||
btn('Server neu starten / wiederholen', { path: '/server/retry' })}
|
||||
{m.server && !['stopped'].includes(m.server.status) && btn('Server stoppen', { path: '/server/stop', confirm: 'Server stoppen?' })}
|
||||
{!closed && btn('Match abschließen', { path: '/finish', confirm: 'Match als abgeschlossen markieren?' })}
|
||||
{!closed && btn('Match abbrechen', { path: '/cancel', confirm: 'Match abbrechen?' }, 'btn-ghost danger')}
|
||||
{btn('Löschen', { method: 'DELETE', path: '', confirm: `Match ${m.teams.A.name} vs. ${m.teams.B.name} endgültig löschen?`, success: 'Match gelöscht' }, 'btn-ghost danger')}
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<div className="admin-details">
|
||||
<div className="admin-grid">
|
||||
<div>
|
||||
<h3>Einladungen</h3>
|
||||
<div className="small muted">Team A</div>
|
||||
<CopyField value={m.organizer.links.A} />
|
||||
<div className="small muted">Team B</div>
|
||||
<CopyField value={m.organizer.links.B} />
|
||||
<div className="small muted">Zuschauer</div>
|
||||
<CopyField value={m.organizer.links.spectator} />
|
||||
<div className="small muted">Stream-Ansicht</div>
|
||||
<CopyField value={m.organizer.links.stream} />
|
||||
<div className="row">
|
||||
{['A', 'B', 'spectator'].map((t) => (
|
||||
<button key={t} className="link-btn" disabled={!!busy}
|
||||
onClick={() => run({ path: `/matches/${id}/codes/regenerate`, body: { target: t }, confirm: 'Neuer Code? Der alte Link wird ungültig.', success: 'Neuer Code erzeugt' })}>
|
||||
Code {t === 'spectator' ? 'Zuschauer' : t} erneuern
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3>Server</h3>
|
||||
{m.server?.connect && <CopyField label="Spieler-Verbindung" value={m.server.connect.command} />}
|
||||
{m.server?.gotv && <CopyField label="CSTV" value={m.server.gotv.command} />}
|
||||
<p className="small muted">{m.server?.message ?? 'kein Server'}</p>
|
||||
<p className="small muted">Versuche: {m.server?.attempts ?? 0} · Spieler auf dem Server: {m.server?.playerCount ?? '–'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>Spieler</h3>
|
||||
<div className="admin-players">
|
||||
{['A', 'B'].map((t) => (
|
||||
<div key={t}>
|
||||
<div className="small muted">{m.teams[t].name} ({m.teams[t].readiness.count}/{m.teams[t].teamSize})</div>
|
||||
<ul>
|
||||
{m.teams[t].players.map((p) => (
|
||||
<li key={p.id}>
|
||||
{p.nickname} {p.isCaptain && <em>Captain</em>} {p.ready && <span className="tone-ok">bereit</span>}
|
||||
{!closed && (
|
||||
<button className="link-btn danger" disabled={!!busy}
|
||||
onClick={() => run({ path: `/matches/${id}/kick`, body: { playerId: p.id }, confirm: `${p.nickname} entfernen?`, success: `${p.nickname} entfernt` })}>
|
||||
Entfernen
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
{m.teams[t].players.length === 0 && <li className="muted">niemand</li>}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{m.status === 'live' && m.server?.status === 'configured' && (
|
||||
<RollbackPanel matchId={id} token={token} reload={reload} setNotice={setNotice} />
|
||||
)}
|
||||
|
||||
{m.veto && !m.veto.done && m.veto.current && (
|
||||
<p className="small">Veto: {m.teams[m.veto.current.team].name} ist am Zug ({m.veto.current.type}).</p>
|
||||
)}
|
||||
|
||||
<h3>Verlauf</h3>
|
||||
<ol className="admin-log">
|
||||
{m.events.slice(0, 15).map((e, i) => (
|
||||
<li key={i}><time>{new Date(e.at).toLocaleTimeString('de-DE')}</time> {e.message}</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
/** Auslastung der Docker-Hosts: wie viele Gameserver läuft auf welchem Host, mit Liste der Matches. */
|
||||
function HostUsage({ hosts }) {
|
||||
if (hosts.length === 0) return null;
|
||||
return (
|
||||
<section className="host-usage">
|
||||
<h2>Docker-Hosts</h2>
|
||||
<div className="host-grid">
|
||||
{hosts.map((h) => {
|
||||
const percent = h.total ? Math.round((h.used / h.total) * 100) : 0;
|
||||
return (
|
||||
<div key={h.name} className={`host-card ${percent >= 90 ? 'is-full' : ''}`}>
|
||||
<div className="host-head">
|
||||
<strong>{h.name}</strong>
|
||||
<span className="muted small">{h.publicHost}</span>
|
||||
<span className="host-count">{h.used} / {h.total} belegt</span>
|
||||
</div>
|
||||
<div className="host-bar" role="progressbar" aria-valuenow={percent} aria-valuemin={0} aria-valuemax={100}>
|
||||
<span style={{ width: `${percent}%` }} />
|
||||
</div>
|
||||
{h.disk && (
|
||||
<div className={`host-disk ${h.disk.freeBytes < 20e9 ? 'is-low' : ''}`}>
|
||||
Speicher: {gb(h.disk.freeBytes)} frei von {gb(h.disk.totalBytes)}
|
||||
</div>
|
||||
)}
|
||||
{h.matches.length === 0 ? (
|
||||
<p className="muted small">Keine Gameserver</p>
|
||||
) : (
|
||||
<ul className="host-matches">
|
||||
{h.matches.map((m) => <li key={m.id}>{m.title} <span className="muted small">{m.status}</span></li>)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
function gb(bytes) {
|
||||
return `${Math.round(bytes / 1e9)} GB`;
|
||||
}
|
||||
@@ -127,7 +127,7 @@ export default function MatchPage() {
|
||||
|
||||
{viewer.role === 'public' && <PublicNotice />}
|
||||
|
||||
<div className={`match-grid ${state.status !== 'lobby' && viewer.role !== 'public' ? 'stage-wide' : ''}`}>
|
||||
<div className={`match-grid ${state.status !== 'lobby' ? 'stage-wide' : ''}`}>
|
||||
<TeamCard team={state.teams.A} side="A" {...ctx} />
|
||||
<section className="stage">
|
||||
<Stage {...ctx} />
|
||||
@@ -156,8 +156,8 @@ function describeViewer(viewer, teams) {
|
||||
|
||||
function Stage(ctx) {
|
||||
const { state, isOrg, act, busy } = ctx;
|
||||
if (state.viewer.role === 'public') {
|
||||
return <div className="stage-note"><h2>{state.statusLabel}</h2><p className="muted">Details sehen nur Teilnehmer.</p></div>;
|
||||
if (state.viewer.role === 'public' && state.status === 'lobby') {
|
||||
return <div className="stage-note"><h2>{state.statusLabel}</h2><p className="muted">Das Veto und der Spielstand erscheinen hier, sobald das Match startet.</p></div>;
|
||||
}
|
||||
if (state.status === 'lobby') {
|
||||
const { A, B } = state.teams;
|
||||
@@ -230,8 +230,8 @@ function MapPoolPreview({ maps }) {
|
||||
function PublicNotice() {
|
||||
return (
|
||||
<div className="alert">
|
||||
Du siehst diese Lobby ohne Zugang. Spieler treten über den Einladungslink ihres Teams bei,
|
||||
Zuschauer über den Zuschauerlink. Beides bekommst du vom Organisator.
|
||||
Du siehst dieses Match ohne Zugang. Veto, Serienstand und Ergebnisse sind öffentlich.
|
||||
Teamdetails und Verbindungsdaten gibt es nur für Teilnehmer.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ 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';
|
||||
import MapImage from '../components/MapImage.jsx';
|
||||
|
||||
const GROUPS = [
|
||||
{ key: 'open', title: 'Offen', empty: 'Keine offenen Lobbys. Erstelle ein Match, um zu starten.' },
|
||||
@@ -32,7 +33,11 @@ export default function Overview() {
|
||||
return (
|
||||
<section key={g.key} className={`group group-${g.key}`}>
|
||||
<h2>{g.title} <span className="count">{list.length}</span></h2>
|
||||
{list.length === 0 ? <p className="muted small">{g.empty}</p> : (
|
||||
{list.length === 0 ? <p className="muted small">{g.empty}</p> : g.key === 'running' ? (
|
||||
<div className="run-grid">
|
||||
{list.map((m) => <RunningCard key={m.id} match={m} />)}
|
||||
</div>
|
||||
) : (
|
||||
<ul className="match-list">
|
||||
{list.map((m) => <MatchRow key={m.id} match={m} />)}
|
||||
</ul>
|
||||
@@ -79,6 +84,34 @@ function MatchRow({ match: m }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Karte für ein laufendes Match: Map als Hintergrund, Stand groß, Serie und Status darunter. */
|
||||
function RunningCard({ match: m }) {
|
||||
const server = serverLabel(m.server);
|
||||
const map = m.currentMap;
|
||||
const live = map && !map.finished;
|
||||
const bo1 = m.format === 'bo1';
|
||||
return (
|
||||
<Link to={`/m/${m.id}`} className={`run-card ${live ? 'is-live' : ''}`} style={{ '--map': map?.color ?? '#3a4150' }}>
|
||||
{map?.image && <MapImage map={{ image: map.image, color: map.color, name: map.name }} className="run-art" />}
|
||||
<div className="run-top">
|
||||
<span className="run-mode">{m.modeLabel}, {FORMAT_LABEL[m.format]}</span>
|
||||
{live ? <span className="live-badge run-live">Live</span> : <span className={`run-status tone-${server.tone}`}>{m.statusLabel}</span>}
|
||||
</div>
|
||||
<div className="run-teams">
|
||||
<span className="run-team a">{m.teamA}</span>
|
||||
<span className="run-score">
|
||||
{map ? <>{map.score.A}<i>:</i>{map.score.B}</> : <i className="run-vs">vs.</i>}
|
||||
</span>
|
||||
<span className="run-team b">{m.teamB}</span>
|
||||
</div>
|
||||
<div className="run-foot">
|
||||
<span>{map ? `Map ${map.number}: ${map.name}` : 'Veto läuft'}</span>
|
||||
{!bo1 && m.seriesScore && <span className="run-series">Serie {m.seriesScore.A}:{m.seriesScore.B}</span>}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function CodeEntry() {
|
||||
const [code, setCode] = useState('');
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { api } from '../lib/api.js';
|
||||
import { FORMAT_LABEL } from '../lib/labels.js';
|
||||
import { getSocket } from '../lib/useMatch.js';
|
||||
@@ -14,11 +15,13 @@ const PAGE_SECONDS = 12;
|
||||
/**
|
||||
* Stream-Übersicht aller laufenden Vetos und Matches: /stream
|
||||
* Mit STREAM_KEY: /stream?key=<schlüssel>. Optional ?bg=transparent.
|
||||
* Ein einzelnes Match: /stream/match/<matchId>, daraus führen die Karten der Übersicht.
|
||||
*/
|
||||
export default function StreamOverviewPage() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const key = params.get('key') ?? '';
|
||||
const transparent = params.get('bg') === 'transparent';
|
||||
const { matchId } = useParams();
|
||||
const { data, error } = useStreamOverview(key);
|
||||
const now = useClock();
|
||||
const [page, setPage] = useState(0);
|
||||
@@ -29,8 +32,10 @@ export default function StreamOverviewPage() {
|
||||
return () => document.documentElement.classList.remove('stream-mode', 'stream-transparent');
|
||||
}, [transparent]);
|
||||
|
||||
const activeMatches = (data?.active ?? []).filter((m) => !matchId || m.id === matchId);
|
||||
|
||||
// Mehr Matches als auf eine Seite passen: automatisch weiterblättern
|
||||
const pages = data ? Math.max(1, Math.ceil(data.active.length / PER_PAGE)) : 1;
|
||||
const pages = Math.max(1, Math.ceil(activeMatches.length / PER_PAGE));
|
||||
useEffect(() => {
|
||||
if (pages <= 1) { setPage(0); return undefined; }
|
||||
const t = setInterval(() => setPage((p) => (p + 1) % pages), PAGE_SECONDS * 1000);
|
||||
@@ -49,7 +54,7 @@ export default function StreamOverviewPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const active = data?.active ?? [];
|
||||
const active = activeMatches;
|
||||
const shown = active.slice(page * PER_PAGE, page * PER_PAGE + PER_PAGE);
|
||||
const liveCount = active.filter((m) => m.status === 'live').length;
|
||||
const vetoCount = active.filter((m) => m.status === 'veto').length;
|
||||
@@ -73,11 +78,24 @@ export default function StreamOverviewPage() {
|
||||
{!data && <div className="so-empty">Lade Matches …</div>}
|
||||
{data && shown.length === 0 && (
|
||||
<div className="so-empty">
|
||||
<h2>Gerade läuft kein Match</h2>
|
||||
<p>{data.upcoming.length ? 'Die nächsten Lobbys stehen unten.' : 'Sobald ein Veto beginnt, erscheint es hier.'}</p>
|
||||
{matchId ? (
|
||||
<>
|
||||
<h2>Dieses Match läuft gerade nicht</h2>
|
||||
<p>Es erscheint hier, sobald das Veto oder das Match beginnt.</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h2>Gerade läuft kein Match</h2>
|
||||
<p>{data.upcoming.length ? 'Die nächsten Lobbys stehen unten.' : 'Sobald ein Veto beginnt, erscheint es hier.'}</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{shown.map((m) => <MatchCard key={m.id} m={m} />)}
|
||||
{shown.map((m) => (
|
||||
<a key={m.id} className="so-link" href={matchHref(m.id, key)}>
|
||||
<MatchCard m={m} />
|
||||
</a>
|
||||
))}
|
||||
</main>
|
||||
|
||||
<footer className="so-bottom">
|
||||
@@ -87,8 +105,10 @@ export default function StreamOverviewPage() {
|
||||
<ul>
|
||||
{data.upcoming.map((m) => (
|
||||
<li key={m.id}>
|
||||
<span className="a">{m.teams.A}</span> <span className="vs">vs</span> <span className="b">{m.teams.B}</span>
|
||||
<small>{FORMAT_LABEL[m.format]}{m.ready.A && m.ready.B ? ', bereit' : ''}</small>
|
||||
<a className="so-link" href={matchHref(m.id, key)}>
|
||||
<span className="a">{m.teams.A}</span> <span className="vs">vs</span> <span className="b">{m.teams.B}</span>
|
||||
<small>{FORMAT_LABEL[m.format]}{m.ready.A && m.ready.B ? ', bereit' : ''}</small>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -100,9 +120,11 @@ export default function StreamOverviewPage() {
|
||||
<ul>
|
||||
{data.results.map((m) => (
|
||||
<li key={m.id}>
|
||||
<span className={`a ${winner(m) === 'A' ? 'won' : ''}`}>{m.teams.A}</span>
|
||||
<b>{resultScore(m)}</b>
|
||||
<span className={`b ${winner(m) === 'B' ? 'won' : ''}`}>{m.teams.B}</span>
|
||||
<a className="so-link" href={matchHref(m.id, key)}>
|
||||
<span className={`a ${winner(m) === 'A' ? 'won' : ''}`}>{m.teams.A}</span>
|
||||
<b>{resultScore(m)}</b>
|
||||
<span className={`b ${winner(m) === 'B' ? 'won' : ''}`}>{m.teams.B}</span>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -114,6 +136,11 @@ export default function StreamOverviewPage() {
|
||||
);
|
||||
}
|
||||
|
||||
/** Link zur Stream-Übersicht eines einzelnen Matches. Der Zuschauercode bleibt geheim, daher nur die Match-ID. */
|
||||
function matchHref(id, key) {
|
||||
return `/stream/match/${encodeURIComponent(id)}${key ? `?key=${encodeURIComponent(key)}` : ''}`;
|
||||
}
|
||||
|
||||
function useStreamOverview(key) {
|
||||
const [data, setData] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
@@ -145,7 +172,6 @@ function winner(m) {
|
||||
const [a, b] = resultScore(m).split(':').map(Number);
|
||||
return a === b ? null : a > b ? 'A' : 'B';
|
||||
}
|
||||
|
||||
function MatchCard({ m }) {
|
||||
const current = m.series.find((s) => s.number === m.currentMapNumber);
|
||||
const isBo1 = m.format === 'bo1';
|
||||
|
||||
@@ -114,3 +114,9 @@
|
||||
.so-strip li b { font-variant-numeric: tabular-nums; }
|
||||
.so-strip li .won { text-decoration: underline; text-underline-offset: 5px; }
|
||||
.so-none { color: var(--muted); font-size: 22px; margin: 0; }
|
||||
|
||||
/* Klickbare Karten und Listeneinträge führen zur Übersicht des jeweiligen Matches */
|
||||
.so-link { display: block; color: inherit; text-decoration: none; }
|
||||
.so-strip li .so-link { display: flex; align-items: baseline; gap: 10px; color: inherit; text-decoration: none; }
|
||||
.so-link:hover { filter: brightness(1.15); }
|
||||
.so-link:focus-visible { outline: 3px solid var(--brand-red); outline-offset: 4px; }
|
||||
@@ -483,3 +483,88 @@ input:focus, select:focus { border-color: var(--link); outline: none; }
|
||||
@media (max-width: 640px) {
|
||||
.series-score { grid-column: 2; grid-row: 2; font-size: 1.75rem; }
|
||||
}
|
||||
|
||||
/* ---------- Admin ---------- */
|
||||
.admin-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; margin-bottom: 20px; }
|
||||
.admin-list { display: grid; gap: 14px; }
|
||||
.admin-card { border-left: 4px solid var(--line-strong); }
|
||||
.admin-card.status-live { border-left-color: var(--brand-red); }
|
||||
.admin-card.status-veto { border-left-color: var(--pending); }
|
||||
.admin-card-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; }
|
||||
.admin-card-head strong { font: 700 1.5rem var(--display); }
|
||||
.admin-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; align-items: center; }
|
||||
.admin-details { margin-top: 16px; border-top: 1px solid var(--line); padding-top: 14px; display: grid; gap: 10px; }
|
||||
.admin-details h3 { margin-top: 6px; }
|
||||
.admin-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 20px; }
|
||||
.admin-players { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
|
||||
.admin-players ul { list-style: none; padding: 0; margin: 4px 0 0; }
|
||||
.admin-players li { display: flex; gap: 10px; align-items: baseline; padding: 4px 0; border-bottom: 1px solid var(--line); }
|
||||
.admin-players em { font-style: normal; color: var(--pending); font-size: .8125rem; }
|
||||
.admin-log { list-style: none; padding: 0; margin: 0; max-height: 260px; overflow-y: auto; font-size: .875rem; }
|
||||
.admin-log li { padding: 4px 0; border-bottom: 1px solid var(--line); }
|
||||
.admin-log time { color: var(--muted); margin-right: 8px; }
|
||||
.admin-login { margin-top: 40px; }
|
||||
.danger { color: var(--error); }
|
||||
@media (max-width: 640px) { .admin-players { grid-template-columns: 1fr; } }
|
||||
|
||||
/* ---------- Rollback (nur Admin) ---------- */
|
||||
.rollback { border: 1px dashed var(--brand-red); border-radius: var(--radius); padding: 12px 14px; }
|
||||
.rollback h3 { margin: 0 0 8px; display: flex; align-items: center; gap: 10px; }
|
||||
.rollback select { min-width: 200px; }
|
||||
.admin-badge { font: 600 .75rem var(--body); color: #fff; background: var(--brand-red); padding: 2px 8px; border-radius: 3px; }
|
||||
|
||||
.admin-settings { border: 1px solid var(--line); background: var(--surface); border-radius: var(--radius); padding: 16px 20px; margin-bottom: 20px; }
|
||||
.admin-settings h2 { margin: 0 0 12px; font-size: 1.1rem; }
|
||||
.admin-settings-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 8px 16px; margin-bottom: 12px; }
|
||||
.admin-check { display: flex; align-items: center; gap: 8px; cursor: pointer; }
|
||||
|
||||
.admin-tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--line); margin-bottom: 20px; }
|
||||
.admin-tab { background: none; border: 0; border-bottom: 2px solid transparent; color: var(--muted); padding: 10px 14px; font: inherit; cursor: pointer; }
|
||||
.admin-tab:hover { color: var(--text); }
|
||||
.admin-tab.is-active { color: var(--text); border-bottom-color: var(--primary); }
|
||||
|
||||
.turn-timer { grid-column: 1 / -1; display: flex; align-items: center; gap: 10px; margin-top: 8px; }
|
||||
.turn-bar { flex: 1; height: 6px; border-radius: 3px; background: color-mix(in srgb, var(--team) 18%, transparent); overflow: hidden; }
|
||||
.turn-bar span { display: block; height: 100%; background: var(--team); transition: width .25s linear; }
|
||||
.turn-seconds { min-width: 3ch; text-align: right; color: var(--muted); font-size: .875rem; font-variant-numeric: tabular-nums; }
|
||||
.turn-timer.is-low .turn-bar span { background: var(--error); }
|
||||
.turn-timer.is-low .turn-seconds { color: var(--error); font-weight: 600; }
|
||||
.admin-live { display: flex; gap: 10px; align-items: baseline; margin-top: 4px; font-size: .9rem; }
|
||||
.admin-live strong { font-variant-numeric: tabular-nums; }
|
||||
.admin-badge { display: inline-block; margin-top: 6px; padding: 2px 10px; border-radius: 999px; font-size: .8rem; font-weight: 600; }
|
||||
.admin-badge.is-paused { background: color-mix(in srgb, var(--pending) 22%, transparent); color: var(--pending); border: 1px solid var(--pending); }
|
||||
.btn-warn { border-color: var(--pending); color: var(--pending); }
|
||||
/* Laufende Matches in der Übersicht */
|
||||
.run-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 16px; }
|
||||
.run-card { position: relative; display: flex; flex-direction: column; justify-content: space-between; gap: 10px; min-height: 190px; padding: 16px 18px; border-radius: var(--radius); border: 1px solid var(--line); background: var(--surface); color: var(--text); text-decoration: none; overflow: hidden; isolation: isolate; transition: transform .15s ease, border-color .15s ease; }
|
||||
.run-card:hover { transform: translateY(-2px); border-color: var(--map); }
|
||||
.run-card.is-live { box-shadow: inset 3px 0 0 var(--brand-red); }
|
||||
.run-art { position: absolute; inset: 0; z-index: -1; opacity: .28; }
|
||||
.run-art::after { background: linear-gradient(180deg, rgba(0,0,0,.2), rgba(0,0,0,.92)); }
|
||||
.run-top { display: flex; justify-content: space-between; align-items: center; gap: 8px; font-size: .8rem; color: var(--muted); }
|
||||
.run-live { animation: run-pulse 1.6s ease-in-out infinite; }
|
||||
@keyframes run-pulse { 50% { opacity: .55; } }
|
||||
.run-status { font-weight: 600; }
|
||||
.run-teams { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 8px; text-align: center; }
|
||||
.run-team { font: 700 1.25rem var(--display); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.run-team.a { color: var(--team-a); text-align: right; }
|
||||
.run-team.b { color: var(--team-b); text-align: left; }
|
||||
.run-score { font: 700 2.25rem var(--display); font-variant-numeric: tabular-nums; }
|
||||
.run-score i { font-style: normal; color: var(--muted); padding: 0 4px; }
|
||||
.run-vs { font-size: 1rem; color: var(--muted); font-style: normal; }
|
||||
.run-foot { display: flex; justify-content: space-between; gap: 8px; font-size: .875rem; color: var(--muted); }
|
||||
.run-series { font-variant-numeric: tabular-nums; }
|
||||
.host-usage { margin-bottom: 20px; }
|
||||
.host-usage h2 { font-size: 1.1rem; margin: 0 0 10px; }
|
||||
.host-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 12px; }
|
||||
.host-card { border: 1px solid var(--line); background: var(--surface); border-radius: var(--radius); padding: 12px 14px; }
|
||||
.host-card.is-full { border-color: var(--pending); }
|
||||
.host-head { display: flex; flex-wrap: wrap; gap: 4px 10px; align-items: baseline; margin-bottom: 8px; }
|
||||
.host-count { margin-left: auto; font-variant-numeric: tabular-nums; font-size: .875rem; }
|
||||
.host-bar { height: 6px; border-radius: 3px; background: var(--line); overflow: hidden; margin-bottom: 8px; }
|
||||
.host-bar span { display: block; height: 100%; background: var(--primary); transition: width .3s ease; }
|
||||
.host-card.is-full .host-bar span { background: var(--pending); }
|
||||
.host-matches { list-style: none; margin: 0; padding: 0; font-size: .9rem; }
|
||||
.host-matches li { padding: 3px 0; border-top: 1px solid var(--line); }
|
||||
.host-disk { font-size: .85rem; color: var(--muted); margin: -2px 0 8px; font-variant-numeric: tabular-nums; }
|
||||
.host-disk.is-low { color: var(--error); font-weight: 600; }
|
||||
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env bash
|
||||
# Richtet einen Docker-Host für CS2 Mapveto ein. Läuft vom eigenen Rechner aus per SSH.
|
||||
#
|
||||
# Aufruf: bash deploy/docker-host-einrichten.sh <ssh-ziel> [volume]
|
||||
# Beispiel: bash deploy/docker-host-einrichten.sh cs2@192.168.178.111
|
||||
#
|
||||
# Voraussetzungen: SSH-Schlüssel hinterlegt (ohne Passwortabfrage), Docker auf dem Host installiert.
|
||||
set -euo pipefail
|
||||
|
||||
TARGET="${1:?Aufruf: $0 <ssh-ziel> [volume]}"
|
||||
VOLUME="${2:-cs2-mapveto-gamefiles}"
|
||||
IMAGE="joedwards32/cs2:latest"
|
||||
HOST_PART="${TARGET#*@}"
|
||||
|
||||
echo "→ Verbindung zu $TARGET prüfen"
|
||||
ssh -o BatchMode=yes -o ConnectTimeout=10 "$TARGET" true
|
||||
|
||||
echo "→ Docker prüfen"
|
||||
if ! ssh "$TARGET" "command -v docker >/dev/null 2>&1"; then
|
||||
echo "Docker fehlt auf $HOST_PART. Installation: https://docs.docker.com/engine/install/" >&2
|
||||
exit 1
|
||||
fi
|
||||
ssh "$TARGET" "docker info >/dev/null"
|
||||
|
||||
echo "→ Freier Platz für Docker (CS2 braucht rund 90 GB)"
|
||||
ssh "$TARGET" "df -BG /var/lib/docker 2>/dev/null | tail -1 || df -BG / | tail -1"
|
||||
|
||||
echo "→ Volume $VOLUME anlegen (falls nicht vorhanden)"
|
||||
ssh "$TARGET" "docker volume inspect $VOLUME >/dev/null 2>&1 || docker volume create $VOLUME >/dev/null"
|
||||
|
||||
echo "→ Image $IMAGE laden"
|
||||
ssh "$TARGET" "docker pull $IMAGE"
|
||||
|
||||
cat <<EOF
|
||||
|
||||
Fertig. Diesen Eintrag in CS2_DOCKER_HOSTS (JSON-Liste in der .env) aufnehmen:
|
||||
|
||||
{"name":"$HOST_PART","dockerHost":"ssh://$TARGET","publicHost":"$HOST_PART","portStart":27100,"portEnd":27139}
|
||||
|
||||
Hinweise:
|
||||
- name muss pro Host eindeutig sein.
|
||||
- Ports 27100–27139 dürfen auf dem Host frei sein. Pro Host sind bis zu 20 Matches möglich.
|
||||
- Der erste Server-Start lädt die CS2-Spieldateien (rund 60 bis 90 GB). Das dauert lange.
|
||||
EOF
|
||||
Binary file not shown.
@@ -0,0 +1,132 @@
|
||||
import express from 'express';
|
||||
import { HttpError } from './matchService.js';
|
||||
import { createRateLimiter, randomToken, safeEqual } from './security.js';
|
||||
|
||||
const SESSION_HOURS = 12;
|
||||
|
||||
function bearer(req) {
|
||||
const header = req.get('authorization') ?? '';
|
||||
return header.startsWith('Bearer ') ? header.slice(7).trim() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin-Modus unter /api/admin. Anmeldung mit ADMIN_PASSWORD, danach ein zufälliger Sitzungs-Token
|
||||
* (nur im Speicher, verfällt nach 12 Stunden oder beim Neustart). Admin-Aktionen nutzen die bestehenden
|
||||
* Organisator-Funktionen mit einem Admin-Zugang, die Berechtigungen bleiben also an einer Stelle.
|
||||
*/
|
||||
export function createAdminRouter({ service, manager, config }) {
|
||||
const router = express.Router();
|
||||
const sessions = new Map(); // Token -> Ablaufzeit (ms)
|
||||
const loginLimit = createRateLimiter({ windowMs: 60_000, max: 5 });
|
||||
|
||||
const wrap = (fn) => async (req, res, next) => {
|
||||
try {
|
||||
const result = await fn(req, res);
|
||||
res.json(result ?? { ok: true });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
};
|
||||
|
||||
const requireEnabled = (req, res, next) => {
|
||||
if (config.adminPassword) return next();
|
||||
return next(new HttpError(404, 'Admin-Modus ist nicht eingerichtet (ADMIN_PASSWORD fehlt)', 'admin_disabled'));
|
||||
};
|
||||
|
||||
const requireAdmin = (req, res, next) => {
|
||||
const token = bearer(req);
|
||||
const expires = token && sessions.get(token);
|
||||
if (expires && expires > Date.now()) return next();
|
||||
if (token) sessions.delete(token);
|
||||
return next(new HttpError(401, 'Admin-Anmeldung erforderlich', 'unauthorized'));
|
||||
};
|
||||
|
||||
// Admin-Zugang für eine Match-Aktion: wie ein Organisator, bei Bedarf als Captain eines Teams
|
||||
const adminViewer = (matchId, extra = {}) => ({
|
||||
matchId,
|
||||
role: 'organizer',
|
||||
isOrganizer: true,
|
||||
isCaptain: false,
|
||||
isSpectator: false,
|
||||
playerId: null,
|
||||
team: null,
|
||||
...extra,
|
||||
});
|
||||
|
||||
router.use(requireEnabled);
|
||||
|
||||
router.post('/login', (req, res, next) => {
|
||||
if (!loginLimit(`admin:${req.ip}`)) {
|
||||
return next(new HttpError(429, 'Zu viele Anmeldeversuche – bitte eine Minute warten', 'rate_limited'));
|
||||
}
|
||||
if (!safeEqual(String(req.body?.password ?? ''), config.adminPassword)) {
|
||||
return next(new HttpError(401, 'Falsches Passwort', 'unauthorized'));
|
||||
}
|
||||
const token = randomToken();
|
||||
const expiresAt = Date.now() + SESSION_HOURS * 3_600_000;
|
||||
sessions.set(token, expiresAt);
|
||||
return res.json({ token, expiresAt });
|
||||
});
|
||||
|
||||
router.post('/logout', (req, res) => {
|
||||
const token = bearer(req);
|
||||
if (token) sessions.delete(token);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.get('/session', requireAdmin, (req, res) => res.json({ ok: true }));
|
||||
|
||||
// Discord-Meldungen: welche Ereignisse gemeldet werden
|
||||
router.get('/notifications', requireAdmin, wrap(() => service.notificationSettings()));
|
||||
router.put('/notifications', requireAdmin, wrap((req) => service.setNotificationEvents(req.body?.events)));
|
||||
router.post('/notifications/test', requireAdmin, wrap(async () => {
|
||||
if (!service.notifier.enabled) throw new HttpError(409, 'Kein Discord-Webhook eingerichtet (DISCORD_WEBHOOK_URL)', 'not_configured');
|
||||
const test = { embeds: [{ title: '🧪 Testmeldung', description: 'Die Verbindung zu CS2 Mapveto funktioniert.', color: 0x3b82f6 }] };
|
||||
if (!(await service.notifier.send(test))) {
|
||||
throw new HttpError(502, 'Discord hat die Meldung nicht angenommen (siehe Log)', 'send_failed');
|
||||
}
|
||||
return { ok: true };
|
||||
}));
|
||||
|
||||
// Alle Matches mit allen Details (Codes, Verbindungsdaten, Spieler, Veto, Serie, Ereignisse)
|
||||
router.get('/matches', requireAdmin, wrap(async () => {
|
||||
const ids = service.db.prepare('SELECT id FROM matches ORDER BY created_at DESC LIMIT 100').all();
|
||||
return { matches: ids.map(({ id }) => service.snapshot(id, adminViewer(id))), hosts: await manager.hostUsage() };
|
||||
}));
|
||||
|
||||
const action = (path, fn) => router.post(`/matches/:id/${path}`, requireAdmin, wrap((req) => {
|
||||
service.getMatch(req.params.id);
|
||||
return fn(adminViewer(req.params.id), req.body ?? {}, req);
|
||||
}));
|
||||
|
||||
action('finish', (v) => service.close(v, 'finished'));
|
||||
action('cancel', (v) => service.close(v, 'cancelled'));
|
||||
action('server/stop', (v) => manager.stopByOrganizer(v));
|
||||
action('server/retry', (v) => manager.retry(v));
|
||||
action('server/warmup-end', (v) => manager.endWarmup(v));
|
||||
action('server/pause', (v) => manager.setPaused(v, true));
|
||||
action('server/resume', (v) => manager.setPaused(v, false));
|
||||
action('series/next', (v) => manager.loadNextMap(v));
|
||||
action('veto/start', (v) => service.startVeto(v));
|
||||
action('veto/action', (v, b) => service.vetoAction(
|
||||
adminViewer(v.matchId, { team: b.team, isCaptain: true, role: 'captain' }),
|
||||
{ stepIndex: b.stepIndex, map: b.map, side: b.side },
|
||||
));
|
||||
action('kick', (v, b) => service.kick(v, b.playerId));
|
||||
action('codes/regenerate', (v, b) => service.regenerateCode(v, b.target));
|
||||
|
||||
// Rollback: nur für Admins. Diese Routen gibt es für Organisatoren nicht.
|
||||
router.get('/matches/:id/rollback-points', requireAdmin, wrap(async (req) => {
|
||||
service.getMatch(req.params.id);
|
||||
return { points: await manager.rollbackPoints(adminViewer(req.params.id)) };
|
||||
}));
|
||||
action('rollback', (v, b) => manager.rollback(v, b.file));
|
||||
|
||||
// Match endgültig löschen (Server wird vorher freigegeben)
|
||||
router.delete('/matches/:id', requireAdmin, wrap(async (req) => {
|
||||
await manager.deleteMatch(req.params.id);
|
||||
return { ok: true };
|
||||
}));
|
||||
|
||||
return router;
|
||||
}
|
||||
+8
-2
@@ -5,6 +5,7 @@ import express from 'express';
|
||||
import { REPO_ROOT } from './config.js';
|
||||
import { openDatabase } from './db.js';
|
||||
import { createApiRouter } from './http.js';
|
||||
import { createDiscordNotifier } from './discord.js';
|
||||
import { loadMapPools } from './mapPools.js';
|
||||
import { MatchService } from './matchService.js';
|
||||
import { attachRealtime } from './realtime.js';
|
||||
@@ -15,12 +16,16 @@ import { ServerManager } from './servers/serverManager.js';
|
||||
export function createApp(config, { adapter: adapterOverride, logger = console } = {}) {
|
||||
const db = openDatabase(config.databaseFile);
|
||||
const pools = loadMapPools(config.mapPoolFile);
|
||||
const service = new MatchService({ db, pools, config });
|
||||
const notifier = createDiscordNotifier(config.discordWebhookUrl, { logger });
|
||||
const service = new MatchService({ db, pools, config, notifier });
|
||||
|
||||
let manager;
|
||||
const adapter = adapterOverride ?? createServerAdapter(config, { getUsedPorts: () => manager.usedPorts() });
|
||||
const adapter = adapterOverride ?? createServerAdapter(config, {
|
||||
getUsedPorts: (host) => (host ? manager.usedPortsOn(host) : manager.usedPorts()),
|
||||
});
|
||||
manager = new ServerManager({ db, service, adapter, config, logger });
|
||||
manager.init();
|
||||
service.startVetoTimer();
|
||||
|
||||
const app = express();
|
||||
if (config.trustProxy) app.set('trust proxy', 1);
|
||||
@@ -52,6 +57,7 @@ export function createApp(config, { adapter: adapterOverride, logger = console }
|
||||
},
|
||||
async close() {
|
||||
manager.stopLoops();
|
||||
service.stopVetoTimer();
|
||||
// Laufende Server-Aktionen (Start, Konfiguration) kurz abwarten, bevor die Datenbank schließt
|
||||
await manager.idle(5000);
|
||||
io.disposeRealtime();
|
||||
|
||||
@@ -22,6 +22,32 @@ function bool(name, fallback) {
|
||||
return ['1', 'true', 'yes', 'ja', 'on'].includes(raw.toLowerCase());
|
||||
}
|
||||
|
||||
function parseDockerHosts(raw) {
|
||||
if (!raw) return null;
|
||||
let list;
|
||||
try {
|
||||
list = JSON.parse(raw);
|
||||
} catch {
|
||||
throw new Error('CS2_DOCKER_HOSTS ist kein gültiges JSON');
|
||||
}
|
||||
if (!Array.isArray(list) || list.length === 0) throw new Error('CS2_DOCKER_HOSTS braucht mindestens einen Eintrag');
|
||||
const names = new Set();
|
||||
return list.map((h) => {
|
||||
if (!h.name || !h.publicHost) throw new Error('Jeder Docker-Host braucht name und publicHost');
|
||||
if (names.has(h.name)) throw new Error(`Docker-Host-Name "${h.name}" kommt doppelt vor`);
|
||||
names.add(h.name);
|
||||
return {
|
||||
name: String(h.name),
|
||||
dockerHost: h.dockerHost ?? null,
|
||||
publicHost: String(h.publicHost),
|
||||
rconHost: h.rconHost ?? h.publicHost,
|
||||
portRangeStart: h.portStart == null ? undefined : Number(h.portStart),
|
||||
portRangeEnd: h.portEnd == null ? undefined : Number(h.portEnd),
|
||||
volume: h.volume ?? undefined,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function str(name, fallback = '') {
|
||||
const raw = process.env[name];
|
||||
return raw === undefined || raw === '' ? fallback : raw;
|
||||
@@ -41,6 +67,9 @@ export function loadConfig(overrides = {}) {
|
||||
createMatchPassword: str('CREATE_MATCH_PASSWORD'),
|
||||
// Optionaler Schlüssel für die Stream-Übersicht aller Matches (/stream?key=…); leer = offen
|
||||
streamKey: str('STREAM_KEY'),
|
||||
discordWebhookUrl: str('DISCORD_WEBHOOK_URL'),
|
||||
// Passwort für den Admin-Modus (/admin). Leer = Admin-Modus ist aus.
|
||||
adminPassword: str('ADMIN_PASSWORD'),
|
||||
requireFullTeams: bool('REQUIRE_FULL_TEAMS', false),
|
||||
trustProxy: bool('TRUST_PROXY', false),
|
||||
|
||||
@@ -49,6 +78,8 @@ export function loadConfig(overrides = {}) {
|
||||
maxLifetimeHours: int('SERVER_MAX_LIFETIME_HOURS', 8),
|
||||
statusPollSeconds: int('SERVER_STATUS_POLL_SECONDS', 5),
|
||||
reaperIntervalSeconds: int('SERVER_REAPER_INTERVAL_SECONDS', 60),
|
||||
// Frist pro Veto-Schritt in Sekunden, danach führt das Backend den Schritt automatisch aus. 0 = keine Frist
|
||||
vetoStepSeconds: int('VETO_STEP_SECONDS', 30),
|
||||
|
||||
match: {
|
||||
tvDelaySeconds: int('CS2_TV_DELAY', 90),
|
||||
@@ -80,6 +111,9 @@ export function loadConfig(overrides = {}) {
|
||||
volume: str('CS2_DOCKER_VOLUME', 'cs2-mapveto-gamefiles'),
|
||||
extraArgs: str('CS2_DOCKER_EXTRA_ARGS'),
|
||||
startTimeoutMinutes: int('CS2_START_TIMEOUT_MINUTES', 20),
|
||||
// Optional: mehrere Docker-Hosts als JSON-Liste, z. B.
|
||||
// [{"name":"host1","dockerHost":"ssh://cs2@192.168.178.111","publicHost":"192.168.178.111","portStart":27100,"portEnd":27139}]
|
||||
hosts: parseDockerHosts(str('CS2_DOCKER_HOSTS')),
|
||||
},
|
||||
|
||||
staticPool: {
|
||||
|
||||
@@ -97,6 +97,10 @@ CREATE TABLE IF NOT EXISTS events (
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS events_match ON events(match_id, id);
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
`;
|
||||
|
||||
export function openDatabase(file) {
|
||||
@@ -124,4 +128,6 @@ function migrate(db) {
|
||||
for (const [name, type] of add) {
|
||||
if (!seriesColumns.has(name)) db.exec(`ALTER TABLE series_maps ADD COLUMN ${name} ${type}`);
|
||||
}
|
||||
const serverColumns = columns('servers');
|
||||
if (!serverColumns.has('paused')) db.exec('ALTER TABLE servers ADD COLUMN paused INTEGER NOT NULL DEFAULT 0');
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// Meldungen an einen Discord-Webhook, z. B. für Streamer. Fehler werden nur protokolliert
|
||||
// und bremsen den Matchablauf nie aus.
|
||||
|
||||
const WEBHOOK_PATTERN = /^https:\/\/(discord\.com|discordapp\.com)\/api\/webhooks\/\d+\/[\w-]+$/;
|
||||
const TIMEOUT_MS = 5000;
|
||||
const MAX_LENGTH = 1900;
|
||||
|
||||
/** Ereignisse, die gemeldet werden können. Alle sind standardmäßig aktiv. */
|
||||
export const NOTIFY_EVENTS = [
|
||||
{ key: 'veto_start', label: 'Veto gestartet' },
|
||||
{ key: 'match_start', label: 'Match gestartet (Aufwärmphase beendet)' },
|
||||
{ key: 'map_end', label: 'Map beendet (mit Ergebnis)' },
|
||||
{ key: 'series_end', label: 'Serie entschieden' },
|
||||
{ key: 'match_closed', label: 'Match abgeschlossen oder abgebrochen' },
|
||||
{ key: 'server_error', label: 'Serverfehler' },
|
||||
];
|
||||
|
||||
/**
|
||||
* @param {string} url Webhook-URL aus DISCORD_WEBHOOK_URL, leer = keine Meldungen
|
||||
* @param {object} options fetchImpl und logger, vor allem für Tests
|
||||
*/
|
||||
export function createDiscordNotifier(url, { fetchImpl = fetch, logger = console } = {}) {
|
||||
if (!url) return { enabled: false, send: async () => false };
|
||||
// Die URL enthält ein Geheimnis. Sie wird deshalb nie in Fehlermeldungen ausgegeben.
|
||||
if (!WEBHOOK_PATTERN.test(url)) throw new Error('DISCORD_WEBHOOK_URL ist keine gültige Discord-Webhook-URL');
|
||||
|
||||
return {
|
||||
enabled: true,
|
||||
/** Liefert true, wenn Discord die Nachricht angenommen hat. */
|
||||
async send(message) {
|
||||
// Text oder vollständige Discord-Payload (z. B. mit embeds)
|
||||
const payload = typeof message === 'string' ? { content: message.slice(0, MAX_LENGTH) } : message;
|
||||
try {
|
||||
const res = await fetchImpl(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
signal: AbortSignal.timeout(TIMEOUT_MS),
|
||||
});
|
||||
if (res.ok) return true;
|
||||
logger.warn(`Discord-Webhook antwortet mit HTTP ${res.status}`);
|
||||
} catch (err) {
|
||||
logger.warn(`Discord-Meldung fehlgeschlagen: ${err.name}`);
|
||||
}
|
||||
return false;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import express from 'express';
|
||||
import { HttpError } from './matchService.js';
|
||||
import { publicPools } from './mapPools.js';
|
||||
import { createRateLimiter, safeEqual } from './security.js';
|
||||
import { createAdminRouter } from './adminRoutes.js';
|
||||
|
||||
function bearer(req) {
|
||||
const header = req.get('authorization') ?? '';
|
||||
@@ -31,6 +32,7 @@ export function createApiRouter({ service, manager, config, pools, adapter }) {
|
||||
});
|
||||
|
||||
router.use(express.json({ limit: '32kb' }));
|
||||
router.use('/admin', createAdminRouter({ service, manager, config }));
|
||||
|
||||
const limiters = {
|
||||
create: createRateLimiter({ windowMs: 60_000, max: 10 }),
|
||||
|
||||
+159
-3
@@ -2,7 +2,9 @@ import { EventEmitter } from 'node:events';
|
||||
import { BEST_OF, computeVetoState, resolveAction, VetoError } from './veto.js';
|
||||
import { formatCode, hashToken, normalizeCode, randomCode, randomId, randomToken, safeEqual } from './security.js';
|
||||
import { applyLogLines, initialLiveState, seriesScore, splitLogBody } from './liveScore.js';
|
||||
import { trimLiveState } from './rounds.js';
|
||||
import { sanitizeConsoleText } from './servers/cs2Config.js';
|
||||
import { NOTIFY_EVENTS } from './discord.js';
|
||||
|
||||
export class HttpError extends Error {
|
||||
constructor(status, message, code = 'error') {
|
||||
@@ -40,11 +42,12 @@ function cleanSteamId(value) {
|
||||
}
|
||||
|
||||
export class MatchService extends EventEmitter {
|
||||
constructor({ db, pools, config }) {
|
||||
constructor({ db, pools, config, notifier = { enabled: false, send: async () => {} } }) {
|
||||
super();
|
||||
this.db = db;
|
||||
this.pools = pools;
|
||||
this.config = config;
|
||||
this.notifier = notifier;
|
||||
this.presence = () => new Set();
|
||||
}
|
||||
|
||||
@@ -338,10 +341,68 @@ export class MatchService extends EventEmitter {
|
||||
.run(firstTeam, this.now(), this.now(), match.id);
|
||||
const how = match.veto_start === 'coin' ? 'per Münzwurf ausgelost' : 'vom Organisator festgelegt';
|
||||
this.log(match.id, 'veto', `Veto gestartet – ${this.teamName(match, firstTeam)} beginnt (${how})`);
|
||||
this.notify('veto_start', match.id, {
|
||||
title: '🎮 Veto gestartet',
|
||||
description: `**${this.matchTitle(match.id)}**`,
|
||||
color: 0x3b82f6,
|
||||
fields: [{ name: 'Beginnt', value: this.teamName(match, firstTeam), inline: true }],
|
||||
});
|
||||
})();
|
||||
this.changed(viewer.matchId);
|
||||
}
|
||||
|
||||
/** Ablaufzeitpunkt des aktuellen Veto-Schritts (ms), null wenn keine Frist gilt. */
|
||||
vetoDeadline(match) {
|
||||
if (!this.config.vetoStepSeconds || match.status !== 'veto') return null;
|
||||
const actions = this.vetoActions(match.id);
|
||||
const since = actions.length ? actions[actions.length - 1].created_at : match.veto_started_at;
|
||||
return since == null ? null : since + this.config.vetoStepSeconds * 1000;
|
||||
}
|
||||
|
||||
vetoRemainingMs(match) {
|
||||
const deadline = this.vetoDeadline(match);
|
||||
return deadline === null ? null : Math.max(0, deadline - this.now());
|
||||
}
|
||||
|
||||
/** Führt den aktuellen Veto-Schritt für das Team am Zug aus: zufällige Map bzw. zufällige Seite. */
|
||||
autoVetoStep(match) {
|
||||
const state = this.vetoState(match);
|
||||
if (state.done || !state.current) return;
|
||||
const { type, team, index } = state.current;
|
||||
const pick = (list) => list[Math.floor(Math.random() * list.length)];
|
||||
const viewer = {
|
||||
matchId: match.id, role: 'captain', isOrganizer: false, isCaptain: true, isSpectator: false, playerId: null, team,
|
||||
};
|
||||
const action = type === 'side'
|
||||
? { stepIndex: index, side: pick(['CT', 'T']) }
|
||||
: { stepIndex: index, map: pick(state.maps.filter((m) => m.status === 'available')).id };
|
||||
this.log(match.id, 'veto', `Zeit abgelaufen – ${this.teamName(match, team)} wird automatisch vertreten`);
|
||||
this.vetoAction(viewer, action);
|
||||
}
|
||||
|
||||
/** Läuft die Frist eines Veto-Schritts ab, führt das Backend den Schritt aus. */
|
||||
autoVetoTimeouts() {
|
||||
for (const match of this.db.prepare("SELECT * FROM matches WHERE status = 'veto'").all()) {
|
||||
const deadline = this.vetoDeadline(match);
|
||||
if (deadline === null || this.now() < deadline) continue;
|
||||
try {
|
||||
this.autoVetoStep(match);
|
||||
} catch (err) {
|
||||
this.log(match.id, 'veto', `Automatischer Schritt fehlgeschlagen: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
startVetoTimer() {
|
||||
if (!this.config.vetoStepSeconds || this.vetoTimer) return;
|
||||
this.vetoTimer = setInterval(() => this.autoVetoTimeouts(), 1000);
|
||||
}
|
||||
|
||||
stopVetoTimer() {
|
||||
clearInterval(this.vetoTimer);
|
||||
this.vetoTimer = null;
|
||||
}
|
||||
|
||||
vetoAction(viewer, { stepIndex, map, side }) {
|
||||
this.require(viewer, 'captain');
|
||||
let finished = false;
|
||||
@@ -448,6 +509,20 @@ export class MatchService extends EventEmitter {
|
||||
.run(matchId, number);
|
||||
}
|
||||
|
||||
/** Stand auf den Zeitpunkt nach einer Runde zurückstellen (nach Rollback). */
|
||||
rewindLiveScore(matchId, roundNumber, file) {
|
||||
const match = this.getMatch(matchId);
|
||||
const row = this.seriesMaps(matchId).find((m) => m.number === match.current_map_number);
|
||||
if (!row) return;
|
||||
const live = row.live ? trimLiveState(JSON.parse(row.live), roundNumber) : null;
|
||||
const score = live ? live.score : { A: 0, B: 0 };
|
||||
this.db
|
||||
.prepare("UPDATE series_maps SET score_a = ?, score_b = ?, winner = NULL, status = 'live', live = ?, live_updated_at = ? WHERE match_id = ? AND number = ?")
|
||||
.run(score.A, score.B, live ? JSON.stringify(live) : null, this.now(), matchId, row.number);
|
||||
this.log(matchId, 'rollback', `Map ${row.number} zurückgespielt auf Runde ${roundNumber} (${file})`);
|
||||
this.changed(matchId);
|
||||
}
|
||||
|
||||
/** Organisator hat die Aufwärmphase beendet: ab dem nächsten Match_Start wird gezählt. */
|
||||
markWarmupEnded(matchId, ended = true) {
|
||||
const match = this.getMatch(matchId);
|
||||
@@ -501,6 +576,16 @@ export class MatchService extends EventEmitter {
|
||||
const winner = state.winner ? this.teamName(match, state.winner) : 'Unentschieden';
|
||||
this.log(matchId, 'score',
|
||||
`Map ${row.number} (${this.mapName(match, row.map)}) beendet: ${match.team_a_name} ${state.score.A}:${state.score.B} ${match.team_b_name} – Sieger: ${winner}`);
|
||||
this.notify('map_end', matchId, {
|
||||
title: '🗺️ Map beendet',
|
||||
description: `**${this.matchTitle(matchId)}**`,
|
||||
color: 0xf59e0b,
|
||||
fields: [
|
||||
{ name: `Map ${row.number}`, value: this.mapName(match, row.map), inline: true },
|
||||
{ name: 'Stand', value: `${state.score.A} : ${state.score.B}`, inline: true },
|
||||
{ name: 'Sieger', value: winner, inline: true },
|
||||
],
|
||||
});
|
||||
}
|
||||
})();
|
||||
if (ended) {
|
||||
@@ -509,6 +594,18 @@ export class MatchService extends EventEmitter {
|
||||
const needed = Math.floor(BEST_OF[ended.match.format] / 2) + 1;
|
||||
const decided = score.A >= needed ? 'A' : score.B >= needed ? 'B' : null;
|
||||
if (decided) this.log(matchId, 'score', `Serie entschieden: ${this.teamName(ended.match, decided)} gewinnt ${score[decided]}:${score[decided === 'A' ? 'B' : 'A']}`);
|
||||
if (decided) {
|
||||
const loser = decided === 'A' ? 'B' : 'A';
|
||||
this.notify('series_end', matchId, {
|
||||
title: '🏆 Serie entschieden',
|
||||
description: `**${this.matchTitle(matchId)}**`,
|
||||
color: 0x22c55e,
|
||||
fields: [
|
||||
{ name: 'Sieger', value: this.teamName(ended.match, decided), inline: true },
|
||||
{ name: 'Serienstand', value: `${score[decided]} : ${score[loser]}`, inline: true },
|
||||
],
|
||||
});
|
||||
}
|
||||
const hasNext = maps.some((m) => m.number === ended.row.number + 1);
|
||||
this.changed(matchId);
|
||||
this.emit('mapEnded', { matchId, number: ended.row.number, decided, hasNext });
|
||||
@@ -540,6 +637,12 @@ export class MatchService extends EventEmitter {
|
||||
this.db.prepare("UPDATE series_maps SET status = 'done' WHERE match_id = ? AND status IN ('live','next')").run(match.id);
|
||||
}
|
||||
this.log(match.id, 'status', message ?? (status === 'finished' ? 'Match abgeschlossen' : 'Match abgebrochen'));
|
||||
const finished = status === 'finished';
|
||||
this.notify('match_closed', match.id, {
|
||||
title: finished ? '✅ Match abgeschlossen' : '⛔ Match abgebrochen',
|
||||
description: `**${this.matchTitle(match.id)}**${message ? `\n${message}` : ''}`,
|
||||
color: finished ? 0x6b7280 : 0x9ca3af,
|
||||
});
|
||||
this.setStatus(match.id, status);
|
||||
this.emit('closed', match.id);
|
||||
}
|
||||
@@ -554,6 +657,47 @@ export class MatchService extends EventEmitter {
|
||||
this.db.prepare('UPDATE matches SET last_activity_at = ? WHERE id = ?').run(this.now(), matchId);
|
||||
}
|
||||
|
||||
/** Titel für Meldungen, z. B. „Team A vs. Team B (BO3)“. */
|
||||
matchTitle(matchId) {
|
||||
const m = this.getMatch(matchId);
|
||||
return `${m.team_a_name} vs. ${m.team_b_name} (${m.format.toUpperCase()})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Meldung an den Discord-Webhook, falls konfiguriert. Ohne Stream-Schlüssel gehört die Übersicht
|
||||
* des Matches dazu, mit Schlüssel entfällt der Link, damit der Schlüssel nicht in den Kanal gelangt.
|
||||
*/
|
||||
/** Discord-Meldungen: der Webhook muss eingerichtet und das Ereignis ausgewählt sein. */
|
||||
notificationSettings() {
|
||||
const row = this.db.prepare("SELECT value FROM settings WHERE key = 'discord_events'").get();
|
||||
let stored = {};
|
||||
try {
|
||||
stored = row ? JSON.parse(row.value) : {};
|
||||
} catch {
|
||||
stored = {};
|
||||
}
|
||||
const events = Object.fromEntries(NOTIFY_EVENTS.map(({ key }) => [key, stored[key] !== false]));
|
||||
return { enabled: this.notifier.enabled, events, catalog: NOTIFY_EVENTS };
|
||||
}
|
||||
|
||||
setNotificationEvents(input) {
|
||||
if (!input || typeof input !== 'object') throw new HttpError(400, 'Ungültige Auswahl der Meldungen', 'invalid');
|
||||
const events = Object.fromEntries(NOTIFY_EVENTS.map(({ key }) => [key, input[key] === true]));
|
||||
this.db
|
||||
.prepare("INSERT INTO settings (key, value) VALUES ('discord_events', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value")
|
||||
.run(JSON.stringify(events));
|
||||
return this.notificationSettings();
|
||||
}
|
||||
|
||||
/** Meldung als Discord-Embed. Ohne Stream-Schlüssel ist der Titel ein Link auf die Match-Übersicht. */
|
||||
notify(type, matchId, embed) {
|
||||
if (!this.notifier.enabled || !this.notificationSettings().events[type]) return;
|
||||
const url = this.config.streamKey ? undefined : `${this.config.publicUrl}/stream/match/${matchId}`;
|
||||
this.notifier.send({
|
||||
embeds: [{ ...embed, url, timestamp: new Date(this.now()).toISOString(), footer: { text: 'CS2 Mapveto' } }],
|
||||
});
|
||||
}
|
||||
|
||||
log(matchId, type, message) {
|
||||
this.db
|
||||
.prepare('INSERT INTO events (match_id, type, message, created_at) VALUES (?, ?, ?, ?)')
|
||||
@@ -647,7 +791,14 @@ export class MatchService extends EventEmitter {
|
||||
return {
|
||||
seriesScore: maps.length ? seriesScore(maps) : null,
|
||||
currentMap: current && (current.status === 'live' || current.status === 'done')
|
||||
? { number: current.number, name: this.mapName(m, current.map), score: { A: current.score_a, B: current.score_b }, finished: current.status === 'done' }
|
||||
? {
|
||||
number: current.number,
|
||||
name: this.mapName(m, current.map),
|
||||
image: JSON.parse(m.maps).find((x) => x.id === current.map)?.image ?? null,
|
||||
color: JSON.parse(m.maps).find((x) => x.id === current.map)?.color ?? null,
|
||||
score: { A: current.score_a, B: current.score_b },
|
||||
finished: current.status === 'done',
|
||||
}
|
||||
: null,
|
||||
id: m.id,
|
||||
teamA: m.team_a_name,
|
||||
@@ -741,7 +892,7 @@ export class MatchService extends EventEmitter {
|
||||
requireFullTeams: this.config.requireFullTeams,
|
||||
autoAdvanceSeconds: this.config.autoAdvanceSeconds,
|
||||
maps: pool.maps,
|
||||
veto: publicView
|
||||
veto: publicView && match.status === 'lobby'
|
||||
? null
|
||||
: {
|
||||
started: match.status !== 'lobby',
|
||||
@@ -749,6 +900,8 @@ export class MatchService extends EventEmitter {
|
||||
steps: veto.steps,
|
||||
maps: veto.maps,
|
||||
current: match.status === 'veto' ? veto.current : null,
|
||||
remainingMs: this.vetoRemainingMs(match),
|
||||
stepSeconds: this.config.vetoStepSeconds,
|
||||
done: veto.done,
|
||||
history: actions.map((a) => ({
|
||||
index: a.idx,
|
||||
@@ -781,6 +934,8 @@ export class MatchService extends EventEmitter {
|
||||
spectator: `${base}/watch/${formatCode(match.code_spectator)}`,
|
||||
// Vollbild-Übersicht für den Stream (OBS-Browserquelle), nutzt den Zuschauercode
|
||||
stream: `${base}/stream/${formatCode(match.code_spectator)}`,
|
||||
// Stream-Übersicht nur für dieses Match, ohne Zuschauercode (Schlüssel nur wenn gesetzt)
|
||||
overview: `${base}/stream/match/${match.id}${this.config.streamKey ? `?key=${encodeURIComponent(this.config.streamKey)}` : ''}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -796,6 +951,7 @@ export class MatchService extends EventEmitter {
|
||||
status: row.status,
|
||||
message: row.message,
|
||||
playerCount: row.player_count,
|
||||
paused: !!row.paused,
|
||||
attempts: row.attempts,
|
||||
updatedAt: row.updated_at,
|
||||
idleTimeoutMinutes: this.config.idleTimeoutMinutes,
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
// Rundensicherung und Zurückspielen (Rollback).
|
||||
//
|
||||
// CS2 speichert nach jeder Runde eine Sicherungsdatei des Spielzustands auf dem Server (mp_backup_round_auto).
|
||||
// Diese Datei lässt sich später wieder laden (mp_backup_restore_load_file). Dieses Modul kennt die Namen,
|
||||
// wertet die Liste aus und kürzt den gespeicherten Live-Stand passend zur Rückkehr.
|
||||
|
||||
// Präfix und Muster wie bei TMT2: der Präfix enthält die Match-ID, das Muster Map, Runde und Stand.
|
||||
export const backupPrefix = (matchId) => `round_backup_${matchId}`;
|
||||
export const BACKUP_PATTERN = '%prefix%_%date%_%time%_%map%_round%round%_score_%score1%_%score2%.txt';
|
||||
|
||||
// Endung: _<map>_round<N>_score_<a>_<b>.txt – die Map beginnt mit zwei Kleinbuchstaben und Unterstrich (de_, cs_, ar_ …)
|
||||
const ROUND_RE = /_([a-z]{2}_[a-z0-9_]+?)_round(\d+)_score_(\d+)_(\d+)\.txt$/;
|
||||
|
||||
/** Ist der Dateiname eine Sicherung genau dieses Matches? Schützt vor Pfaden wie "../" und fremden Dateien. */
|
||||
export function isValidBackupFile(file, matchId) {
|
||||
if (typeof file !== 'string' || file.length > 200) return false;
|
||||
if (!file.startsWith(backupPrefix(matchId))) return false;
|
||||
return /^[A-Za-z0-9_.-]+\.txt$/.test(file) && !file.includes('..') && ROUND_RE.test(file);
|
||||
}
|
||||
|
||||
/** Findet alle Sicherungsdateien dieses Matches in einer RCON-Antwort (Format der Antwort ist nicht festgelegt). */
|
||||
export function parseBackupFiles(text, matchId) {
|
||||
const prefix = backupPrefix(matchId);
|
||||
const found = new Set();
|
||||
const re = new RegExp(`${prefix}_[A-Za-z0-9_.-]+\\.txt`, 'g');
|
||||
for (const m of String(text ?? '').matchAll(re)) found.add(m[0]);
|
||||
return [...found].filter((f) => isValidBackupFile(f, matchId)).map((file) => describeBackup(file, matchId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Zerlegt einen Dateinamen in Map, Rundennummer und Stand. Mit matchId wird vorher der Präfix entfernt,
|
||||
* damit Teile der Match-ID nicht mit einem Map-Namen verwechselt werden.
|
||||
*/
|
||||
export function describeBackup(file, matchId = null) {
|
||||
const rest = matchId && file.startsWith(backupPrefix(matchId)) ? file.slice(backupPrefix(matchId).length) : file;
|
||||
const m = ROUND_RE.exec(rest);
|
||||
if (!m) return { file, map: null, round: null, score: null };
|
||||
return { file, map: m[1], round: Number(m[2]), score: { s1: Number(m[3]), s2: Number(m[4]) } };
|
||||
}
|
||||
|
||||
/** Nur die Sicherungen der gerade gespielten Map, neueste Runde zuerst. */
|
||||
export function pointsForMap(points, map) {
|
||||
return points.filter((p) => p.map === map).sort((a, b) => b.round - a.round);
|
||||
}
|
||||
|
||||
/**
|
||||
* Kürzt den Live-Stand auf die Runden bis einschließlich roundNumber und rechnet den Stand neu.
|
||||
* Danach zählt die Anzeige weiter, sobald CS2 wieder Rundenmeldungen schickt.
|
||||
*/
|
||||
export function trimLiveState(state, roundNumber) {
|
||||
const rounds = (state.rounds ?? []).filter((r) => r.number <= roundNumber);
|
||||
const score = { A: 0, B: 0 };
|
||||
for (const r of rounds) if (r.winner) score[r.winner] += 1;
|
||||
return {
|
||||
...state,
|
||||
status: 'live',
|
||||
rounds,
|
||||
score,
|
||||
winner: null,
|
||||
pendingRound: null,
|
||||
};
|
||||
}
|
||||
@@ -29,7 +29,7 @@
|
||||
*/
|
||||
|
||||
import { DemoServerAdapter } from './demoAdapter.js';
|
||||
import { DockerServerAdapter } from './dockerAdapter.js';
|
||||
import { DockerHostPool } from './dockerAdapter.js';
|
||||
import { StaticPoolServerAdapter } from './staticPoolAdapter.js';
|
||||
|
||||
export function createServerAdapter(config, deps) {
|
||||
@@ -37,7 +37,7 @@ export function createServerAdapter(config, deps) {
|
||||
case 'demo':
|
||||
return new DemoServerAdapter(config.demo);
|
||||
case 'docker':
|
||||
return new DockerServerAdapter(config.docker, deps);
|
||||
return new DockerHostPool(config.docker, deps);
|
||||
case 'static':
|
||||
return new StaticPoolServerAdapter(config.staticPool, deps);
|
||||
default:
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// Erzeugt die Konsolenbefehle, mit denen ein laufender CS2-Server für eine Map der Serie konfiguriert wird.
|
||||
// Alle Werte werden so bereinigt, dass sie keine weiteren Befehle einschleusen können.
|
||||
|
||||
import { BACKUP_PATTERN } from '../rounds.js';
|
||||
|
||||
export function sanitizeConsoleText(value, maxLength = 32) {
|
||||
return String(value ?? '')
|
||||
.replace(/["';\\\r\n\t]/g, '')
|
||||
@@ -23,7 +25,7 @@ export function sanitizeConsoleText(value, maxLength = 32) {
|
||||
* @param {object} p.secrets { serverPassword, tvPassword }
|
||||
* @param {object} p.settings config.match
|
||||
*/
|
||||
export function buildMapCommands({ match, pool, seriesMap, secrets, settings, logUrl }) {
|
||||
export function buildMapCommands({ match, pool, seriesMap, secrets, settings, logUrl, backupPrefix }) {
|
||||
const isWingman = pool.mode === 'wingman';
|
||||
const ctTeam = seriesMap.ctTeam ?? 'A';
|
||||
const tTeam = ctTeam === 'A' ? 'B' : 'A';
|
||||
@@ -40,6 +42,13 @@ export function buildMapCommands({ match, pool, seriesMap, secrets, settings, lo
|
||||
...(logUrl && /^https?:\/\/[\w.:/-]+$/.test(logUrl)
|
||||
? ['log on', 'logaddress_delall_http', `logaddress_add_http "${logUrl}"`]
|
||||
: []),
|
||||
// Rundensicherung: CS2 speichert nach jeder Runde den Spielzustand, damit sich Runden zurückspielen lassen
|
||||
...(backupPrefix ? [
|
||||
`mp_backup_round_file "${backupPrefix}"`,
|
||||
`mp_backup_round_file_pattern "${BACKUP_PATTERN}"`,
|
||||
'mp_backup_round_auto 1',
|
||||
'mp_backup_restore_load_autopause 1',
|
||||
] : []),
|
||||
`changelevel ${seriesMap.map}`,
|
||||
];
|
||||
|
||||
@@ -54,6 +63,13 @@ export function buildMapCommands({ match, pool, seriesMap, secrets, settings, lo
|
||||
'mp_limitteams 0',
|
||||
// Aufwärmphase läuft, bis der Organisator das Match startet (mp_warmup_end)
|
||||
'mp_warmup_pausetimer 1',
|
||||
// Turnierregeln (FACEIT/ESL-Richtwerte): Pausen erlauben, feste Rundenzeiten, Halbzeit
|
||||
'sv_pausable 1',
|
||||
'mp_halftime 1',
|
||||
'mp_freezetime 20',
|
||||
'mp_buytime 20',
|
||||
'mp_startmoney 800',
|
||||
'mp_roundtime 1.92',
|
||||
...settings.extraCommands,
|
||||
];
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
// Er simuliert nur Startdauer und Konfiguration, damit der Ablauf ohne Hosting getestet werden kann.
|
||||
// Das Flag simulated=true sorgt dafür, dass das Frontend den Server ausdrücklich als Simulation kennzeichnet.
|
||||
|
||||
import { backupPrefix, parseBackupFiles } from '../rounds.js';
|
||||
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
export class DemoServerAdapter {
|
||||
@@ -41,6 +43,14 @@ export class DemoServerAdapter {
|
||||
handle.map = plan.map;
|
||||
}
|
||||
|
||||
// Simulierte Sicherungen: eine Datei je Runde der aktuellen Map, damit die Oberfläche testbar ist
|
||||
async listRoundBackups(handle, matchId, map = 'de_demo') {
|
||||
const files = [1, 2, 3].map((r) => `${backupPrefix(matchId)}_01_01_${map}_round${r}_score_${r}_0.txt`);
|
||||
return parseBackupFiles(files.join('\n'), matchId);
|
||||
}
|
||||
|
||||
async loadRoundBackup() {}
|
||||
|
||||
async sendCommands(handle, commands) {
|
||||
return commands.map(() => '(Demo – nicht ausgeführt)');
|
||||
}
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import { configureViaRcon, rconStatus } from './rconConfigure.js';
|
||||
import { configureViaRcon, listRoundBackups, loadRoundBackup, rconStatus } from './rconConfigure.js';
|
||||
import { runRcon } from './rcon.js';
|
||||
|
||||
const exec = promisify(execFile);
|
||||
const DISK_CACHE_MS = 60_000;
|
||||
|
||||
/** Liest Gesamt- und freien Speicher aus einer Zeile von `df -B1` (Spalten: Filesystem, 1B-blocks, Used, Available …). */
|
||||
export function parseDf(output) {
|
||||
const parts = String(output).trim().split(/\s+/);
|
||||
const totalBytes = Number(parts[1]);
|
||||
const freeBytes = Number(parts[3]);
|
||||
if (!Number.isFinite(totalBytes) || !Number.isFinite(freeBytes)) return null;
|
||||
return { totalBytes, freeBytes };
|
||||
}
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
// Diese Zeile schreibt das Image, sobald SteamCMD fertig ist und der Spielserver startet.
|
||||
@@ -37,7 +47,8 @@ export class DockerServerAdapter {
|
||||
|
||||
async docker(args, timeout = 60_000) {
|
||||
if (this.runDocker) return this.runDocker(args);
|
||||
const { stdout } = await exec(this.options.bin, args, { timeout, maxBuffer: 4 * 1024 * 1024 });
|
||||
const env = this.options.dockerHost ? { ...process.env, DOCKER_HOST: this.options.dockerHost } : process.env;
|
||||
const { stdout } = await exec(this.options.bin, args, { timeout, maxBuffer: 4 * 1024 * 1024, env });
|
||||
return stdout.trim();
|
||||
}
|
||||
|
||||
@@ -112,6 +123,8 @@ export class DockerServerAdapter {
|
||||
CS2_GAMETYPE: '0',
|
||||
CS2_GAMEMODE: mode === 'wingman' ? '2' : '1',
|
||||
CS2_SERVER_HIBERNATE: '0',
|
||||
// Ohne diesen Startparameter legt CS2 keine Rundensicherungen an (Rollback)
|
||||
CS2_ADDITIONAL_ARGS: '-allow_round_backup_files',
|
||||
TV_ENABLE: '1',
|
||||
TV_PORT: String(tvPort),
|
||||
TV_PW: secrets.tvPassword,
|
||||
@@ -135,7 +148,14 @@ export class DockerServerAdapter {
|
||||
await this.docker(args, 15 * 60_000);
|
||||
|
||||
return {
|
||||
handle: { container, rconHost: this.options.rconHost, port, startedAt: Date.now(), rconPassword: secrets.rconPassword },
|
||||
handle: {
|
||||
container,
|
||||
hostName: this.options.name,
|
||||
rconHost: this.options.rconHost,
|
||||
port,
|
||||
startedAt: Date.now(),
|
||||
rconPassword: secrets.rconPassword,
|
||||
},
|
||||
host: this.options.host,
|
||||
port,
|
||||
tvPort,
|
||||
@@ -174,6 +194,14 @@ export class DockerServerAdapter {
|
||||
await configureViaRcon(this.target(handle), plan);
|
||||
}
|
||||
|
||||
async listRoundBackups(handle, matchId) {
|
||||
return listRoundBackups(this.target(handle), matchId);
|
||||
}
|
||||
|
||||
async loadRoundBackup(handle, file) {
|
||||
await loadRoundBackup(this.target(handle), file);
|
||||
}
|
||||
|
||||
async sendCommands(handle, commands) {
|
||||
return runRcon(this.target(handle), commands);
|
||||
}
|
||||
@@ -182,3 +210,138 @@ export class DockerServerAdapter {
|
||||
await this.docker(['rm', '-f', handle.container]).catch(() => {});
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Mehrere Docker-Hosts. Jedes Match läuft auf dem Host mit den meisten freien Port-Paaren,
|
||||
* der Host steht im Handle und damit im Match. Start, Status, Konfiguration und Stopp gehen
|
||||
* immer an denselben Host. Ohne CS2_DOCKER_HOSTS gibt es genau einen Host: den bisherigen.
|
||||
*/
|
||||
export class DockerHostPool {
|
||||
constructor(options, { getUsedPorts, runDocker } = {}) {
|
||||
this.name = 'docker';
|
||||
this.simulated = false;
|
||||
this.depsGetUsedPorts = getUsedPorts;
|
||||
const hosts = options.hosts ?? [{
|
||||
name: 'default',
|
||||
dockerHost: process.env.DOCKER_HOST || null,
|
||||
publicHost: options.host,
|
||||
rconHost: options.rconHost,
|
||||
portRangeStart: options.portRangeStart,
|
||||
portRangeEnd: options.portRangeEnd,
|
||||
volume: options.volume,
|
||||
}];
|
||||
// Fehlende Angaben kommen aus den allgemeinen Docker-Einstellungen
|
||||
this.hosts = hosts.map((h) => {
|
||||
const host = {
|
||||
name: h.name,
|
||||
dockerHost: h.dockerHost ?? null,
|
||||
publicHost: h.publicHost ?? options.host,
|
||||
rconHost: h.rconHost ?? options.rconHost,
|
||||
portRangeStart: h.portRangeStart ?? options.portRangeStart,
|
||||
portRangeEnd: h.portRangeEnd ?? options.portRangeEnd,
|
||||
volume: h.volume ?? options.volume,
|
||||
};
|
||||
return { ...options, ...host, host: host.publicHost };
|
||||
});
|
||||
this.adapters = new Map(this.hosts.map((h) => [h.name, new DockerServerAdapter(h, {
|
||||
getUsedPorts: () => (getUsedPorts ? getUsedPorts(h.publicHost) : new Set()),
|
||||
runDocker,
|
||||
})]));
|
||||
this.diskCache = new Map();
|
||||
this.diskInflight = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* Freier Speicher unter /var/lib/docker auf einem Host, per SSH. Ergebnis wird eine Minute lang
|
||||
* zwischengespeichert, weil die Admin-Seite alle paar Sekunden abfragt. Ohne ssh:// gibt es null.
|
||||
*/
|
||||
async diskUsage(name) {
|
||||
const host = this.hosts.find((h) => h.name === name);
|
||||
if (!host?.dockerHost?.startsWith('ssh://')) return null;
|
||||
const cached = this.diskCache.get(name);
|
||||
if (cached && Date.now() - cached.at < DISK_CACHE_MS) return cached.value;
|
||||
if (!this.diskInflight.has(name)) {
|
||||
this.diskInflight.set(name, (async () => {
|
||||
let value = null;
|
||||
try {
|
||||
const { stdout } = await exec('ssh', [
|
||||
'-o', 'BatchMode=yes', '-o', 'ConnectTimeout=5', host.dockerHost.slice('ssh://'.length),
|
||||
'df -B1 /var/lib/docker 2>/dev/null || df -B1 /',
|
||||
], { timeout: 10_000 });
|
||||
value = parseDf(stdout.trim().split('\n').pop());
|
||||
} catch {
|
||||
value = null;
|
||||
}
|
||||
this.diskCache.set(name, { at: Date.now(), value });
|
||||
this.diskInflight.delete(name);
|
||||
return value;
|
||||
})());
|
||||
}
|
||||
return this.diskInflight.get(name);
|
||||
}
|
||||
|
||||
/** Freie Port-Paare auf einem Host. */
|
||||
freeSlots(host) {
|
||||
const used = this.getUsedPorts(host.publicHost);
|
||||
let free = 0;
|
||||
for (let p = host.portRangeStart; p + 1 <= host.portRangeEnd; p += 2) {
|
||||
if (!used.has(p) && !used.has(p + 1)) free += 1;
|
||||
}
|
||||
return free;
|
||||
}
|
||||
|
||||
/** Auslastung pro Host: Anzahl Port-Paare insgesamt und belegt. */
|
||||
hostStats() {
|
||||
return this.hosts.map((h) => {
|
||||
const total = Math.floor((h.portRangeEnd - h.portRangeStart + 1) / 2);
|
||||
return { name: h.name, publicHost: h.publicHost, total, used: total - this.freeSlots(h) };
|
||||
});
|
||||
}
|
||||
|
||||
getUsedPorts(publicHost) {
|
||||
return this.depsGetUsedPorts?.(publicHost) ?? new Set();
|
||||
}
|
||||
|
||||
pickHost() {
|
||||
const best = this.hosts
|
||||
.map((h) => ({ host: h, free: this.freeSlots(h) }))
|
||||
.filter((x) => x.free > 0)
|
||||
.sort((a, b) => b.free - a.free)[0];
|
||||
if (!best) throw new Error('Kein Docker-Host mit freien Ports');
|
||||
return best.host;
|
||||
}
|
||||
|
||||
async start(args) {
|
||||
return this.adapters.get(this.pickHost().name).start(args);
|
||||
}
|
||||
|
||||
adapterFor(handle) {
|
||||
const name = handle?.hostName ?? this.hosts[0].name;
|
||||
const adapter = this.adapters.get(name);
|
||||
if (!adapter) throw new Error(`Docker-Host "${name}" ist nicht (mehr) konfiguriert`);
|
||||
return adapter;
|
||||
}
|
||||
|
||||
async status(handle) {
|
||||
return this.adapterFor(handle).status(handle);
|
||||
}
|
||||
|
||||
async configure(handle, plan) {
|
||||
return this.adapterFor(handle).configure(handle, plan);
|
||||
}
|
||||
|
||||
async listRoundBackups(handle, matchId) {
|
||||
return this.adapterFor(handle).listRoundBackups(handle, matchId);
|
||||
}
|
||||
|
||||
async loadRoundBackup(handle, file) {
|
||||
return this.adapterFor(handle).loadRoundBackup(handle, file);
|
||||
}
|
||||
|
||||
async sendCommands(handle, commands) {
|
||||
return this.adapterFor(handle).sendCommands(handle, commands);
|
||||
}
|
||||
|
||||
async stop(handle) {
|
||||
return this.adapterFor(handle).stop(handle);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { parseBackupFiles } from '../rounds.js';
|
||||
import { parseStatus, runRcon } from './rcon.js';
|
||||
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
@@ -42,3 +43,19 @@ export async function rconStatus(target) {
|
||||
const [output] = await runRcon(target, ['status'], { timeoutMs: 4000 });
|
||||
return parseStatus(output);
|
||||
}
|
||||
|
||||
/** Sicherungsdateien dieses Matches vom Server holen. */
|
||||
export async function listRoundBackups(target, matchId) {
|
||||
const [out] = await runRcon(target, ['mp_backup_restore_list_files 60'], { timeoutMs: 8000, quietMs: 500 });
|
||||
return parseBackupFiles(out, matchId);
|
||||
}
|
||||
|
||||
/** Sicherung laden. Der Dateiname muss vorher mit isValidBackupFile geprüft sein. */
|
||||
export async function loadRoundBackup(target, file) {
|
||||
// mp_backup_restore_load_autopause wirkt in CS2 nicht zuverlässig, deshalb pausieren wir ausdrücklich
|
||||
await runRcon(target, [
|
||||
'mp_backup_restore_load_autopause 1',
|
||||
`mp_backup_restore_load_file "${file}"`,
|
||||
'mp_pause_match',
|
||||
], { timeoutMs: 15000, quietMs: 800 });
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { buildMapCommands, sanitizeConsoleText } from './cs2Config.js';
|
||||
import { DemoMatchSimulator } from './demoMatchSimulator.js';
|
||||
import { backupPrefix, describeBackup, isValidBackupFile, pointsForMap } from '../rounds.js';
|
||||
import { HttpError, CLOSED_STATUSES } from '../matchService.js';
|
||||
import { randomPassword } from '../security.js';
|
||||
|
||||
@@ -86,6 +87,54 @@ export class ServerManager {
|
||||
this.timers.push(setInterval(() => this.reap().catch((e) => this.logger.error(e)), this.config.reaperIntervalSeconds * 1000));
|
||||
}
|
||||
|
||||
/** Admin: Rücksprungpunkte der aktuellen Map, neueste Runde zuerst. */
|
||||
async rollbackPoints(viewer) {
|
||||
this.service.require(viewer, 'organizer');
|
||||
const { match, seriesMap, handle } = this.rollbackContext(viewer.matchId);
|
||||
const all = await this.adapter.listRoundBackups(handle, match.id, seriesMap.map);
|
||||
return pointsForMap(all, seriesMap.map);
|
||||
}
|
||||
|
||||
/** Admin: Match auf den Stand nach einer Runde zurückspielen. */
|
||||
async rollback(viewer, file) {
|
||||
this.service.require(viewer, 'organizer');
|
||||
const { match, handle, seriesMap } = this.rollbackContext(viewer.matchId);
|
||||
if (!isValidBackupFile(file, match.id)) throw new HttpError(400, 'Ungültige Sicherungsdatei', 'invalid_input');
|
||||
const point = describeBackup(file, match.id);
|
||||
if (point.map !== seriesMap.map) throw new HttpError(409, 'Die Sicherung gehört zu einer anderen Map', 'wrong_map');
|
||||
if (this.busy.has(match.id)) throw new HttpError(409, 'Für diesen Server läuft bereits eine Aktion', 'busy');
|
||||
this.busy.add(match.id);
|
||||
try {
|
||||
await this.adapter.loadRoundBackup(handle, file);
|
||||
} catch (err) {
|
||||
throw new HttpError(502, `Zurückspielen fehlgeschlagen: ${err.message}`, 'server_error');
|
||||
} finally {
|
||||
this.busy.delete(match.id);
|
||||
}
|
||||
this.service.rewindLiveScore(match.id, point.round, file);
|
||||
}
|
||||
|
||||
rollbackContext(matchId) {
|
||||
const match = this.service.getMatch(matchId);
|
||||
const row = this.row(matchId);
|
||||
if (!row?.handle || row.status !== 'configured' || match.status !== 'live') {
|
||||
throw new HttpError(409, 'Zurückspielen ist nur bei einem laufenden Match mit bereitem Server möglich', 'wrong_state');
|
||||
}
|
||||
const seriesMap = this.service.seriesMaps(matchId).find((m) => m.number === match.current_map_number);
|
||||
return { match, row, seriesMap, handle: JSON.parse(row.handle) };
|
||||
}
|
||||
|
||||
/** Löscht ein Match samt Server-Datensatz. Ein laufender Server wird vorher freigegeben. */
|
||||
async deleteMatch(matchId) {
|
||||
this.service.getMatch(matchId);
|
||||
clearTimeout(this.autoAdvance.get(matchId));
|
||||
this.autoAdvance.delete(matchId);
|
||||
this.simulator.stop(matchId);
|
||||
await this.stop(matchId, 'Match vom Admin gelöscht');
|
||||
this.db.prepare('DELETE FROM matches WHERE id = ?').run(matchId);
|
||||
this.service.emit('changed', matchId);
|
||||
}
|
||||
|
||||
/** Wartet, bis keine Server-Aktion mehr läuft (höchstens timeoutMs). */
|
||||
async idle(timeoutMs = 5000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
@@ -111,6 +160,35 @@ export class ServerManager {
|
||||
this.service.changed(matchId);
|
||||
}
|
||||
|
||||
/** Auslastung der Docker-Hosts mit den Matches, die dort laufen. Leer, wenn der Adapter keine Hosts kennt. */
|
||||
async hostUsage() {
|
||||
if (!this.adapter.hostStats) return [];
|
||||
const rows = this.db.prepare(`
|
||||
SELECT s.host, s.status, m.id AS match_id, m.team_a_name, m.team_b_name
|
||||
FROM servers s JOIN matches m ON m.id = s.match_id
|
||||
WHERE s.status != 'stopped' AND s.host IS NOT NULL
|
||||
`).all();
|
||||
const stats = this.adapter.hostStats();
|
||||
const disks = await Promise.all(stats.map((h) => this.adapter.diskUsage?.(h.name) ?? null));
|
||||
return stats.map((h, i) => ({
|
||||
...h,
|
||||
disk: disks[i],
|
||||
matches: rows
|
||||
.filter((r) => r.host === h.publicHost)
|
||||
.map((r) => ({ id: r.match_id, title: `${r.team_a_name} vs. ${r.team_b_name}`, status: r.status })),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Ports, die auf einem bestimmten Docker-Host belegt sind. */
|
||||
usedPortsOn(host) {
|
||||
const used = new Set();
|
||||
for (const r of this.db.prepare(`SELECT port, tv_port FROM servers WHERE host = ? AND status != 'stopped' AND port IS NOT NULL`).all(host)) {
|
||||
used.add(r.port);
|
||||
used.add(r.tv_port);
|
||||
}
|
||||
return used;
|
||||
}
|
||||
|
||||
usedPorts() {
|
||||
const used = new Set();
|
||||
for (const r of this.db.prepare(`SELECT host, port, tv_port FROM servers WHERE status != 'stopped' AND port IS NOT NULL`).all()) {
|
||||
@@ -216,6 +294,11 @@ export class ServerManager {
|
||||
} else if (status.state === 'failed') {
|
||||
Object.assign(fields, { status: current.status === 'starting' ? 'start_failed' : 'failed', message: status.message });
|
||||
this.service.log(matchId, 'server', `Server-Fehler: ${status.message}`);
|
||||
this.service.notify('server_error', matchId, {
|
||||
title: '⚠️ Serverfehler',
|
||||
description: `**${this.service.matchTitle(matchId)}**\n${status.message}`,
|
||||
color: 0xdc2626,
|
||||
});
|
||||
} else if (status.state === 'stopped') {
|
||||
Object.assign(fields, { status: 'stopped', stopped_at: Date.now(), message: status.message ?? 'Server wurde extern beendet' });
|
||||
this.service.log(matchId, 'server', 'Server wurde außerhalb der Anwendung beendet');
|
||||
@@ -254,7 +337,8 @@ export class ServerManager {
|
||||
if (!seriesMap) throw new HttpError(409, 'Das Veto ist noch nicht abgeschlossen', 'veto_pending');
|
||||
|
||||
const mapName = pool.maps.find((m) => m.id === seriesMap.map)?.name ?? seriesMap.map;
|
||||
this.update(matchId, { status: 'configuring', message: `Lade Map ${seriesMap.number}: ${mapName}` });
|
||||
// Ein Mapwechsel beendet die Pause, CS2 lädt die Map neu
|
||||
this.update(matchId, { status: 'configuring', paused: 0, message: `Lade Map ${seriesMap.number}: ${mapName}` });
|
||||
this.service.log(matchId, 'server', `Konfiguration für Map ${seriesMap.number} (${mapName}) gestartet`);
|
||||
|
||||
const plan = buildMapCommands({
|
||||
@@ -264,6 +348,7 @@ export class ServerManager {
|
||||
secrets: { serverPassword: row.server_password, tvPassword: row.tv_password },
|
||||
settings: this.config.match,
|
||||
logUrl: `${this.config.logReceiverUrl}/api/logs/${matchId}/${row.config_key}`,
|
||||
backupPrefix: backupPrefix(matchId),
|
||||
});
|
||||
this.simulator.stop(matchId);
|
||||
this.service.resetLiveScore(matchId, seriesMap.number);
|
||||
@@ -337,10 +422,36 @@ export class ServerManager {
|
||||
throw new HttpError(502, `Befehl konnte nicht gesendet werden: ${err.message}`, 'server_error');
|
||||
}
|
||||
this.service.log(viewer.matchId, 'server', this.adapter.simulated ? 'Aufwärmphase beendet (simuliert)' : 'Aufwärmphase beendet – Match startet');
|
||||
// CSTV-Zugang für Zuschauer, gleiche Form wie im Zuschauerbereich der Webseite
|
||||
const fields = [];
|
||||
if (this.config.match.tvEnabled && row.tv_port) {
|
||||
fields.push({ name: '📺 CSTV', value: `\`password ${row.tv_password}; connect ${row.host}:${row.tv_port}\`` });
|
||||
}
|
||||
this.service.notify('match_start', viewer.matchId, {
|
||||
title: '🔴 Match läuft',
|
||||
description: `**${this.service.matchTitle(viewer.matchId)}**`,
|
||||
color: 0xef4444,
|
||||
fields,
|
||||
});
|
||||
if (this.adapter.simulated) this.startDemoSimulation(viewer.matchId);
|
||||
this.service.changed(viewer.matchId);
|
||||
}
|
||||
|
||||
/** Admin: pausiert oder setzt das laufende Match auf dem Server fort (mp_pause_match / mp_unpause_match). */
|
||||
async setPaused(viewer, paused) {
|
||||
this.service.require(viewer, 'organizer');
|
||||
const row = this.row(viewer.matchId);
|
||||
if (row?.status !== 'configured') throw new HttpError(409, 'Der Server ist nicht bereit', 'wrong_state');
|
||||
if (!this.adapter.sendCommands) throw new HttpError(501, 'Dieser Adapter unterstützt keine Befehle', 'unsupported');
|
||||
try {
|
||||
await this.adapter.sendCommands(JSON.parse(row.handle), [paused ? 'mp_pause_match' : 'mp_unpause_match']);
|
||||
} catch (err) {
|
||||
throw new HttpError(502, `Befehl konnte nicht gesendet werden: ${err.message}`, 'server_error');
|
||||
}
|
||||
this.update(viewer.matchId, { paused: paused ? 1 : 0 });
|
||||
this.service.log(viewer.matchId, 'server', paused ? 'Match pausiert (Admin)' : 'Match fortgesetzt (Admin)');
|
||||
}
|
||||
|
||||
/** Demo-Modus: simuliert einen Matchverlauf, indem echte CS2-Logzeilen erzeugt und ausgewertet werden. */
|
||||
startDemoSimulation(matchId) {
|
||||
const match = this.service.getMatch(matchId);
|
||||
@@ -382,7 +493,7 @@ export class ServerManager {
|
||||
const row = this.row(matchId);
|
||||
if (!row || row.status === 'stopped') return;
|
||||
if (row.handle) await this.safeStop(row);
|
||||
this.update(matchId, { status: 'stopped', stopped_at: Date.now(), message: reason, player_count: null });
|
||||
this.update(matchId, { status: 'stopped', stopped_at: Date.now(), message: reason, player_count: null, paused: 0 });
|
||||
this.service.log(matchId, 'server', `Server gestoppt: ${reason}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { configureViaRcon, rconStatus } from './rconConfigure.js';
|
||||
import { configureViaRcon, listRoundBackups, loadRoundBackup, rconStatus } from './rconConfigure.js';
|
||||
import { runRcon } from './rcon.js';
|
||||
|
||||
/**
|
||||
@@ -45,6 +45,14 @@ export class StaticPoolServerAdapter {
|
||||
await configureViaRcon(this.target(handle), plan);
|
||||
}
|
||||
|
||||
async listRoundBackups(handle, matchId) {
|
||||
return listRoundBackups(this.target(handle), matchId);
|
||||
}
|
||||
|
||||
async loadRoundBackup(handle, file) {
|
||||
await loadRoundBackup(this.target(handle), file);
|
||||
}
|
||||
|
||||
async sendCommands(handle, commands) {
|
||||
return runRcon(this.target(handle), commands);
|
||||
}
|
||||
|
||||
@@ -107,6 +107,9 @@ test('kompletter Ablauf: Lobby, Berechtigungen, Veto, Server-Konfiguration', asy
|
||||
const orgView = (await call('GET', `/matches/${id}`, { token: org })).body;
|
||||
assert.equal(orgView.viewer.role, 'organizer');
|
||||
const { codes } = orgView.organizer;
|
||||
// Match-Übersicht nur für dieses Match, ohne Zuschauercode
|
||||
assert.match(orgView.organizer.links.overview, new RegExp(`/stream/match/${id}$`));
|
||||
assert.ok(!orgView.organizer.links.overview.includes(codes.spectator));
|
||||
|
||||
// Zuschauer- und fremde Codes erlauben keinen Teambeitritt
|
||||
assert.equal((await call('POST', `/matches/${id}/join`, { body: { code: codes.spectator, nickname: 'spy' } })).status, 403);
|
||||
@@ -377,6 +380,87 @@ test('Stream-Übersicht aller Matches: Inhalte, keine Geheimnisse, optionaler Sc
|
||||
}
|
||||
});
|
||||
|
||||
test('Admin-Modus: Anmeldung, Verwaltung aller Matches, Sperre ohne Anmeldung', async () => {
|
||||
const { app, call, close } = await setup({ adminPassword: 'Admin123' });
|
||||
try {
|
||||
const { id } = (await call('POST', '/matches', {
|
||||
body: { teamA: 'Alpha', teamB: 'Bravo', mode: 'wingman', format: 'bo1' },
|
||||
})).body;
|
||||
const adminListed = (await call('GET', '/admin/matches')).body; // ohne Anmeldung
|
||||
assert.equal(adminListed.error, 'Admin-Anmeldung erforderlich');
|
||||
|
||||
assert.equal((await call('POST', '/admin/login', { body: { password: 'falsch' } })).status, 401);
|
||||
const login = (await call('POST', '/admin/login', { body: { password: 'Admin123' } })).body;
|
||||
const admin = { token: login.token };
|
||||
assert.ok(login.token);
|
||||
|
||||
const list = (await call('GET', '/admin/matches', admin)).body.matches;
|
||||
const mine = list.find((m) => m.id === id);
|
||||
assert.ok(mine, 'alle Matches sichtbar, nicht nur die eigenen');
|
||||
assert.ok(mine.organizer.codes.A, 'Codes sichtbar');
|
||||
|
||||
// Ein Match abbrechen wie ein Admin
|
||||
assert.equal((await call('POST', `/admin/matches/${id}/cancel`, { ...admin, body: {} })).status, 200);
|
||||
assert.equal((await call('POST', `/admin/matches/${id}/server/pause`, { ...admin, body: {} })).status, 409, 'ohne Server keine Pause');
|
||||
assert.equal(app.service.getMatch(id).status, 'cancelled');
|
||||
|
||||
// Endgültig löschen
|
||||
assert.equal((await call('DELETE', `/admin/matches/${id}`, admin)).status, 200);
|
||||
assert.equal((await call('GET', `/matches/${id}`)).status, 404);
|
||||
|
||||
// Abmelden: Token ist danach ungültig
|
||||
await call('POST', '/admin/logout', admin);
|
||||
assert.equal((await call('GET', '/admin/session', admin)).status, 401);
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
});
|
||||
|
||||
test('Rollback: nur mit Admin-Anmeldung, nur für die laufende Map, Dateiname wird geprüft', async () => {
|
||||
const { app, call, close } = await setup({ adminPassword: 'Admin123', demo: { startSeconds: 0, failFirstConfigure: false, roundSeconds: 600 } });
|
||||
try {
|
||||
const { id, org, tokens } = await readyWingmanMatch(call, 'bo3');
|
||||
await call('POST', `/matches/${id}/veto/start`, { token: org });
|
||||
await playVeto(call, id, { A: tokens.a1, B: tokens.b1 });
|
||||
await app.manager.poll();
|
||||
await waitFor(() => app.service.getMatch(id).status === 'live', { label: 'live' });
|
||||
await call('POST', `/matches/${id}/server/warmup-end`, { token: org });
|
||||
|
||||
// Organisator sieht keine Rollback-Funktion
|
||||
assert.equal((await call('GET', `/admin/matches/${id}/rollback-points`, { token: org })).status, 401);
|
||||
assert.equal((await call('POST', `/matches/${id}/rollback`, { token: org, body: { file: 'x.txt' } })).status, 404);
|
||||
|
||||
const login = (await call('POST', '/admin/login', { body: { password: 'Admin123' } })).body;
|
||||
const admin = { token: login.token };
|
||||
const points = (await call('GET', `/admin/matches/${id}/rollback-points`, admin)).body.points;
|
||||
assert.equal(points.length, 3, 'Demo liefert drei Sicherungen der aktuellen Map');
|
||||
const map = app.service.seriesMaps(id)[0].map;
|
||||
assert.ok(points.every((p) => p.map === map));
|
||||
|
||||
// Dateien, die nicht zu diesem Match gehören, werden abgelehnt
|
||||
assert.equal((await call('POST', `/admin/matches/${id}/rollback`, { ...admin, body: { file: '../../x.txt' } })).status, 400);
|
||||
|
||||
const ok = await call('POST', `/admin/matches/${id}/rollback`, { ...admin, body: { file: points[0].file } });
|
||||
assert.equal(ok.status, 200);
|
||||
const log = app.service.db.prepare("SELECT message FROM events WHERE match_id = ? AND type = 'rollback'").all(id);
|
||||
assert.equal(log.length, 1);
|
||||
assert.match(log[0].message, /zurückgespielt/);
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
});
|
||||
|
||||
test('Admin-Modus ohne ADMIN_PASSWORD ist komplett aus', async () => {
|
||||
const { call, close } = await setup({ adminPassword: '' });
|
||||
try {
|
||||
const res = await call('POST', '/admin/login', { body: { password: 'x' } });
|
||||
assert.equal(res.status, 404);
|
||||
assert.equal(res.body.code, 'admin_disabled');
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
});
|
||||
|
||||
test('Spieler verlassen die Lobby, Captain geht über', async () => {
|
||||
const { call, close } = await setup();
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createDiscordNotifier } from '../src/discord.js';
|
||||
|
||||
const URL_OK = 'https://discord.com/api/webhooks/123456/geheim_TOKEN-1';
|
||||
|
||||
test('Ohne URL ist der Notifier aus und tut nichts', async () => {
|
||||
let called = false;
|
||||
const n = createDiscordNotifier('', { fetchImpl: async () => { called = true; } });
|
||||
assert.equal(n.enabled, false);
|
||||
await n.send('Test');
|
||||
assert.equal(called, false);
|
||||
});
|
||||
|
||||
test('Ungültige URL wird beim Start abgelehnt, ohne die URL zu zeigen', () => {
|
||||
assert.throws(() => createDiscordNotifier('https://example.com/geheim'), (err) => {
|
||||
assert.match(err.message, /keine gültige Discord-Webhook-URL/);
|
||||
assert.ok(!err.message.includes('geheim'));
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
test('Nachricht wird als JSON an den Webhook geschickt, überlange Texte gekürzt', async () => {
|
||||
const sent = [];
|
||||
const n = createDiscordNotifier(URL_OK, {
|
||||
fetchImpl: async (url, init) => { sent.push({ url, init }); return { ok: true, status: 204 }; },
|
||||
});
|
||||
await n.send('Hallo');
|
||||
await n.send('x'.repeat(5000));
|
||||
assert.equal(sent[0].url, URL_OK);
|
||||
assert.equal(sent[0].init.method, 'POST');
|
||||
assert.equal(JSON.parse(sent[0].init.body).content, 'Hallo');
|
||||
assert.equal(JSON.parse(sent[1].init.body).content.length, 1900);
|
||||
});
|
||||
|
||||
test('Fehler des Webhooks brechen den Ablauf nicht ab und werden protokolliert ohne URL', async () => {
|
||||
const warnings = [];
|
||||
const logger = { warn: (m) => warnings.push(m) };
|
||||
const failing = createDiscordNotifier(URL_OK, { logger, fetchImpl: async () => { throw new TypeError('fetch failed'); } });
|
||||
await failing.send('Test');
|
||||
const bad = createDiscordNotifier(URL_OK, { logger, fetchImpl: async () => ({ ok: false, status: 404 }) });
|
||||
await bad.send('Test');
|
||||
assert.equal(warnings.length, 2);
|
||||
assert.match(warnings[1], /HTTP 404/);
|
||||
assert.ok(warnings.every((w) => !w.includes('TOKEN')));
|
||||
});
|
||||
@@ -37,6 +37,7 @@ test('Ohne GSLT startet der Container im LAN-Modus, mit GSLT mit Token', async (
|
||||
assert.equal(env.SRCDS_TOKEN, undefined);
|
||||
assert.equal(env.CS2_GAMEMODE, '2');
|
||||
assert.equal(env.CS2_MAXPLAYERS, '6', '4 Spieler + CSTV + Reserve');
|
||||
assert.equal(env.CS2_ADDITIONAL_ARGS, '-allow_round_backup_files', 'Rundensicherung für Rollback');
|
||||
assert.equal(env.CS2_PORT, '27100');
|
||||
assert.equal(env.TV_PORT, '27101');
|
||||
assert.ok(runArgs.includes('cs2-mapveto-gamefiles:/home/steam/cs2-dedicated'));
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { DockerHostPool } from '../src/servers/dockerAdapter.js';
|
||||
|
||||
const secrets = { rconPassword: 'Rcon123', serverPassword: 'Pw123', tvPassword: 'Tv123' };
|
||||
|
||||
const hostA = { name: 'a', publicHost: '10.0.0.1', portRangeStart: 27100, portRangeEnd: 27103 }; // 2 Paare
|
||||
const hostB = { name: 'b', publicHost: '10.0.0.2', portRangeStart: 27100, portRangeEnd: 27139 }; // 20 Paare
|
||||
|
||||
function options(hosts) {
|
||||
return {
|
||||
bin: 'docker', image: 'joedwards32/cs2:latest', host: '10.0.0.9', rconHost: '10.0.0.9',
|
||||
portRangeStart: 27015, portRangeEnd: 27115, gslt: '', volume: 'cs2-mapveto-gamefiles', extraArgs: '',
|
||||
startTimeoutMinutes: 1, hosts,
|
||||
};
|
||||
}
|
||||
|
||||
/** Docker-Aufrufe merken, Container gelten sofort als fertig. */
|
||||
function fakeDocker() {
|
||||
const calls = [];
|
||||
const run = async (args) => {
|
||||
calls.push(args);
|
||||
if (args[0] === 'inspect') return 'running';
|
||||
if (args[0] === 'logs') return 'Starting CS2 Dedicated Server';
|
||||
return '';
|
||||
};
|
||||
return { run, calls };
|
||||
}
|
||||
|
||||
/** Nur die Konfiguration der Hosts, ohne auf echte Container zu warten. */
|
||||
function pool(hosts, used = {}) {
|
||||
const docker = fakeDocker();
|
||||
const p = new DockerHostPool(options(hosts), {
|
||||
getUsedPorts: (publicHost) => new Set(used[publicHost] ?? []),
|
||||
runDocker: docker.run,
|
||||
});
|
||||
for (const a of p.adapters.values()) a.waitForUpdateCheck = async () => {};
|
||||
return { pool: p, calls: docker.calls };
|
||||
}
|
||||
|
||||
test('Ohne CS2_DOCKER_HOSTS gibt es genau einen Host, wie bisher', () => {
|
||||
const { pool: p } = pool(null);
|
||||
assert.equal(p.hosts.length, 1);
|
||||
assert.equal(p.hosts[0].publicHost, '10.0.0.9');
|
||||
});
|
||||
|
||||
test('Ein Match landet auf dem Host mit den meisten freien Ports', async () => {
|
||||
const { pool: p } = pool([hostA, hostB], { '10.0.0.1': [27100] }); // A hat ein Paar belegt, B ist frei
|
||||
const r = await p.start({ matchId: 'm1', mode: 'competitive', secrets });
|
||||
assert.equal(r.handle.hostName, 'b');
|
||||
assert.equal(r.host, '10.0.0.2');
|
||||
});
|
||||
|
||||
test('Ein voller Host wird übersprungen', async () => {
|
||||
const fullA = Array.from({ length: 4 }, (_, i) => 27100 + i); // alle Ports von A belegt
|
||||
const { pool: p } = pool([hostA, hostB], { '10.0.0.1': fullA });
|
||||
const r = await p.start({ matchId: 'm2', mode: 'wingman', secrets });
|
||||
assert.equal(r.handle.hostName, 'b');
|
||||
});
|
||||
|
||||
test('Sind alle Hosts voll, gibt es einen klaren Fehler', async () => {
|
||||
const fullA = Array.from({ length: 4 }, (_, i) => 27100 + i);
|
||||
const fullB = Array.from({ length: 40 }, (_, i) => 27100 + i);
|
||||
const { pool: p } = pool([hostA, hostB], { '10.0.0.1': fullA, '10.0.0.2': fullB });
|
||||
await assert.rejects(p.start({ matchId: 'm3', mode: 'wingman', secrets }), /Kein Docker-Host mit freien Ports/);
|
||||
});
|
||||
|
||||
test('Status, Stopp und Befehle gehen an den Host des Matches', async () => {
|
||||
const { pool: p, calls } = pool([hostA, hostB], { '10.0.0.1': [27100] });
|
||||
const r = await p.start({ matchId: 'm4', mode: 'competitive', secrets });
|
||||
calls.length = 0;
|
||||
await p.stop(r.handle);
|
||||
assert.deepEqual(calls.at(-1)[0], 'rm');
|
||||
assert.equal(calls.at(-1).at(-1), r.handle.container);
|
||||
// Ein Handle von einem Host, der nicht mehr konfiguriert ist, wird abgelehnt
|
||||
await assert.rejects(p.status({ ...r.handle, hostName: 'gelöscht' }), /nicht \(mehr\) konfiguriert/);
|
||||
});
|
||||
|
||||
test('Alte Handles ohne Host-Namen laufen auf dem ersten Host', () => {
|
||||
const { pool: p } = pool([hostA, hostB]);
|
||||
assert.equal(p.adapterFor({ container: 'x' }), p.adapters.get('a'));
|
||||
});
|
||||
|
||||
test('Auslastung pro Host: Port-Paare insgesamt und belegt', () => {
|
||||
const { pool: p } = pool([hostA, hostB], { '10.0.0.1': [27100] });
|
||||
assert.deepEqual(p.hostStats(), [
|
||||
{ name: 'a', publicHost: '10.0.0.1', total: 2, used: 1 },
|
||||
{ name: 'b', publicHost: '10.0.0.2', total: 20, used: 0 },
|
||||
]);
|
||||
});
|
||||
test('df-Ausgabe wird in Gesamt- und freien Speicher zerlegt', async () => {
|
||||
const { parseDf } = await import('../src/servers/dockerAdapter.js');
|
||||
assert.deepEqual(parseDf('/dev/sda1 1000000000000 400000000000 600000000000 40% /var/lib/docker'), {
|
||||
totalBytes: 1000000000000,
|
||||
freeBytes: 600000000000,
|
||||
});
|
||||
assert.equal(parseDf('kaputt'), null);
|
||||
});
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
10/25/2026 - 18:06:44.021 - rcon from "192.168.178.31:54420": command "status"
|
||||
10/25/2026 - 18:06:48.883 - "Spieler1<0><[U:1:1000001]><>" entered the game
|
||||
10/25/2026 - 18:07:05.771 - "Spieler1<0><[U:1:1000001]>" switched from team <Unassigned> to <CT>
|
||||
10/25/2026 - 18:07:18.570 - "Spieler1<0><[U:1:1000001]><CT>" [1331 132 138] killed "Sarge<10><BOT><TERRORIST>" [1410 322 128] with "usp_silencer" (headshot)
|
||||
10/25/2026 - 18:07:52.100 - rcon from "192.168.178.31:61554": command "mp_warmup_pausetimer 0"
|
||||
10/25/2026 - 18:07:52.381 - rcon from "192.168.178.31:61554": command "mp_warmup_end"
|
||||
10/25/2026 - 18:07:52.396 - Starting Freeze period
|
||||
10/25/2026 - 18:07:52.396 - JSON_BEGIN{
|
||||
10/25/2026 - 18:07:52.396 - "name": "round_stats",
|
||||
10/25/2026 - 18:07:52.396 - "round_number" : "1",
|
||||
10/25/2026 - 18:07:52.396 - "map" : "de_inferno",
|
||||
10/25/2026 - 18:07:52.396 - }}JSON_END
|
||||
10/25/2026 - 18:07:52.396 - World triggered "Match_Start" on "de_inferno"
|
||||
10/25/2026 - 18:07:52.396 - Team playing "CT": MaxLAN Blau
|
||||
10/25/2026 - 18:07:52.396 - Team playing "TERRORIST": MaxLAN Rot
|
||||
10/25/2026 - 18:07:52.396 - "Darryl<12><BOT><TERRORIST>" triggered "Got_The_Bomb"
|
||||
10/25/2026 - 18:07:52.396 - MatchStatus: Team playing "CT": MaxLAN Blau
|
||||
10/25/2026 - 18:07:52.396 - MatchStatus: Team playing "TERRORIST": MaxLAN Rot
|
||||
10/25/2026 - 18:07:52.396 - MatchStatus: Score: 0:0 on map "de_inferno" RoundsPlayed: 0
|
||||
10/25/2026 - 18:08:01.869 - World triggered "Round_Start"
|
||||
10/25/2026 - 18:08:24.463 - "Darryl<12><BOT><TERRORIST>" triggered "Planted_The_Bomb" at bombsite A
|
||||
10/25/2026 - 18:08:30.541 - "Darryl<12><BOT><TERRORIST>" [2059 398 160] killed "Maximus<11><BOT><CT>" [2356 255 126] with "glock"
|
||||
10/25/2026 - 18:08:30.541 - Team "TERRORIST" triggered "SFUI_Notice_Terrorists_Win" (CT "0") (T "1")
|
||||
10/25/2026 - 18:08:30.541 - Team "CT" scored "0" with "3" players
|
||||
10/25/2026 - 18:08:30.541 - Team "TERRORIST" scored "1" with "2" players
|
||||
10/25/2026 - 18:08:30.541 - MatchStatus: Team playing "CT": MaxLAN Blau
|
||||
10/25/2026 - 18:08:30.541 - MatchStatus: Team playing "TERRORIST": MaxLAN Rot
|
||||
10/25/2026 - 18:08:30.541 - MatchStatus: Score: 0:1 on map "de_inferno" RoundsPlayed: 1
|
||||
10/25/2026 - 18:08:30.541 - World triggered "Round_End"
|
||||
10/25/2026 - 18:08:33.541 - Starting Freeze period
|
||||
10/25/2026 - 18:08:33.541 - MatchStatus: Team playing "CT": MaxLAN Blau
|
||||
10/25/2026 - 18:08:33.541 - MatchStatus: Team playing "TERRORIST": MaxLAN Rot
|
||||
10/25/2026 - 18:08:33.541 - MatchStatus: Score: 0:1 on map "de_inferno" RoundsPlayed: 1
|
||||
10/25/2026 - 18:08:36.543 - World triggered "Round_Start"
|
||||
10/25/2026 - 18:09:08.839 - "Spieler1<0><[U:1:1000001]><CT>" [2254 1009 159] killed "Sarge<10><BOT><TERRORIST>" [2287 352 132] with "famas" (headshot)
|
||||
10/25/2026 - 18:09:08.839 - Team "CT" triggered "SFUI_Notice_CTs_Win" (CT "1") (T "1")
|
||||
10/25/2026 - 18:09:08.839 - Team "CT" scored "1" with "3" players
|
||||
10/25/2026 - 18:09:08.839 - Team "TERRORIST" scored "1" with "3" players
|
||||
10/25/2026 - 18:09:08.839 - MatchStatus: Team playing "CT": MaxLAN Blau
|
||||
10/25/2026 - 18:09:08.839 - MatchStatus: Team playing "TERRORIST": MaxLAN Rot
|
||||
10/25/2026 - 18:09:08.839 - MatchStatus: Score: 1:1 on map "de_inferno" RoundsPlayed: 2
|
||||
10/25/2026 - 18:09:08.839 - World triggered "Round_End"
|
||||
10/25/2026 - 18:09:17.339 - "Spieler1<0><[U:1:1000001]>" switched from team <CT> to <TERRORIST>
|
||||
10/25/2026 - 18:09:17.339 - "Sarge<10><BOT>" switched from team <TERRORIST> to <CT>
|
||||
10/25/2026 - 18:09:17.339 - Starting Freeze period
|
||||
10/25/2026 - 18:09:17.339 - "Spieler1<0><[U:1:1000001]><TERRORIST>" triggered "Got_The_Bomb"
|
||||
10/25/2026 - 18:09:17.339 - MatchStatus: Team playing "CT": MaxLAN Blau
|
||||
10/25/2026 - 18:09:17.339 - MatchStatus: Team playing "TERRORIST": MaxLAN Rot
|
||||
10/25/2026 - 18:09:17.339 - MatchStatus: Score: 1:1 on map "de_inferno" RoundsPlayed: 2
|
||||
10/25/2026 - 18:09:26.841 - World triggered "Round_Start"
|
||||
10/25/2026 - 18:09:35.935 - "Spieler1<0><[U:1:1000001]><TERRORIST>" [1167 474 111] killed "Darryl<12><BOT><CT>" [1326 435 129] with "deagle" (headshot)
|
||||
10/25/2026 - 18:09:35.935 - Team "TERRORIST" triggered "SFUI_Notice_Terrorists_Win" (CT "1") (T "2")
|
||||
10/25/2026 - 18:09:35.935 - Team "CT" scored "1" with "3" players
|
||||
10/25/2026 - 18:09:35.935 - Team "TERRORIST" scored "2" with "4" players
|
||||
10/25/2026 - 18:09:35.935 - MatchStatus: Team playing "CT": MaxLAN Rot
|
||||
10/25/2026 - 18:09:35.935 - MatchStatus: Team playing "TERRORIST": MaxLAN Blau
|
||||
10/25/2026 - 18:09:35.935 - MatchStatus: Score: 1:2 on map "de_inferno" RoundsPlayed: 3
|
||||
10/25/2026 - 18:09:35.935 - World triggered "Round_End"
|
||||
10/25/2026 - 18:09:38.935 - Starting Freeze period
|
||||
10/25/2026 - 18:09:38.935 - MatchStatus: Team playing "CT": MaxLAN Rot
|
||||
10/25/2026 - 18:09:38.935 - MatchStatus: Team playing "TERRORIST": MaxLAN Blau
|
||||
10/25/2026 - 18:09:38.935 - MatchStatus: Score: 1:2 on map "de_inferno" RoundsPlayed: 3
|
||||
10/25/2026 - 18:09:41.936 - World triggered "Round_Start"
|
||||
10/25/2026 - 18:09:59.311 - "Spieler1<0><[U:1:1000001]><TERRORIST>" [1364 124 132] killed "Sarge<10><BOT><CT>" [1746 -51 135] with "ak47"
|
||||
10/25/2026 - 18:09:59.311 - Team "TERRORIST" triggered "SFUI_Notice_Terrorists_Win" (CT "1") (T "3")
|
||||
10/25/2026 - 18:09:59.311 - Team "CT" scored "1" with "5" players
|
||||
10/25/2026 - 18:09:59.311 - Team "TERRORIST" scored "3" with "2" players
|
||||
10/25/2026 - 18:09:59.311 - MatchStatus: Team playing "CT": MaxLAN Rot
|
||||
10/25/2026 - 18:09:59.311 - MatchStatus: Team playing "TERRORIST": MaxLAN Blau
|
||||
10/25/2026 - 18:09:59.311 - MatchStatus: Score: 1:3 on map "de_inferno" RoundsPlayed: 4
|
||||
10/25/2026 - 18:09:59.311 - World triggered "Round_End"
|
||||
10/25/2026 - 18:09:59.311 - ACCOLADE, FINAL: {killreward}, Spieler1<0>, VALUE: 1800.000000, POS: 1, SCORE: 70.000000
|
||||
10/25/2026 - 18:09:59.311 - MatchStatus: Team playing "CT": MaxLAN Rot
|
||||
10/25/2026 - 18:09:59.311 - MatchStatus: Team playing "TERRORIST": MaxLAN Blau
|
||||
10/25/2026 - 18:09:59.311 - MatchStatus: Score: 1:3 on map "de_inferno" RoundsPlayed: 4
|
||||
10/25/2026 - 18:09:59.311 - Game Over: scrimcomp2v2 de_inferno score 1:3 after 3 min
|
||||
10/25/2026 - 18:10:24.326 - World triggered "Match_Start" on "de_inferno"
|
||||
10/25/2026 - 18:10:24.326 - Team playing "CT": MaxLAN Rot
|
||||
10/25/2026 - 18:10:24.326 - Team playing "TERRORIST": MaxLAN Blau
|
||||
10/25/2026 - 18:10:24.326 - MatchStatus: Score: 0:0 on map "de_inferno" RoundsPlayed: -1
|
||||
10/25/2026 - 18:10:26.336 - World triggered "Warmup_Start"
|
||||
10/25/2026 - 18:10:31.304 - World triggered "Game_Commencing"
|
||||
10/25/2026 - 18:10:31.304 - World triggered "Match_Start" on "de_inferno"
|
||||
10/25/2026 - 18:10:33.293 - World triggered "Warmup_Start"
|
||||
@@ -60,8 +60,8 @@ test('Seitenzuordnung nach dem Rundenende hat Vorrang, veraltete Zeilen zu Runde
|
||||
assert.deepEqual(s.rounds.map((r) => r.winner), ['A', 'B']);
|
||||
});
|
||||
|
||||
test('Echte Logzeilen eines CS2-Servers (Wingman, 4 Runden, Halbzeit nach 2)', () => {
|
||||
const lines = fs.readFileSync(path.join(here, 'fixtures', 'echte-logs-wingman.log'), 'utf8').split(/\r?\n/).filter(Boolean);
|
||||
test('Echte, anonymisierte Logzeilen eines CS2-Servers (Wingman, 4 Runden, Halbzeit nach 2)', () => {
|
||||
const lines = fs.readFileSync(path.join(here, 'fixtures', 'wingman-anonym.log'), 'utf8').split(/\r?\n/).filter(Boolean);
|
||||
const warmupEnd = lines.findIndex((l) => l.includes('command "mp_warmup_end"'));
|
||||
const ctx = { teamNames: { A: 'MaxLAN Blau', B: 'MaxLAN Rot' }, maxRounds: 4, overtimeRounds: 6 };
|
||||
|
||||
|
||||
@@ -64,6 +64,8 @@ test('Konsolentexte können keine Befehle einschleusen', () => {
|
||||
assert.ok(afterMapChange.includes('mp_teamname_1 "Bravo"'));
|
||||
assert.ok(afterMapChange.includes('mp_teamname_2 "Alphaquit"'));
|
||||
assert.ok(afterMapChange.includes('mp_maxrounds 16'));
|
||||
assert.ok(afterMapChange.includes('sv_pausable 1'), 'Pausen-Knopf braucht sv_pausable');
|
||||
assert.ok(afterMapChange.includes('mp_freezetime 20'));
|
||||
});
|
||||
|
||||
test('Log-Weiterleitung wird vor dem Mapwechsel gesetzt, unsichere URLs nicht', () => {
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { backupPrefix, describeBackup, isValidBackupFile, parseBackupFiles, pointsForMap, trimLiveState } from '../src/rounds.js';
|
||||
|
||||
const id = 'Ab_9-xY';
|
||||
const file = (map, round, s1, s2) => `${backupPrefix(id)}_2026-10-25_18-30-00_${map}_round${round}_score_${s1}_${s2}.txt`;
|
||||
|
||||
test('Gültige Sicherungsdateien nur für dieses Match', () => {
|
||||
assert.equal(isValidBackupFile(file('de_inferno', 3, 2, 1), id), true);
|
||||
assert.equal(isValidBackupFile(file('de_inferno', 3, 2, 1), 'anderes'), false, 'fremdes Match');
|
||||
assert.equal(isValidBackupFile(`${backupPrefix(id)}_../../etc/passwd.txt`, id), false, 'Pfad');
|
||||
assert.equal(isValidBackupFile(`${backupPrefix(id)}_x;quit_round1_score_0_0.txt`, id), false, 'Befehl');
|
||||
assert.equal(isValidBackupFile(`${backupPrefix(id)}_de_inferno.txt`, id), false, 'ohne Runde');
|
||||
assert.equal(isValidBackupFile(undefined, id), false);
|
||||
});
|
||||
|
||||
test('Liste aus einer RCON-Antwort auslesen, fremde Dateien ignorieren', () => {
|
||||
const text = [
|
||||
'Backups:',
|
||||
file('de_inferno', 1, 1, 0),
|
||||
file('de_inferno', 2, 1, 1),
|
||||
'round_backup_fremd_2026_de_nuke_round4_score_3_1.txt',
|
||||
file('de_nuke', 1, 1, 0),
|
||||
].join('\n');
|
||||
const found = parseBackupFiles(text, id);
|
||||
assert.equal(found.length, 3);
|
||||
assert.deepEqual(found[0], { file: file('de_inferno', 1, 1, 0), map: 'de_inferno', round: 1, score: { s1: 1, s2: 0 } });
|
||||
});
|
||||
|
||||
test('Nur die Map der laufenden Serie, neueste Runde zuerst', () => {
|
||||
const all = parseBackupFiles([file('de_inferno', 1, 1, 0), file('de_inferno', 3, 2, 1), file('de_nuke', 2, 1, 1)].join('\n'), id);
|
||||
const points = pointsForMap(all, 'de_inferno');
|
||||
assert.deepEqual(points.map((p) => p.round), [3, 1]);
|
||||
});
|
||||
|
||||
test('Stand nach Rollback: Runden bis zur Sicherung, Punkte neu gerechnet', () => {
|
||||
const state = {
|
||||
map: 'de_inferno', status: 'live', warmupEnded: true, score: { A: 3, B: 2 }, winner: 'A', pendingRound: null,
|
||||
rounds: [
|
||||
{ number: 1, winner: 'A' }, { number: 2, winner: 'B' }, { number: 3, winner: 'A' },
|
||||
{ number: 4, winner: 'B' }, { number: 5, winner: 'A' },
|
||||
],
|
||||
};
|
||||
const back = trimLiveState(state, 3);
|
||||
assert.deepEqual(back.rounds.map((r) => r.number), [1, 2, 3]);
|
||||
assert.deepEqual(back.score, { A: 2, B: 1 });
|
||||
assert.equal(back.winner, null);
|
||||
assert.equal(back.status, 'live');
|
||||
assert.equal(back.warmupEnded, true, 'Zustand bleibt erhalten');
|
||||
});
|
||||
|
||||
test('Beschreibung eines Dateinamens', () => {
|
||||
assert.deepEqual(describeBackup('nichts.txt'), { file: 'nichts.txt', map: null, round: null, score: null });
|
||||
});
|
||||
Reference in new issue
Block a user