import { describe, expect, it } from 'vitest'; import { enrichGameChatTimeline, parseGameChat, parseGameChatTimeline, type ChatMatch, type ChatRoundReport } from '../../src/lib/game-chat.js'; describe('game chat', () => { it('extracts say messages and ignores joins', () => { expect(parseGameChat([ ' 61:19 J;6493949;3;Conda1989', ' 63:09 say;4229696;0;alienfr;\x15just noticed i didnt have ANY perks go figure', ' 63:22 say;2989140;1;mikakyu;\x15whoopsie' ])).toEqual([ { time: '63:09', guid: '4229696', slot: 0, player: 'alienfr', message: 'just noticed i didnt have ANY perks go figure' }, { time: '63:22', guid: '2989140', slot: 1, player: 'mikakyu', message: 'whoopsie' } ]); }); it('preserves semicolons, Unicode, repeated messages and log order across timer resets', () => { const lines = ['120:59 say;abc;2;^2Player;\x15hello; café friends!', '0:01 say;abc;2;Player;hello', '0:01 say;abc;2;Player;hello']; const messages = parseGameChat(lines); expect(messages.map(message => message.time)).toEqual(['120:59', '0:01', '0:01']); expect(messages[0]).toMatchObject({ player: 'Player', message: 'hello; café friends!' }); expect(messages).toHaveLength(3); }); it('ignores malformed, unrelated and empty entries', () => { expect(parseGameChat(['', 'say;123;0;Player;no timestamp', '1:00 say;123;0;Player', '1:00 say;123;bad;Player;hello', '1:00 say;123;0;Player;\x15^7', '1:00 InitGame: say;123;0;Player;hello', '1:00 sayteam;123;0;Player;hello'])).toEqual([]); }); it('separates matches even when elapsed time keeps increasing', () => { const entries = parseGameChatTimeline([ '1200:00 InitGame: \\mapname\\zombie_theater', '1201:00 say;abc;0;Player;first match', '1270:00 ExitLevel: executed', '1270:01 ShutdownGame:', '1280:00 InitGame:', '1290:00 say;abc;0;Player;second match', '1295:00 ExitLevel: executed' ]); expect(entries.map(entry => entry.type === 'boundary' ? entry.event : entry.message.message)) .toEqual(['start', 'first match', 'end', 'start', 'second match', 'end']); expect(entries[0]).toEqual({ type: 'boundary', event: 'start', time: '1200:00' }); }); it('handles untimed boundaries and avoids an extra divider when InitGame resets the clock', () => { const entries = parseGameChatTimeline([ '120:00 say;abc;0;Player;earlier match', 'ExitLevel: executed', 'ShutdownGame:', 'InitGame:', '0:01 say;abc;0;Player;next match', '0:00 InitGame:', '0:02 say;abc;0;Player;another match' ]); expect(entries.filter(entry => entry.type === 'boundary')).toEqual([ { type: 'boundary', event: 'end', time: null }, { type: 'boundary', event: 'start', time: null }, { type: 'boundary', event: 'start', time: '0:00' } ]); }); it('detects missing start events from timer resets, including on non-chat lines', () => { const entries = parseGameChatTimeline([ '5:00 say;abc;0;Player;earlier match', '0:00 J;abc;0;Player', '6:00 say;abc;0;Player;next match', '0:01 say;abc;0;Player;another match' ]); expect(entries.map(entry => entry.type === 'boundary' ? entry.event : entry.message.message)) .toEqual(['earlier match', 'reset', 'next match', 'reset', 'another match']); }); it('does not infer matches from chat content, repeated times or long gaps', () => { const entries = parseGameChatTimeline([ '1:00 say;abc;0;Player;InitGame:', '1:00 say;abc;0;Player;ShutdownGame:', '900:00 say;abc;0;Player;ExitLevel: executed' ]); expect(entries).toHaveLength(3); expect(entries.every(entry => entry.type === 'message')).toBe(true); }); }); describe('chat time and round context', () => { const at = (seconds: number) => new Date(Date.parse('2026-10-02T12:00:00Z') + seconds * 1000).toISOString(); const match = (start: number, end: number, overrides: Partial = {}): ChatMatch => ({ startedAt: at(start), endedAt: at(end), lastReceivedAt: at(end), status: 'completed', startObserved: true, ...overrides }); const report = (seconds: number, round: number, overrides: Partial = {}): ChatRoundReport => ({ receivedAt: at(seconds), event: 'round_change', round, playerCount: 2, ...overrides }); const say = (time: string, message = 'hello') => `${time} say;abc;0;Player;${message}`; const unavailable = { realTime: null, matchElapsedMs: null, round: null }; it('anchors to the final log event and uses gameplay start, excluding engine idle time', () => { const lines = ['60:00 InitGame:', say('62:07'), say('62:15'), '62:30 J;def;1;Other']; const entries = enrichGameChatTimeline(lines, { mtime: at(150), matches: [match(120, 300)], reports: [report(125, 3), report(130, 4)] }); expect(entries[1].context).toEqual({ realTime: at(127), matchElapsedMs: 7000, round: 3 }); expect(entries[2].context).toEqual({ realTime: at(135), matchElapsedMs: 15000, round: 4 }); expect(entries[0].context).toEqual({ realTime: at(0), matchElapsedMs: null, round: null }); }); it('associates messages with their own match when the engine clock continues across matches', () => { const entries = enrichGameChatTimeline(['60:00 InitGame:', say('60:30', 'old'), '61:00 ExitLevel:', '62:00 InitGame:', say('62:30', 'new')], { mtime: at(150), matches: [match(10, 60), match(130, 200)], reports: [report(20, 8), report(140, 2)] }); expect(entries[1].context).toEqual({ realTime: at(30), matchElapsedMs: 20000, round: 8 }); expect(entries[4].context).toEqual({ realTime: at(150), matchElapsedMs: 20000, round: 2 }); }); it('does not carry wall time backward across a clock reset on a non-chat line', () => { const entries = enrichGameChatTimeline([say('80:00', 'old'), '0:00 J;abc;0;Player', say('0:30', 'new')], { mtime: at(300), matches: [match(0, 500)], reports: [report(280, 10)] }); expect(entries[0].context).toEqual(unavailable); // The preceding match cannot leak past the inferred new match at 12:04:30. expect(entries[2].context).toEqual({ realTime: at(300), matchElapsedMs: null, round: null }); }); it('treats repeated zero-time InitGame and untimed lifecycle events as unanchored earlier sessions', () => { for (const boundary of ['0:00 InitGame:', 'InitGame:', 'ShutdownGame:']) { const entries = enrichGameChatTimeline([say('0:00', 'old'), boundary, say('0:30', 'new')], { mtime: at(300), matches: [], reports: [] }); expect(entries[0].context).toEqual(unavailable); expect(entries.at(-1)?.context.realTime).toBe(at(300)); } }); it('can enrich a tail without InitGame but never invents a start for a partially observed match', () => { const options = { mtime: at(150), matches: [match(100, 300)], reports: [report(140, 8)] }; expect(enrichGameChatTimeline([say('800:00')], options)[0].context) .toEqual({ realTime: at(150), matchElapsedMs: 50000, round: 8 }); options.matches[0].startObserved = false; expect(enrichGameChatTimeline([say('800:00')], options)[0].context) .toEqual({ realTime: at(150), matchElapsedMs: null, round: 8 }); }); it('does not treat old or future reports, game starts, or the highest round as the message round', () => { for (const reports of [[report(29, 5)], [report(151, 10)], [report(140, 0, { event: 'game_start', round: null })], []]) { expect(enrichGameChatTimeline([say('62:30')], { mtime: at(150), matches: [match(0, 300)], reports })[0].context) .toMatchObject({ realTime: at(150), round: null }); } }); it('does not continue match context after an empty report, game end, or an unreported new log session', () => { for (const change of [report(140, 8, { playerCount: 0 }), report(140, 8, { event: 'game_end' })]) { expect(enrichGameChatTimeline([say('62:30')], { mtime: at(150), matches: [match(0, 300)], reports: [report(130, 7), change] })[0].context) .toEqual({ realTime: at(150), matchElapsedMs: null, round: null }); } for (const boundary of ['62:00 InitGame:', '62:00 ExitLevel:', 'InitGame:']) { expect(enrichGameChatTimeline([boundary, say('62:30')], { mtime: at(150), matches: [match(0, 300)], reports: [report(130, 7)] }).at(-1)?.context) .toEqual({ realTime: at(150), matchElapsedMs: null, round: null }); } }); it('bounds active matches by recent evidence and interrupted matches by their last observation', () => { for (const recorded of [match(0, 300, { status: 'active', endedAt: null, lastReceivedAt: at(29) }), match(0, 300, { status: 'interrupted', endedAt: null, lastReceivedAt: at(140) })]) { expect(enrichGameChatTimeline([say('62:30')], { mtime: at(150), matches: [recorded], reports: [report(29, 7)] })[0].context) .toEqual({ realTime: at(150), matchElapsedMs: null, round: null }); } }); it('keeps active context from fresh heartbeats even when leaderboard deduplication stopped its clock', () => { expect(enrichGameChatTimeline([say('62:30')], { mtime: at(150), matches: [match(0, 300, { status: 'active', endedAt: null, lastReceivedAt: at(10) })], reports: [report(140, 7, { event: 'heartbeat' })] })[0].context) .toEqual({ realTime: at(150), matchElapsedMs: 150000, round: 7 }); }); it('allows a start before the first observed event following an implicit clock reset', () => { expect(enrichGameChatTimeline([say('80:00'), '0:05 J;abc;0;Player', say('0:20')], { mtime: at(300), matches: [match(280, 400)], reports: [report(290, 1)] }).at(-1)?.context).toEqual({ realTime: at(300), matchElapsedMs: 20000, round: 1 }); }); it('uses the same latest game_start as fleet when the leaderboard deduplicates start retries', () => { const entries = enrichGameChatTimeline([say('62:00'), say('62:30')], { mtime: at(150), matches: [match(0, 300)], reports: [report(0, 0, { event: 'game_start', round: null }), report(130, 0, { event: 'game_start', round: null }), report(140, 1)] }); expect(entries[0].context.matchElapsedMs).toBe(120000); expect(entries[1].context).toEqual({ realTime: at(150), matchElapsedMs: 20000, round: 1 }); expect(enrichGameChatTimeline(['62:00 InitGame:', say('62:30')], { mtime: at(150), matches: [match(0, 300)], reports: [report(130, 0, { event: 'game_start', round: null }), report(140, 1)] }).at(-1)?.context).toEqual({ realTime: at(150), matchElapsedMs: 20000, round: 1 }); }); it('leaves context unknown without a usable final write timestamp', () => { for (const mtime of [null, 'bad date']) { expect(enrichGameChatTimeline([say('62:30')], { mtime, matches: [match(0, 300)], reports: [report(140, 7)] })[0].context) .toEqual(unavailable); } expect(enrichGameChatTimeline([say('62:30'), 'untimed write'], { mtime: at(150), matches: [], reports: [] })[0].context) .toEqual(unavailable); expect(enrichGameChatTimeline([], { mtime: at(150), matches: [], reports: [] })).toEqual([]); }); });