Live-Ergebnisse: Stream-Seite, Anzeige, automatischer Mapwechsel

- Stream-Übersicht unter /stream/<Zuschauercode> (1920x1080, Lobby, Veto, Spielstand)
- Live-Stand, Rundenverlauf und Serienstand auf Match-Seite und Übersicht
- Parser mit echten AMP-Logs geprüft: Game Over mit leerer Map-Gruppe,
  Match_Start vor der Aufwärmphase, veraltete Seitenzuordnung nach der Halbzeit
- Nach Map-Ende automatisch nächste Map bzw. Abschluss der entschiedenen Serie
- LOG_DEBUG zur Fehlersuche, README und .env.example ergänzt
This commit is contained in:
mregosar committed 2026-10-25 19:20:10 +01:00
1 parent 306b3aa7e5
commit 263c7e7b7c
20 files changed
+901 -44

No files matched your search

+39
View File
@@ -309,6 +309,45 @@ test('Live-Ergebnisse aus Serverlogs und Demo-Simulation', async () => {
}
});
test('Nach Map-Ende lädt die nächste Map automatisch, entschiedene Serie wird abgeschlossen', async () => {
const { app, call, close } = await setup({ autoAdvanceSeconds: 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 key = app.service.serverRow(id).config_key;
const winMapForAlpha = async (map) => {
const body = [
'Team playing "CT": Alpha',
'Team playing "TERRORIST": Bravo',
'Team "CT" triggered "SFUI_Notice_CTs_Win" (CT "1") (T "0")',
'World triggered "Round_End"',
`Game Over: scrimcomp2v2 ${map} score 1:0 after 1 min`,
].join('\n');
const res = await fetch(`http://127.0.0.1:${app.server.address().port}/api/logs/${id}/${key}`, { method: 'POST', body });
assert.equal(res.status, 200);
};
await winMapForAlpha(app.service.seriesMaps(id)[0].map);
// Serie 1:0, nicht entschieden -> Map 2 wird automatisch geladen
await waitFor(() => app.service.getMatch(id).current_map_number === 2 && app.service.serverRow(id).status === 'configured', { label: 'Map 2 automatisch' });
assert.equal(app.service.getMatch(id).status, 'live');
await winMapForAlpha(app.service.seriesMaps(id)[1].map);
// 2:0 im Best of 3 -> Match wird automatisch abgeschlossen, Map 3 nicht gespielt
await waitFor(() => app.service.getMatch(id).status === 'finished', { label: 'Serie abgeschlossen' });
await waitFor(() => app.service.serverRow(id).status === 'stopped', { label: 'Server freigegeben' });
const snap = (await call('GET', `/matches/${id}`, { token: org })).body;
assert.deepEqual(snap.seriesScore, { A: 2, B: 0 });
assert.equal(snap.series[2].winner, null);
} finally {
await close();
}
});
test('Spieler verlassen die Lobby, Captain geht über', async () => {
const { call, close } = await setup();
try {
+60 -13
View File
@@ -1,7 +1,12 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { applyLogLines, initialLiveState, seriesScore, splitLogBody } from '../src/liveScore.js';
const here = path.dirname(fileURLToPath(import.meta.url));
const ctx = { teamNames: { A: 'MaxLAN Blau', B: 'MaxLAN Rot' }, maxRounds: 24, overtimeRounds: 6 };
const ts = '10/25/2026 - 18:00:00.000 - ';
@@ -12,7 +17,7 @@ function round(winnerSide, ct, t, notice) {
}
test('Logzeilen ergeben Spielstand, Rundenverlauf und Gründe', () => {
let state = initialLiveState({ map: 'de_mirage', ctTeam: 'A' });
let state = initialLiveState({ map: 'de_mirage', ctTeam: 'A', warmupEnded: true });
const lines = [
`${ts}World triggered "Match_Start" on "de_mirage"`,
round('T', 0, 1, 'Target_Bombed'),
@@ -28,31 +33,62 @@ test('Logzeilen ergeben Spielstand, Rundenverlauf und Gründe', () => {
});
test('Doppelt gesendete Zeilen werden nur einmal gezählt', () => {
const state = initialLiveState({ map: 'de_nuke', ctTeam: 'A' });
const state = initialLiveState({ map: 'de_nuke', ctTeam: 'A', warmupEnded: true });
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' });
test('Seitenzuordnung nach dem Rundenende hat Vorrang, veraltete Zeilen zu Rundenbeginn zählen nicht', () => {
let s = initialLiveState({ map: 'de_nuke', ctTeam: 'B', warmupEnded: true });
const playing = (ct, t) => [`${ts}MatchStatus: Team playing "CT": ${ct}`, `${ts}MatchStatus: Team playing "TERRORIST": ${t}`];
({ state: s } = applyLogLines(s, [
`${ts}MatchStatus: Team playing "CT": MaxLAN Blau`,
`${ts}MatchStatus: Team playing "TERRORIST": MaxLAN Rot`,
// Veto sagt Rot auf CT, tatsächlich steht Blau auf CT – das Log gewinnt
`${ts}World triggered "Match_Start" on "de_nuke"`,
...playing('MaxLAN Blau', 'MaxLAN Rot'),
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),
...playing('MaxLAN Blau', 'MaxLAN Rot'),
`${ts}World triggered "Round_End"`,
// veraltete Zeile zu Beginn der nächsten Runde (darf nichts ändern)
...playing('MaxLAN Rot', 'MaxLAN Blau'),
round('T', 1, 1),
...playing('MaxLAN Blau', 'MaxLAN Rot'),
`${ts}World triggered "Round_End"`,
], ctx));
assert.deepEqual(s.score, { A: 1, B: 1 });
assert.deepEqual(s.rounds.map((r) => r.winner), ['A', 'B']);
});
test('Echte Logzeilen eines AMP-Servers (Wingman, 4 Runden, Halbzeit nach 2)', () => {
const lines = fs.readFileSync(path.join(here, 'fixtures', 'amp-wingman-inferno.log'), 'utf8').split(/\r?\n/).filter(Boolean);
const warmupEnd = lines.findIndex((l) => l.includes('command "mp_warmup_end"'));
const amp = { teamNames: { A: 'MaxLAN Blau', B: 'MaxLAN Rot' }, maxRounds: 4, overtimeRounds: 6 };
const check = (s) => {
assert.equal(s.status, 'finished');
assert.equal(s.winner, 'A');
assert.deepEqual(s.score, { A: 3, B: 1 });
assert.deepEqual(s.rounds.map((r) => r.winner), ['B', 'A', 'A', 'A']);
assert.ok(s.rounds.every((r) => r.reason === 'elimination'));
};
// a) wie vom Server in Paketen geschickt
let s = initialLiveState({ map: 'de_inferno', ctTeam: 'A' });
({ state: s } = applyLogLines(s, lines.slice(0, warmupEnd), amp));
assert.equal(s.status, 'warmup');
s.warmupEnded = true; // Organisator hat "Aufwärmphase beenden" geklickt
({ state: s } = applyLogLines(s, lines.slice(warmupEnd), amp));
check(s);
// b) jede Zeile einzeln (ungünstigste Paketgrenzen)
let t = initialLiveState({ map: 'de_inferno', ctTeam: 'A', warmupEnded: true });
for (const line of lines.slice(warmupEnd)) ({ state: t } = applyLogLines(t, [line], amp));
check(t);
});
test('Ohne "Team playing": Halbzeitwechsel nach Regelwerk', () => {
let s = initialLiveState({ map: 'de_inferno', ctTeam: 'B' });
let s = initialLiveState({ map: 'de_inferno', ctTeam: 'B', warmupEnded: true });
const small = { ...ctx, maxRounds: 4 };
({ state: s } = applyLogLines(s, [
`${ts}World triggered "Match_Start" on "de_inferno"`,
@@ -65,7 +101,7 @@ test('Ohne "Team playing": Halbzeitwechsel nach Regelwerk', () => {
});
test('Game Over beendet die Map, Aufwärmrunden vor Match_Start zählen nicht', () => {
let s = initialLiveState({ map: 'de_dust2', ctTeam: 'A' });
let s = initialLiveState({ map: 'de_dust2', ctTeam: 'A', warmupEnded: true });
({ state: s } = applyLogLines(s, [
round('T', 0, 1), // Aufwärm-/Messerrunde
`${ts}World triggered "Match_Start" on "de_dust2"`,
@@ -79,9 +115,20 @@ test('Game Over beendet die Map, Aufwärmrunden vor Match_Start zählen nicht',
});
test('Zeilen einer anderen Map werden ignoriert, Serienstand wird gezählt', () => {
const s = initialLiveState({ map: 'de_dust2', ctTeam: 'A' });
const s = initialLiveState({ map: 'de_dust2', ctTeam: 'A', warmupEnded: true });
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']);
});
test('Match_Start vor dem Ende der Aufwärmphase startet keine Zählung', () => {
let s = initialLiveState({ map: 'de_inferno', ctTeam: 'A' });
const start = `${ts}World triggered "Match_Start" on "de_inferno"`;
({ state: s } = applyLogLines(s, [start], ctx));
assert.equal(s.status, 'warmup');
s.warmupEnded = true;
const { state, events } = applyLogLines(s, [start], ctx);
assert.equal(state.status, 'live');
assert.equal(events[0].type, 'match_start');
});