Compare commits

..
Author SHA1 Message Date
mregosar e1ee009063 Gäste sehen laufende Matches: Veto, Serienstand und Map-Reihenfolge
Ohne Zugang sind Veto und Serienstand sichtbar. Spielerlisten, Codes,
Verbindungsdaten und das Ereignisprotokoll bleiben Teilnehmern vorbehalten.
2026-10-29 09:35:35 +01:00
mregosar d2625d23ab Merge docs/admin-hilfe in main 2026-10-29 09:22:01 +01:00
mregosar 8b6abdb8b2 Merge feature/admin-status in main 2026-10-29 09:22:01 +01:00
mregosar eb80c4845c Docs: Hilfe für Turnier-Admins (PDF mit Screenshots) 2026-10-29 09:21:33 +01:00
mregosar 90996d1b0a Admin: Pausenanzeige, Live-Stand und passender Umschalter
Pausierte Matches zeigen ein Badge und den Knopf Fortsetzen. Laufende Matches
zeigen Map, Stand und Runde. Der Pausenzustand wird gespeichert und bei
Mapwechsel oder Serverstopp zurückgesetzt.
2026-10-29 09:21:32 +01:00
SPP UserandClaude Opus 5.5 da986d44c8 Merge feature/remove-real-logs in main
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 07:38:38 +01:00
SPP UserandClaude Opus 5.5 5383008c8e Echte Logdatei durch anonymisierte Fassung ersetzen
Die Testdatei enthielt Spielernamen und SteamID eines echten Accounts.
Sie ist jetzt durch eine Kopie mit Platzhaltern ersetzt, der Parser-Test
prüft weiterhin echte CS2-Logzeilen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 07:38:38 +01:00
SPP UserandClaude Opus 5.5 11ccbf01a1 Merge docs/reverse-proxy in main
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 07:32:41 +01:00
SPP UserandClaude Opus 5.5 5012e8b16d README: Betrieb hinter einem Reverse-Proxy
Abschnitt mit nginx-Beispiel, Hinweisen zu Subdomain, WebSockets,
PUBLIC_URL und TRUST_PROXY sowie zur Absicherung. TRUST_PROXY steht
jetzt auch in der Variablentabelle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 07:32:41 +01:00
SPP UserandClaude Opus 5.5 1744ff3f73 Merge feature/veto-timer-bar in main
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 14:02:46 +01:00
SPP UserandClaude Opus 5.5 d560c15fef 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.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 14:02:46 +01:00
SPP UserandClaude Haiku 5.5 364c0b57c2 Merge feature/veto-timer in main
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-26 11:26:34 +01:00
SPP UserandClaude Haiku 5.5 9f2e810fa1 Merge feature/admin-settings in main
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-26 11:26:25 +01:00
SPP UserandClaude Haiku 5.5 e9efe38b76 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).

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-26 11:03:54 +01:00
SPP UserandClaude Haiku 5.5 dea0618b69 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.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-26 11:02:33 +01:00
SPP UserandClaude Haiku 5.5 0197cd05ce Merge feature/discord-webhook in main
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-26 01:12:15 +01:00
SPP UserandClaude Haiku 5.5 9af83cb47c 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.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-26 01:12:15 +01:00
SPP UserandClaude Haiku 5.5 5a332655b2 Merge docs/readme-update in main
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-26 00:53:46 +01:00
SPP UserandClaude Haiku 5.5 c4bf4f19ae README: Match-Übersicht, Pausen und Turnierregeln dokumentiert
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-26 00:53:46 +01:00
SPP UserandClaude Haiku 5.5 2c0f86dccc Merge Rules in main
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-26 00:50:27 +01:00
SPP UserandClaude Haiku 5.5 91022e307c Merge feature/admin-pause in main
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-26 00:50:27 +01:00
SPP UserandClaude Haiku 5.5 fe251a13ef 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.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-26 00:48:26 +01:00
SPP UserandClaude Haiku 5.5 3e682d7fbc 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.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-26 00:48:25 +01:00
SPP UserandClaude Haiku 5.5 a6e7d7d287 Merge feature/organizer-stream-link in main
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-26 00:24:41 +01:00
SPP UserandClaude Haiku 5.5 d7677f3afe 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.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-26 00:24:41 +01:00
SPP UserandClaude Haiku 5.5 0341921655 Merge feature/stream-match-link in main
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-26 00:15:37 +01:00
SPP UserandClaude Haiku 5.5 fde3baafb5 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.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-26 00:15:37 +01:00
SPP UserandClaude Haiku 5.5 82239c8447 Merge feature/round-rollback in main
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-25 23:44:59 +01:00
SPP UserandClaude Haiku 5.5 b9dafbc6ab 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.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-25 23:44:40 +01:00
SPP UserandClaude Haiku 5.5 a4218c47d1 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.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-25 23:24:49 +01:00
SPP UserandClaude Haiku 5.5 22996cc4e4 Merge feature/admin-mode in main
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-25 23:16:45 +01:00
SPP UserandClaude Haiku 5.5 330048acfc 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.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-25 23:16:44 +01:00
SPP UserandClaude Haiku 5.5 726974fe82 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.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-25 22:54:38 +01:00
SPP UserandClaude Haiku 5.5 413df03237 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.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-25 22:46:51 +01:00
SPP UserandClaude Opus 5.5 d78f399612 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.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 22:40:52 +01:00
SPP UserandClaude Opus 5.5 6ee2de5a46 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.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 22:16:57 +01:00
SPP UserandClaude Opus 5.5 7c74c6c4eb 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.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 22:09:43 +01:00
SPP UserandClaude Opus 5.5 59d6dc7177 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).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 21:54:30 +01:00
SPP UserandClaude Opus 5.5 8996294d7e 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.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 19:32:53 +01:00
SPP UserandClaude Opus 5.5 54bfe198d8 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.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 19:23:53 +01:00
SPP UserandClaude Opus 5.5 43dc5b837a 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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 19:20:10 +01:00
SPP UserandClaude Opus 5.5 5fade769e7 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.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 18:54:23 +01:00
SPP UserandClaude Opus 5.5 5ea5638115 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.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 18:07:17 +01:00
SPP UserandClaude Opus 5.5 4152f9c0a6 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.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 17:53:39 +01:00
SPP UserandClaude Opus 5.5 470b12f232 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.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 17:45:41 +01:00
SPP UserandClaude Opus 5.5 8a0a1309a2 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.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 17:42:38 +01:00
SPP UserandClaude Opus 5.5 9d63799c48 CT/T-Embleme ergänzt, kein Verbindungshinweis beim ersten Laden
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 17:34:03 +01:00
SPP UserandClaude Opus 5.5 54168ae763 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.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 17:23:55 +01:00
SPP UserandClaude Opus 5.5 3a790ce0b1 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.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 17:11:46 +01:00
SPP UserandClaude Opus 5.5 3999c4b111 Lobby zeigt konkret, was einem Team zum Veto-Start fehlt
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 17:09:31 +01:00
SPP UserandClaude Opus 5.5 568ccf9b09 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.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 17:02:18 +01:00
SPP UserandClaude Opus 5.5 104aa1a67b 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.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 16:50:22 +01:00
17 changed files with 564 additions and 19 deletions

No files matched your search

+2
View File
@@ -68,3 +68,5 @@ LOG_DEBUG=false
SERIES_AUTO_ADVANCE_SECONDS=30 SERIES_AUTO_ADVANCE_SECONDS=30
# Schlüssel für die Stream-Übersicht aller Matches (/stream?key=...); leer = offen # Schlüssel für die Stream-Übersicht aller Matches (/stream?key=...); leer = offen
STREAM_KEY= STREAM_KEY=
# Discord-Webhook für Meldungen (Veto, Match-Start, Map- und Serienende, Fehler). Leer = aus
DISCORD_WEBHOOK_URL=
+58 -1
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), 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: 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.
@@ -341,6 +341,61 @@ Für die Übersicht eines einzelnen Matches ohne Zuschauercode gibt es `/stream/
- **Laufband:** zeigt das letzte für Zuschauer relevante Ereignis, keine technischen Servermeldungen. - **Laufband:** zeigt das letzte für Zuschauer relevante Ereignis, keine technischen Servermeldungen.
- **Datenschutz:** Verbindungsdaten und Passwörter erscheinen dort nie. - **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 ## Umgebungsvariablen
@@ -366,6 +421,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_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 | | `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 |
| `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:
+27
View File
@@ -10,6 +10,7 @@ export default function VetoBoard({ state, viewer, act, busy, compact = false })
const { current } = veto; const { current } = veto;
const [selected, setSelected] = useState(null); const [selected, setSelected] = useState(null);
useEffect(() => setSelected(null), [current?.index]); useEffect(() => setSelected(null), [current?.index]);
const left = useCountdown(veto.remainingMs);
const mapsById = Object.fromEntries(state.maps.map((m) => [m.id, m])); const mapsById = Object.fromEntries(state.maps.map((m) => [m.id, m]));
const yourTurn = !!current && viewer.isCaptain && viewer.team === current.team; const yourTurn = !!current && viewer.isCaptain && viewer.team === current.team;
@@ -38,6 +39,7 @@ export default function VetoBoard({ state, viewer, act, busy, compact = false })
? 'Live-Ansicht – nur Captains können wählen.' ? 'Live-Ansicht – nur Captains können wählen.'
: 'Das andere Team ist am Zug.'} : 'Das andere Team ist am Zug.'}
</span> </span>
{left !== null && <TimerBar left={left} totalMs={(veto.stepSeconds || 30) * 1000} />}
</div> </div>
)} )}
{!current && veto.done && !compact && <div className="turn turn-done">Veto abgeschlossen</div>} {!current && veto.done && !compact && <div className="turn turn-done">Veto abgeschlossen</div>}
@@ -129,3 +131,28 @@ function statusText(m, teams) {
default: return 'Verfügbar'; default: return 'Verfügbar';
} }
} }
/** Leiste für die verbleibende Zeit des Veto-Schritts. In den letzten 10 Sekunden wird sie rot. */
function TimerBar({ left, totalMs }) {
const seconds = Math.ceil(left / 1000);
const percent = Math.max(0, Math.min(100, (left / totalMs) * 100));
return (
<div className={`turn-timer ${left <= 10_000 ? 'is-low' : ''}`} role="timer" aria-label={`Noch ${seconds} Sekunden`}>
<div className="turn-bar"><span style={{ width: `${percent}%` }} /></div>
<span className="turn-seconds">{seconds} s</span>
</div>
);
}
/** Verbleibende Zeit des Veto-Schritts: der Server setzt sie bei jeder Änderung, danach zählt die Anzeige lokal herunter. */
function useCountdown(ms) {
const [left, setLeft] = useState(ms ?? null);
useEffect(() => {
setLeft(ms ?? null);
if (ms == null) return undefined;
const started = Date.now();
const t = setInterval(() => setLeft(Math.max(0, ms - (Date.now() - started))), 250);
return () => clearInterval(t);
}, [ms]);
return left;
}
+90 -5
View File
@@ -29,6 +29,7 @@ export default function AdminPage() {
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [notice, setNotice] = useState(null); const [notice, setNotice] = useState(null);
const [busy, setBusy] = useState(null); const [busy, setBusy] = useState(null);
const [tab, setTab] = useState('matches');
const logout = useCallback(() => { const logout = useCallback(() => {
api('/admin/logout', { method: 'POST', token }).catch(() => {}); api('/admin/logout', { method: 'POST', token }).catch(() => {});
@@ -92,17 +93,90 @@ export default function AdminPage() {
<button className="btn btn-sm" onClick={logout}>Abmelden</button> <button className="btn btn-sm" onClick={logout}>Abmelden</button>
</header> </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>} {notice && <div className={`toast toast-${notice.tone}`} role="status">{notice.text}</div>}
{error && <div className="alert alert-error">{error}</div>} {error && <div className="alert alert-error">{error}</div>}
{matches === null && !error && <p className="muted">Lade Matches …</p>} {tab === 'matches' && matches === null && !error && <p className="muted">Lade Matches …</p>}
{matches && matches.length === 0 && <p className="muted">Noch keine Matches.</p>} {tab === 'matches' && matches && matches.length === 0 && <p className="muted">Noch keine Matches.</p>}
<div className="admin-list"> {tab === 'matches' && <div className="admin-list">
{matches?.map((m) => ( {matches?.map((m) => (
<MatchAdminCard key={m.id} m={m} run={run} busy={busy} token={token} reload={load} setNotice={setNotice} /> <MatchAdminCard key={m.id} m={m} run={run} busy={busy} token={token} reload={load} setNotice={setNotice} />
))} ))}
</div>}
</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>
<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>
); );
} }
@@ -197,6 +271,9 @@ 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}`}>
@@ -209,6 +286,14 @@ 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>
@@ -217,8 +302,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' })}
{(m.server?.status === 'configured') && btn('Pausieren', { path: '/server/pause', confirm: 'Match pausieren?' })} {configured && !paused && btn('Pausieren', { path: '/server/pause', confirm: 'Match pausieren?' }, 'btn-warn')}
{(m.server?.status === 'configured') && btn('Fortsetzen', { path: '/server/resume' })} {configured && paused && btn('Fortsetzen', { path: '/server/resume' }, 'btn-primary')}
{(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 -5
View File
@@ -127,7 +127,7 @@ export default function MatchPage() {
{viewer.role === 'public' && <PublicNotice />} {viewer.role === 'public' && <PublicNotice />}
<div className={`match-grid ${state.status !== 'lobby' && viewer.role !== 'public' ? 'stage-wide' : ''}`}> <div className={`match-grid ${state.status !== 'lobby' ? 'stage-wide' : ''}`}>
<TeamCard team={state.teams.A} side="A" {...ctx} /> <TeamCard team={state.teams.A} side="A" {...ctx} />
<section className="stage"> <section className="stage">
<Stage {...ctx} /> <Stage {...ctx} />
@@ -156,8 +156,8 @@ function describeViewer(viewer, teams) {
function Stage(ctx) { function Stage(ctx) {
const { state, isOrg, act, busy } = ctx; const { state, isOrg, act, busy } = ctx;
if (state.viewer.role === 'public') { if (state.viewer.role === 'public' && state.status === 'lobby') {
return <div className="stage-note"><h2>{state.statusLabel}</h2><p className="muted">Details sehen nur Teilnehmer.</p></div>; return <div className="stage-note"><h2>{state.statusLabel}</h2><p className="muted">Das Veto und der Spielstand erscheinen hier, sobald das Match startet.</p></div>;
} }
if (state.status === 'lobby') { if (state.status === 'lobby') {
const { A, B } = state.teams; const { A, B } = state.teams;
@@ -230,8 +230,8 @@ function MapPoolPreview({ maps }) {
function PublicNotice() { function PublicNotice() {
return ( return (
<div className="alert"> <div className="alert">
Du siehst diese Lobby ohne Zugang. Spieler treten über den Einladungslink ihres Teams bei, Du siehst dieses Match ohne Zugang. Veto, Serienstand und Ergebnisse sind öffentlich.
Zuschauer über den Zuschauerlink. Beides bekommst du vom Organisator. Teamdetails und Verbindungsdaten gibt es nur für Teilnehmer.
</div> </div>
); );
} }
+22
View File
@@ -512,3 +512,25 @@ input:focus, select:focus { border-color: var(--link); outline: none; }
.rollback h3 { margin: 0 0 8px; display: flex; align-items: center; gap: 10px; } .rollback h3 { margin: 0 0 8px; display: flex; align-items: center; gap: 10px; }
.rollback select { min-width: 200px; } .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-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); }
Binary file not shown.
+12
View File
@@ -76,6 +76,18 @@ export function createAdminRouter({ service, manager, config }) {
router.get('/session', requireAdmin, (req, res) => 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) // Alle Matches mit allen Details (Codes, Verbindungsdaten, Spieler, Veto, Serie, Ereignisse)
router.get('/matches', requireAdmin, wrap(() => { router.get('/matches', requireAdmin, wrap(() => {
const ids = service.db.prepare('SELECT id FROM matches ORDER BY created_at DESC LIMIT 100').all(); const ids = service.db.prepare('SELECT id FROM matches ORDER BY created_at DESC LIMIT 100').all();
+5 -1
View File
@@ -5,6 +5,7 @@ import express from 'express';
import { REPO_ROOT } from './config.js'; import { REPO_ROOT } from './config.js';
import { openDatabase } from './db.js'; import { openDatabase } from './db.js';
import { createApiRouter } from './http.js'; import { createApiRouter } from './http.js';
import { createDiscordNotifier } from './discord.js';
import { loadMapPools } from './mapPools.js'; import { loadMapPools } from './mapPools.js';
import { MatchService } from './matchService.js'; import { MatchService } from './matchService.js';
import { attachRealtime } from './realtime.js'; import { attachRealtime } from './realtime.js';
@@ -15,12 +16,14 @@ import { ServerManager } from './servers/serverManager.js';
export function createApp(config, { adapter: adapterOverride, logger = console } = {}) { export function createApp(config, { adapter: adapterOverride, logger = console } = {}) {
const db = openDatabase(config.databaseFile); const db = openDatabase(config.databaseFile);
const pools = loadMapPools(config.mapPoolFile); const pools = loadMapPools(config.mapPoolFile);
const service = new MatchService({ db, pools, config }); const notifier = createDiscordNotifier(config.discordWebhookUrl, { logger });
const service = new MatchService({ db, pools, config, notifier });
let manager; let manager;
const adapter = adapterOverride ?? createServerAdapter(config, { getUsedPorts: () => manager.usedPorts() }); const adapter = adapterOverride ?? createServerAdapter(config, { getUsedPorts: () => manager.usedPorts() });
manager = new ServerManager({ db, service, adapter, config, logger }); manager = new ServerManager({ db, service, adapter, config, logger });
manager.init(); manager.init();
service.startVetoTimer();
const app = express(); const app = express();
if (config.trustProxy) app.set('trust proxy', 1); if (config.trustProxy) app.set('trust proxy', 1);
@@ -52,6 +55,7 @@ export function createApp(config, { adapter: adapterOverride, logger = console }
}, },
async close() { async close() {
manager.stopLoops(); manager.stopLoops();
service.stopVetoTimer();
// Laufende Server-Aktionen (Start, Konfiguration) kurz abwarten, bevor die Datenbank schließt // Laufende Server-Aktionen (Start, Konfiguration) kurz abwarten, bevor die Datenbank schließt
await manager.idle(5000); await manager.idle(5000);
io.disposeRealtime(); io.disposeRealtime();
+3
View File
@@ -41,6 +41,7 @@ export function loadConfig(overrides = {}) {
createMatchPassword: str('CREATE_MATCH_PASSWORD'), createMatchPassword: str('CREATE_MATCH_PASSWORD'),
// Optionaler Schlüssel für die Stream-Übersicht aller Matches (/stream?key=…); leer = offen // Optionaler Schlüssel für die Stream-Übersicht aller Matches (/stream?key=…); leer = offen
streamKey: str('STREAM_KEY'), streamKey: str('STREAM_KEY'),
discordWebhookUrl: str('DISCORD_WEBHOOK_URL'),
// Passwort für den Admin-Modus (/admin). Leer = Admin-Modus ist aus. // Passwort für den Admin-Modus (/admin). Leer = Admin-Modus ist aus.
adminPassword: str('ADMIN_PASSWORD'), adminPassword: str('ADMIN_PASSWORD'),
requireFullTeams: bool('REQUIRE_FULL_TEAMS', false), requireFullTeams: bool('REQUIRE_FULL_TEAMS', false),
@@ -51,6 +52,8 @@ export function loadConfig(overrides = {}) {
maxLifetimeHours: int('SERVER_MAX_LIFETIME_HOURS', 8), maxLifetimeHours: int('SERVER_MAX_LIFETIME_HOURS', 8),
statusPollSeconds: int('SERVER_STATUS_POLL_SECONDS', 5), statusPollSeconds: int('SERVER_STATUS_POLL_SECONDS', 5),
reaperIntervalSeconds: int('SERVER_REAPER_INTERVAL_SECONDS', 60), reaperIntervalSeconds: int('SERVER_REAPER_INTERVAL_SECONDS', 60),
// Frist pro Veto-Schritt in Sekunden, danach führt das Backend den Schritt automatisch aus. 0 = keine Frist
vetoStepSeconds: int('VETO_STEP_SECONDS', 30),
match: { match: {
tvDelaySeconds: int('CS2_TV_DELAY', 90), tvDelaySeconds: int('CS2_TV_DELAY', 90),
+6
View File
@@ -97,6 +97,10 @@ CREATE TABLE IF NOT EXISTS events (
created_at INTEGER NOT NULL created_at INTEGER NOT NULL
); );
CREATE INDEX IF NOT EXISTS events_match ON events(match_id, id); CREATE INDEX IF NOT EXISTS events_match ON events(match_id, id);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
`; `;
export function openDatabase(file) { export function openDatabase(file) {
@@ -124,4 +128,6 @@ 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');
} }
+48
View File
@@ -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;
},
};
}
+134 -2
View File
@@ -4,6 +4,7 @@ import { formatCode, hashToken, normalizeCode, randomCode, randomId, randomToken
import { applyLogLines, initialLiveState, seriesScore, splitLogBody } from './liveScore.js'; import { applyLogLines, initialLiveState, seriesScore, splitLogBody } from './liveScore.js';
import { trimLiveState } from './rounds.js'; import { trimLiveState } from './rounds.js';
import { sanitizeConsoleText } from './servers/cs2Config.js'; import { sanitizeConsoleText } from './servers/cs2Config.js';
import { NOTIFY_EVENTS } from './discord.js';
export class HttpError extends Error { export class HttpError extends Error {
constructor(status, message, code = 'error') { constructor(status, message, code = 'error') {
@@ -41,11 +42,12 @@ function cleanSteamId(value) {
} }
export class MatchService extends EventEmitter { export class MatchService extends EventEmitter {
constructor({ db, pools, config }) { constructor({ db, pools, config, notifier = { enabled: false, send: async () => {} } }) {
super(); super();
this.db = db; this.db = db;
this.pools = pools; this.pools = pools;
this.config = config; this.config = config;
this.notifier = notifier;
this.presence = () => new Set(); this.presence = () => new Set();
} }
@@ -339,10 +341,68 @@ export class MatchService extends EventEmitter {
.run(firstTeam, this.now(), this.now(), match.id); .run(firstTeam, this.now(), this.now(), match.id);
const how = match.veto_start === 'coin' ? 'per Münzwurf ausgelost' : 'vom Organisator festgelegt'; const how = match.veto_start === 'coin' ? 'per Münzwurf ausgelost' : 'vom Organisator festgelegt';
this.log(match.id, 'veto', `Veto gestartet – ${this.teamName(match, firstTeam)} beginnt (${how})`); this.log(match.id, 'veto', `Veto gestartet – ${this.teamName(match, firstTeam)} beginnt (${how})`);
this.notify('veto_start', match.id, {
title: '🎮 Veto gestartet',
description: `**${this.matchTitle(match.id)}**`,
color: 0x3b82f6,
fields: [{ name: 'Beginnt', value: this.teamName(match, firstTeam), inline: true }],
});
})(); })();
this.changed(viewer.matchId); this.changed(viewer.matchId);
} }
/** Ablaufzeitpunkt des aktuellen Veto-Schritts (ms), null wenn keine Frist gilt. */
vetoDeadline(match) {
if (!this.config.vetoStepSeconds || match.status !== 'veto') return null;
const actions = this.vetoActions(match.id);
const since = actions.length ? actions[actions.length - 1].created_at : match.veto_started_at;
return since == null ? null : since + this.config.vetoStepSeconds * 1000;
}
vetoRemainingMs(match) {
const deadline = this.vetoDeadline(match);
return deadline === null ? null : Math.max(0, deadline - this.now());
}
/** Führt den aktuellen Veto-Schritt für das Team am Zug aus: zufällige Map bzw. zufällige Seite. */
autoVetoStep(match) {
const state = this.vetoState(match);
if (state.done || !state.current) return;
const { type, team, index } = state.current;
const pick = (list) => list[Math.floor(Math.random() * list.length)];
const viewer = {
matchId: match.id, role: 'captain', isOrganizer: false, isCaptain: true, isSpectator: false, playerId: null, team,
};
const action = type === 'side'
? { stepIndex: index, side: pick(['CT', 'T']) }
: { stepIndex: index, map: pick(state.maps.filter((m) => m.status === 'available')).id };
this.log(match.id, 'veto', `Zeit abgelaufen – ${this.teamName(match, team)} wird automatisch vertreten`);
this.vetoAction(viewer, action);
}
/** Läuft die Frist eines Veto-Schritts ab, führt das Backend den Schritt aus. */
autoVetoTimeouts() {
for (const match of this.db.prepare("SELECT * FROM matches WHERE status = 'veto'").all()) {
const deadline = this.vetoDeadline(match);
if (deadline === null || this.now() < deadline) continue;
try {
this.autoVetoStep(match);
} catch (err) {
this.log(match.id, 'veto', `Automatischer Schritt fehlgeschlagen: ${err.message}`);
}
}
}
startVetoTimer() {
if (!this.config.vetoStepSeconds || this.vetoTimer) return;
this.vetoTimer = setInterval(() => this.autoVetoTimeouts(), 1000);
}
stopVetoTimer() {
clearInterval(this.vetoTimer);
this.vetoTimer = null;
}
vetoAction(viewer, { stepIndex, map, side }) { vetoAction(viewer, { stepIndex, map, side }) {
this.require(viewer, 'captain'); this.require(viewer, 'captain');
let finished = false; let finished = false;
@@ -516,6 +576,16 @@ export class MatchService extends EventEmitter {
const winner = state.winner ? this.teamName(match, state.winner) : 'Unentschieden'; const winner = state.winner ? this.teamName(match, state.winner) : 'Unentschieden';
this.log(matchId, 'score', 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}`); `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) { if (ended) {
@@ -524,6 +594,18 @@ export class MatchService extends EventEmitter {
const needed = Math.floor(BEST_OF[ended.match.format] / 2) + 1; const needed = Math.floor(BEST_OF[ended.match.format] / 2) + 1;
const decided = score.A >= needed ? 'A' : score.B >= needed ? 'B' : null; 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) 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); const hasNext = maps.some((m) => m.number === ended.row.number + 1);
this.changed(matchId); this.changed(matchId);
this.emit('mapEnded', { matchId, number: ended.row.number, decided, hasNext }); this.emit('mapEnded', { matchId, number: ended.row.number, decided, hasNext });
@@ -555,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.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')); this.log(match.id, 'status', message ?? (status === 'finished' ? 'Match abgeschlossen' : 'Match abgebrochen'));
const finished = status === 'finished';
this.notify('match_closed', match.id, {
title: finished ? '✅ Match abgeschlossen' : '⛔ Match abgebrochen',
description: `**${this.matchTitle(match.id)}**${message ? `\n${message}` : ''}`,
color: finished ? 0x6b7280 : 0x9ca3af,
});
this.setStatus(match.id, status); this.setStatus(match.id, status);
this.emit('closed', match.id); this.emit('closed', match.id);
} }
@@ -569,6 +657,47 @@ export class MatchService extends EventEmitter {
this.db.prepare('UPDATE matches SET last_activity_at = ? WHERE id = ?').run(this.now(), matchId); this.db.prepare('UPDATE matches SET last_activity_at = ? WHERE id = ?').run(this.now(), matchId);
} }
/** Titel für Meldungen, z. B. „Team A vs. Team B (BO3)“. */
matchTitle(matchId) {
const m = this.getMatch(matchId);
return `${m.team_a_name} vs. ${m.team_b_name} (${m.format.toUpperCase()})`;
}
/**
* Meldung an den Discord-Webhook, falls konfiguriert. Ohne Stream-Schlüssel gehört die Übersicht
* des Matches dazu, mit Schlüssel entfällt der Link, damit der Schlüssel nicht in den Kanal gelangt.
*/
/** Discord-Meldungen: der Webhook muss eingerichtet und das Ereignis ausgewählt sein. */
notificationSettings() {
const row = this.db.prepare("SELECT value FROM settings WHERE key = 'discord_events'").get();
let stored = {};
try {
stored = row ? JSON.parse(row.value) : {};
} catch {
stored = {};
}
const events = Object.fromEntries(NOTIFY_EVENTS.map(({ key }) => [key, stored[key] !== false]));
return { enabled: this.notifier.enabled, events, catalog: NOTIFY_EVENTS };
}
setNotificationEvents(input) {
if (!input || typeof input !== 'object') throw new HttpError(400, 'Ungültige Auswahl der Meldungen', 'invalid');
const events = Object.fromEntries(NOTIFY_EVENTS.map(({ key }) => [key, input[key] === true]));
this.db
.prepare("INSERT INTO settings (key, value) VALUES ('discord_events', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value")
.run(JSON.stringify(events));
return this.notificationSettings();
}
/** Meldung als Discord-Embed. Ohne Stream-Schlüssel ist der Titel ein Link auf die Match-Übersicht. */
notify(type, matchId, embed) {
if (!this.notifier.enabled || !this.notificationSettings().events[type]) return;
const url = this.config.streamKey ? undefined : `${this.config.publicUrl}/stream/match/${matchId}`;
this.notifier.send({
embeds: [{ ...embed, url, timestamp: new Date(this.now()).toISOString(), footer: { text: 'CS2 Mapveto' } }],
});
}
log(matchId, type, message) { log(matchId, type, message) {
this.db this.db
.prepare('INSERT INTO events (match_id, type, message, created_at) VALUES (?, ?, ?, ?)') .prepare('INSERT INTO events (match_id, type, message, created_at) VALUES (?, ?, ?, ?)')
@@ -756,7 +885,7 @@ export class MatchService extends EventEmitter {
requireFullTeams: this.config.requireFullTeams, requireFullTeams: this.config.requireFullTeams,
autoAdvanceSeconds: this.config.autoAdvanceSeconds, autoAdvanceSeconds: this.config.autoAdvanceSeconds,
maps: pool.maps, maps: pool.maps,
veto: publicView veto: publicView && match.status === 'lobby'
? null ? null
: { : {
started: match.status !== 'lobby', started: match.status !== 'lobby',
@@ -764,6 +893,8 @@ export class MatchService extends EventEmitter {
steps: veto.steps, steps: veto.steps,
maps: veto.maps, maps: veto.maps,
current: match.status === 'veto' ? veto.current : null, current: match.status === 'veto' ? veto.current : null,
remainingMs: this.vetoRemainingMs(match),
stepSeconds: this.config.vetoStepSeconds,
done: veto.done, done: veto.done,
history: actions.map((a) => ({ history: actions.map((a) => ({
index: a.idx, index: a.idx,
@@ -813,6 +944,7 @@ 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,
+20 -3
View File
@@ -265,6 +265,11 @@ export class ServerManager {
} else if (status.state === 'failed') { } else if (status.state === 'failed') {
Object.assign(fields, { status: current.status === 'starting' ? 'start_failed' : 'failed', message: status.message }); Object.assign(fields, { status: current.status === 'starting' ? 'start_failed' : 'failed', message: status.message });
this.service.log(matchId, 'server', `Server-Fehler: ${status.message}`); this.service.log(matchId, 'server', `Server-Fehler: ${status.message}`);
this.service.notify('server_error', matchId, {
title: '⚠️ Serverfehler',
description: `**${this.service.matchTitle(matchId)}**\n${status.message}`,
color: 0xdc2626,
});
} else if (status.state === 'stopped') { } else if (status.state === 'stopped') {
Object.assign(fields, { status: 'stopped', stopped_at: Date.now(), message: status.message ?? 'Server wurde extern beendet' }); Object.assign(fields, { status: 'stopped', stopped_at: Date.now(), message: status.message ?? 'Server wurde extern beendet' });
this.service.log(matchId, 'server', 'Server wurde außerhalb der Anwendung beendet'); this.service.log(matchId, 'server', 'Server wurde außerhalb der Anwendung beendet');
@@ -303,7 +308,8 @@ export class ServerManager {
if (!seriesMap) throw new HttpError(409, 'Das Veto ist noch nicht abgeschlossen', 'veto_pending'); if (!seriesMap) throw new HttpError(409, 'Das Veto ist noch nicht abgeschlossen', 'veto_pending');
const mapName = pool.maps.find((m) => m.id === seriesMap.map)?.name ?? seriesMap.map; const mapName = pool.maps.find((m) => m.id === seriesMap.map)?.name ?? seriesMap.map;
this.update(matchId, { status: 'configuring', message: `Lade Map ${seriesMap.number}: ${mapName}` }); // Ein Mapwechsel beendet die Pause, CS2 lädt die Map neu
this.update(matchId, { status: 'configuring', paused: 0, message: `Lade Map ${seriesMap.number}: ${mapName}` });
this.service.log(matchId, 'server', `Konfiguration für Map ${seriesMap.number} (${mapName}) gestartet`); this.service.log(matchId, 'server', `Konfiguration für Map ${seriesMap.number} (${mapName}) gestartet`);
const plan = buildMapCommands({ const plan = buildMapCommands({
@@ -387,6 +393,17 @@ export class ServerManager {
throw new HttpError(502, `Befehl konnte nicht gesendet werden: ${err.message}`, 'server_error'); throw new HttpError(502, `Befehl konnte nicht gesendet werden: ${err.message}`, 'server_error');
} }
this.service.log(viewer.matchId, 'server', this.adapter.simulated ? 'Aufwärmphase beendet (simuliert)' : 'Aufwärmphase beendet – Match startet'); this.service.log(viewer.matchId, 'server', this.adapter.simulated ? 'Aufwärmphase beendet (simuliert)' : 'Aufwärmphase beendet – Match startet');
// CSTV-Zugang für Zuschauer, gleiche Form wie im Zuschauerbereich der Webseite
const fields = [];
if (this.config.match.tvEnabled && row.tv_port) {
fields.push({ name: '📺 CSTV', value: `\`password ${row.tv_password}; connect ${row.host}:${row.tv_port}\`` });
}
this.service.notify('match_start', viewer.matchId, {
title: '🔴 Match läuft',
description: `**${this.service.matchTitle(viewer.matchId)}**`,
color: 0xef4444,
fields,
});
if (this.adapter.simulated) this.startDemoSimulation(viewer.matchId); if (this.adapter.simulated) this.startDemoSimulation(viewer.matchId);
this.service.changed(viewer.matchId); this.service.changed(viewer.matchId);
} }
@@ -402,8 +419,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. */
@@ -447,7 +464,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 }); this.update(matchId, { status: 'stopped', stopped_at: Date.now(), message: reason, player_count: null, paused: 0 });
this.service.log(matchId, 'server', `Server gestoppt: ${reason}`); this.service.log(matchId, 'server', `Server gestoppt: ${reason}`);
} }
+46
View File
@@ -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')));
});
+84
View File
@@ -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"
+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 Logzeilen eines CS2-Servers (Wingman, 4 Runden, Halbzeit nach 2)', () => { test('Echte, anonymisierte 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); 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 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 };