- 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
163 lines
6.9 KiB
JavaScript
163 lines
6.9 KiB
JavaScript
// 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;
|
||
}
|