From 8186bb1cccf77228c8b5f16d54b7b7948e345eaa Mon Sep 17 00:00:00 2001 From: josh Date: Sun, 13 Sep 2026 21:55:05 -0400 Subject: [PATCH] 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;;;` 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. --- src/lib/server/fleet/match-log.ts | 49 +++++++++++++++++++++---------- 1 file changed, 33 insertions(+), 16 deletions(-) diff --git a/src/lib/server/fleet/match-log.ts b/src/lib/server/fleet/match-log.ts index f8ebf7f..a62f27c 100644 --- a/src/lib/server/fleet/match-log.ts +++ b/src/lib/server/fleet/match-log.ts @@ -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;;;`. 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 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 }; }