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

+95 -9
View File
@@ -1,6 +1,8 @@
import { EventEmitter } from 'node:events';
import { BEST_OF, computeVetoState, resolveAction, VetoError } from './veto.js';
import { formatCode, hashToken, normalizeCode, randomCode, randomId, randomToken, safeEqual } from './security.js';
import { applyLogLines, initialLiveState, seriesScore, splitLogBody } from './liveScore.js';
import { sanitizeConsoleText } from './servers/cs2Config.js';
export class HttpError extends Error {
constructor(status, message, code = 'error') {
@@ -432,6 +434,68 @@ export class MatchService extends EventEmitter {
return nextNumber;
}
// ---------- Live-Ergebnisse ----------
/** Startet die Zählung einer Map neu (beim Laden der Map auf dem Server). */
resetLiveScore(matchId, number) {
this.db
.prepare('UPDATE series_maps SET score_a = 0, score_b = 0, winner = NULL, live = NULL, live_updated_at = NULL WHERE match_id = ? AND number = ?')
.run(matchId, number);
}
/** Logzeilen vom CS2-Server (logaddress_add_http). Der Schlüssel in der URL identifiziert den Server. */
ingestLogs(matchId, key, body) {
const server = this.serverRow(matchId);
if (!server || !safeEqual(String(key ?? ''), server.config_key)) {
throw new HttpError(403, 'Unbekannter Log-Schlüssel', 'forbidden');
}
this.applyLiveLog(matchId, splitLogBody(body));
}
applyLiveLog(matchId, lines) {
if (!lines.length) return;
let ended = null;
this.db.transaction(() => {
const match = this.getMatch(matchId);
if (CLOSED_STATUSES.includes(match.status)) return;
const row = this.seriesMaps(matchId).find((m) => m.number === match.current_map_number);
if (!row) return;
const before = row.live ? JSON.parse(row.live) : initialLiveState({ map: row.map, ctTeam: row.ct_team });
const { state, events } = applyLogLines(before, lines, {
teamNames: { A: sanitizeConsoleText(match.team_a_name), B: sanitizeConsoleText(match.team_b_name) },
maxRounds: match.mode === 'wingman' ? this.config.match.maxRoundsWingman : this.config.match.maxRoundsCompetitive,
overtimeRounds: 6,
});
if (!events.length && JSON.stringify(before) === JSON.stringify(state)) return;
const finished = events.find((e) => e.type === 'map_end');
this.db
.prepare(
`UPDATE series_maps SET score_a = ?, score_b = ?, winner = ?, live = ?, live_updated_at = ?
${finished ? ", status = 'done'" : ''} WHERE match_id = ? AND number = ?`,
)
.run(state.score.A, state.score.B, state.winner, JSON.stringify(state), this.now(), matchId, row.number);
if (events.some((e) => e.type === 'match_start')) this.log(matchId, 'score', `Map ${row.number} (${this.mapName(match, row.map)}) live – Zählung beginnt`);
if (finished) {
ended = { row, state, match };
const winner = state.winner ? this.teamName(match, state.winner) : 'Unentschieden';
this.log(matchId, 'score',
`Map ${row.number} (${this.mapName(match, row.map)}) beendet: ${match.team_a_name} ${state.score.A}:${state.score.B} ${match.team_b_name} – Sieger: ${winner}`);
}
})();
if (ended) {
const maps = this.seriesMaps(matchId);
const score = seriesScore(maps);
const needed = Math.floor(BEST_OF[ended.match.format] / 2) + 1;
const decided = score.A >= needed ? 'A' : score.B >= needed ? 'B' : null;
if (decided) this.log(matchId, 'score', `Serie entschieden: ${this.teamName(ended.match, decided)} gewinnt ${score[decided]}:${score[decided === 'A' ? 'B' : 'A']}`);
}
this.changed(matchId);
}
mapName(match, mapId) {
return JSON.parse(match.maps).find((m) => m.id === mapId)?.name ?? mapId;
}
markCurrentMapLive(matchId) {
const match = this.getMatch(matchId);
this.db
@@ -479,7 +543,13 @@ export class MatchService extends EventEmitter {
const byTeam = Object.fromEntries(counts.all(m.id).map((r) => [r.team, r.n]));
const server = this.serverRow(m.id);
const pool = this.pools[m.mode];
const maps = this.seriesMaps(m.id);
const current = maps.find((s) => s.number === m.current_map_number);
return {
seriesScore: maps.length ? seriesScore(maps) : null,
currentMap: current && (current.status === 'live' || current.status === 'done')
? { number: current.number, name: this.mapName(m, current.map), score: { A: current.score_a, B: current.score_b }, finished: current.status === 'done' }
: null,
id: m.id,
teamA: m.team_a_name,
teamB: m.team_b_name,
@@ -528,15 +598,27 @@ export class MatchService extends EventEmitter {
const veto = this.vetoState(match);
const actions = this.vetoActions(matchId);
const series = this.seriesMaps(matchId).map((s) => ({
number: s.number,
map: s.map,
mapName: mapNames[s.map] ?? s.map,
pickedBy: s.picked_by,
sideChosenBy: s.side_chosen_by,
ctTeam: s.ct_team,
status: s.status,
}));
const seriesRows = this.seriesMaps(matchId);
const series = seriesRows.map((s) => {
const live = s.live ? JSON.parse(s.live) : null;
return {
number: s.number,
map: s.map,
mapName: mapNames[s.map] ?? s.map,
pickedBy: s.picked_by,
sideChosenBy: s.side_chosen_by,
ctTeam: s.ct_team,
status: s.status,
score: { A: s.score_a, B: s.score_b },
winner: s.winner,
live: live && {
status: live.status,
sides: live.sides,
rounds: live.rounds,
updatedAt: s.live_updated_at,
},
};
});
const snapshot = {
id: match.id,
@@ -578,6 +660,8 @@ export class MatchService extends EventEmitter {
})),
},
series,
seriesScore: seriesScore(seriesRows),
maxRounds: match.mode === 'wingman' ? this.config.match.maxRoundsWingman : this.config.match.maxRoundsCompetitive,
server: this.serverView(match, viewer),
events: publicView
? []
@@ -594,6 +678,8 @@ export class MatchService extends EventEmitter {
A: `${base}/join/${formatCode(match.code_a)}`,
B: `${base}/join/${formatCode(match.code_b)}`,
spectator: `${base}/watch/${formatCode(match.code_spectator)}`,
// Vollbild-Übersicht für den Stream (OBS-Browserquelle), nutzt den Zuschauercode
stream: `${base}/stream/${formatCode(match.code_spectator)}`,
},
};
}