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;;;`. 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 { 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 }; }