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
+508 -4

No files matched your search

+4
View File
@@ -31,6 +31,8 @@ export function loadConfig(overrides = {}) {
const config = { const config = {
port: int('PORT', 3000), port: int('PORT', 3000),
publicUrl: str('PUBLIC_URL', 'http://localhost:5173').replace(/\/$/, ''), publicUrl: str('PUBLIC_URL', 'http://localhost:5173').replace(/\/$/, ''),
// Adresse, unter der der CS2-Server dieses Backend für Live-Logs erreicht (Standard: PUBLIC_URL)
logReceiverUrl: str('LOG_RECEIVER_URL', str('PUBLIC_URL', 'http://localhost:3000')).replace(/\/$/, ''),
databaseFile: path.resolve(REPO_ROOT, str('DATABASE_FILE', 'data/cs2-mapveto.db')), databaseFile: path.resolve(REPO_ROOT, str('DATABASE_FILE', 'data/cs2-mapveto.db')),
mapPoolFile: path.resolve(REPO_ROOT, str('MAP_POOL_FILE', 'server/config/map-pools.json')), mapPoolFile: path.resolve(REPO_ROOT, str('MAP_POOL_FILE', 'server/config/map-pools.json')),
createMatchPassword: str('CREATE_MATCH_PASSWORD'), createMatchPassword: str('CREATE_MATCH_PASSWORD'),
@@ -58,6 +60,8 @@ export function loadConfig(overrides = {}) {
demo: { demo: {
startSeconds: int('DEMO_START_SECONDS', 20), startSeconds: int('DEMO_START_SECONDS', 20),
failFirstConfigure: bool('DEMO_FAIL_FIRST_CONFIGURE', false), failFirstConfigure: bool('DEMO_FAIL_FIRST_CONFIGURE', false),
// Sekunden pro simulierter Runde nach "Aufwärmphase beenden"
roundSeconds: int('DEMO_ROUND_SECONDS', 4),
}, },
docker: { docker: {
+18
View File
@@ -105,5 +105,23 @@ export function openDatabase(file) {
db.pragma('journal_mode = WAL'); db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON'); db.pragma('foreign_keys = ON');
db.exec(SCHEMA); db.exec(SCHEMA);
migrate(db);
return db; return db;
} }
/** Ergänzt Spalten, die in älteren Datenbanken noch fehlen. */
function migrate(db) {
const columns = (table) => new Set(db.prepare(`PRAGMA table_info(${table})`).all().map((c) => c.name));
const seriesColumns = columns('series_maps');
const add = [
['score_a', 'INTEGER NOT NULL DEFAULT 0'],
['score_b', 'INTEGER NOT NULL DEFAULT 0'],
['winner', 'TEXT'],
// Live-Zustand aus den Serverlogs (JSON, siehe liveScore.js)
['live', 'TEXT'],
['live_updated_at', 'INTEGER'],
];
for (const [name, type] of add) {
if (!seriesColumns.has(name)) db.exec(`ALTER TABLE series_maps ADD COLUMN ${name} ${type}`);
}
}
+12
View File
@@ -15,6 +15,18 @@ function accessTokens(req) {
export function createApiRouter({ service, manager, config, pools, adapter }) { export function createApiRouter({ service, manager, config, pools, adapter }) {
const router = express.Router(); const router = express.Router();
// Live-Logs vom CS2-Server (logaddress_add_http). Vor dem JSON-Parser, weil CS2 Klartext sendet.
// Der Schlüssel in der URL ist pro Server und Match zufällig und nur im Backend bekannt.
router.post('/logs/:id/:key', express.text({ type: () => true, limit: '2mb' }), (req, res) => {
try {
service.ingestLogs(req.params.id, req.params.key, typeof req.body === 'string' ? req.body : '');
res.status(200).end();
} catch (err) {
res.status(err.status ?? 500).end();
}
});
router.use(express.json({ limit: '32kb' })); router.use(express.json({ limit: '32kb' }));
const limiters = { const limiters = {
+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;
}
+88 -2
View File
@@ -1,6 +1,8 @@
import { EventEmitter } from 'node:events'; import { EventEmitter } from 'node:events';
import { BEST_OF, computeVetoState, resolveAction, VetoError } from './veto.js'; import { BEST_OF, computeVetoState, resolveAction, VetoError } from './veto.js';
import { formatCode, hashToken, normalizeCode, randomCode, randomId, randomToken, safeEqual } from './security.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 { export class HttpError extends Error {
constructor(status, message, code = 'error') { constructor(status, message, code = 'error') {
@@ -432,6 +434,68 @@ export class MatchService extends EventEmitter {
return nextNumber; 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) { markCurrentMapLive(matchId) {
const match = this.getMatch(matchId); const match = this.getMatch(matchId);
this.db 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 byTeam = Object.fromEntries(counts.all(m.id).map((r) => [r.team, r.n]));
const server = this.serverRow(m.id); const server = this.serverRow(m.id);
const pool = this.pools[m.mode]; const pool = this.pools[m.mode];
const maps = this.seriesMaps(m.id);
const current = maps.find((s) => s.number === m.current_map_number);
return { 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, id: m.id,
teamA: m.team_a_name, teamA: m.team_a_name,
teamB: m.team_b_name, teamB: m.team_b_name,
@@ -528,7 +598,10 @@ export class MatchService extends EventEmitter {
const veto = this.vetoState(match); const veto = this.vetoState(match);
const actions = this.vetoActions(matchId); const actions = this.vetoActions(matchId);
const series = this.seriesMaps(matchId).map((s) => ({ const seriesRows = this.seriesMaps(matchId);
const series = seriesRows.map((s) => {
const live = s.live ? JSON.parse(s.live) : null;
return {
number: s.number, number: s.number,
map: s.map, map: s.map,
mapName: mapNames[s.map] ?? s.map, mapName: mapNames[s.map] ?? s.map,
@@ -536,7 +609,16 @@ export class MatchService extends EventEmitter {
sideChosenBy: s.side_chosen_by, sideChosenBy: s.side_chosen_by,
ctTeam: s.ct_team, ctTeam: s.ct_team,
status: s.status, 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 = { const snapshot = {
id: match.id, id: match.id,
@@ -578,6 +660,8 @@ export class MatchService extends EventEmitter {
})), })),
}, },
series, series,
seriesScore: seriesScore(seriesRows),
maxRounds: match.mode === 'wingman' ? this.config.match.maxRoundsWingman : this.config.match.maxRoundsCompetitive,
server: this.serverView(match, viewer), server: this.serverView(match, viewer),
events: publicView events: publicView
? [] ? []
@@ -594,6 +678,8 @@ export class MatchService extends EventEmitter {
A: `${base}/join/${formatCode(match.code_a)}`, A: `${base}/join/${formatCode(match.code_a)}`,
B: `${base}/join/${formatCode(match.code_b)}`, B: `${base}/join/${formatCode(match.code_b)}`,
spectator: `${base}/watch/${formatCode(match.code_spectator)}`, 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)}`,
}, },
}; };
} }
+5 -1
View File
@@ -23,7 +23,7 @@ export function sanitizeConsoleText(value, maxLength = 32) {
* @param {object} p.secrets { serverPassword, tvPassword } * @param {object} p.secrets { serverPassword, tvPassword }
* @param {object} p.settings config.match * @param {object} p.settings config.match
*/ */
export function buildMapCommands({ match, pool, seriesMap, secrets, settings }) { export function buildMapCommands({ match, pool, seriesMap, secrets, settings, logUrl }) {
const isWingman = pool.mode === 'wingman'; const isWingman = pool.mode === 'wingman';
const ctTeam = seriesMap.ctTeam ?? 'A'; const ctTeam = seriesMap.ctTeam ?? 'A';
const tTeam = ctTeam === 'A' ? 'B' : 'A'; const tTeam = ctTeam === 'A' ? 'B' : 'A';
@@ -36,6 +36,10 @@ export function buildMapCommands({ match, pool, seriesMap, secrets, settings })
`tv_enable ${settings.tvEnabled ? 1 : 0}`, `tv_enable ${settings.tvEnabled ? 1 : 0}`,
`tv_delay ${Number(settings.tvDelaySeconds)}`, `tv_delay ${Number(settings.tvDelaySeconds)}`,
`tv_password "${secrets.tvPassword}"`, `tv_password "${secrets.tvPassword}"`,
// Live-Ergebnisse: Server schickt seine Logzeilen per HTTP an das Backend
...(logUrl && /^https?:\/\/[\w.:/-]+$/.test(logUrl)
? ['log on', 'logaddress_delall_http', `logaddress_add_http "${logUrl}"`]
: []),
`changelevel ${seriesMap.map}`, `changelevel ${seriesMap.map}`,
]; ];
+69
View File
@@ -0,0 +1,69 @@
// DEMO: erzeugt einen zufälligen Matchverlauf als CS2-Logzeilen.
// Die Zeilen laufen durch denselben Parser wie echte Serverlogs, so lässt sich die
// Live-Anzeige ohne CS2-Server testen. Ergebnisse sind frei erfunden.
const REASONS = {
CT: ['CTs_Win', 'Bomb_Defused', 'Target_Saved'],
T: ['Terrorists_Win', 'Target_Bombed'],
};
const stamp = () => {
const d = new Date();
const p = (n, l = 2) => String(n).padStart(l, '0');
return `${p(d.getMonth() + 1)}/${p(d.getDate())}/${d.getFullYear()} - ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}.${p(d.getMilliseconds(), 3)} - `;
};
export class DemoMatchSimulator {
constructor({ random = Math.random } = {}) {
this.runs = new Map();
this.random = random;
}
start(matchId, { map, teamNames, ctTeam, maxRounds, roundMs, onLines }) {
this.stop(matchId);
const other = (t) => (t === 'A' ? 'B' : 'A');
const run = { sides: { CT: ctTeam, T: other(ctTeam) }, score: { A: 0, B: 0 }, played: 0 };
const toWin = maxRounds / 2 + 1;
// Leichte Stärke-Unterschiede, damit nicht jedes Demo-Match gleich aussieht
const strengthA = 0.4 + this.random() * 0.2;
const teamLines = () => [
`${stamp()}MatchStatus: Team playing "CT": ${teamNames[run.sides.CT]}`,
`${stamp()}MatchStatus: Team playing "TERRORIST": ${teamNames[run.sides.T]}`,
];
onLines([...teamLines(), `${stamp()}World triggered "Match_Start" on "${map}"`]);
run.timer = setInterval(() => {
const winner = this.random() < strengthA ? 'A' : 'B';
const side = run.sides.CT === winner ? 'CT' : 'T';
run.score[winner] += 1;
run.played += 1;
const notice = REASONS[side][Math.floor(this.random() * REASONS[side].length)];
const lines = [
`${stamp()}Team "${side === 'CT' ? 'CT' : 'TERRORIST'}" triggered "SFUI_Notice_${notice}" (CT "${run.score[run.sides.CT]}") (T "${run.score[run.sides.T]}")`,
];
if (run.score[winner] >= toWin) {
lines.push(`${stamp()}Game Over: competitive mg_active ${map} score ${run.score[run.sides.CT]}:${run.score[run.sides.T]} after 1 min`);
onLines(lines);
this.stop(matchId);
return;
}
if (run.played === maxRounds / 2) {
run.sides = { CT: run.sides.T, T: run.sides.CT };
lines.push(...teamLines());
}
onLines(lines);
}, roundMs);
this.runs.set(matchId, run);
}
stop(matchId) {
const run = this.runs.get(matchId);
if (run) clearInterval(run.timer);
this.runs.delete(matchId);
}
stopAll() {
for (const id of [...this.runs.keys()]) this.stop(id);
}
}
+32 -1
View File
@@ -1,4 +1,5 @@
import { buildMapCommands } from './cs2Config.js'; import { buildMapCommands, sanitizeConsoleText } from './cs2Config.js';
import { DemoMatchSimulator } from './demoMatchSimulator.js';
import { HttpError, CLOSED_STATUSES } from '../matchService.js'; import { HttpError, CLOSED_STATUSES } from '../matchService.js';
import { randomPassword } from '../security.js'; import { randomPassword } from '../security.js';
@@ -24,6 +25,7 @@ export class ServerManager {
constructor({ db, service, adapter, config, logger = console }) { constructor({ db, service, adapter, config, logger = console }) {
Object.assign(this, { db, service, adapter, config, logger }); Object.assign(this, { db, service, adapter, config, logger });
this.busy = new Set(); this.busy = new Set();
this.simulator = new DemoMatchSimulator();
this.lastPoll = new Map(); this.lastPoll = new Map();
this.timers = []; this.timers = [];
} }
@@ -53,6 +55,7 @@ export class ServerManager {
} }
stopLoops() { stopLoops() {
this.simulator.stopAll();
this.timers.forEach(clearInterval); this.timers.forEach(clearInterval);
this.timers = []; this.timers = [];
} }
@@ -220,7 +223,10 @@ export class ServerManager {
seriesMap: { map: seriesMap.map, ctTeam: seriesMap.ct_team }, seriesMap: { map: seriesMap.map, ctTeam: seriesMap.ct_team },
secrets: { serverPassword: row.server_password, tvPassword: row.tv_password }, secrets: { serverPassword: row.server_password, tvPassword: row.tv_password },
settings: this.config.match, settings: this.config.match,
logUrl: `${this.config.logReceiverUrl}/api/logs/${matchId}/${row.config_key}`,
}); });
this.simulator.stop(matchId);
this.service.resetLiveScore(matchId, seriesMap.number);
try { try {
await this.adapter.configure(JSON.parse(row.handle), { map: seriesMap.map, ...plan }); await this.adapter.configure(JSON.parse(row.handle), { map: seriesMap.map, ...plan });
} catch (err) { } catch (err) {
@@ -286,9 +292,33 @@ export class ServerManager {
throw new HttpError(502, `Befehl konnte nicht gesendet werden: ${err.message}`, 'server_error'); throw new HttpError(502, `Befehl konnte nicht gesendet werden: ${err.message}`, 'server_error');
} }
this.service.log(viewer.matchId, 'server', this.adapter.simulated ? 'Aufwärmphase beendet (simuliert)' : 'Aufwärmphase beendet – Match startet'); this.service.log(viewer.matchId, 'server', this.adapter.simulated ? 'Aufwärmphase beendet (simuliert)' : 'Aufwärmphase beendet – Match startet');
if (this.adapter.simulated) this.startDemoSimulation(viewer.matchId);
this.service.changed(viewer.matchId); this.service.changed(viewer.matchId);
} }
/** Demo-Modus: simuliert einen Matchverlauf, indem echte CS2-Logzeilen erzeugt und ausgewertet werden. */
startDemoSimulation(matchId) {
const match = this.service.getMatch(matchId);
const seriesMap = this.service.seriesMaps(matchId).find((m) => m.number === match.current_map_number);
if (!seriesMap) return;
this.service.log(matchId, 'server', 'Demo: simulierter Spielverlauf startet (keine echten Ergebnisse)');
this.simulator.start(matchId, {
map: seriesMap.map,
teamNames: { A: sanitizeConsoleText(match.team_a_name), B: sanitizeConsoleText(match.team_b_name) },
ctTeam: seriesMap.ct_team ?? 'A',
maxRounds: match.mode === 'wingman' ? this.config.match.maxRoundsWingman : this.config.match.maxRoundsCompetitive,
roundMs: this.config.demo.roundSeconds * 1000,
onLines: (lines) => {
try {
this.service.applyLiveLog(matchId, lines);
} catch (err) {
this.logger.warn(`[demo] Simulation für ${matchId}: ${err.message}`);
this.simulator.stop(matchId);
}
},
});
}
async stopByOrganizer(viewer) { async stopByOrganizer(viewer) {
this.service.require(viewer, 'organizer'); this.service.require(viewer, 'organizer');
await this.stop(viewer.matchId, 'Vom Organisator gestoppt'); await this.stop(viewer.matchId, 'Vom Organisator gestoppt');
@@ -303,6 +333,7 @@ export class ServerManager {
} }
async stop(matchId, reason) { async stop(matchId, reason) {
this.simulator.stop(matchId);
const row = this.row(matchId); const row = this.row(matchId);
if (!row || row.status === 'stopped') return; if (!row || row.status === 'stopped') return;
if (row.handle) await this.safeStop(row); if (row.handle) await this.safeStop(row);
+56
View File
@@ -253,6 +253,62 @@ test('Organisator spielt im selben Browser selbst mit (zwei Zugänge)', async ()
} }
}); });
test('Live-Ergebnisse aus Serverlogs und Demo-Simulation', async () => {
const { app, call, close } = await setup({ demo: { startSeconds: 0, failFirstConfigure: false, roundSeconds: 0 } });
try {
const { id, org, tokens } = await readyWingmanMatch(call, 'bo3');
await call('POST', `/matches/${id}/veto/start`, { token: org });
await playVeto(call, id, { A: tokens.a1, B: tokens.b1 });
await app.manager.poll();
await waitFor(() => app.service.getMatch(id).status === 'live', { label: 'live' });
const { config_key: key } = app.service.serverRow(id);
const map1 = app.service.seriesMaps(id)[0];
const base = `http://127.0.0.1:${app.server.address().port}/api/logs/${id}`;
const post = (k, text) => fetch(`${base}/${k}`, { method: 'POST', headers: { 'content-type': 'text/plain' }, body: text });
assert.equal((await post('falsch', 'x')).status, 403);
const ct = map1.ct_team === 'A' ? 'Alpha' : 'Bravo';
const t = map1.ct_team === 'A' ? 'Bravo' : 'Alpha';
const res = await post(key, [
`L 10/25/2026 - 18:00:00: MatchStatus: Team playing "CT": ${ct}`,
`L 10/25/2026 - 18:00:00: MatchStatus: Team playing "TERRORIST": ${t}`,
`L 10/25/2026 - 18:00:01: World triggered "Match_Start" on "${map1.map}"`,
'L 10/25/2026 - 18:01:00: Team "CT" triggered "SFUI_Notice_CTs_Win" (CT "1") (T "0")',
'L 10/25/2026 - 18:02:00: Team "TERRORIST" triggered "SFUI_Notice_Target_Bombed" (CT "1") (T "1")',
].join('\n'));
assert.equal(res.status, 200);
const snap = (await call('GET', `/matches/${id}`, { token: org })).body;
const ctTeam = map1.ct_team;
assert.equal(snap.series[0].score[ctTeam], 1);
assert.equal(snap.series[0].live.rounds.length, 2);
assert.equal(snap.series[0].live.rounds[1].reason, 'bomb');
await post(key, `Game Over: wingman mg_active ${map1.map} score 1:1 after 3 min`);
// Gleichstand beendet die Map ohne Sieger; Serienstand bleibt 0:0
const after = (await call('GET', `/matches/${id}`, { token: org })).body;
assert.equal(after.series[0].status, 'done');
assert.deepEqual(after.seriesScore, { A: 0, B: 0 });
// Map 2 laden und den Demo-Verlauf bis zum Ende laufen lassen
await call('POST', `/matches/${id}/series/next`, { token: org });
await waitFor(() => app.service.serverRow(id).status === 'configured' && app.service.getMatch(id).current_map_number === 2, { label: 'Map 2' });
assert.equal((await call('POST', `/matches/${id}/server/warmup-end`, { token: org })).status, 200);
await waitFor(() => app.service.seriesMaps(id)[1].status === 'done', { label: 'Demo-Map beendet', timeoutMs: 15000 });
const final = (await call('GET', `/matches/${id}`, { token: org })).body;
const winner = final.series[1].winner;
assert.ok(['A', 'B'].includes(winner));
assert.equal(final.series[1].score[winner], 9, 'Wingman: 9 Runden zum Sieg');
assert.equal(final.seriesScore[winner], 1);
const list = (await call('GET', '/matches')).body.matches.find((m) => m.id === id);
assert.equal(list.seriesScore[winner], 1);
} finally {
await close();
}
});
test('Spieler verlassen die Lobby, Captain geht über', async () => { test('Spieler verlassen die Lobby, Captain geht über', async () => {
const { call, close } = await setup(); const { call, close } = await setup();
try { try {
+87
View File
@@ -0,0 +1,87 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { applyLogLines, initialLiveState, seriesScore, splitLogBody } from '../src/liveScore.js';
const ctx = { teamNames: { A: 'MaxLAN Blau', B: 'MaxLAN Rot' }, maxRounds: 24, overtimeRounds: 6 };
const ts = '10/25/2026 - 18:00:00.000 - ';
function round(winnerSide, ct, t, notice) {
const n = notice ?? (winnerSide === 'CT' ? 'CTs_Win' : 'Terrorists_Win');
const side = winnerSide === 'CT' ? 'CT' : 'TERRORIST';
return `${ts}Team "${side}" triggered "SFUI_Notice_${n}" (CT "${ct}") (T "${t}")`;
}
test('Logzeilen ergeben Spielstand, Rundenverlauf und Gründe', () => {
let state = initialLiveState({ map: 'de_mirage', ctTeam: 'A' });
const lines = [
`${ts}World triggered "Match_Start" on "de_mirage"`,
round('T', 0, 1, 'Target_Bombed'),
round('CT', 1, 1, 'Bomb_Defused'),
round('CT', 2, 1, 'Target_Saved'),
];
const { state: next, events } = applyLogLines(state, lines, ctx);
state = next;
assert.equal(state.status, 'live');
assert.deepEqual(state.score, { A: 2, B: 1 });
assert.deepEqual(state.rounds.map((r) => [r.winner, r.reason]), [['B', 'bomb'], ['A', 'defuse'], ['A', 'time']]);
assert.equal(events.filter((e) => e.type === 'round').length, 3);
});
test('Doppelt gesendete Zeilen werden nur einmal gezählt', () => {
const state = initialLiveState({ map: 'de_nuke', ctTeam: 'A' });
const line = round('CT', 1, 0);
const { state: s } = applyLogLines(state, [line, line, line], ctx);
assert.equal(s.rounds.length, 1);
assert.deepEqual(s.score, { A: 1, B: 0 });
});
test('Seitenwechsel über "Team playing"-Zeilen', () => {
let s = initialLiveState({ map: 'de_nuke', ctTeam: 'A' });
({ state: s } = applyLogLines(s, [
`${ts}MatchStatus: Team playing "CT": MaxLAN Blau`,
`${ts}MatchStatus: Team playing "TERRORIST": MaxLAN Rot`,
`${ts}World triggered "Match_Start" on "de_nuke"`,
round('CT', 1, 0),
// Halbzeit: Rot ist jetzt CT. In den Seitenwerten steht der Stand der Teams auf der jeweiligen Seite.
`${ts}MatchStatus: Team playing "CT": MaxLAN Rot`,
`${ts}MatchStatus: Team playing "TERRORIST": MaxLAN Blau`,
round('CT', 1, 1),
], ctx));
assert.deepEqual(s.score, { A: 1, B: 1 });
assert.deepEqual(s.rounds.map((r) => r.winner), ['A', 'B']);
});
test('Ohne "Team playing": Halbzeitwechsel nach Regelwerk', () => {
let s = initialLiveState({ map: 'de_inferno', ctTeam: 'B' });
const small = { ...ctx, maxRounds: 4 };
({ state: s } = applyLogLines(s, [
`${ts}World triggered "Match_Start" on "de_inferno"`,
round('CT', 1, 0), // B gewinnt als CT
round('CT', 2, 0), // B gewinnt als CT -> Halbzeit, B ist jetzt T
round('T', 0, 3), // B gewinnt als T: CT-Seite (A) hat 0, T-Seite (B) hat 3
], small));
assert.deepEqual(s.score, { A: 0, B: 3 });
assert.deepEqual(s.rounds.map((r) => r.winner), ['B', 'B', 'B']);
});
test('Game Over beendet die Map, Aufwärmrunden vor Match_Start zählen nicht', () => {
let s = initialLiveState({ map: 'de_dust2', ctTeam: 'A' });
({ state: s } = applyLogLines(s, [
round('T', 0, 1), // Aufwärm-/Messerrunde
`${ts}World triggered "Match_Start" on "de_dust2"`,
round('CT', 1, 0),
`${ts}Game Over: competitive mg_active de_dust2 score 1:0 after 2 min`,
round('T', 1, 1), // nach Spielende ignoriert
], ctx));
assert.equal(s.status, 'finished');
assert.equal(s.winner, 'A');
assert.deepEqual(s.score, { A: 1, B: 0 });
});
test('Zeilen einer anderen Map werden ignoriert, Serienstand wird gezählt', () => {
const s = initialLiveState({ map: 'de_dust2', ctTeam: 'A' });
const { state } = applyLogLines(s, [`${ts}Game Over: competitive mg_active de_nuke score 13:2 after 30 min`], ctx);
assert.equal(state.status, 'warmup');
assert.deepEqual(seriesScore([{ winner: 'A' }, { winner: 'B' }, { winner: 'A' }, { winner: null }]), { A: 2, B: 1 });
assert.deepEqual(splitLogBody('a\r\n\r\nb\n'), ['a', 'b']);
});
+15
View File
@@ -65,3 +65,18 @@ test('Konsolentexte können keine Befehle einschleusen', () => {
assert.ok(afterMapChange.includes('mp_teamname_2 "Alphaquit"')); assert.ok(afterMapChange.includes('mp_teamname_2 "Alphaquit"'));
assert.ok(afterMapChange.includes('mp_maxrounds 16')); assert.ok(afterMapChange.includes('mp_maxrounds 16'));
}); });
test('Log-Weiterleitung wird vor dem Mapwechsel gesetzt, unsichere URLs nicht', () => {
const base = {
match: { team_a_name: 'A', team_b_name: 'B' },
pool: { mode: 'competitive', gameType: 0, gameMode: 1 },
seriesMap: { map: 'de_nuke', ctTeam: 'A' },
secrets: { serverPassword: 'abc', tvPassword: 'def' },
settings: { tvEnabled: true, tvDelaySeconds: 90, maxRoundsWingman: 16, maxRoundsCompetitive: 24, overtime: true, extraCommands: [] },
};
const ok = buildMapCommands({ ...base, logUrl: 'http://192.168.178.31:3000/api/logs/abc_D-1/KEY123' }).beforeMapChange;
assert.ok(ok.includes('logaddress_add_http "http://192.168.178.31:3000/api/logs/abc_D-1/KEY123"'));
assert.ok(ok.indexOf('log on') < ok.indexOf('changelevel de_nuke'));
const bad = buildMapCommands({ ...base, logUrl: 'http://x"; quit' }).beforeMapChange;
assert.ok(!bad.some((c) => c.startsWith('logaddress')));
});