Fix game timer: cycle-key tracking, wall-clock only, active gate
games_sp.log is not wiped on restart, so old J; lines from prior sessions were poisoning the anchor. Now the scanner walks the tail backward to the LATEST InitGame and only considers lines after it, returning an opaque cycle key (the first J; line) plus a state. The worker stamps game_started_at/ended_at with Date.now() at observation time instead of inferring from the log's own timestamps. Same-players game restarts produce a new key and cleanly re-anchor. When the server transitions to 0 players, game state is cleared server-side and the UI also gates the timer display on activeSince. Adds servers.game_cycle_key column with additive migration.
This commit is contained in:
@@ -12,15 +12,10 @@ 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.
|
||||
// Player join line: `MM:SS J;<guid>;<slot>;<name>`. games_sp.log accumulates
|
||||
// across restarts and contains many old J; lines; only the ones AFTER the
|
||||
// latest InitGame belong to the current match cycle.
|
||||
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 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/;
|
||||
|
||||
/**
|
||||
* Poll a server's games_sp.log tail for match markers. Returns whether a
|
||||
@@ -51,25 +46,26 @@ export type MatchScanKind = 'no-signal' | 'idle' | 'in-progress' | 'ended';
|
||||
|
||||
export interface MatchScanResult {
|
||||
kind: MatchScanKind;
|
||||
// Populated when kind === 'in-progress': wall-clock ISO of the InitGame moment,
|
||||
// back-computed from the most recent line's MM:SS prefix so it stays accurate
|
||||
// across restarts and matches the log's own timer.
|
||||
startedAt?: string;
|
||||
// Populated when kind === 'ended': wall-clock ISO of observation.
|
||||
endedAt?: string;
|
||||
// Opaque identifier for the currently-observed match cycle. When kind is
|
||||
// 'in-progress' or 'ended', this is the first J; line after the latest
|
||||
// InitGame. Compare against persisted state to detect a fresh cycle
|
||||
// (e.g. game auto-restart with same players — new J; line = new key).
|
||||
cycleKey?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the games_sp.log tail and classify the current match state:
|
||||
* - `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`: 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.
|
||||
* Read the games_sp.log tail and classify the current match state. We do NOT
|
||||
* infer any wall-clock timestamps from log content — the caller stamps
|
||||
* game_started_at/game_ended_at with the observation time.
|
||||
*
|
||||
* - `in-progress`: latest InitGame has at least one J; join line after it
|
||||
* and no ExitLevel/ShutdownGame between that J; and end-of-tail. `cycleKey`
|
||||
* is set to the first J; line after the latest InitGame.
|
||||
* - `ended`: latest InitGame has a J; join line followed by an ExitLevel
|
||||
* or ShutdownGame. `cycleKey` identifies which cycle ended.
|
||||
* - `idle`: no J; line after the latest InitGame — empty map loaded, no
|
||||
* match happening.
|
||||
* - `no-signal`: log missing / unreadable / no InitGame in tail.
|
||||
*/
|
||||
export async function scanMatchState(serverName: string): Promise<MatchScanResult> {
|
||||
const path = resolveLogPath(serverName, 'games_sp');
|
||||
@@ -79,64 +75,36 @@ export async function scanMatchState(serverName: string): Promise<MatchScanResul
|
||||
if (!tail || tail.lines.length === 0) return { kind: 'no-signal' };
|
||||
|
||||
const lines = tail.lines;
|
||||
// Find the LATEST InitGame — everything before it is prior sessions that
|
||||
// this log accumulated. Only what comes after is the current cycle.
|
||||
let lastInit = -1;
|
||||
let lastExit = -1;
|
||||
let lastShut = -1;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (MATCH_START.test(lines[i])) lastInit = i;
|
||||
if (MATCH_END.test(lines[i])) lastExit = i;
|
||||
if (MATCH_SHUTDOWN.test(lines[i])) lastShut = i;
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
if (MATCH_START.test(lines[i])) {
|
||||
lastInit = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (lastInit === -1) return { kind: 'no-signal' };
|
||||
|
||||
const endAfterInit = Math.max(lastExit, lastShut) > lastInit;
|
||||
if (endAfterInit) {
|
||||
// Was it an idle cycle? Idle = ShutdownGame at 0:00 with no ExitLevel
|
||||
// between the InitGame and that ShutdownGame.
|
||||
const exitBetween = lastExit > lastInit && lastExit < lastShut;
|
||||
const shutIsIdle = !exitBetween && lastShut > lastInit && isZeroTime(lines[lastShut]);
|
||||
if (shutIsIdle) return { kind: 'idle' };
|
||||
return { kind: 'ended', endedAt: new Date().toISOString() };
|
||||
}
|
||||
|
||||
// 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.
|
||||
// Scan only the current cycle (everything after the latest InitGame).
|
||||
let firstJoinIdx = -1;
|
||||
let endIdx = -1;
|
||||
for (let i = lastInit + 1; i < lines.length; i++) {
|
||||
if (MATCH_JOIN.test(lines[i])) {
|
||||
if (firstJoinIdx === -1 && MATCH_JOIN.test(lines[i])) {
|
||||
firstJoinIdx = i;
|
||||
}
|
||||
if (MATCH_END.test(lines[i]) || MATCH_SHUTDOWN.test(lines[i])) {
|
||||
endIdx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// No join in the current cycle -> nobody entered this match yet.
|
||||
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 cycleKey = lines[firstJoinIdx];
|
||||
if (endIdx !== -1 && endIdx > firstJoinIdx) {
|
||||
return { kind: 'ended', cycleKey };
|
||||
}
|
||||
const elapsedSinceJoin = Math.max(0, currentGameTime - joinGameTime);
|
||||
const startedAt = new Date(Date.now() - elapsedSinceJoin).toISOString();
|
||||
return { kind: 'in-progress', startedAt };
|
||||
}
|
||||
|
||||
function parseTimePrefix(line: string): number | null {
|
||||
const m = TIME_PREFIX.exec(line);
|
||||
if (!m) return null;
|
||||
const mm = Number(m[1]);
|
||||
const ss = Number(m[2]);
|
||||
if (!Number.isFinite(mm) || !Number.isFinite(ss)) return null;
|
||||
return (mm * 60 + ss) * 1000;
|
||||
}
|
||||
|
||||
function isZeroTime(line: string): boolean {
|
||||
const t = parseTimePrefix(line);
|
||||
return t !== null && t < 2000; // <2s — idle cycles are at 0:00
|
||||
return { kind: 'in-progress', cycleKey };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user