Add active/inactive/game-time timers to fleet + server pages

Fleet cards and the per-server detail hero now show three live-ticking
timers: Active (since players > 0), Inactive (since server emptied), and
Game (since InitGame in games_sp.log, back-computed from the log's own
MM:SS prefix so it stays accurate across restarts). Anchors are
persisted on `servers` and fanned out via the SSE bus; a single 1s
ticker feeds every card. Idle map cycles are filtered out so the game
timer doesn't flicker.
This commit is contained in:
2026-09-13 21:45:54 -04:00
parent 79b2ba1464
commit 3d111880d3
11 changed files with 403 additions and 13 deletions
+87
View File
@@ -11,6 +11,12 @@ import { resolveLogPath, tailLog } from './logs.js';
// would kill the container before ExitLevel fires and take RCON with it).
export const MATCH_START = /InitGame:/;
export const MATCH_END = /ExitLevel:\s*executed/;
export const MATCH_SHUTDOWN = /ShutdownGame:/;
// 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`).
const TIME_PREFIX = /^\s*(\d+):(\d{2})\s/;
/**
* Poll a server's games_sp.log tail for match markers. Returns whether a
@@ -36,3 +42,84 @@ export async function scanForMatchEnd(
}
return { ended, started };
}
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;
}
/**
* 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.
* - `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.
* - `no-signal`: log file missing / no InitGame anywhere in the tail —
* caller should leave persisted timer state alone.
*/
export async function scanMatchState(serverName: string): Promise<MatchScanResult> {
const path = resolveLogPath(serverName, 'games_sp');
if (!path) return { kind: 'no-signal' };
const tail = await tailLog(path, 2000);
if (!tail || tail.lines.length === 0) return { kind: 'no-signal' };
const lines = tail.lines;
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;
}
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. 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;
break;
}
}
const startedAt = new Date(Date.now() - elapsedMs).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
}