Anchor game timer to first player join, not InitGame

An idle map load emits InitGame in games_sp.log without anyone joining,
which was falsely surfacing the game timer on empty servers. Track the
first `J;<guid>;<slot>;<name>` line inside each InitGame cycle instead
and back-compute the wall-clock start from that line's in-game MM:SS.
Removes the coarse client-side activeSince gate since the scanner is
now the source of truth.
This commit is contained in:
2026-09-13 21:55:05 -04:00
parent 3d111880d3
commit 8186bb1ccc
+33 -16
View File
@@ -12,10 +12,14 @@ import { resolveLogPath, tailLog } from './logs.js';
export const MATCH_START = /InitGame:/; export const MATCH_START = /InitGame:/;
export const MATCH_END = /ExitLevel:\s*executed/; export const MATCH_END = /ExitLevel:\s*executed/;
export const MATCH_SHUTDOWN = /ShutdownGame:/; export const MATCH_SHUTDOWN = /ShutdownGame:/;
// Player join line: `MM:SS J;<guid>;<slot>;<name>`. Every joining player emits
// one — the first J; line after an InitGame is when the first player actually
// entered the match, which is what "game time" should anchor to. An empty map
// load still emits InitGame but no J; lines follow.
export const MATCH_JOIN = /\sJ;[^;]*;\d+;/;
// Every log line starts with a whitespace-padded MM:SS in-game time. Capture // Every log line starts with a whitespace-padded MM:SS in-game time. Capture
// it so we can back-compute a wall-clock game_started_at from the most recent // it so we can back-compute a wall-clock game_started_at that stays accurate
// line — this stays accurate across process restarts and matches what the // across restarts and matches what the game itself reports (e.g. `102:47 J;...`).
// game itself reports (e.g. `83:55 ExitLevel: executed`).
const TIME_PREFIX = /^\s*(\d+):(\d{2})\s/; const TIME_PREFIX = /^\s*(\d+):(\d{2})\s/;
/** /**
@@ -57,12 +61,13 @@ export interface MatchScanResult {
/** /**
* Read the games_sp.log tail and classify the current match state: * Read the games_sp.log tail and classify the current match state:
* - `in-progress`: the most recent InitGame in the tail has no ShutdownGame * - `in-progress`: the most recent InitGame has at least one J; join line
* or ExitLevel after it. Emits `startedAt` = now - MM:SS_of_last_line. * after it and no ExitLevel/ShutdownGame after that. Emits `startedAt`
* back-computed from the first join line's in-game MM:SS.
* - `ended`: the most recent InitGame is followed by ExitLevel/ShutdownGame, * - `ended`: the most recent InitGame is followed by ExitLevel/ShutdownGame,
* and the shutdown wasn't the trivial 0:00 idle cycle. Emits `endedAt` = now. * and the shutdown wasn't the trivial 0:00 idle cycle. Emits `endedAt` = now.
* - `idle`: the visible cycle was an idle map load (adjacent InitGame -> * - `idle`: no join line has fired inside the current InitGame cycle (empty
* ShutdownGame at 0:00 with no ExitLevel between them). No timer. * map loaded, or completed idle cycle at 0:00). No timer.
* - `no-signal`: log file missing / no InitGame anywhere in the tail — * - `no-signal`: log file missing / no InitGame anywhere in the tail —
* caller should leave persisted timer state alone. * caller should leave persisted timer state alone.
*/ */
@@ -95,18 +100,30 @@ export async function scanMatchState(serverName: string): Promise<MatchScanResul
return { kind: 'ended', endedAt: new Date().toISOString() }; return { kind: 'ended', endedAt: new Date().toISOString() };
} }
// In progress. Back-compute the wall-clock start from the most recent // In progress from InitGame's POV — but only anchor the game timer once a
// line's MM:SS prefix. Walk backwards from the tail to find the last line // real player has joined. Find the FIRST J; line after lastInit.
// with a parseable time — falls back to the InitGame line itself (0:00). let firstJoinIdx = -1;
let elapsedMs = 0; for (let i = lastInit + 1; i < lines.length; i++) {
for (let i = lines.length - 1; i > lastInit; i--) { if (MATCH_JOIN.test(lines[i])) {
const t = parseTimePrefix(lines[i]); firstJoinIdx = i;
if (t !== null) {
elapsedMs = t;
break; break;
} }
} }
const startedAt = new Date(Date.now() - elapsedMs).toISOString(); if (firstJoinIdx === -1) return { kind: 'idle' };
// Back-compute the wall-clock start from the delta between the first join
// line's in-game time and the most recent parseable line's in-game time.
const joinGameTime = parseTimePrefix(lines[firstJoinIdx]) ?? 0;
let currentGameTime = joinGameTime;
for (let i = lines.length - 1; i > firstJoinIdx; i--) {
const t = parseTimePrefix(lines[i]);
if (t !== null) {
currentGameTime = t;
break;
}
}
const elapsedSinceJoin = Math.max(0, currentGameTime - joinGameTime);
const startedAt = new Date(Date.now() - elapsedSinceJoin).toISOString();
return { kind: 'in-progress', startedAt }; return { kind: 'in-progress', startedAt };
} }