Files
Cs2-Mapveto/server/src/liveScore.js
T
mregosar e18b8b4430 Absicherung: Socket-Eingaben, Geheimnisse in Fehlern, Logparser, Start/Stopp, RCON, Stream-Zugang
- Socket-Nachrichten werden geprüft, Handlerfehler abgefangen, Abfragen pro Verbindung begrenzt
  (vorher beendete stream:subscribe mit null ohne Anmeldung das Backend).
- Fehlermeldungen von Prozessen ohne Befehlszeile und mit geschwärzten Passwörtern/Tokens.
- Logparser wertet nur ganze Systemzeilen nach dem Zeitstempel aus; Chat kann keine Map beenden.
- Verspätet ankommende Starts nach Stopp, Neuanforderung oder Löschen bauen ihren Container ab.
- RCON: kaputte Pakete trennen nur die Verbindung, Befehle ohne Antwort gelten als Fehler.
- Organisatoren bekommen einen Stream-Schlüssel nur für ihr Match statt des globalen STREAM_KEY.
- Rate-Limiter mit regelmäßiger Bereinigung und Obergrenze, alte Admin-Sitzungen werden entfernt.
2026-11-01 20:25:45 +01:00

174 lines
7.5 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)
// Alle Muster gelten für die ganze Zeile nach dem Zeitstempel. Spielerzeilen (z. B. Chat:
// "Name<2><[U:1:1]><CT>" say "Game Over: …") beginnen mit einem Anführungszeichen und passen nie.
const RE = {
matchStart: /^World triggered "Match_Start" on "([^"]+)"$/,
warmupStart: /^World triggered "Warmup_Start"$/,
worldRoundEnd: /^World triggered "Round_End"$/,
teamPlaying: /^(?:MatchStatus: )?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: [\w ]*?(\S+)\s+score\s+(\d+):(\d+) after \d+ min$/,
};
// Zeitstempel am Zeilenanfang: "10/25/2026 - 18:07:52.396 - " (HTTP-Log) oder "L 10/25/2026 - 18:07:52: " (Datei)
const PREFIX = /^(?:L\s+)?\d{2}\/\d{2}\/\d{4}\s+-\s+\d{2}:\d{2}:\d{2}(?:\.\d+)?\s*[-:]\s*/;
/** Zeile ohne Zeitstempel, so wie sie die Muster oben erwarten. */
export function logPayload(line) {
return String(line).trim().replace(PREFIX, '');
}
// 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 raw of lines) {
const line = logPayload(raw);
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;
}