// Reine Auswertung von CS2-Serverlogs (logaddress_add_http) zu Live-Spielständen. // Kein Datenbank- oder Netzwerkzugriff, damit sich die Logik mit Beispielzeilen testen lässt. // // Ausgewertete Zeilen (Präfix mit Datum/Uhrzeit wird ignoriert): // 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+)"\)/, // 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 const REASONS = { Target_Bombed: 'bomb', Bomb_Defused: 'defuse', Target_Saved: 'time', CTs_Win: 'elimination', Terrorists_Win: 'elimination', Terrorists_Surrender: 'surrender', CTs_Surrender: 'surrender', }; 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. * 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, }; } /** * Wendet Logzeilen auf den Zustand an. * @param {object} state Zustand aus initialLiveState bzw. vorherigem Aufruf * @param {string[]} lines Logzeilen * @param {object} ctx { teamNames: {A, B}, maxRounds, overtimeRounds } * @returns {{ state, events }} events: [{ type: 'match_start'|'round'|'map_end', ... }] */ export function applyLogLines(state, lines, ctx) { const s = structuredClone(state); 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. // 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) }; 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; if (team) { s.sides[side] = team; s.sides[side === 'CT' ? 'T' : 'CT'] = other(team); s.sidesFromLog = true; } } else if ((m = RE.roundEnd.exec(line))) { if (s.status === 'finished') continue; finalize(); if (s.status === 'warmup') s.status = 'live'; const sideScore = { CT: Number(m[3]), T: Number(m[4]) }; 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 }; } /** * 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 switchSidesByRules(s, roundsPlayed, { maxRounds, overtimeRounds = 6 }) { const half = maxRounds / 2; const otHalf = overtimeRounds / 2; const regulationSwitch = roundsPlayed === half; const overtimeSwitch = roundsPlayed > maxRounds && (roundsPlayed - maxRounds) % otHalf === 0 && ((roundsPlayed - maxRounds) / otHalf) % 2 === 1; if (regulationSwitch || overtimeSwitch) s.sides = { CT: s.sides.T, T: s.sides.CT }; } /** Teilt einen HTTP-Body in Logzeilen. */ export function splitLogBody(body) { return String(body ?? '').split(/\r?\n/).map((l) => l.trim()).filter(Boolean); } /** Serienstand aus den Ergebnissen der einzelnen Maps. */ export function seriesScore(maps) { const score = { A: 0, B: 0 }; for (const m of maps) if (m.winner) score[m.winner] += 1; return score; }