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:
2026-09-13 22:47:03 -04:00
parent 8186bb1ccc
commit efd4a4fa66
7 changed files with 87 additions and 97 deletions
+2 -1
View File
@@ -42,7 +42,8 @@ const SERVERS_COLUMNS: Array<{ name: string; ddl: string }> = [
{ name: 'active_since', ddl: 'ALTER TABLE servers ADD COLUMN active_since TEXT' },
{ name: 'inactive_since', ddl: 'ALTER TABLE servers ADD COLUMN inactive_since TEXT' },
{ name: 'game_started_at', ddl: 'ALTER TABLE servers ADD COLUMN game_started_at TEXT' },
{ name: 'game_ended_at', ddl: 'ALTER TABLE servers ADD COLUMN game_ended_at TEXT' }
{ name: 'game_ended_at', ddl: 'ALTER TABLE servers ADD COLUMN game_ended_at TEXT' },
{ name: 'game_cycle_key', ddl: 'ALTER TABLE servers ADD COLUMN game_cycle_key TEXT' }
];
function migrateServers(handle: Database.Database): void {
+3 -2
View File
@@ -38,8 +38,9 @@ CREATE TABLE IF NOT EXISTS servers (
-- and resume from the same anchor when the server comes back.
active_since TEXT, -- ISO ts of last 0 -> >0 player transition
inactive_since TEXT, -- ISO ts of last >0 -> 0 player transition
game_started_at TEXT, -- ISO ts we first observed InitGame: for the current match
game_ended_at TEXT, -- ISO ts we observed ExitLevel/ShutdownGame ending it
game_started_at TEXT, -- wall-clock ISO when we first saw a J; line for the current match cycle
game_ended_at TEXT, -- wall-clock ISO when we first saw ExitLevel/ShutdownGame end it
game_cycle_key TEXT, -- opaque marker for the currently-tracked cycle (the first J; line after the latest InitGame); lets us detect same-players game restarts
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
+39 -71
View File
@@ -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 };
}
+3 -1
View File
@@ -88,6 +88,7 @@ export interface ServerRow {
inactive_since: string | null;
game_started_at: string | null;
game_ended_at: string | null;
game_cycle_key: string | null;
created_at: string;
}
@@ -177,7 +178,8 @@ export function setServerField(
'active_since',
'inactive_since',
'game_started_at',
'game_ended_at'
'game_ended_at',
'game_cycle_key'
];
if (!allowed.includes(field)) throw new Error(`refusing to set ${field}`);
db().prepare(`UPDATE servers SET ${field} = ? WHERE name = ?`).run(value, name);
+33 -20
View File
@@ -55,29 +55,32 @@ async function scanMatches(): Promise<void> {
return { kind: 'no-signal' as const };
});
const persistedInProgress = !!srv.game_started_at && !srv.game_ended_at;
const persistedKey = srv.game_cycle_key;
const now = new Date().toISOString();
if (result.kind === 'in-progress' && result.startedAt) {
// Transition to in-progress on fresh state OR when a new match
// has begun after a crash left us with a stale in-progress
// anchor. The back-computed startedAt for the SAME match stays
// stable across polls (log time and wall time advance in
// lockstep), so a new anchor >30s later means a new match.
const newStart = new Date(result.startedAt).getTime();
const persistedStart = srv.game_started_at
? new Date(srv.game_started_at).getTime()
: 0;
if (!persistedInProgress || newStart > persistedStart + 30_000) {
setServerField(srv.name, 'game_started_at', result.startedAt);
if (result.kind === 'in-progress' && result.cycleKey) {
// New cycle when persisted key differs (fresh start, or a game
// restart with the same players — new J; line = new key).
if (result.cycleKey !== persistedKey) {
setServerField(srv.name, 'game_started_at', now);
setServerField(srv.name, 'game_ended_at', null);
setServerField(srv.name, 'game_cycle_key', result.cycleKey);
} else if (!persistedInProgress) {
// Same key but previous cycle was ended — treat as new run
// (shouldn't happen in practice since key would differ,
// but be defensive).
setServerField(srv.name, 'game_started_at', now);
setServerField(srv.name, 'game_ended_at', null);
}
} else if (result.kind === 'ended') {
if (persistedInProgress && result.endedAt) {
setServerField(srv.name, 'game_ended_at', result.endedAt);
} else if (result.kind === 'ended' && result.cycleKey) {
// Only stamp the end if it's the cycle we've been tracking and
// we haven't already stamped it.
if (result.cycleKey === persistedKey && persistedInProgress) {
setServerField(srv.name, 'game_ended_at', now);
}
}
// 'idle' / 'no-signal': leave state alone. If we were previously
// in-progress and the log has rolled past the match, we'll only
// learn that later when a new InitGame appears — that's fine.
// 'idle' / 'no-signal': leave state alone. UI hides the timer when
// the server has no players (see ServerCard/detail page).
}
}
await Promise.all(Array.from({ length: STATUS_CONCURRENCY }, pump));
@@ -121,6 +124,8 @@ async function fanoutStatus(): Promise<void> {
const nextPlayers = result.status.players;
let activeSince = srv.active_since;
let inactiveSince = srv.inactive_since;
let gameStartedAt = srv.game_started_at;
let gameEndedAt = srv.game_ended_at;
if (nextPlayers > 0 && (activeSince === null || prevPlayers === 0 || prevPlayers === null)) {
activeSince = now;
inactiveSince = null;
@@ -131,6 +136,14 @@ async function fanoutStatus(): Promise<void> {
activeSince = null;
setServerField(srv.name, 'inactive_since', inactiveSince);
setServerField(srv.name, 'active_since', null);
// Game timer cannot run while inactive. Drop any tracked
// match state so the next active session anchors fresh
// when a new J; line fires.
gameStartedAt = null;
gameEndedAt = null;
setServerField(srv.name, 'game_started_at', null);
setServerField(srv.name, 'game_ended_at', null);
setServerField(srv.name, 'game_cycle_key', null);
}
// Persist last-observed state. last_seen_at is the health anchor.
setServerField(srv.name, 'last_players', nextPlayers);
@@ -155,8 +168,8 @@ async function fanoutStatus(): Promise<void> {
replySnippet: result.rawReply,
activeSince,
inactiveSince,
gameStartedAt: srv.game_started_at,
gameEndedAt: srv.game_ended_at
gameStartedAt,
gameEndedAt
};
} else {
// RCON unreachable — leave last_seen_at, active_since, and
+3 -1
View File
@@ -67,7 +67,9 @@
// ISO timestamps and re-derived from a shared 1s ticker.
let activeElapsed = $derived(activeSince ? elapsedSince(activeSince, $uiNow) : null);
let inactiveElapsed = $derived(inactiveSince ? elapsedSince(inactiveSince, $uiNow) : null);
let gameInProgress = $derived(!!gameStartedAt && !gameEndedAt);
// Game timer only runs while the server is active. Belt-and-suspenders for
// the server-side clear on inactive transition.
let gameInProgress = $derived(!!gameStartedAt && !gameEndedAt && !!activeSince);
let gameElapsed = $derived(gameInProgress ? elapsedSince(gameStartedAt, $uiNow) : null);
let showTiming = $derived(
health !== 'offline' && (activeElapsed !== null || inactiveElapsed !== null || gameElapsed !== null)