export interface ChatMessage { time: string; guid: string; slot: number; player: string; message: string; } export type ChatEntry = | { type: 'message'; message: ChatMessage } | { type: 'boundary'; event: 'start' | 'end' | 'reset'; time: string | null }; export interface ChatContext { /** Estimated from the final log write, never from the time chat is fetched. */ realTime: string | null; matchElapsedMs: number | null; round: number | null; } export type ContextualChatEntry = ChatEntry & { context: ChatContext }; export interface ChatMatch { startedAt: string; endedAt: string | null; lastReceivedAt: string; status: 'active' | 'completed' | 'interrupted'; startObserved: boolean; } export interface ChatRoundReport { receivedAt: string; event: 'game_start' | 'round_change' | 'heartbeat' | 'game_end'; round: number | null; playerCount: number; } function logTimestamp(line: string) { return /^\s*(\d+:[0-5]\d)\s+/.exec(line); } function logSeconds(time: string): number { const [minutes, seconds] = time.split(':').map(Number); return minutes * 60 + seconds; } function plainText(value: string): string { return value.replace(/\^[0-9]/g, '').replace(/[\x00-\x1f\x7f]/g, '').trim(); } /** Preserve log order: elapsed timestamps can reset between matches. */ export function parseGameChat(lines: readonly string[]): ChatMessage[] { return parseGameChatTimeline(lines).flatMap(entry => entry.type === 'message' ? [entry.message] : []); } /** Keep match boundaries alongside messages, including when the log clock resets. */ export function parseGameChatTimeline(lines: readonly string[]): ChatEntry[] { return indexedTimeline(lines).map(({ entry }) => entry); } function indexedTimeline(lines: readonly string[]): { entry: ChatEntry; lineIndex: number }[] { const entries: { entry: ChatEntry; lineIndex: number }[] = []; let previousSeconds: number | null = null; let ended = false; for (const [lineIndex, line] of lines.entries()) { const push = (entry: ChatEntry) => entries.push({ entry, lineIndex }); const timestamp = logTimestamp(line); const time = timestamp?.[1] ?? null; const seconds = time === null ? null : logSeconds(time); const event = line.slice(timestamp?.[0].length ?? 0).trimStart(); if (/^InitGame(?:\s|:|$)/.test(event)) { push({ type: 'boundary', event: 'start', time }); ended = false; } else if (/^(?:ExitLevel|ShutdownGame)(?:\s|:|$)/.test(event)) { // Engines may emit both end events for the same match. if (!ended) push({ type: 'boundary', event: 'end', time }); ended = true; } else if (seconds !== null && previousSeconds !== null && seconds < previousSeconds) { push({ type: 'boundary', event: 'reset', time }); ended = false; } // Untimed lifecycle events also clear the previous match's clock. if (seconds !== null || /^(?:InitGame|ExitLevel|ShutdownGame)(?:\s|:|$)/.test(event)) { previousSeconds = seconds; } const match = /^\s*(\d+:[0-5]\d)\s+say;([^;]+);(\d+);([^;]*);(.*)$/.exec(line); if (!match) continue; const message = plainText(match[5]); if (!message) continue; push({ type: 'message', message: { time: match[1], guid: match[2], slot: Number(match[3]), player: plainText(match[4]) || 'Unknown player', message } }); } return entries; } /** * The engine clock is not the match clock. Anchor only its final uninterrupted * segment to the file's last write, then join observed gameplay at that time. * Resets have unknown downtime, so earlier segments cannot share this anchor. */ export function enrichGameChatTimeline(lines: readonly string[], options: { mtime: string | null; matches: readonly ChatMatch[]; reports: readonly ChatRoundReport[]; }): ContextualChatEntry[] { let segmentStart = 0; let previousSeconds: number | null = null; let lastLine = -1; for (const [index, line] of lines.entries()) { if (!line.trim()) continue; lastLine = index; const timestamp = logTimestamp(line); const event = line.slice(timestamp?.[0].length ?? 0).trimStart(); const lifecycle = /^(?:InitGame|ExitLevel|ShutdownGame)(?:\s|:|$)/.test(event); if (!timestamp) { if (lifecycle) { segmentStart = index + 1; previousSeconds = null; } continue; } const seconds = logSeconds(timestamp[1]); if (previousSeconds !== null && (seconds < previousSeconds || (seconds === 0 && /^InitGame(?:\s|:|$)/.test(event)))) segmentStart = index; previousSeconds = seconds; } // An untimed final write cannot locate the preceding timed event in real time. const finalTimestamp = lastLine < 0 ? null : logTimestamp(lines[lastLine]); const writtenAt = options.mtime ? Date.parse(options.mtime) : NaN; const offset = finalTimestamp ? writtenAt - logSeconds(finalTimestamp[1]) * 1000 : NaN; const reports = options.reports.map(report => ({ ...report, at: Date.parse(report.receivedAt) })) .sort((a, b) => b.at - a.at); const matches = options.matches.map(match => ({ ...match, start: Date.parse(match.startedAt) })) .sort((a, b) => b.start - a.start) .map((match, index, sorted) => { const nextStart = sorted[index - 1]?.start ?? Infinity; // Identical heartbeats are deduplicated by the leaderboard, but still // establish that this match was running when the message was sent. const latest = reports.find(report => report.at >= match.start && report.at < nextStart); const lastSeen = Math.max(Date.parse(match.lastReceivedAt), latest?.at ?? -Infinity); const end = match.endedAt ? Date.parse(match.endedAt) : match.status === 'active' ? lastSeen + 120_000 : Date.parse(match.lastReceivedAt); return { ...match, end: Math.min(end, nextStart) }; }); let logStartedAt: number | null = null; let logEnded = false; return indexedTimeline(lines).map(({ entry, lineIndex }) => { const context: ChatContext = { realTime: null, matchElapsedMs: null, round: null }; const time = entry.type === 'message' ? entry.message.time : entry.time; const at = time !== null && lineIndex >= segmentStart ? offset + logSeconds(time) * 1000 : NaN; if (Number.isFinite(at) && Number.isFinite(new Date(at).getTime())) context.realTime = new Date(at).toISOString(); if (entry.type === 'boundary' && entry.event !== 'end') { // The first event after an implicit reset can be several seconds into // the new process. Its clock origin is the lower bound, not that event. // Untimed starts provide no safe bound for attaching an older match. logStartedAt = context.realTime ? (entry.event === 'reset' ? offset : at) : Infinity; logEnded = false; } const match = context.realTime && !logEnded ? matches.find(match => at >= match.start && at <= match.end // Allow the log's one-second precision at a start, but never cross a later InitGame. && (logStartedAt === null || match.start + 1000 >= logStartedAt || reports.some(report => report.event === 'game_start' && report.playerCount > 0 && report.at >= match.start && report.at <= at && report.at + 1000 >= logStartedAt!))) : undefined; if (match) { const report = reports.find(report => report.at <= at && report.at >= match.start); if (!report || (report.event !== 'game_end' && report.playerCount > 0)) { // A repeated start updates the fleet timer even if the leaderboard // deduplicates it. Use that same observed start for later messages. const start = reports.find(report => report.event === 'game_start' && report.playerCount > 0 && report.at >= match.start && report.at <= at)?.at ?? (match.startObserved ? match.start : null); if (start !== null) context.matchElapsedMs = at - start; if (report && at - report.at <= 120_000 && (report.event === 'round_change' || report.event === 'heartbeat')) { context.round = report.round; } } } if (entry.type === 'boundary' && entry.event === 'end') logEnded = true; return { ...entry, context }; }); }