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>
This commit is contained in:
SPP UserandClaude Opus 5.5 committed 2026-10-25 18:54:23 +01:00
1 parent 5ea5638115
commit 5fade769e7
11 files changed
+515 -11

No files matched your search

+122
View File
@@ -0,0 +1,122 @@
// 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")
// Game Over: competitive mg_active de_mirage score 13:9 after 35 min
const RE = {
matchStart: /World triggered "Match_Start" on "([^"]+)"/,
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+)/,
};
// 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. */
export function initialLiveState({ map, ctTeam }) {
const startCt = ctTeam ?? 'A';
return {
map,
status: 'warmup',
startCt,
sides: { CT: startCt, T: other(startCt) },
sidesFromLog: false,
score: { A: 0, B: 0 },
rounds: [],
winner: 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) };
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 });
// 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 });
} else if ((m = RE.teamPlaying.exec(line))) {
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;
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);
} else if ((m = RE.gameOver.exec(line))) {
if (m[1] !== s.map || s.status === 'finished') continue;
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 } });
}
}
return { state: s, events };
}
/**
* Ersatz, wenn der Server keine "Team playing"-Zeilen schickt: Seitenwechsel nach Regelwerk.
* Halbzeit nach maxRounds/2, in der Verlängerung nach jeder halben Verlängerung.
*/
function maybeSwitchSides(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;
}