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 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5fade769e7
commit
43dc5b837a
20 files changed
+901
-44
No files matched your search
@@ -33,6 +33,9 @@ export function loadConfig(overrides = {}) {
|
||||
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(/\/$/, ''),
|
||||
logDebug: bool('LOG_DEBUG', false),
|
||||
// Sekunden nach Map-Ende bis zur nächsten Map bzw. zum Abschluss der Serie; -1 = nur manuell
|
||||
autoAdvanceSeconds: int('SERIES_AUTO_ADVANCE_SECONDS', 30),
|
||||
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')),
|
||||
createMatchPassword: str('CREATE_MATCH_PASSWORD'),
|
||||
|
||||
+4
-1
@@ -20,7 +20,10 @@ export function createApiRouter({ service, manager, config, pools, adapter }) {
|
||||
// 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 : '');
|
||||
const body = typeof req.body === 'string' ? req.body : '';
|
||||
// LOG_DEBUG=true: empfangene Zeilen ausgeben, z. B. um das Logformat eines Servers zu prüfen
|
||||
if (config.logDebug) console.log(`[logs ${req.params.id}] ${body.trim().replace(/\n/g, `\n[logs ${req.params.id}] `)}`);
|
||||
service.ingestLogs(req.params.id, req.params.key, body);
|
||||
res.status(200).end();
|
||||
} catch (err) {
|
||||
res.status(err.status ?? 500).end();
|
||||
|
||||
+57
-17
@@ -5,13 +5,18 @@
|
||||
// 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)
|
||||
|
||||
const RE = {
|
||||
matchStart: /World triggered "Match_Start" on "([^"]+)"/,
|
||||
warmupStart: /World triggered "Warmup_Start"/,
|
||||
worldRoundEnd: /World triggered "Round_End"/,
|
||||
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+)/,
|
||||
// Das Wort direkt vor "score" ist die Map – Modus und Map-Gruppe davor sind beliebig bzw. leer.
|
||||
gameOver: /Game Over:.*?(\S+)\s+score\s+(\d+):(\d+)/,
|
||||
};
|
||||
|
||||
// Gründe für ein Rundenende aus den SFUI-Hinweisen
|
||||
@@ -28,18 +33,23 @@ const REASONS = {
|
||||
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 }) {
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -55,16 +65,42 @@ export function applyLogLines(state, lines, ctx) {
|
||||
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 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 });
|
||||
// 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) };
|
||||
events.push({ type: 'match_start', map: s.map });
|
||||
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;
|
||||
@@ -75,32 +111,36 @@ export function applyLogLines(state, lines, ctx) {
|
||||
}
|
||||
} else if ((m = RE.roundEnd.exec(line))) {
|
||||
if (s.status === 'finished') continue;
|
||||
finalize();
|
||||
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);
|
||||
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 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Ersatz, wenn der Server keine "Team playing"-Zeilen schickt: Seitenwechsel nach Regelwerk.
|
||||
* 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 maybeSwitchSides(s, roundsPlayed, { maxRounds, overtimeRounds = 6 }) {
|
||||
function switchSidesByRules(s, roundsPlayed, { maxRounds, overtimeRounds = 6 }) {
|
||||
const half = maxRounds / 2;
|
||||
const otHalf = overtimeRounds / 2;
|
||||
const regulationSwitch = roundsPlayed === half;
|
||||
|
||||
@@ -416,9 +416,14 @@ export class MatchService extends EventEmitter {
|
||||
|
||||
advanceSeries(viewer) {
|
||||
this.require(viewer, 'organizer');
|
||||
return this.advanceSeriesFor(viewer.matchId);
|
||||
}
|
||||
|
||||
/** Nächste Map der Serie vormerken (Organisator oder automatisch nach Map-Ende). */
|
||||
advanceSeriesFor(matchId) {
|
||||
let nextNumber;
|
||||
this.db.transaction(() => {
|
||||
const match = this.getMatch(viewer.matchId);
|
||||
const match = this.getMatch(matchId);
|
||||
if (match.status !== 'live') throw new HttpError(409, 'Die nächste Map kann erst geladen werden, wenn das Match läuft', 'wrong_phase');
|
||||
const maps = this.seriesMaps(match.id);
|
||||
nextNumber = match.current_map_number + 1;
|
||||
@@ -430,7 +435,7 @@ export class MatchService extends EventEmitter {
|
||||
.run(nextNumber, this.now(), match.id);
|
||||
this.log(match.id, 'series', `Map ${nextNumber} der Serie wird geladen`);
|
||||
})();
|
||||
this.changed(viewer.matchId);
|
||||
this.changed(matchId);
|
||||
return nextNumber;
|
||||
}
|
||||
|
||||
@@ -443,6 +448,18 @@ export class MatchService extends EventEmitter {
|
||||
.run(matchId, number);
|
||||
}
|
||||
|
||||
/** Organisator hat die Aufwärmphase beendet: ab dem nächsten Match_Start wird gezählt. */
|
||||
markWarmupEnded(matchId) {
|
||||
const match = this.getMatch(matchId);
|
||||
const row = this.seriesMaps(matchId).find((m) => m.number === match.current_map_number);
|
||||
if (!row) return;
|
||||
const state = row.live ? JSON.parse(row.live) : initialLiveState({ map: row.map, ctTeam: row.ct_team });
|
||||
state.warmupEnded = true;
|
||||
this.db
|
||||
.prepare('UPDATE series_maps SET live = ? WHERE match_id = ? AND number = ?')
|
||||
.run(JSON.stringify(state), matchId, row.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);
|
||||
@@ -488,6 +505,10 @@ export class MatchService extends EventEmitter {
|
||||
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']}`);
|
||||
const hasNext = maps.some((m) => m.number === ended.row.number + 1);
|
||||
this.changed(matchId);
|
||||
this.emit('mapEnded', { matchId, number: ended.row.number, decided, hasNext });
|
||||
return;
|
||||
}
|
||||
this.changed(matchId);
|
||||
}
|
||||
@@ -505,12 +526,16 @@ export class MatchService extends EventEmitter {
|
||||
|
||||
close(viewer, status) {
|
||||
this.require(viewer, 'organizer');
|
||||
const match = this.getMatch(viewer.matchId);
|
||||
this.closeMatch(viewer.matchId, status);
|
||||
}
|
||||
|
||||
closeMatch(matchId, status, message) {
|
||||
const match = this.getMatch(matchId);
|
||||
if (CLOSED_STATUSES.includes(match.status)) throw new HttpError(409, 'Das Match ist bereits beendet', 'match_closed');
|
||||
if (status === 'finished') {
|
||||
this.db.prepare("UPDATE series_maps SET status = 'done' WHERE match_id = ? AND status IN ('live','next')").run(match.id);
|
||||
}
|
||||
this.log(match.id, 'status', status === 'finished' ? 'Match abgeschlossen' : 'Match abgebrochen');
|
||||
this.log(match.id, 'status', message ?? (status === 'finished' ? 'Match abgeschlossen' : 'Match abgebrochen'));
|
||||
this.setStatus(match.id, status);
|
||||
this.emit('closed', match.id);
|
||||
}
|
||||
@@ -639,6 +664,7 @@ export class MatchService extends EventEmitter {
|
||||
},
|
||||
canStartVeto: match.status === 'lobby' && readiness.A.ready && readiness.B.ready,
|
||||
requireFullTeams: this.config.requireFullTeams,
|
||||
autoAdvanceSeconds: this.config.autoAdvanceSeconds,
|
||||
maps: pool.maps,
|
||||
veto: publicView
|
||||
? null
|
||||
|
||||
@@ -39,8 +39,11 @@ export class DemoMatchSimulator {
|
||||
run.score[winner] += 1;
|
||||
run.played += 1;
|
||||
const notice = REASONS[side][Math.floor(this.random() * REASONS[side].length)];
|
||||
// Reihenfolge wie bei einem echten CS2-Server: Rundenende, Seitenzuordnung der gespielten Runde, Round_End
|
||||
const lines = [
|
||||
`${stamp()}Team "${side === 'CT' ? 'CT' : 'TERRORIST'}" triggered "SFUI_Notice_${notice}" (CT "${run.score[run.sides.CT]}") (T "${run.score[run.sides.T]}")`,
|
||||
...teamLines(),
|
||||
`${stamp()}World triggered "Round_End"`,
|
||||
];
|
||||
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`);
|
||||
@@ -48,10 +51,8 @@ export class DemoMatchSimulator {
|
||||
this.stop(matchId);
|
||||
return;
|
||||
}
|
||||
if (run.played === maxRounds / 2) {
|
||||
run.sides = { CT: run.sides.T, T: run.sides.CT };
|
||||
lines.push(...teamLines());
|
||||
}
|
||||
// Halbzeit: Seiten wechseln, die nächsten Zeilen nennen die neue Zuordnung
|
||||
if (run.played === maxRounds / 2) run.sides = { CT: run.sides.T, T: run.sides.CT };
|
||||
onLines(lines);
|
||||
}, roundMs);
|
||||
this.runs.set(matchId, run);
|
||||
|
||||
@@ -26,6 +26,7 @@ export class ServerManager {
|
||||
Object.assign(this, { db, service, adapter, config, logger });
|
||||
this.busy = new Set();
|
||||
this.simulator = new DemoMatchSimulator();
|
||||
this.autoAdvance = new Map();
|
||||
this.lastPoll = new Map();
|
||||
this.timers = [];
|
||||
}
|
||||
@@ -34,6 +35,37 @@ export class ServerManager {
|
||||
this.service.on('created', (id) => this.provision(id).catch((err) => this.logger.error(err)));
|
||||
this.service.on('vetoFinished', (id) => this.maybeConfigure(id));
|
||||
this.service.on('closed', (id) => this.stop(id, 'Match beendet').catch((err) => this.logger.error(err)));
|
||||
this.service.on('mapEnded', (e) => this.onMapEnded(e));
|
||||
}
|
||||
|
||||
/**
|
||||
* Nach Map-Ende: ist die Serie entschieden, wird das Match abgeschlossen, sonst die nächste Map geladen.
|
||||
* Die Wartezeit lässt Spielern und Stream Zeit für den Endstand. Lädt der Organisator vorher selbst, entfällt es.
|
||||
*/
|
||||
onMapEnded({ matchId, number, decided, hasNext }) {
|
||||
const seconds = this.config.autoAdvanceSeconds;
|
||||
if (seconds < 0) return;
|
||||
clearTimeout(this.autoAdvance.get(matchId));
|
||||
const finish = Boolean(decided) || !hasNext;
|
||||
this.service.log(matchId, 'series', finish
|
||||
? `Serie entschieden – das Match wird in ${seconds} s automatisch abgeschlossen`
|
||||
: `Map ${number + 1} wird in ${seconds} s automatisch geladen`);
|
||||
this.service.changed(matchId);
|
||||
this.autoAdvance.set(matchId, setTimeout(() => {
|
||||
this.autoAdvance.delete(matchId);
|
||||
const match = this.service.getMatch(matchId);
|
||||
if (CLOSED_STATUSES.includes(match.status) || match.current_map_number !== number) return;
|
||||
try {
|
||||
if (finish) {
|
||||
this.service.closeMatch(matchId, 'finished', 'Match automatisch abgeschlossen – Serie entschieden');
|
||||
} else if (this.row(matchId)?.status === 'configured') {
|
||||
this.service.advanceSeriesFor(matchId);
|
||||
this.configure(matchId).catch((err) => this.logger.error(err));
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn(`[serie] Automatischer Wechsel für ${matchId} fehlgeschlagen: ${err.message}`);
|
||||
}
|
||||
}, seconds * 1000));
|
||||
}
|
||||
|
||||
/** Nach einem Neustart des Backends: unterbrochene Vorgänge aufräumen und fortsetzen. */
|
||||
@@ -56,6 +88,8 @@ export class ServerManager {
|
||||
|
||||
stopLoops() {
|
||||
this.simulator.stopAll();
|
||||
for (const t of this.autoAdvance.values()) clearTimeout(t);
|
||||
this.autoAdvance.clear();
|
||||
this.timers.forEach(clearInterval);
|
||||
this.timers = [];
|
||||
}
|
||||
@@ -277,6 +311,8 @@ export class ServerManager {
|
||||
this.service.require(viewer, 'organizer');
|
||||
const row = this.row(viewer.matchId);
|
||||
if (row?.status !== 'configured') throw new HttpError(409, 'Der Server ist nicht bereit für die nächste Map', 'wrong_state');
|
||||
clearTimeout(this.autoAdvance.get(viewer.matchId));
|
||||
this.autoAdvance.delete(viewer.matchId);
|
||||
this.service.advanceSeries(viewer);
|
||||
this.configure(viewer.matchId).catch((err) => this.logger.error(err));
|
||||
}
|
||||
@@ -292,6 +328,7 @@ export class ServerManager {
|
||||
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.markWarmupEnded(viewer.matchId);
|
||||
if (this.adapter.simulated) this.startDemoSimulation(viewer.matchId);
|
||||
this.service.changed(viewer.matchId);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
Reference in new issue
Block a user