Compare commits

..
Author SHA1 Message Date
mregosar f24b2bedbc Veto-Timer als Leiste in Teamfarbe
Die verbleibende Zeit des Veto-Schritts erscheint als Leiste mit
Sekundenanzeige, in den letzten 10 Sekunden rot. Der Server schickt
die Schrittdauer mit, damit die Leiste zu VETO_STEP_SECONDS passt.
2026-10-26 14:02:46 +01:00
mregosar 656bfbaadf Merge feature/veto-timer in main 2026-10-26 11:26:34 +01:00
mregosar fcc9371a9e Merge feature/admin-settings in main 2026-10-26 11:26:25 +01:00
mregosar be474ee143 Admin: Reiter Matches und Einstellungen, Discord-Embeds, CSTV-Zugang
Der Admin-Bereich bekommt Reiter. Die Discord-Einstellungen liegen
unter „Einstellungen“. Meldungen sind als Embeds formatiert, und die
Match-Startmeldung enthält den CSTV-Zugang (Passwort und Adresse).
2026-10-26 11:03:54 +01:00
mregosar a7539091ff Veto-Timer: 30 Sekunden pro Schritt, danach automatische Auswahl
Läuft die Frist eines Veto-Schritts ab (VETO_STEP_SECONDS, Standard 30,
0 = aus), wählt das Backend für das Team am Zug eine zufällige Map bzw.
Seite. Die Veto-Anzeige zeigt die verbleibende Zeit.
2026-10-26 11:02:33 +01:00
mregosar 301da179ed Merge feature/discord-webhook in main 2026-10-26 01:12:15 +01:00
mregosar 7bceceb31d Discord-Webhook für Streamer-Meldungen mit wählbaren Ereignissen
Meldungen zu Veto, Match-Start, Map- und Serienende, Matchende und
Serverfehlern gehen an DISCORD_WEBHOOK_URL. Im Admin-Bereich wählt man,
welche Ereignisse gemeldet werden, und kann eine Testmeldung senden.
2026-10-26 01:12:15 +01:00
mregosar eee4ceb315 Merge docs/readme-update in main 2026-10-26 00:53:46 +01:00
mregosar c33185db3e README: Match-Übersicht, Pausen und Turnierregeln dokumentiert 2026-10-26 00:53:46 +01:00
mregosar 1da08b256e Merge Rules in main 2026-10-26 00:50:27 +01:00
mregosar 1d627c212a Merge feature/admin-pause in main 2026-10-26 00:50:27 +01:00
mregosar e7f595176a Turnierregeln für die CS2-Server-Konfiguration
Setzt Freezetime 20, Buytime 20, Startgeld 800, Rundenzeit 1:55 und
Halbzeit 1 fest und erlaubt Pausen mit sv_pausable 1. Die Werte
stammen aus FACEIT- und ESL-Richtwerten, nicht aus einer aktuellen
Regel des konkreten Turniers.
2026-10-26 00:48:26 +01:00
mregosar bfe315d70c Admin: Pausieren und Fortsetzen des laufenden Matches
Neue Admin-Aktionen server/pause und server/resume senden
mp_pause_match bzw. mp_unpause_match an den CS2-Server. Nur bei
konfiguriertem Server verfügbar.
2026-10-26 00:48:25 +01:00
mregosar d2a811f177 Merge feature/organizer-stream-link in main 2026-10-26 00:24:41 +01:00
mregosar c69d5548e5 Organisator: Link zur Übersicht des eigenen Matches
Der Organisator bekommt einen Link auf /stream/match/<id>, die
Stream-Übersicht nur für dieses Match. Der Zuschauercode bleibt
außen vor. Ist STREAM_KEY gesetzt, wird der Schlüssel angehängt.
2026-10-26 00:24:41 +01:00
mregosar af4f036515 Merge feature/stream-match-link in main 2026-10-26 00:15:37 +01:00
mregosar 91213d354f Stream-Übersicht: Matches anklickbar, Einzelansicht pro Match
Karten und Listeneinträge der Stream-Übersicht führen zur Übersicht
des jeweiligen Matches (/stream/match/:matchId). Der Zuschauercode
bleibt geheim, daher wird nur die Match-ID verlinkt.
2026-10-26 00:15:37 +01:00
mregosar 4fa4db3640 Merge feature/round-rollback in main 2026-10-25 23:44:59 +01:00
mregosar a98a34d769 Rollback: Rundensicherung per Startparameter aktivieren
CS2 legt Rundensicherungen nur mit -allow_round_backup_files an (sonst
Antwort 'Round backup files are disabled'). Der Docker-Adapter setzt den
Parameter über CS2_ADDITIONAL_ARGS. Am echten Server geprüft: Sicherungen
für Runde 0 bis 2 vorhanden, Zurückspielen auf Runde 1 funktioniert.
2026-10-25 23:44:40 +01:00
mregosar 3bfe087b7d Rollback: Runden aus CS2-Sicherungen zurückspielen (nur Admin)
Beim Konfigurieren aktiviert CS2 Mapveto die Rundensicherung des Servers
(mp_backup_round_auto, Präfix mit Match-ID). Im Admin-Modus lassen sich
die Sicherungen der laufenden Map auflisten und zurückspielen. Der
Live-Stand wird auf die Runden bis zur Sicherung gekürzt. Organisatoren
haben keinen Zugang zu den Rollback-Routen.

Sicherheit: Dateinamen werden gegen Match-ID und Muster geprüft, damit
keine fremden Dateien oder Befehle in die RCON-Zeile gelangen.
2026-10-25 23:24:49 +01:00
mregosar 43ecb8a1ad Merge feature/admin-mode in main 2026-10-25 23:16:45 +01:00
mregosar 46d1954a6d Admin-Modus: Verwaltung aller Matches unter /admin
Anmeldung mit ADMIN_PASSWORD (12 h, Sitzung nur im Speicher, Sperre nach
5 Fehlversuchen pro Minute). Alle Matches mit Codes, Servern, Spielern,
Veto und Verlauf. Aktionen: Veto starten bzw. Schritte für beide Teams,
Warmup beenden, nächste Map, Server stoppen/neu starten, abschließen,
abbrechen, Spieler entfernen, Codes erneuern, Match löschen.
Ohne ADMIN_PASSWORD ist der Admin-Modus komplett aus.
2026-10-25 23:16:44 +01:00
mregosar 30ec5359b1 AMP und feste Host-Namen aus Doku und Tests entfernen
Der Docker-Host ist beliebig; Beispiele nutzen jetzt DOCKER-HOST-IP bzw.
den SSH-Alias docker-host statt eines Rechnernamens. Testfixture heißt
echte-logs-wingman.log.
2026-10-25 22:54:38 +01:00
mregosar 5527c2ce58 Stack unabhängig vom Host: deploy/compose.yml mit .env
CS2 Mapveto startet jetzt auf jedem Docker-Host (Linux) mit
'docker compose --env-file deploy/.env -f deploy/compose.yml up -d'.
Die Adresse kommt aus HOST_IP, nichts ist mehr fest eingetragen.
Anleitung für einen neuen Server: Image laden oder bauen, Spieldateien
kopieren oder neu laden, konfigurieren, starten.

Geprüft auf amp-cluster mit Demo-Adapter: Stack startet, Webseite
antwortet, Stack wieder entfernt, Spieldateien unverändert.
2026-10-25 22:46:51 +01:00
mregosar 1b7c13b592 CS2 Mapveto als ein Container (Dockerfile, compose.yml, Anleitung)
Ein Image mit Webseite, Backend, SQLite und Docker-Client; startet
Match-Container über /var/run/docker.sock auf demselben Host. Tests
laufen beim Bauen mit. Auf amp-cluster gebaut (301 MB) und geprüft.

Dazu: Testbefehl für Node 22 angepasst, beim Beenden werden ausstehende
Live-Updates verworfen und laufende Server-Aktionen abgewartet, damit nach
dem Schließen der Datenbank nichts mehr darauf zugreift.
2026-10-25 22:40:52 +01:00
mregosar e3a1ed4a63 Docker-Warteschlange: Update-Check per RCON statt nur per Logzeile erkennen
Die Zeile 'Starting CS2 Dedicated Server' rutscht nach dem Serverstart
schnell aus den letzten Logzeilen. Der nächste Match-Container wartete
dadurch bis zur Zeitgrenze (20 min). Jetzt gilt ein Container als fertig,
sobald er per RCON antwortet; die Logzeile bleibt als zweite Erkennung.
2026-10-25 22:16:57 +01:00
mregosar 84dad0993c Docker-Adapter mit echtem Host getestet, zwei Fehler behoben
- CSTV belegt einen Spielerplatz: Container starten jetzt mit 6 bzw. 12
  Plätzen (Spieler + CSTV + Reserve), sonst kam der letzte Spieler nicht drauf
- 'Live-Daten empfangen' wird auch in der Aufwärmphase angezeigt, wenn
  der Server Logs liefert, die den Spielstand noch nicht ändern
- Anleitung: Serverliste, Plätze, Bots zum Testen

Getestet auf amp-cluster (Docker per SSH, LAN-Modus): Container-Start,
Veto, Konfiguration, Live-Runden, automatischer Mapwechsel, Entfernen.
2026-10-25 22:09:43 +01:00
mregosar 3a109883b3 Docker-Adapter: LAN-Modus ohne GSLT, Container starten nacheinander
Ohne CS2_GSLT starten Match-Container mit CS2_LAN=1. Neue Container
warten, bis der vorige seinen SteamCMD-Update-Check abgeschlossen hat,
damit nie zwei gleichzeitig das gemeinsame Volume aktualisieren.
Anleitung: Docker per SSH (DOCKER_HOST), eindeutige Passwort-Beispiele
mit Warnung vor Sonderzeichen (Startskript nutzt eval).
2026-10-25 21:54:30 +01:00
mregosar 85041b2bff Stream-Übersicht aller laufenden Vetos und Matches
/stream zeigt alle aktiven Matches als Karten (Veto mit Map-Board, Match
mit Live-Stand und Serie), dazu offene Lobbys und Ergebnisse. Raster passt
sich der Anzahl an, ab 7 Matches wird geblättert. Live per Socket, optional
mit STREAM_KEY geschützt, ohne Codes oder Verbindungsdaten.
2026-10-25 19:32:53 +01:00
mregosar ea2207899f Aufwärmphase vor mp_warmup_end als beendet markieren
CS2 sendet Match_Start sofort nach mp_warmup_end, oft bevor die
RCON-Antwort da ist. Bei fehlgeschlagenem Befehl wird die Markierung
zurückgenommen.
2026-10-25 19:23:53 +01:00
mregosar 263c7e7b7c Live-Ergebnisse: Stream-Seite, Anzeige, automatischer Mapwechsel
- Stream-Übersicht unter /stream/<Zuschauercode> (1920x1080, Lobby, Veto, Spielstand)
- Live-Stand, Rundenverlauf und Serienstand auf Match-Seite und Übersicht
- Parser mit echten AMP-Logs geprüft: Game Over mit leerer Map-Gruppe,
  Match_Start vor der Aufwärmphase, veraltete Seitenzuordnung nach der Halbzeit
- Nach Map-Ende automatisch nächste Map bzw. Abschluss der entschiedenen Serie
- LOG_DEBUG zur Fehlersuche, README und .env.example ergänzt
2026-10-25 19:20:10 +01:00
mregosar 306b3aa7e5 Live-Ergebnisse aus CS2-Serverlogs (Backend)
Server bekommt beim Konfigurieren logaddress_add_http mit geheimem
Match-Schlüssel. Ein Parser wertet Rundenende, Seitenzuordnung, Matchstart
und Spielende aus; Stand, Rundenverlauf und Sieger werden pro Map gespeichert
und live verteilt. Der Demo-Adapter simuliert nach der Aufwärmphase einen
Matchverlauf über dieselben Logzeilen.
2026-10-25 18:54:23 +01:00
mregosar 34e5fc7d0e Anleitung: CS2-Server mit Portainer und dauerhaftem Volume
docs/portainer-cs2.md beschreibt Stack, einmaligen Download, Updates ohne
Neu-Download, Anbindung per Static- oder Docker-Adapter, mehrere Server
und Fehlersuche. Standard-Image auf joedwards32/cs2 umgestellt, da
cm2network/cs2 nicht mehr gepflegt wird.
2026-10-25 18:07:17 +01:00
mregosar 8be8b3d879 Organisator kann im selben Browser selbst mitspielen
Ein Aufrufer darf Organisator- und Spieler-Zugang kombinieren
(Authorization + X-Player-Token). Berechtigungen prüfen jetzt einzelne
Merkmale statt einer Rangstufe. In der Lobby gibt es „Selbst mitspielen?“,
Teamlinks funktionieren auch im Organisator-Browser. Online-Status wird
direkt nach dem Beitritt aktualisiert.
2026-10-25 17:53:39 +01:00
mregosar 3a74e16bb8 Größere Map-Kacheln und Ban/Pick-Leiste
Ab dem Veto bekommt das Map-Board die volle Breite (4 Spalten), die
Teamkarten rücken darunter. Die Schrittleiste zeigt pro Schritt das Team.
2026-10-25 17:45:41 +01:00
mregosar f1fc1642ab MaxLAN-Branding: Logo in der Kopfzeile, Farben aus dem Logo
Team A in Logo-Blau, Team B in Logo-Rot, schwarze Kopfzeile mit rotem
Abschluss; CT/T-Farben aus den Emblemen, Warnungen in Orange.
2026-10-25 17:42:38 +01:00
mregosar db4a6bedf7 CT/T-Embleme ergänzt, kein Verbindungshinweis beim ersten Laden 2026-10-25 17:34:03 +01:00
mregosar 7be0d85ab5 Map-Bilder und Seitenwahl mit Emblemen
Map-Bilder aus ghostcap-gaming/cs2-map-images lokal als WebP gespeichert
(npm run images lädt und verkleinert sie). Bilder in Veto-Kacheln,
Pool-Vorschau, Serie und Seitenwahl; CT/T-Embleme aus client/public/sides
mit Ersatzsymbol, falls die Dateien fehlen.
2026-10-25 17:23:55 +01:00
mregosar e304556346 Ein bereiter Spieler pro Team reicht standardmäßig für den Veto-Start
REQUIRE_FULL_TEAMS ist jetzt standardmäßig false; vollständige Teams
lassen sich weiterhin per Umgebungsvariable erzwingen.
2026-10-25 17:11:46 +01:00
mregosar bf438511d6 Lobby zeigt konkret, was einem Team zum Veto-Start fehlt 2026-10-25 17:09:31 +01:00
mregosar f7520a6ca7 Frontend, README und Konfigurationsvorlage
React-Oberfläche mit Übersicht, Match-Erstellung, Beitritt über Team- und
Zuschauercodes, Teamkarten, visuellem Map-Veto, Serie und Serverpanel.
README beschreibt Einrichtung, Umgebungsvariablen, Rollen, Veto-Abläufe,
Serveradapter und CSTV-Zuschauerzugang.
2026-10-25 17:02:18 +01:00
mregosar f08297dd0d Backend: Match-Lobbys, Map-Veto, Echtzeit und CS2-Serveradapter
Express + Socket.IO + SQLite. Veto-Engine mit konfigurierbaren Map-Pools
und validierten Abläufen, rollenbasierte Berechtigungen, Server-Lebenszyklus
mit Demo-, Docker- und Static-Pool-Adapter (RCON), automatisches Herunterfahren.
2026-10-25 16:50:22 +01:00
8 changed files with 8 additions and 152 deletions

No files matched your search

+1 -41
View File
@@ -311,7 +311,7 @@ Beim Konfigurieren setzt CS2 Mapveto auf dem Server `log on` und `logaddress_add
- Stream-Seite - Stream-Seite
- **Nach dem Spielende:** Ist die Serie noch offen, lädt CS2 Mapveto nach `SERIES_AUTO_ADVANCE_SECONDS` (Standard 30) die nächste Map. Ist sie entschieden (z. B. 2:0 im Best of 3), wird das Match abgeschlossen und der Server freigegeben. Mit `-1` geht beides nur manuell. - **Nach dem Spielende:** Ist die Serie noch offen, lädt CS2 Mapveto nach `SERIES_AUTO_ADVANCE_SECONDS` (Standard 30) die nächste Map. Ist sie entschieden (z. B. 2:0 im Best of 3), wird das Match abgeschlossen und der Server freigegeben. Mit `-1` geht beides nur manuell.
- **Voraussetzung:** Der CS2-Server muss `LOG_RECEIVER_URL` erreichen, im LAN z. B. `http://192.168.178.31:3000`. Ob Daten ankommen, zeigt das Serverpanel des Organisators. Mit `LOG_DEBUG=true` gibt das Backend alle empfangenen Zeilen aus. - **Voraussetzung:** Der CS2-Server muss `LOG_RECEIVER_URL` erreichen, im LAN z. B. `http://192.168.178.31:3000`. Ob Daten ankommen, zeigt das Serverpanel des Organisators. Mit `LOG_DEBUG=true` gibt das Backend alle empfangenen Zeilen aus.
- **Geprüft:** mit echten Logzeilen eines CS2-Servers (Wingman), anonymisiert in `server/test/fixtures/wingman-anonym.log`. - **Geprüft:** mit echten Logzeilen eines CS2-Servers (Wingman), siehe `server/test/fixtures/echte-logs-wingman.log`.
Zwei Eigenheiten von CS2 berücksichtigt der Parser: Zwei Eigenheiten von CS2 berücksichtigt der Parser:
- CS2 sendet `Match_Start` schon beim Laden der Map. Gezählt wird erst, nachdem der Organisator „Aufwärmphase beenden“ geklickt hat. - CS2 sendet `Match_Start` schon beim Laden der Map. Gezählt wird erst, nachdem der Organisator „Aufwärmphase beenden“ geklickt hat.
@@ -357,45 +357,6 @@ Jede Meldung enthält einen Link auf die Match-Übersicht (`/stream/match/<id>`)
Webhook anlegen: Discord-Kanal → Einstellungen → Integrationen → Webhooks → Neuer Webhook, dann die URL kopieren. 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. 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 ## Umgebungsvariablen
@@ -422,7 +383,6 @@ Alle Variablen mit Standardwerten stehen in [`.env.example`](.env.example). Die
| `LOG_DEBUG` | `false` | empfangene Logzeilen im Terminal ausgeben | | `LOG_DEBUG` | `false` | empfangene Logzeilen im Terminal ausgeben |
| `STREAM_KEY` | leer | Schlüssel für die Stream-Übersicht aller Matches (`/stream?key=…`); leer = offen | | `STREAM_KEY` | leer | Schlüssel für die Stream-Übersicht aller Matches (`/stream?key=…`); leer = offen |
| `DISCORD_WEBHOOK_URL` | leer | Discord-Webhook für Meldungen zu Veto, Match-Start, Map- und Serienende sowie Serverfehlern; leer = aus | | `DISCORD_WEBHOOK_URL` | leer | Discord-Webhook für Meldungen zu Veto, Match-Start, Map- und Serienende sowie Serverfehlern; leer = aus |
| `TRUST_PROXY` | `false` | `true`, wenn die Anwendung hinter einem Reverse-Proxy läuft (echte Client-IP für die Begrenzung der Anmeldeversuche) |
| `SERIES_AUTO_ADVANCE_SECONDS` | `30` | Wartezeit nach Map-Ende bis zur nächsten Map bzw. zum Abschluss; `-1` = manuell | | `SERIES_AUTO_ADVANCE_SECONDS` | `30` | Wartezeit nach Map-Ende bis zur nächsten Map bzw. zum Abschluss; `-1` = manuell |
Benötigte Zugangsdaten für den Echtbetrieb: Benötigte Zugangsdaten für den Echtbetrieb:
+2 -13
View File
@@ -271,9 +271,6 @@ function MatchAdminCard({ m, run, busy, token, reload, setNotice }) {
</button> </button>
); );
const hasNext = m.series.some((s) => s.number === m.currentMapNumber + 1); const hasNext = m.series.some((s) => s.number === m.currentMapNumber + 1);
const current = m.series.find((s) => s.number === m.currentMapNumber);
const configured = m.server?.status === 'configured';
const paused = !!m.server?.paused;
return ( return (
<section className={`panel admin-card status-${m.status}`}> <section className={`panel admin-card status-${m.status}`}>
@@ -286,14 +283,6 @@ function MatchAdminCard({ m, run, busy, token, reload, setNotice }) {
{m.modeLabel}, {FORMAT_LABEL[m.format]} · {m.statusLabel} · <span className={`tone-${server.tone}`}>{server.text}</span> {m.modeLabel}, {FORMAT_LABEL[m.format]} · {m.statusLabel} · <span className={`tone-${server.tone}`}>{server.text}</span>
{m.format !== 'bo1' && <> · Serie {m.seriesScore.A}:{m.seriesScore.B}</>} {m.format !== 'bo1' && <> · Serie {m.seriesScore.A}:{m.seriesScore.B}</>}
</div> </div>
{m.status === 'live' && current && (
<div className="admin-live">
<span>Map {current.number}: {current.name}</span>
<strong>{current.score.A} : {current.score.B}</strong>
{current.live?.roundsPlayed != null && <span className="muted">Runde {current.live.roundsPlayed + 1}</span>}
</div>
)}
{paused && <span className="admin-badge is-paused">Pausiert</span>}
</div> </div>
<button className="link-btn" onClick={() => setOpen(!open)}>{open ? 'Weniger' : 'Details'}</button> <button className="link-btn" onClick={() => setOpen(!open)}>{open ? 'Weniger' : 'Details'}</button>
</div> </div>
@@ -302,8 +291,8 @@ function MatchAdminCard({ m, run, busy, token, reload, setNotice }) {
<Link className="btn btn-sm" to={`/m/${id}`}>Ansehen</Link> <Link className="btn btn-sm" to={`/m/${id}`}>Ansehen</Link>
{m.status === 'lobby' && btn('Veto starten', { path: '/veto/start' }, 'btn-primary')} {m.status === 'lobby' && btn('Veto starten', { path: '/veto/start' }, 'btn-primary')}
{(m.server?.status === 'configured') && btn('Aufwärmphase beenden', { path: '/server/warmup-end' })} {(m.server?.status === 'configured') && btn('Aufwärmphase beenden', { path: '/server/warmup-end' })}
{configured && !paused && btn('Pausieren', { path: '/server/pause', confirm: 'Match pausieren?' }, 'btn-warn')} {(m.server?.status === 'configured') && btn('Pausieren', { path: '/server/pause', confirm: 'Match pausieren?' })}
{configured && paused && btn('Fortsetzen', { path: '/server/resume' }, 'btn-primary')} {(m.server?.status === 'configured') && btn('Fortsetzen', { path: '/server/resume' })}
{(m.server?.status === 'configured') && hasNext && btn('Nächste Map', { path: '/series/next' })} {(m.server?.status === 'configured') && hasNext && btn('Nächste Map', { path: '/series/next' })}
{m.server && ['start_failed', 'failed', 'stopped', 'config_failed', 'ready'].includes(m.server.status) && !closed && {m.server && ['start_failed', 'failed', 'stopped', 'config_failed', 'ready'].includes(m.server.status) && !closed &&
btn('Server neu starten / wiederholen', { path: '/server/retry' })} btn('Server neu starten / wiederholen', { path: '/server/retry' })}
-5
View File
@@ -529,8 +529,3 @@ input:focus, select:focus { border-color: var(--link); outline: none; }
.turn-seconds { min-width: 3ch; text-align: right; color: var(--muted); font-size: .875rem; font-variant-numeric: tabular-nums; } .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-bar span { background: var(--error); }
.turn-timer.is-low .turn-seconds { color: var(--error); font-weight: 600; } .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); }
-2
View File
@@ -128,6 +128,4 @@ function migrate(db) {
for (const [name, type] of add) { for (const [name, type] of add) {
if (!seriesColumns.has(name)) db.exec(`ALTER TABLE series_maps ADD COLUMN ${name} ${type}`); if (!seriesColumns.has(name)) db.exec(`ALTER TABLE series_maps ADD COLUMN ${name} ${type}`);
} }
const serverColumns = columns('servers');
if (!serverColumns.has('paused')) db.exec('ALTER TABLE servers ADD COLUMN paused INTEGER NOT NULL DEFAULT 0');
} }
-1
View File
@@ -944,7 +944,6 @@ export class MatchService extends EventEmitter {
status: row.status, status: row.status,
message: row.message, message: row.message,
playerCount: row.player_count, playerCount: row.player_count,
paused: !!row.paused,
attempts: row.attempts, attempts: row.attempts,
updatedAt: row.updated_at, updatedAt: row.updated_at,
idleTimeoutMinutes: this.config.idleTimeoutMinutes, idleTimeoutMinutes: this.config.idleTimeoutMinutes,
+3 -4
View File
@@ -308,8 +308,7 @@ export class ServerManager {
if (!seriesMap) throw new HttpError(409, 'Das Veto ist noch nicht abgeschlossen', 'veto_pending'); if (!seriesMap) throw new HttpError(409, 'Das Veto ist noch nicht abgeschlossen', 'veto_pending');
const mapName = pool.maps.find((m) => m.id === seriesMap.map)?.name ?? seriesMap.map; const mapName = pool.maps.find((m) => m.id === seriesMap.map)?.name ?? seriesMap.map;
// Ein Mapwechsel beendet die Pause, CS2 lädt die Map neu this.update(matchId, { status: 'configuring', message: `Lade Map ${seriesMap.number}: ${mapName}` });
this.update(matchId, { status: 'configuring', paused: 0, message: `Lade Map ${seriesMap.number}: ${mapName}` });
this.service.log(matchId, 'server', `Konfiguration für Map ${seriesMap.number} (${mapName}) gestartet`); this.service.log(matchId, 'server', `Konfiguration für Map ${seriesMap.number} (${mapName}) gestartet`);
const plan = buildMapCommands({ const plan = buildMapCommands({
@@ -419,8 +418,8 @@ export class ServerManager {
} catch (err) { } catch (err) {
throw new HttpError(502, `Befehl konnte nicht gesendet werden: ${err.message}`, 'server_error'); throw new HttpError(502, `Befehl konnte nicht gesendet werden: ${err.message}`, 'server_error');
} }
this.update(viewer.matchId, { paused: paused ? 1 : 0 });
this.service.log(viewer.matchId, 'server', paused ? 'Match pausiert (Admin)' : 'Match fortgesetzt (Admin)'); this.service.log(viewer.matchId, 'server', paused ? 'Match pausiert (Admin)' : 'Match fortgesetzt (Admin)');
this.service.changed(viewer.matchId);
} }
/** Demo-Modus: simuliert einen Matchverlauf, indem echte CS2-Logzeilen erzeugt und ausgewertet werden. */ /** Demo-Modus: simuliert einen Matchverlauf, indem echte CS2-Logzeilen erzeugt und ausgewertet werden. */
@@ -464,7 +463,7 @@ export class ServerManager {
const row = this.row(matchId); const row = this.row(matchId);
if (!row || row.status === 'stopped') return; if (!row || row.status === 'stopped') return;
if (row.handle) await this.safeStop(row); if (row.handle) await this.safeStop(row);
this.update(matchId, { status: 'stopped', stopped_at: Date.now(), message: reason, player_count: null, paused: 0 }); this.update(matchId, { status: 'stopped', stopped_at: Date.now(), message: reason, player_count: null });
this.service.log(matchId, 'server', `Server gestoppt: ${reason}`); this.service.log(matchId, 'server', `Server gestoppt: ${reason}`);
} }
-84
View File
@@ -1,84 +0,0 @@
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"
+2 -2
View File
@@ -60,8 +60,8 @@ test('Seitenzuordnung nach dem Rundenende hat Vorrang, veraltete Zeilen zu Runde
assert.deepEqual(s.rounds.map((r) => r.winner), ['A', 'B']); assert.deepEqual(s.rounds.map((r) => r.winner), ['A', 'B']);
}); });
test('Echte, anonymisierte Logzeilen eines CS2-Servers (Wingman, 4 Runden, Halbzeit nach 2)', () => { test('Echte 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 lines = fs.readFileSync(path.join(here, 'fixtures', 'echte-logs-wingman.log'), 'utf8').split(/\r?\n/).filter(Boolean);
const warmupEnd = lines.findIndex((l) => l.includes('command "mp_warmup_end"')); const warmupEnd = lines.findIndex((l) => l.includes('command "mp_warmup_end"'));
const ctx = { teamNames: { A: 'MaxLAN Blau', B: 'MaxLAN Rot' }, maxRounds: 4, overtimeRounds: 6 }; const ctx = { teamNames: { A: 'MaxLAN Blau', B: 'MaxLAN Rot' }, maxRounds: 4, overtimeRounds: 6 };