336 lines
13 KiB
TypeScript
336 lines
13 KiB
TypeScript
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é <b>friends</b>!',
|
|
'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é <b>friends</b>!'
|
|
});
|
|
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> = {}): ChatMatch => ({
|
|
startedAt: at(start),
|
|
endedAt: at(end),
|
|
lastReceivedAt: at(end),
|
|
status: 'completed',
|
|
startObserved: true,
|
|
...overrides
|
|
});
|
|
const report = (
|
|
seconds: number,
|
|
round: number,
|
|
overrides: Partial<ChatRoundReport> = {}
|
|
): 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(
|
|
[]
|
|
);
|
|
});
|
|
});
|