Files
Cs2-Mapveto/server/src/liveScore.js
T
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

163 lines
6.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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;
}