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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -84,6 +84,10 @@ export interface ServerRow {
|
||||
image: string | null;
|
||||
created_by: string | null;
|
||||
destroyed_at: string | null;
|
||||
active_since: string | null;
|
||||
inactive_since: string | null;
|
||||
game_started_at: string | null;
|
||||
game_ended_at: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
@@ -169,7 +173,11 @@ export function setServerField(
|
||||
'game_id',
|
||||
'image',
|
||||
'created_by',
|
||||
'destroyed_at'
|
||||
'destroyed_at',
|
||||
'active_since',
|
||||
'inactive_since',
|
||||
'game_started_at',
|
||||
'game_ended_at'
|
||||
];
|
||||
if (!allowed.includes(field)) throw new Error(`refusing to set ${field}`);
|
||||
db().prepare(`UPDATE servers SET ${field} = ? WHERE name = ?`).run(value, name);
|
||||
|
||||
Reference in New Issue
Block a user