From 43dc5b837a46f2079e8bf3eaa2026cb96fd3a47b Mon Sep 17 00:00:00 2001 From: SPP User Date: Sun, 25 Oct 2026 19:20:10 +0100 Subject: [PATCH] Live-Ergebnisse: Stream-Seite, Anzeige, automatischer Mapwechsel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Stream-Übersicht unter /stream/ (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 --- .env.example | 8 + README.md | 37 ++- client/src/App.jsx | 11 + client/src/components/OrganizerPanel.jsx | 6 + client/src/components/RoundStrip.jsx | 53 +++++ client/src/components/SeriesList.jsx | 24 +- client/src/components/ServerPanel.jsx | 28 +++ client/src/pages/MatchPage.jsx | 8 +- client/src/pages/Overview.jsx | 11 +- client/src/pages/StreamPage.jsx | 278 +++++++++++++++++++++++ client/src/stream.css | 167 ++++++++++++++ client/src/styles.css | 40 ++++ server/src/config.js | 3 + server/src/http.js | 5 +- server/src/liveScore.js | 74 ++++-- server/src/matchService.js | 34 ++- server/src/servers/demoMatchSimulator.js | 9 +- server/src/servers/serverManager.js | 37 +++ server/test/api.test.js | 39 ++++ server/test/liveScore.test.js | 73 ++++-- 20 files changed, 901 insertions(+), 44 deletions(-) create mode 100644 client/src/components/RoundStrip.jsx create mode 100644 client/src/pages/StreamPage.jsx create mode 100644 client/src/stream.css diff --git a/.env.example b/.env.example index c936bbc..94a139e 100644 --- a/.env.example +++ b/.env.example @@ -58,3 +58,11 @@ DOCKER_BIN=docker # --- Static-Pool-Adapter (SERVER_ADAPTER=static) --- # host:port:rconPasswort[:tvPort], mehrere durch Komma getrennt CS2_STATIC_SERVERS= + +# --- Live-Ergebnisse --- +# Adresse, unter der der CS2-Server dieses Backend erreicht (Standard: PUBLIC_URL), z. B. http://192.168.178.31:3000 +LOG_RECEIVER_URL= +# true: empfangene Logzeilen im Terminal ausgeben (Fehlersuche) +LOG_DEBUG=false +# Sekunden nach Map-Ende bis zur nächsten Map bzw. zum Abschluss der Serie; -1 = nur manuell +SERIES_AUTO_ADVANCE_SECONDS=30 diff --git a/README.md b/README.md index 4f4ed17..f2b27fe 100644 --- a/README.md +++ b/README.md @@ -269,7 +269,38 @@ Bekannte Werte: `game_type 0` + `game_mode 1` = Competitive, `game_mode 2` = Win ### Bekannte Grenzen von Vanilla-CS2 - Ohne Server-Plugin kann CS2 Spieler **nicht automatisch einem Team zuordnen**. Spieler wählen ihr Team im Spiel selbst. Teamnamen und Startseiten setzt die Anwendung. Für feste Teamzuordnung, Pausen und automatischen Mapwechsel eignen sich Plugins wie MatchZy oder Get5-Nachfolger. Sie lassen sich über einen eigenen Adapter anbinden; die SteamID64 der Spieler wird dafür bereits erfasst. -- Ergebnisse werden nicht ausgelesen. Der Organisator lädt die nächste Map manuell. +- Das Team eines Spielers ergibt sich aus der Seite, die er im Spiel wählt. Wechselt ein Spieler ins falsche Team, zählen seine Runden für das falsche Team. + +### Live-Ergebnisse + +Beim Konfigurieren setzt CS2 Mapveto auf dem Server `log on` und `logaddress_add_http "/api/logs//"`. Der CS2-Server schickt damit seine Logzeilen per HTTP an das Backend. Der Schlüssel ist pro Match und Serverstart zufällig, Zeilen mit falschem Schlüssel werden abgelehnt. + +- **Was ausgewertet wird:** + - Matchstart nach der Aufwärmphase + - jedes Rundenende mit Sieger und Grund (Bombe, Entschärfung, Zeit, Ausschalten) + - die Seitenzuordnung der Teams + - das Spielende +- **Was angezeigt wird:** + - Live-Spielstand und Rundenverlauf pro Map + - Serienstand + - Ergebnisse in der Übersicht + - Stream-Seite +- **Nach dem Spielende:** Ist die Serie noch offen, lädt CS2 Mapveto nach `SERIES_AUTO_ADVANCE_SECONDS` (Standard 30) die nächste Map. Ist sie entschieden (z. B. 2:0 im Best of 3), wird das Match abgeschlossen und der Server freigegeben. Mit `-1` geht beides nur manuell. +- **Voraussetzung:** Der CS2-Server muss `LOG_RECEIVER_URL` erreichen, im LAN z. B. `http://192.168.178.31:3000`. Ob Daten ankommen, zeigt das Serverpanel des Organisators. Mit `LOG_DEBUG=true` gibt das Backend alle empfangenen Zeilen aus. +- **Geprüft:** mit einem AMP-Server (Wingman), siehe `server/test/fixtures/amp-wingman-inferno.log`. + +Zwei Eigenheiten von CS2 berücksichtigt der Parser: +- CS2 sendet `Match_Start` schon beim Laden der Map. Gezählt wird erst, nachdem der Organisator „Aufwärmphase beenden“ geklickt hat. +- Direkt nach der Halbzeit nennt CS2 zu Rundenbeginn noch die alte Seitenzuordnung. Ausgewertet werden deshalb nur die Zeilen direkt nach einem Rundenende. + +### Stream-Übersicht + +Unter `/stream/` gibt es eine Vollbild-Übersicht für den Stream. Den Link findet der Organisator bei den Einladungen. + +- **Format:** 1920×1080, skaliert auf jede Fenstergröße. In OBS als Browserquelle mit 1920×1080 einbinden. Mit `?bg=transparent` ist der Hintergrund durchsichtig. +- **Ablauf:** Die Seite wechselt automatisch zwischen Lobby (Aufstellungen), Map-Veto (live) und Match (Spielstand, Rundenverlauf, Map-Leiste). +- **Laufband:** zeigt das letzte für Zuschauer relevante Ereignis, keine technischen Servermeldungen. +- **Datenschutz:** Verbindungsdaten und Passwörter erscheinen dort nie. --- @@ -292,6 +323,10 @@ Alle Variablen mit Standardwerten stehen in [`.env.example`](.env.example). Die | `CS2_PUBLIC_HOST` | – | **Pflicht bei Docker:** öffentliche Adresse | | `CS2_STATIC_SERVERS` | – | **Pflicht bei Static:** Serverliste | | `DEMO_FAIL_FIRST_CONFIGURE` | `false` | Demo: Konfigurationsfehler simulieren | +| `DEMO_ROUND_SECONDS` | `4` | Demo: Sekunden pro simulierter Runde | +| `LOG_RECEIVER_URL` | `PUBLIC_URL` | Adresse, unter der der CS2-Server das Backend für Live-Logs erreicht | +| `LOG_DEBUG` | `false` | empfangene Logzeilen im Terminal ausgeben | +| `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: diff --git a/client/src/App.jsx b/client/src/App.jsx index 2dde64e..a7b85dd 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -4,9 +4,20 @@ import { api } from './lib/api.js'; import Overview from './pages/Overview.jsx'; import CreateMatch from './pages/CreateMatch.jsx'; import MatchPage from './pages/MatchPage.jsx'; +import StreamPage from './pages/StreamPage.jsx'; import { JoinPage, WatchPage } from './pages/AccessPages.jsx'; export default function App() { + return ( + + {/* Stream-Übersicht ohne Kopfzeile, als Vollbild bzw. OBS-Browserquelle */} + } /> + } /> + + ); +} + +function MainLayout() { const [config, setConfig] = useState(null); const [configError, setConfigError] = useState(null); diff --git a/client/src/components/OrganizerPanel.jsx b/client/src/components/OrganizerPanel.jsx index 4099eaa..5c6f8be 100644 --- a/client/src/components/OrganizerPanel.jsx +++ b/client/src/components/OrganizerPanel.jsx @@ -25,6 +25,12 @@ export default function OrganizerPanel({ state, act, busy, matchId, token }) { {open && } + {key === 'spectator' && ( + + )} ))}
diff --git a/client/src/components/RoundStrip.jsx b/client/src/components/RoundStrip.jsx new file mode 100644 index 0000000..fb99e0d --- /dev/null +++ b/client/src/components/RoundStrip.jsx @@ -0,0 +1,53 @@ +// Rundenverlauf einer Map: ein Feld pro Runde in der Farbe des Siegerteams, +// mit Symbol für den Grund. Nach der Halbzeit (und jeder Verlängerungshälfte) ein Trenner. + +export const REASON_LABEL = { + bomb: 'Bombe explodiert', + defuse: 'Bombe entschärft', + time: 'Zeit abgelaufen', + elimination: 'Alle ausgeschaltet', + surrender: 'Aufgegeben', + other: 'Runde gewonnen', +}; + +export function ReasonIcon({ reason, size = 12 }) { + const common = { width: size, height: size, viewBox: '0 0 16 16', 'aria-hidden': true, className: 'reason-icon' }; + switch (reason) { + case 'bomb': + return ( + + ); + case 'defuse': + return ( + + ); + case 'time': + return ( + + ); + case 'elimination': + return ( + + ); + default: + return ; + } +} + +export default function RoundStrip({ rounds = [], maxRounds = 24, teams, size = 'md' }) { + const half = maxRounds / 2; + const total = Math.max(rounds.length, maxRounds); + const cells = []; + for (let i = 1; i <= total; i++) { + const r = rounds.find((x) => x.number === i); + const breakBefore = i > 1 && (i - 1 === half || (i - 1 > maxRounds && (i - 1 - maxRounds) % 3 === 0)); + cells.push( +
  • + {r ? : null} +
  • , + ); + } + return
      {cells}
    ; +} diff --git a/client/src/components/SeriesList.jsx b/client/src/components/SeriesList.jsx index dd2e2c3..b5fb69a 100644 --- a/client/src/components/SeriesList.jsx +++ b/client/src/components/SeriesList.jsx @@ -1,5 +1,6 @@ import MapImage from './MapImage.jsx'; import SideIcon from './SideIcon.jsx'; +import RoundStrip from './RoundStrip.jsx'; const STATUS = { pending: 'Ausstehend', next: 'Wird eingerichtet', live: 'Aktuell', done: 'Gespielt' }; @@ -13,11 +14,26 @@ export default function SeriesList({ state }) { const map = state.maps.find((m) => m.id === s.map); const ct = s.ctTeam ? teams[s.ctTeam].name : null; const t = s.ctTeam ? teams[s.ctTeam === 'A' ? 'B' : 'A'].name : null; + const isLive = s.live?.status === 'live' && s.status !== 'done'; + const hasScore = s.live && (s.live.rounds.length > 0 || s.status === 'done'); return ( -
  • +
  • Map {s.number} {s.mapName} + + {isLive ? Live : STATUS[s.status]} + + {hasScore && ( + + {s.score.A} + : + {s.score.B} + {s.status === 'done' && ( + {s.winner ? `${teams[s.winner].name} gewinnt` : 'Unentschieden'} + )} + + )} {s.pickedBy ? `Pick von ${teams[s.pickedBy].name}` : 'Entscheidungsmap'} @@ -29,7 +45,11 @@ export default function SeriesList({ state }) { ) : 'Seiten im Spiel festlegen'} - {STATUS[s.status]} + {hasScore && ( +
    + +
    + )}
  • ); })} diff --git a/client/src/components/ServerPanel.jsx b/client/src/components/ServerPanel.jsx index 87c691c..ce868a6 100644 --- a/client/src/components/ServerPanel.jsx +++ b/client/src/components/ServerPanel.jsx @@ -10,6 +10,32 @@ const RETRY = { }; const ACTIVE = ['starting', 'ready', 'configuring', 'configured', 'config_failed', 'failed']; +/** Zeigt dem Organisator, ob Live-Daten vom Server ankommen und ob die Map vorbei ist. */ +function LiveDataStatus({ state, hasNext }) { + const current = state.series.find((s) => s.number === state.currentMapNumber); + if (!current) return null; + const updatedAt = current.live?.updatedAt; + if (current.status === 'done') { + const auto = state.autoAdvanceSeconds >= 0; + return ( +
    + Map {current.number} ist beendet.{' '} + {auto + ? (hasNext ? `Die nächste Map wird nach ${state.autoAdvanceSeconds} s automatisch geladen, sofern die Serie nicht entschieden ist.` : 'Das Match wird automatisch abgeschlossen.') + : (hasNext ? 'Lade jetzt die nächste Map.' : 'Das war die letzte Map – schließ das Match ab.')} +
    + ); + } + return ( +

    + + {updatedAt + ? `Live-Daten vom Server empfangen, zuletzt um ${new Date(updatedAt).toLocaleTimeString('de-DE')}.` + : 'Noch keine Live-Daten. Sie kommen, sobald die Aufwärmphase endet und der Server diese Webseite erreicht (LOG_RECEIVER_URL).'} +

    + ); +} + export default function ServerPanel({ state, isOrg, act, busy }) { const { server } = state; const label = serverLabel(server); @@ -63,6 +89,8 @@ export default function ServerPanel({ state, isOrg, act, busy }) {

    CSTV ist auf diesem Server deaktiviert.

    )} + {isOrg && server?.status === 'configured' && } + {isOrg && server && !closed && (
    {canRetry && ( diff --git a/client/src/pages/MatchPage.jsx b/client/src/pages/MatchPage.jsx index 31f60ec..9e3c633 100644 --- a/client/src/pages/MatchPage.jsx +++ b/client/src/pages/MatchPage.jsx @@ -106,7 +106,13 @@ export default function MatchPage() {

    {state.teams.A.name} - vs. + {state.series.length > 1 ? ( + + {state.seriesScore.A}:{state.seriesScore.B} + + ) : ( + vs. + )} {state.teams.B.name}

    diff --git a/client/src/pages/Overview.jsx b/client/src/pages/Overview.jsx index 78c7769..ac6ff62 100644 --- a/client/src/pages/Overview.jsx +++ b/client/src/pages/Overview.jsx @@ -54,10 +54,19 @@ function MatchRow({ match: m }) {
    {m.teamA} - vs. + {m.seriesScore && m.format !== 'bo1' ? ( + {m.seriesScore.A}:{m.seriesScore.B} + ) : ( + vs. + )} {m.teamB}
    + {m.currentMap && ( + + {m.currentMap.finished ? '' : Live} {m.currentMap.name} {m.currentMap.score.A}:{m.currentMap.score.B} + + )} {m.modeLabel} {FORMAT_LABEL[m.format]} {m.group === 'open' && {m.players.A + m.players.B}/{m.players.teamSize * 2} Spieler} diff --git a/client/src/pages/StreamPage.jsx b/client/src/pages/StreamPage.jsx new file mode 100644 index 0000000..12ec570 --- /dev/null +++ b/client/src/pages/StreamPage.jsx @@ -0,0 +1,278 @@ +import { useEffect, useLayoutEffect, useRef, useState } from 'react'; +import { useParams } from 'react-router-dom'; +import { api } from '../lib/api.js'; +import { FORMAT_LABEL } from '../lib/labels.js'; +import { useMatch } from '../lib/useMatch.js'; +import MapImage from '../components/MapImage.jsx'; +import SideIcon from '../components/SideIcon.jsx'; +import RoundStrip from '../components/RoundStrip.jsx'; +import '../stream.css'; + +const STAGE_W = 1920; +const STAGE_H = 1080; + +/** + * Vollbild-Übersicht für den Stream: /stream/ + * Optional ?bg=transparent für eine transparente OBS-Browserquelle. + */ +export default function StreamPage() { + const { code } = useParams(); + const [matchId, setMatchId] = useState(null); + const [error, setError] = useState(null); + const transparent = new URLSearchParams(window.location.search).get('bg') === 'transparent'; + + useEffect(() => { + api('/access', { method: 'POST', body: { code } }) + .then((info) => { + if (info.access !== 'spectator') throw new Error('Für die Stream-Ansicht wird der Zuschauercode benötigt.'); + setMatchId(info.matchId); + }) + .catch((e) => setError(e.message)); + }, [code]); + + useEffect(() => { + document.documentElement.classList.toggle('stream-transparent', transparent); + document.documentElement.classList.add('stream-mode'); + return () => document.documentElement.classList.remove('stream-mode', 'stream-transparent'); + }, [transparent]); + + return ( + + {error ? + : matchId ? + : } + + ); +} + +/** Feste 1920×1080-Bühne, auf die Fenstergröße skaliert. */ +function ScaledStage({ children }) { + const ref = useRef(null); + const [scale, setScale] = useState(1); + useLayoutEffect(() => { + const fit = () => setScale(Math.min(window.innerWidth / STAGE_W, window.innerHeight / STAGE_H)); + fit(); + window.addEventListener('resize', fit); + return () => window.removeEventListener('resize', fit); + }, []); + return ( +
    +
    + {children} +
    +
    + ); +} + +function StreamMessage({ title, text }) { + return ( +
    + MaxLAN e.V. +

    {title}

    + {text &&

    {text}

    } +
    + ); +} + +function useClock() { + const [now, setNow] = useState(() => new Date()); + useEffect(() => { + const t = setInterval(() => setNow(new Date()), 1000); + return () => clearInterval(t); + }, []); + return now; +} + +function StreamContent({ matchId, code }) { + const { state, connected } = useMatch(matchId, code); + const now = useClock(); + if (!state) return ; + + const { teams } = state; + const current = state.series.find((s) => s.number === state.currentMapNumber); + const playing = current?.live?.status === 'live' && current.status !== 'done'; + const phase = state.status === 'lobby' ? 'lobby' : state.status === 'veto' ? 'veto' : 'match'; + const showSeries = state.series.length > 1; + // Im Laufband nur, was Zuschauer interessiert – keine technischen Servermeldungen + const tickerEvent = state.events.find((e) => ['veto', 'score', 'series', 'join', 'captain', 'status'].includes(e.type)); + + return ( +
    0 ? 'has-maps' : 'no-maps'}`}> +
    + MaxLAN e.V. +
    + Counter-Strike 2 + {state.modeLabel} + {FORMAT_LABEL[state.format]} +
    +
    + {playing && Live} + {!connected && connected !== null && Verbindung …} + +
    +
    + +
    +
    +

    {teams.A.name}

    +
    +
    +
    +
    +

    {teams.B.name}

    +
    +
    + +
    + {phase === 'lobby' && } + {phase === 'veto' && } + {phase === 'match' && } +
    + + {phase !== 'lobby' && state.series.length > 0 && ( +
    + {state.series.map((s) => )} +
    + )} + +
    + {phaseLabel(state)} + {tickerEvent?.message ?? 'Warten auf die Teams'} +
    +
    + ); +} + +function phaseLabel(state) { + if (state.status === 'lobby') return 'Lobby'; + if (state.status === 'veto') return 'Map-Veto'; + if (state.status === 'finished') return 'Endstand'; + if (state.status === 'cancelled') return 'Abgebrochen'; + return 'Match'; +} + +function LobbyScene({ state }) { + return ( +
    + {['A', 'B'].map((key) => { + const team = state.teams[key]; + return ( +
    +

    {team.readiness.ready ? 'Bereit' : `${team.readiness.count} / ${team.teamSize} Spieler`}

    +
      + {team.players.map((p) => ( +
    • + {p.nickname} + {p.isCaptain && Captain} +
    • + ))} +
    +
    + ); + })} +
    Das Map-Veto beginnt gleich
    +
    + ); +} + +function VetoScene({ state }) { + const { veto, teams } = state; + const current = veto?.current; + const mapsById = Object.fromEntries(state.maps.map((m) => [m.id, m])); + const action = current && ({ ban: 'bannt', pick: 'wählt', side: 'wählt die Startseite auf' })[current.type]; + return ( +
    + {current && ( +
    + {teams[current.team].name} {action} + {current.type === 'side' ? ` ${mapsById[current.map]?.name}` : ' eine Map'} +
    + )} +
    + {veto.maps.map((m) => ( +
    + + {mapsById[m.id]?.name} + + {m.status === 'banned' && `Bann ${teams[m.by].name}`} + {m.status === 'picked' && `Pick ${teams[m.by].name}`} + {m.status === 'decider' && 'Decider'} + +
    + ))} +
    +
    + ); +} + +function MatchScene({ state, current }) { + const { teams } = state; + if (!current) return
    Server wird vorbereitet
    ; + const map = state.maps.find((m) => m.id === current.map); + const live = current.live; + const sides = live?.sides ?? (current.ctTeam ? { CT: current.ctTeam, T: current.ctTeam === 'A' ? 'B' : 'A' } : null); + const sideOf = (team) => (sides ? (sides.CT === team ? 'CT' : 'T') : null); + const finished = current.status === 'done'; + const waiting = !live || live.status === 'warmup'; + + return ( +
    + +
    + Map {current.number}{state.series.length > 1 ? ` von ${state.series.length}` : ''} + {current.mapName} + {finished ? 'Endstand' : waiting ? 'Aufwärmphase' : `Runde ${(live?.rounds.length ?? 0) + 1}`} +
    +
    +
    + {sideOf('A') && } + {current.score.A} +
    + : +
    + {current.score.B} + {sideOf('B') && } +
    +
    + {finished && current.winner &&
    {teams[current.winner].name} gewinnt {current.mapName}
    } +
    + +
    +
    + ); +} + +function SeriesCard({ s, state }) { + const map = state.maps.find((m) => m.id === s.map); + const isCurrent = s.number === state.currentMapNumber && state.status === 'live'; + const playing = isCurrent && s.live?.status === 'live' && s.status !== 'done'; + return ( +
    + + Map {s.number} + {s.mapName} + + {s.status === 'done' || playing ? ( + + {s.score.A} + : + {s.score.B} + + ) : ( + {s.pickedBy ? `Pick ${state.teams[s.pickedBy].name}` : 'Decider'} + )} + +
    + ); +} diff --git a/client/src/stream.css b/client/src/stream.css new file mode 100644 index 0000000..a5180fd --- /dev/null +++ b/client/src/stream.css @@ -0,0 +1,167 @@ +/* ---------- Stream-Übersicht (1920×1080, skaliert) ---------- */ +html.stream-mode, html.stream-mode body { background: #000; overflow: hidden; height: 100%; } +html.stream-mode.stream-transparent, html.stream-mode.stream-transparent body { background: transparent; } + +.stream-viewport { position: fixed; inset: 0; overflow: hidden; } +.stream-stage { position: absolute; left: 50%; top: 50%; transform-origin: center center; } + +.st-root { + position: relative; width: 100%; height: 100%; overflow: hidden; + display: grid; grid-template-rows: 104px 260px 1fr 190px 60px; + padding: 0 72px; color: var(--text); font-family: var(--body); + background: + radial-gradient(900px 600px at 0% 30%, rgba(52, 74, 153, .28), transparent 70%), + radial-gradient(900px 600px at 100% 30%, rgba(167, 42, 28, .26), transparent 70%), + #000; +} +.stream-transparent .st-root { background: transparent; } +/* Ohne Map-Leiste (Lobby, Veto) bekommt die Hauptfläche den Platz */ +.st-root.no-maps { grid-template-rows: 104px 260px 1fr 60px; } +.phase-veto .st-veto-grid { align-items: stretch; } +.phase-veto .st-vmap { min-height: 420px; } + +/* Kopfzeile */ +.st-top { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; border-bottom: 3px solid var(--brand-red); } +.st-logo { height: 58px; width: auto; } +.st-meta { display: flex; gap: 28px; font: 600 26px var(--display); color: var(--muted); } +.st-meta span + span { padding-left: 28px; border-left: 2px solid var(--line-strong); } +.st-clock { justify-self: end; display: flex; align-items: center; gap: 22px; font: 600 30px var(--display); font-variant-numeric: tabular-nums; } +.st-live { + display: inline-flex; align-items: center; gap: 10px; padding: 4px 16px 4px 12px; border-radius: 4px; + background: var(--brand-red); color: #fff; font: 700 24px var(--display); +} +.st-live::before { content: ''; width: 12px; height: 12px; border-radius: 50%; background: #fff; animation: st-blink 1.4s ease-in-out infinite; } +.st-offline { color: var(--pending); font-size: 22px; } +@keyframes st-blink { 50% { opacity: .25; } } + +/* Teams und Serienstand */ +.st-hero { display: grid; grid-template-columns: 1fr 380px 1fr; align-items: center; } +.st-team h1 { + margin: 0; font: 700 108px/0.95 var(--display); letter-spacing: -.01em; + display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; overflow-wrap: anywhere; +} +.st-team-a { text-align: right; } +.st-team-a h1 { color: var(--team-a); } +.st-team-b h1 { color: var(--team-b); } +.st-center { position: relative; display: grid; place-items: center; height: 100%; } +/* Das rote X aus dem Logo als diagonaler Schnitt */ +.st-slash { + position: absolute; width: 34px; height: 230px; background: var(--brand-red); transform: rotate(18deg); + border-radius: 6px; box-shadow: 0 0 60px rgba(167, 42, 28, .55); opacity: .9; +} +.st-series { position: relative; display: grid; grid-template-columns: 1fr 60px 1fr; align-items: center; gap: 0 34px; text-shadow: 0 4px 24px #000; } +.st-series span { font: 700 150px/1 var(--display); font-variant-numeric: tabular-nums; } +.st-series .a { text-align: right; color: var(--team-a); } +.st-series .b { color: var(--team-b); } +/* Der rote Schrägstrich übernimmt die Trennung zwischen den Ziffern */ +.st-series .sep { visibility: hidden; } +.st-series small { grid-column: 1 / -1; text-align: center; font: 600 24px var(--display); color: var(--muted); margin-top: -6px; } +.st-vs { position: relative; font: 700 84px var(--display); color: #fff; text-shadow: 0 4px 30px #000; } + +.st-main { min-height: 0; padding: 18px 0 24px; } + +/* Lobby */ +.st-lobby { display: grid; grid-template-columns: 1fr 1fr; grid-template-rows: 1fr auto; gap: 24px 80px; height: 100%; } +.st-roster { padding: 30px 40px; background: rgba(17, 20, 30, .82); border-top: 6px solid var(--team-a); border-radius: 8px; } +.st-roster-B { border-top-color: var(--team-b); } +.st-roster h2 { font: 600 36px var(--display); color: var(--muted); margin-bottom: 18px; } +.st-roster ul { list-style: none; padding: 0; margin: 0; display: grid; gap: 12px; } +.st-roster li { display: flex; align-items: baseline; gap: 16px; font: 600 44px var(--display); color: var(--muted); } +.st-roster li.is-ready { color: var(--text); } +.st-roster li.is-ready::after { content: 'bereit'; font: 500 24px var(--body); color: var(--ok); } +.st-roster em { font: 500 22px var(--body); font-style: normal; color: var(--pending); } +.st-lobby-note { grid-column: 1 / -1; text-align: center; font: 600 34px var(--display); color: var(--muted); } + +/* Veto */ +.st-veto { display: grid; grid-template-rows: auto 1fr; gap: 26px; height: 100%; } +.st-turn { + justify-self: center; padding: 14px 40px; font: 600 40px var(--display); border-radius: 6px; + background: rgba(17, 20, 30, .85); border-left: 8px solid var(--team-a); +} +.st-turn-B { border-left-color: var(--team-b); } +.st-turn strong { color: var(--team-a); } +.st-turn-B strong { color: var(--team-b); } +.st-veto-grid { display: grid; grid-template-columns: repeat(var(--count), minmax(0, 1fr)); gap: 18px; align-items: stretch; } +.st-vmap { --by: var(--line-strong); position: relative; overflow: hidden; border-radius: 6px; display: flex; flex-direction: column; justify-content: flex-end; padding: 18px; min-height: 0; } +.st-vmap.by-A { --by: var(--team-a); } +.st-vmap.by-B { --by: var(--team-b); } +.st-vmap .map-art::after { background: linear-gradient(180deg, transparent 35%, rgba(0, 0, 0, .9)); } +.st-vmap-name, .st-vmap-status { position: relative; z-index: 2; } +.st-vmap-name { font: 700 46px/1 var(--display); text-shadow: 0 2px 12px #000; } +.st-vmap-status { font: 600 24px var(--display); color: var(--by); margin-top: 6px; min-height: 30px; } +.st-vmap.status-banned { filter: grayscale(.95) brightness(.5); } +.st-vmap.status-banned::after { + content: ''; position: absolute; inset: 0; z-index: 3; + background: linear-gradient(to top right, transparent calc(50% - 5px), var(--by) calc(50% - 5px), var(--by) calc(50% + 5px), transparent calc(50% + 5px)); +} +.st-vmap.status-picked::before, .st-vmap.status-decider::before, .st-vmap.is-side::before { + content: ''; position: absolute; inset: 0; z-index: 3; border-radius: 6px; box-shadow: inset 0 0 0 6px var(--by); +} +.st-vmap.status-decider { --by: #fff; } +.st-vmap.is-side::before { animation: st-side 1.6s ease-in-out infinite; } +@keyframes st-side { 50% { box-shadow: inset 0 0 0 6px var(--pending); } } + +/* Spielstand der aktuellen Map */ +.st-board { + position: relative; overflow: hidden; height: 100%; border-radius: 10px; + display: grid; grid-template-rows: auto 1fr auto auto; justify-items: center; padding: 26px 48px 30px; + background: rgba(17, 20, 30, .7); box-shadow: inset 0 0 0 2px rgba(255, 255, 255, .06); +} +.st-board-art { opacity: .38; } +.st-board-art::after { background: linear-gradient(180deg, rgba(0, 0, 0, .35), rgba(0, 0, 0, .85)); } +.st-board > :not(.st-board-art) { position: relative; z-index: 1; } +.st-board-head { display: flex; align-items: baseline; gap: 26px; font: 600 30px var(--display); color: var(--muted); } +.st-board-head strong { font: 700 56px var(--display); color: var(--text); } +.st-board-state { color: var(--pending); } +.st-score { display: flex; align-items: center; gap: 40px; } +.st-score-team { display: flex; align-items: center; gap: 28px; } +.st-score .num { font: 700 230px/0.9 var(--display); font-variant-numeric: tabular-nums; min-width: 2ch; } +.st-score-team.a .num { color: var(--team-a); text-align: right; } +.st-score-team.b .num { color: var(--team-b); } +.st-score-team.won .num { text-shadow: 0 0 40px currentColor; } +.st-score-colon { font: 700 120px var(--display); color: var(--muted); } +.st-score .side-icon { filter: drop-shadow(0 4px 16px #000); } +.st-winner { font: 700 40px var(--display); color: var(--ok); margin-bottom: 8px; } +.st-rounds { width: 100%; display: flex; justify-content: center; } +.st-waiting { display: grid; place-items: center; height: 100%; font: 600 48px var(--display); color: var(--muted); } + +/* Map-Leiste */ +.st-maps { display: flex; gap: 18px; padding-bottom: 16px; } +.st-mapcard { + position: relative; flex: 1 1 0; min-width: 0; overflow: hidden; border-radius: 8px; + display: grid; align-content: end; padding: 14px 20px; border-bottom: 6px solid var(--map); +} +.st-mapcard .map-art::after { background: linear-gradient(180deg, rgba(0, 0, 0, .1), rgba(0, 0, 0, .9)); } +.st-mapcard > :not(.map-art) { position: relative; z-index: 1; } +.st-mapcard-no { font: 600 22px var(--display); color: var(--muted); } +.st-mapcard strong { font: 700 40px/1.05 var(--display); } +.st-mapcard-foot { font: 600 26px var(--display); color: var(--muted); min-height: 36px; } +.st-mapcard-score { display: inline-flex; gap: 10px; align-items: baseline; font-size: 38px; } +.st-mapcard-score b { font-weight: 700; color: var(--muted); } +.st-mapcard-score b.won.a { color: var(--team-a); } +.st-mapcard-score b.won.b { color: var(--team-b); } +.st-mapcard.is-playing .st-mapcard-score b.a { color: var(--team-a); } +.st-mapcard.is-playing .st-mapcard-score b.b { color: var(--team-b); } +.st-mapcard.is-pending, .st-mapcard.is-next { opacity: .65; } +.st-mapcard.is-playing { box-shadow: 0 0 0 4px var(--brand-red), 0 0 40px rgba(167, 42, 28, .5); } + +/* Laufband */ +.st-ticker { display: flex; align-items: stretch; margin: 0 -72px; background: #05060a; border-top: 2px solid var(--line); } +.st-ticker-label { display: flex; align-items: center; padding: 0 30px 0 72px; background: var(--brand-red); color: #fff; font: 700 26px var(--display); } +.st-ticker-text { display: flex; align-items: center; padding: 0 30px; font: 500 26px var(--body); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; animation: st-in .5s ease-out; } +@keyframes st-in { from { opacity: 0; transform: translateY(8px); } } + +.st-message { display: grid; place-items: center; align-content: center; gap: 24px; height: 100%; text-align: center; background: #000; } +.st-message img { height: 90px; } +.st-message h1 { font: 700 64px var(--display); margin: 0; } +.st-message p { font-size: 28px; color: var(--muted); } + +/* Große Rundenleiste */ +.round-strip-lg { gap: 6px; } +.round-strip-lg .round { width: 50px; height: 50px; border-radius: 5px; } +.round-strip-lg .round.break { margin-left: 22px; } +.round-strip-lg .reason-icon { width: 24px; height: 24px; } + +@media (prefers-reduced-motion: reduce) { + .st-live::before, .st-vmap.is-side::before, .st-ticker-text { animation: none; } +} diff --git a/client/src/styles.css b/client/src/styles.css index c84f658..864873c 100644 --- a/client/src/styles.css +++ b/client/src/styles.css @@ -443,3 +443,43 @@ input:focus, select:focus { border-color: var(--link); outline: none; } @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation: none !important; transition: none !important; } } + +/* ---------- Live-Ergebnisse ---------- */ +.live-badge { + display: inline-flex; align-items: center; gap: 6px; padding: 1px 8px 1px 6px; border-radius: 3px; + background: var(--brand-red); color: #fff; font: 700 .8125rem var(--display); vertical-align: middle; +} +.live-badge::before { content: ''; width: 7px; height: 7px; border-radius: 50%; background: #fff; animation: pulse 1.4s ease-in-out infinite; } +.series-total { font-variant-numeric: tabular-nums; color: var(--text); } +.series-total .colon, .series-score .colon { color: var(--muted); margin: 0 .12em; } +.row-score { font: 700 1.375rem var(--display); color: var(--text); font-variant-numeric: tabular-nums; } +.row-live { color: var(--text); } + +.series-score { + grid-column: 3; grid-row: 2 / 4; align-self: center; justify-self: end; text-align: right; + font: 700 2.25rem/1 var(--display); font-variant-numeric: tabular-nums; text-shadow: 0 2px 10px rgba(0, 0, 0, .8); +} +.series-score small { display: block; font: 500 .8125rem var(--body); color: var(--muted); margin-top: 4px; } +.series-score .is-winner { text-decoration: underline; text-decoration-thickness: 3px; text-underline-offset: 6px; } +.series-rounds { grid-column: 1 / -1; margin-top: 10px; overflow-x: auto; } +.series-item.is-playing { box-shadow: inset 0 0 0 2px var(--brand-red); } + +.round-strip { list-style: none; display: flex; gap: 3px; padding: 0; margin: 0; } +.round { + display: grid; place-items: center; flex: none; width: 22px; height: 22px; border-radius: 3px; + background: var(--surface-2); color: #fff; box-shadow: inset 0 0 0 1px var(--line); +} +.round.won-A { background: var(--team-a); box-shadow: none; } +.round.won-B { background: var(--team-b); box-shadow: none; } +.round.break { margin-left: 10px; } +.round-strip-sm .round { width: 18px; height: 18px; } +.round-strip-sm .reason-icon { width: 10px; height: 10px; } +.round-strip-sm .round.break { margin-left: 8px; } + +.alert-ok { border-color: var(--ok); background: color-mix(in srgb, var(--ok) 9%, var(--surface)); } +.live-data { display: flex; align-items: center; gap: 8px; color: var(--muted); } +.stream-link { margin-top: 10px; display: grid; gap: 8px; justify-items: start; } +.stream-link .copy-field { width: 100%; } +@media (max-width: 640px) { + .series-score { grid-column: 2; grid-row: 2; font-size: 1.75rem; } +} diff --git a/server/src/config.js b/server/src/config.js index d71edea..d48ec9b 100644 --- a/server/src/config.js +++ b/server/src/config.js @@ -33,6 +33,9 @@ export function loadConfig(overrides = {}) { publicUrl: str('PUBLIC_URL', 'http://localhost:5173').replace(/\/$/, ''), // Adresse, unter der der CS2-Server dieses Backend für Live-Logs erreicht (Standard: PUBLIC_URL) logReceiverUrl: str('LOG_RECEIVER_URL', str('PUBLIC_URL', 'http://localhost:3000')).replace(/\/$/, ''), + logDebug: bool('LOG_DEBUG', false), + // Sekunden nach Map-Ende bis zur nächsten Map bzw. zum Abschluss der Serie; -1 = nur manuell + autoAdvanceSeconds: int('SERIES_AUTO_ADVANCE_SECONDS', 30), databaseFile: path.resolve(REPO_ROOT, str('DATABASE_FILE', 'data/cs2-mapveto.db')), mapPoolFile: path.resolve(REPO_ROOT, str('MAP_POOL_FILE', 'server/config/map-pools.json')), createMatchPassword: str('CREATE_MATCH_PASSWORD'), diff --git a/server/src/http.js b/server/src/http.js index ba6d020..e9ed45e 100644 --- a/server/src/http.js +++ b/server/src/http.js @@ -20,7 +20,10 @@ export function createApiRouter({ service, manager, config, pools, adapter }) { // Der Schlüssel in der URL ist pro Server und Match zufällig und nur im Backend bekannt. router.post('/logs/:id/:key', express.text({ type: () => true, limit: '2mb' }), (req, res) => { try { - service.ingestLogs(req.params.id, req.params.key, typeof req.body === 'string' ? req.body : ''); + const body = typeof req.body === 'string' ? req.body : ''; + // LOG_DEBUG=true: empfangene Zeilen ausgeben, z. B. um das Logformat eines Servers zu prüfen + if (config.logDebug) console.log(`[logs ${req.params.id}] ${body.trim().replace(/\n/g, `\n[logs ${req.params.id}] `)}`); + service.ingestLogs(req.params.id, req.params.key, body); res.status(200).end(); } catch (err) { res.status(err.status ?? 500).end(); diff --git a/server/src/liveScore.js b/server/src/liveScore.js index 0b9f023..890cf45 100644 --- a/server/src/liveScore.js +++ b/server/src/liveScore.js @@ -5,13 +5,18 @@ // World triggered "Match_Start" on "de_mirage" // MatchStatus: Team playing "CT": MaxLAN Blau // Team "TERRORIST" triggered "SFUI_Notice_Terrorists_Win" (CT "3") (T "5") +// World triggered "Warmup_Start" // Game Over: competitive mg_active de_mirage score 13:9 after 35 min +// Game Over: scrimcomp2v2 de_inferno score 1:3 after 3 min (Wingman: leere Map-Gruppe, zwei Leerzeichen) const RE = { matchStart: /World triggered "Match_Start" on "([^"]+)"/, + warmupStart: /World triggered "Warmup_Start"/, + worldRoundEnd: /World triggered "Round_End"/, teamPlaying: /Team playing "(CT|TERRORIST)": (.+?)\s*$/, roundEnd: /Team "(CT|TERRORIST)" triggered "SFUI_Notice_(\w+)" \(CT "(\d+)"\) \(T "(\d+)"\)/, - gameOver: /Game Over: \S+ \S+ (\S+) score (\d+):(\d+)/, + // Das Wort direkt vor "score" ist die Map – Modus und Map-Gruppe davor sind beliebig bzw. leer. + gameOver: /Game Over:.*?(\S+)\s+score\s+(\d+):(\d+)/, }; // Gründe für ein Rundenende aus den SFUI-Hinweisen @@ -28,18 +33,23 @@ const REASONS = { const other = (team) => (team === 'A' ? 'B' : 'A'); const normalizeName = (name) => String(name ?? '').trim().toLowerCase(); -/** Neuer Live-Zustand einer Map. ctTeam = Team, das laut Veto auf CT startet. */ -export function initialLiveState({ map, ctTeam }) { +/** + * Neuer Live-Zustand einer Map. ctTeam = Team, das laut Veto auf CT startet. + * warmupEnded: erst danach zählt ein Match_Start (CS2 sendet ihn auch beim Laden der Map, vor der Aufwärmphase). + */ +export function initialLiveState({ map, ctTeam, warmupEnded = false }) { const startCt = ctTeam ?? 'A'; return { map, status: 'warmup', + warmupEnded, startCt, sides: { CT: startCt, T: other(startCt) }, sidesFromLog: false, score: { A: 0, B: 0 }, rounds: [], winner: null, + pendingRound: null, }; } @@ -55,16 +65,42 @@ export function applyLogLines(state, lines, ctx) { const events = []; const names = { A: normalizeName(ctx.teamNames.A), B: normalizeName(ctx.teamNames.B) }; + // Ein Rundenende wird erst abgeschlossen, wenn die Seitenzuordnung der gerade gespielten Runde feststeht: + // CS2 schickt sie direkt nach dem Rundenende ("MatchStatus: Team playing ..."). Die Zeilen zu Beginn der + // nächsten Runde sind nach der Halbzeit noch veraltet und werden deshalb ignoriert. + const finalize = () => { + const p = s.pendingRound; + if (!p) return; + s.pendingRound = null; + if (p.number <= s.rounds.length) return; // doppelt gesendete Zeile + const winner = s.sides[p.side]; + s.rounds.push({ number: p.number, winner, side: p.side, reason: p.reason }); + // Stand aus den Seitenwerten übernehmen – korrigiert sich bei verlorenen Zeilen selbst. + s.score = { [s.sides.CT]: p.sideScore.CT, [s.sides.T]: p.sideScore.T }; + events.push({ type: 'round', number: p.number, winner, score: { ...s.score } }); + switchSidesByRules(s, p.number, ctx); + }; + + const reset = (status) => { + Object.assign(s, { status, score: { A: 0, B: 0 }, rounds: [], winner: null, pendingRound: null }); + }; + for (const line of lines) { let m; if ((m = RE.matchStart.exec(line))) { // Echter Matchbeginn (nach Aufwärmphase oder mp_restartgame): Zählung beginnt neu. - if (m[1] !== s.map) continue; - Object.assign(s, { status: 'live', score: { A: 0, B: 0 }, rounds: [], winner: null }); + // Nach Spielende startet CS2 von selbst neu – das Ergebnis bleibt dann stehen. + if (m[1] !== s.map || s.status === 'finished') continue; + reset(s.warmupEnded ? 'live' : 'warmup'); // Seitenzuordnung aus dem Log hat Vorrang vor der Annahme aus dem Veto. if (!s.sidesFromLog) s.sides = { CT: s.startCt, T: other(s.startCt) }; - events.push({ type: 'match_start', map: s.map }); + if (s.warmupEnded) events.push({ type: 'match_start', map: s.map }); + } else if (RE.warmupStart.test(line)) { + // Neue Aufwärmphase (Mapstart oder Neustart): bisherige Zählung verwerfen, Ergebnis einer beendeten Map bleibt. + if (s.status !== 'finished') reset('warmup'); } else if ((m = RE.teamPlaying.exec(line))) { + // Nur vor der ersten Runde und direkt nach einem Rundenende verlässlich. + if (!s.pendingRound && s.rounds.length > 0) continue; const side = m[1] === 'CT' ? 'CT' : 'T'; const name = normalizeName(m[2]); const team = name === names.A ? 'A' : name === names.B ? 'B' : null; @@ -75,32 +111,36 @@ export function applyLogLines(state, lines, ctx) { } } else if ((m = RE.roundEnd.exec(line))) { if (s.status === 'finished') continue; + finalize(); if (s.status === 'warmup') s.status = 'live'; - const winnerSide = m[1] === 'CT' ? 'CT' : 'T'; const sideScore = { CT: Number(m[3]), T: Number(m[4]) }; - const winner = s.sides[winnerSide]; - const number = sideScore.CT + sideScore.T; - if (number <= s.rounds.length) continue; // doppelt gesendete Zeile - s.rounds.push({ number, winner, side: winnerSide, reason: REASONS[m[2]] ?? 'other' }); - // Stand aus den Seitenwerten der Zeile übernehmen – korrigiert sich damit bei verlorenen Zeilen selbst. - s.score = { [s.sides.CT]: sideScore.CT, [s.sides.T]: sideScore.T }; - events.push({ type: 'round', number, winner, score: { ...s.score } }); - if (!s.sidesFromLog) maybeSwitchSides(s, number, ctx); + s.pendingRound = { + number: sideScore.CT + sideScore.T, + side: m[1] === 'CT' ? 'CT' : 'T', + reason: REASONS[m[2]] ?? 'other', + sideScore, + }; + } else if (RE.worldRoundEnd.test(line)) { + finalize(); } else if ((m = RE.gameOver.exec(line))) { if (m[1] !== s.map || s.status === 'finished') continue; + finalize(); s.status = 'finished'; s.winner = s.score.A === s.score.B ? null : s.score.A > s.score.B ? 'A' : 'B'; events.push({ type: 'map_end', winner: s.winner, score: { ...s.score } }); } } + // Ende des Pakets: offenes Rundenende mit der bekannten Zuordnung abschließen, damit die Anzeige nicht hängt. + finalize(); return { state: s, events }; } /** - * Ersatz, wenn der Server keine "Team playing"-Zeilen schickt: Seitenwechsel nach Regelwerk. + * Seitenwechsel nach Regelwerk, damit die Anzeige auch während einer Runde stimmt: * Halbzeit nach maxRounds/2, in der Verlängerung nach jeder halben Verlängerung. + * Die Zeilen nach dem nächsten Rundenende korrigieren die Zuordnung, falls ein Server anders wechselt. */ -function maybeSwitchSides(s, roundsPlayed, { maxRounds, overtimeRounds = 6 }) { +function switchSidesByRules(s, roundsPlayed, { maxRounds, overtimeRounds = 6 }) { const half = maxRounds / 2; const otHalf = overtimeRounds / 2; const regulationSwitch = roundsPlayed === half; diff --git a/server/src/matchService.js b/server/src/matchService.js index 11a44da..c8d0c53 100644 --- a/server/src/matchService.js +++ b/server/src/matchService.js @@ -416,9 +416,14 @@ export class MatchService extends EventEmitter { advanceSeries(viewer) { this.require(viewer, 'organizer'); + return this.advanceSeriesFor(viewer.matchId); + } + + /** Nächste Map der Serie vormerken (Organisator oder automatisch nach Map-Ende). */ + advanceSeriesFor(matchId) { let nextNumber; this.db.transaction(() => { - const match = this.getMatch(viewer.matchId); + const match = this.getMatch(matchId); if (match.status !== 'live') throw new HttpError(409, 'Die nächste Map kann erst geladen werden, wenn das Match läuft', 'wrong_phase'); const maps = this.seriesMaps(match.id); nextNumber = match.current_map_number + 1; @@ -430,7 +435,7 @@ export class MatchService extends EventEmitter { .run(nextNumber, this.now(), match.id); this.log(match.id, 'series', `Map ${nextNumber} der Serie wird geladen`); })(); - this.changed(viewer.matchId); + this.changed(matchId); return nextNumber; } @@ -443,6 +448,18 @@ export class MatchService extends EventEmitter { .run(matchId, number); } + /** Organisator hat die Aufwärmphase beendet: ab dem nächsten Match_Start wird gezählt. */ + markWarmupEnded(matchId) { + const match = this.getMatch(matchId); + const row = this.seriesMaps(matchId).find((m) => m.number === match.current_map_number); + if (!row) return; + const state = row.live ? JSON.parse(row.live) : initialLiveState({ map: row.map, ctTeam: row.ct_team }); + state.warmupEnded = true; + this.db + .prepare('UPDATE series_maps SET live = ? WHERE match_id = ? AND number = ?') + .run(JSON.stringify(state), matchId, row.number); + } + /** Logzeilen vom CS2-Server (logaddress_add_http). Der Schlüssel in der URL identifiziert den Server. */ ingestLogs(matchId, key, body) { const server = this.serverRow(matchId); @@ -488,6 +505,10 @@ export class MatchService extends EventEmitter { const needed = Math.floor(BEST_OF[ended.match.format] / 2) + 1; const decided = score.A >= needed ? 'A' : score.B >= needed ? 'B' : null; if (decided) this.log(matchId, 'score', `Serie entschieden: ${this.teamName(ended.match, decided)} gewinnt ${score[decided]}:${score[decided === 'A' ? 'B' : 'A']}`); + const hasNext = maps.some((m) => m.number === ended.row.number + 1); + this.changed(matchId); + this.emit('mapEnded', { matchId, number: ended.row.number, decided, hasNext }); + return; } this.changed(matchId); } @@ -505,12 +526,16 @@ export class MatchService extends EventEmitter { close(viewer, status) { this.require(viewer, 'organizer'); - const match = this.getMatch(viewer.matchId); + this.closeMatch(viewer.matchId, status); + } + + closeMatch(matchId, status, message) { + const match = this.getMatch(matchId); if (CLOSED_STATUSES.includes(match.status)) throw new HttpError(409, 'Das Match ist bereits beendet', 'match_closed'); if (status === 'finished') { 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', status === 'finished' ? 'Match abgeschlossen' : 'Match abgebrochen'); + this.log(match.id, 'status', message ?? (status === 'finished' ? 'Match abgeschlossen' : 'Match abgebrochen')); this.setStatus(match.id, status); this.emit('closed', match.id); } @@ -639,6 +664,7 @@ export class MatchService extends EventEmitter { }, canStartVeto: match.status === 'lobby' && readiness.A.ready && readiness.B.ready, requireFullTeams: this.config.requireFullTeams, + autoAdvanceSeconds: this.config.autoAdvanceSeconds, maps: pool.maps, veto: publicView ? null diff --git a/server/src/servers/demoMatchSimulator.js b/server/src/servers/demoMatchSimulator.js index 8706671..35c5752 100644 --- a/server/src/servers/demoMatchSimulator.js +++ b/server/src/servers/demoMatchSimulator.js @@ -39,8 +39,11 @@ export class DemoMatchSimulator { run.score[winner] += 1; run.played += 1; const notice = REASONS[side][Math.floor(this.random() * REASONS[side].length)]; + // Reihenfolge wie bei einem echten CS2-Server: Rundenende, Seitenzuordnung der gespielten Runde, Round_End const lines = [ `${stamp()}Team "${side === 'CT' ? 'CT' : 'TERRORIST'}" triggered "SFUI_Notice_${notice}" (CT "${run.score[run.sides.CT]}") (T "${run.score[run.sides.T]}")`, + ...teamLines(), + `${stamp()}World triggered "Round_End"`, ]; if (run.score[winner] >= toWin) { lines.push(`${stamp()}Game Over: competitive mg_active ${map} score ${run.score[run.sides.CT]}:${run.score[run.sides.T]} after 1 min`); @@ -48,10 +51,8 @@ export class DemoMatchSimulator { this.stop(matchId); return; } - if (run.played === maxRounds / 2) { - run.sides = { CT: run.sides.T, T: run.sides.CT }; - lines.push(...teamLines()); - } + // Halbzeit: Seiten wechseln, die nächsten Zeilen nennen die neue Zuordnung + if (run.played === maxRounds / 2) run.sides = { CT: run.sides.T, T: run.sides.CT }; onLines(lines); }, roundMs); this.runs.set(matchId, run); diff --git a/server/src/servers/serverManager.js b/server/src/servers/serverManager.js index 6a6e32f..413252a 100644 --- a/server/src/servers/serverManager.js +++ b/server/src/servers/serverManager.js @@ -26,6 +26,7 @@ export class ServerManager { Object.assign(this, { db, service, adapter, config, logger }); this.busy = new Set(); this.simulator = new DemoMatchSimulator(); + this.autoAdvance = new Map(); this.lastPoll = new Map(); this.timers = []; } @@ -34,6 +35,37 @@ export class ServerManager { this.service.on('created', (id) => this.provision(id).catch((err) => this.logger.error(err))); this.service.on('vetoFinished', (id) => this.maybeConfigure(id)); this.service.on('closed', (id) => this.stop(id, 'Match beendet').catch((err) => this.logger.error(err))); + this.service.on('mapEnded', (e) => this.onMapEnded(e)); + } + + /** + * Nach Map-Ende: ist die Serie entschieden, wird das Match abgeschlossen, sonst die nächste Map geladen. + * Die Wartezeit lässt Spielern und Stream Zeit für den Endstand. Lädt der Organisator vorher selbst, entfällt es. + */ + onMapEnded({ matchId, number, decided, hasNext }) { + const seconds = this.config.autoAdvanceSeconds; + if (seconds < 0) return; + clearTimeout(this.autoAdvance.get(matchId)); + const finish = Boolean(decided) || !hasNext; + this.service.log(matchId, 'series', finish + ? `Serie entschieden – das Match wird in ${seconds} s automatisch abgeschlossen` + : `Map ${number + 1} wird in ${seconds} s automatisch geladen`); + this.service.changed(matchId); + this.autoAdvance.set(matchId, setTimeout(() => { + this.autoAdvance.delete(matchId); + const match = this.service.getMatch(matchId); + if (CLOSED_STATUSES.includes(match.status) || match.current_map_number !== number) return; + try { + if (finish) { + this.service.closeMatch(matchId, 'finished', 'Match automatisch abgeschlossen – Serie entschieden'); + } else if (this.row(matchId)?.status === 'configured') { + this.service.advanceSeriesFor(matchId); + this.configure(matchId).catch((err) => this.logger.error(err)); + } + } catch (err) { + this.logger.warn(`[serie] Automatischer Wechsel für ${matchId} fehlgeschlagen: ${err.message}`); + } + }, seconds * 1000)); } /** Nach einem Neustart des Backends: unterbrochene Vorgänge aufräumen und fortsetzen. */ @@ -56,6 +88,8 @@ export class ServerManager { stopLoops() { this.simulator.stopAll(); + for (const t of this.autoAdvance.values()) clearTimeout(t); + this.autoAdvance.clear(); this.timers.forEach(clearInterval); this.timers = []; } @@ -277,6 +311,8 @@ export class ServerManager { this.service.require(viewer, 'organizer'); const row = this.row(viewer.matchId); if (row?.status !== 'configured') throw new HttpError(409, 'Der Server ist nicht bereit für die nächste Map', 'wrong_state'); + clearTimeout(this.autoAdvance.get(viewer.matchId)); + this.autoAdvance.delete(viewer.matchId); this.service.advanceSeries(viewer); this.configure(viewer.matchId).catch((err) => this.logger.error(err)); } @@ -292,6 +328,7 @@ export class ServerManager { throw new HttpError(502, `Befehl konnte nicht gesendet werden: ${err.message}`, 'server_error'); } this.service.log(viewer.matchId, 'server', this.adapter.simulated ? 'Aufwärmphase beendet (simuliert)' : 'Aufwärmphase beendet – Match startet'); + this.service.markWarmupEnded(viewer.matchId); if (this.adapter.simulated) this.startDemoSimulation(viewer.matchId); this.service.changed(viewer.matchId); } diff --git a/server/test/api.test.js b/server/test/api.test.js index 485d8ca..4077670 100644 --- a/server/test/api.test.js +++ b/server/test/api.test.js @@ -309,6 +309,45 @@ test('Live-Ergebnisse aus Serverlogs und Demo-Simulation', async () => { } }); +test('Nach Map-Ende lädt die nächste Map automatisch, entschiedene Serie wird abgeschlossen', async () => { + const { app, call, close } = await setup({ autoAdvanceSeconds: 0 }); + try { + const { id, org, tokens } = await readyWingmanMatch(call, 'bo3'); + await call('POST', `/matches/${id}/veto/start`, { token: org }); + await playVeto(call, id, { A: tokens.a1, B: tokens.b1 }); + await app.manager.poll(); + await waitFor(() => app.service.getMatch(id).status === 'live', { label: 'live' }); + + const key = app.service.serverRow(id).config_key; + const winMapForAlpha = async (map) => { + const body = [ + 'Team playing "CT": Alpha', + 'Team playing "TERRORIST": Bravo', + 'Team "CT" triggered "SFUI_Notice_CTs_Win" (CT "1") (T "0")', + 'World triggered "Round_End"', + `Game Over: scrimcomp2v2 ${map} score 1:0 after 1 min`, + ].join('\n'); + const res = await fetch(`http://127.0.0.1:${app.server.address().port}/api/logs/${id}/${key}`, { method: 'POST', body }); + assert.equal(res.status, 200); + }; + + await winMapForAlpha(app.service.seriesMaps(id)[0].map); + // Serie 1:0, nicht entschieden -> Map 2 wird automatisch geladen + await waitFor(() => app.service.getMatch(id).current_map_number === 2 && app.service.serverRow(id).status === 'configured', { label: 'Map 2 automatisch' }); + assert.equal(app.service.getMatch(id).status, 'live'); + + await winMapForAlpha(app.service.seriesMaps(id)[1].map); + // 2:0 im Best of 3 -> Match wird automatisch abgeschlossen, Map 3 nicht gespielt + await waitFor(() => app.service.getMatch(id).status === 'finished', { label: 'Serie abgeschlossen' }); + await waitFor(() => app.service.serverRow(id).status === 'stopped', { label: 'Server freigegeben' }); + const snap = (await call('GET', `/matches/${id}`, { token: org })).body; + assert.deepEqual(snap.seriesScore, { A: 2, B: 0 }); + assert.equal(snap.series[2].winner, null); + } finally { + await close(); + } +}); + test('Spieler verlassen die Lobby, Captain geht über', async () => { const { call, close } = await setup(); try { diff --git a/server/test/liveScore.test.js b/server/test/liveScore.test.js index 9287e6e..7190cd3 100644 --- a/server/test/liveScore.test.js +++ b/server/test/liveScore.test.js @@ -1,7 +1,12 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; import { applyLogLines, initialLiveState, seriesScore, splitLogBody } from '../src/liveScore.js'; +const here = path.dirname(fileURLToPath(import.meta.url)); + const ctx = { teamNames: { A: 'MaxLAN Blau', B: 'MaxLAN Rot' }, maxRounds: 24, overtimeRounds: 6 }; const ts = '10/25/2026 - 18:00:00.000 - '; @@ -12,7 +17,7 @@ function round(winnerSide, ct, t, notice) { } test('Logzeilen ergeben Spielstand, Rundenverlauf und Gründe', () => { - let state = initialLiveState({ map: 'de_mirage', ctTeam: 'A' }); + let state = initialLiveState({ map: 'de_mirage', ctTeam: 'A', warmupEnded: true }); const lines = [ `${ts}World triggered "Match_Start" on "de_mirage"`, round('T', 0, 1, 'Target_Bombed'), @@ -28,31 +33,62 @@ test('Logzeilen ergeben Spielstand, Rundenverlauf und Gründe', () => { }); test('Doppelt gesendete Zeilen werden nur einmal gezählt', () => { - const state = initialLiveState({ map: 'de_nuke', ctTeam: 'A' }); + const state = initialLiveState({ map: 'de_nuke', ctTeam: 'A', warmupEnded: true }); const line = round('CT', 1, 0); const { state: s } = applyLogLines(state, [line, line, line], ctx); assert.equal(s.rounds.length, 1); assert.deepEqual(s.score, { A: 1, B: 0 }); }); -test('Seitenwechsel über "Team playing"-Zeilen', () => { - let s = initialLiveState({ map: 'de_nuke', ctTeam: 'A' }); +test('Seitenzuordnung nach dem Rundenende hat Vorrang, veraltete Zeilen zu Rundenbeginn zählen nicht', () => { + let s = initialLiveState({ map: 'de_nuke', ctTeam: 'B', warmupEnded: true }); + const playing = (ct, t) => [`${ts}MatchStatus: Team playing "CT": ${ct}`, `${ts}MatchStatus: Team playing "TERRORIST": ${t}`]; ({ state: s } = applyLogLines(s, [ - `${ts}MatchStatus: Team playing "CT": MaxLAN Blau`, - `${ts}MatchStatus: Team playing "TERRORIST": MaxLAN Rot`, + // Veto sagt Rot auf CT, tatsächlich steht Blau auf CT – das Log gewinnt `${ts}World triggered "Match_Start" on "de_nuke"`, + ...playing('MaxLAN Blau', 'MaxLAN Rot'), round('CT', 1, 0), - // Halbzeit: Rot ist jetzt CT. In den Seitenwerten steht der Stand der Teams auf der jeweiligen Seite. - `${ts}MatchStatus: Team playing "CT": MaxLAN Rot`, - `${ts}MatchStatus: Team playing "TERRORIST": MaxLAN Blau`, - round('CT', 1, 1), + ...playing('MaxLAN Blau', 'MaxLAN Rot'), + `${ts}World triggered "Round_End"`, + // veraltete Zeile zu Beginn der nächsten Runde (darf nichts ändern) + ...playing('MaxLAN Rot', 'MaxLAN Blau'), + round('T', 1, 1), + ...playing('MaxLAN Blau', 'MaxLAN Rot'), + `${ts}World triggered "Round_End"`, ], ctx)); assert.deepEqual(s.score, { A: 1, B: 1 }); assert.deepEqual(s.rounds.map((r) => r.winner), ['A', 'B']); }); +test('Echte Logzeilen eines AMP-Servers (Wingman, 4 Runden, Halbzeit nach 2)', () => { + const lines = fs.readFileSync(path.join(here, 'fixtures', 'amp-wingman-inferno.log'), 'utf8').split(/\r?\n/).filter(Boolean); + const warmupEnd = lines.findIndex((l) => l.includes('command "mp_warmup_end"')); + const amp = { teamNames: { A: 'MaxLAN Blau', B: 'MaxLAN Rot' }, maxRounds: 4, overtimeRounds: 6 }; + + const check = (s) => { + assert.equal(s.status, 'finished'); + assert.equal(s.winner, 'A'); + assert.deepEqual(s.score, { A: 3, B: 1 }); + assert.deepEqual(s.rounds.map((r) => r.winner), ['B', 'A', 'A', 'A']); + assert.ok(s.rounds.every((r) => r.reason === 'elimination')); + }; + + // a) wie vom Server in Paketen geschickt + let s = initialLiveState({ map: 'de_inferno', ctTeam: 'A' }); + ({ state: s } = applyLogLines(s, lines.slice(0, warmupEnd), amp)); + assert.equal(s.status, 'warmup'); + s.warmupEnded = true; // Organisator hat "Aufwärmphase beenden" geklickt + ({ state: s } = applyLogLines(s, lines.slice(warmupEnd), amp)); + check(s); + + // b) jede Zeile einzeln (ungünstigste Paketgrenzen) + let t = initialLiveState({ map: 'de_inferno', ctTeam: 'A', warmupEnded: true }); + for (const line of lines.slice(warmupEnd)) ({ state: t } = applyLogLines(t, [line], amp)); + check(t); +}); + test('Ohne "Team playing": Halbzeitwechsel nach Regelwerk', () => { - let s = initialLiveState({ map: 'de_inferno', ctTeam: 'B' }); + let s = initialLiveState({ map: 'de_inferno', ctTeam: 'B', warmupEnded: true }); const small = { ...ctx, maxRounds: 4 }; ({ state: s } = applyLogLines(s, [ `${ts}World triggered "Match_Start" on "de_inferno"`, @@ -65,7 +101,7 @@ test('Ohne "Team playing": Halbzeitwechsel nach Regelwerk', () => { }); test('Game Over beendet die Map, Aufwärmrunden vor Match_Start zählen nicht', () => { - let s = initialLiveState({ map: 'de_dust2', ctTeam: 'A' }); + let s = initialLiveState({ map: 'de_dust2', ctTeam: 'A', warmupEnded: true }); ({ state: s } = applyLogLines(s, [ round('T', 0, 1), // Aufwärm-/Messerrunde `${ts}World triggered "Match_Start" on "de_dust2"`, @@ -79,9 +115,20 @@ test('Game Over beendet die Map, Aufwärmrunden vor Match_Start zählen nicht', }); test('Zeilen einer anderen Map werden ignoriert, Serienstand wird gezählt', () => { - const s = initialLiveState({ map: 'de_dust2', ctTeam: 'A' }); + const s = initialLiveState({ map: 'de_dust2', ctTeam: 'A', warmupEnded: true }); const { state } = applyLogLines(s, [`${ts}Game Over: competitive mg_active de_nuke score 13:2 after 30 min`], ctx); assert.equal(state.status, 'warmup'); assert.deepEqual(seriesScore([{ winner: 'A' }, { winner: 'B' }, { winner: 'A' }, { winner: null }]), { A: 2, B: 1 }); assert.deepEqual(splitLogBody('a\r\n\r\nb\n'), ['a', 'b']); }); + +test('Match_Start vor dem Ende der Aufwärmphase startet keine Zählung', () => { + let s = initialLiveState({ map: 'de_inferno', ctTeam: 'A' }); + const start = `${ts}World triggered "Match_Start" on "de_inferno"`; + ({ state: s } = applyLogLines(s, [start], ctx)); + assert.equal(s.status, 'warmup'); + s.warmupEnded = true; + const { state, events } = applyLogLines(s, [start], ctx); + assert.equal(state.status, 'live'); + assert.equal(events[0].type, 'match_start'); +});