Files
electric-cherry/src/lib/server/fleet/match-log.ts
T
josh efd4a4fa66 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.
2026-09-13 22:47:03 -04:00

111 lines
4.6 KiB
TypeScript

import { resolveLogPath, tailLog } from './logs.js';
// Match markers for T5 Plutonium's `logs/games_sp.log`. Verified against a live
// sample: each game session is bracketed by `InitGame:` and `ShutdownGame:`.
// A REAL completed match always emits `ExitLevel: executed` right before
// `ShutdownGame:`. Idle map cycles print `InitGame:` / `ShutdownGame:` at
// 0:00 with no ExitLevel — those don't count as a played match.
//
// So the reliable discriminator is `ExitLevel: executed`: seeing that line
// after `healthy_at` means a game actually ended normally (no crash — a crash
// 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:/;
// 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+;/;
/**
* Poll a server's games_sp.log tail for match markers. Returns whether a
* completed match end has been observed since `sinceIso` (the server's
* `healthy_at` timestamp).
*/
export async function scanForMatchEnd(
serverName: string,
sinceIso: string
): Promise<{ ended: boolean; started: boolean; reason?: string }> {
const path = resolveLogPath(serverName, 'games_sp');
if (!path) return { ended: false, started: false, reason: 'log path resolution failed' };
const tail = await tailLog(path, 2000);
if (!tail) return { ended: false, started: false, reason: 'log not written yet' };
if (tail.mtime < sinceIso) return { ended: false, started: false, reason: 'no log activity since healthy' };
let started = false;
let ended = false;
for (const line of tail.lines) {
if (MATCH_START.test(line)) started = true;
if (MATCH_END.test(line)) ended = true;
}
return { ended, started };
}
export type MatchScanKind = 'no-signal' | 'idle' | 'in-progress' | 'ended';
export interface MatchScanResult {
kind: MatchScanKind;
// 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. 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');
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;
// 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;
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' };
// 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 (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' };
const cycleKey = lines[firstJoinIdx];
if (endIdx !== -1 && endIdx > firstJoinIdx) {
return { kind: 'ended', cycleKey };
}
return { kind: 'in-progress', cycleKey };
}