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.
This commit is contained in:
mregosar committed 2026-10-25 18:54:23 +01:00
1 parent 34e5fc7d0e
commit 306b3aa7e5
11 files changed
+515 -11

No files matched your search

+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 () => {
const { call, close } = await setup();
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_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')));
});