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_END = /ExitLevel:\s*executed/;
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
// it so we can back-compute a wall-clock game_started_at from the most recent
// line — this stays accurate across process restarts and matches what the
// game itself reports (e.g. `83:55 ExitLevel: executed`).
// it so we can back-compute a wall-clock game_started_at that stays accurate
// across restarts and matches what the game itself reports (e.g. `102:47 J;...`).
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:
* - `in-progress`: the most recent InitGame in the tail has no ShutdownGame
* or ExitLevel after it. Emits `startedAt` = now - MM:SS_of_last_line.
* - `in-progress`: the most recent InitGame has at least one J; join 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,
* 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 ->
* ShutdownGame at 0:00 with no ExitLevel between them). No timer.
* - `idle`: no join line has fired inside the current InitGame cycle (empty
* map loaded, or completed idle cycle at 0:00). No timer.
* - `no-signal`: log file missing / no InitGame anywhere in the tail —
* 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() };
}
// In progress. Back-compute the wall-clock start from the most recent
// line's MM:SS prefix. Walk backwards from the tail to find the last line
// with a parseable time — falls back to the InitGame line itself (0:00).
let elapsedMs = 0;
for (let i = lines.length - 1; i > lastInit; i--) {
const t = parseTimePrefix(lines[i]);
if (t !== null) {
elapsedMs = t;
// In progress from InitGame's POV — but only anchor the game timer once a
// real player has joined. Find the FIRST J; line after lastInit.
let firstJoinIdx = -1;
for (let i = lastInit + 1; i < lines.length; i++) {
if (MATCH_JOIN.test(lines[i])) {
firstJoinIdx = i;
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 };
}