From 50c81456c4da98b14c110b386bc86a504f24b161 Mon Sep 17 00:00:00 2001 From: josh Date: Sun, 20 Sep 2026 22:19:19 -0400 Subject: [PATCH] Separate chat messages with match dividers --- src/lib/game-chat.ts | 50 +++++++++++++++++++++---- src/lib/ui/components/ChatPanel.svelte | 36 ++++++++++++------ tests/unit/game-chat.test.ts | 52 +++++++++++++++++++++++++- 3 files changed, 118 insertions(+), 20 deletions(-) diff --git a/src/lib/game-chat.ts b/src/lib/game-chat.ts index 05a1328..9ea37a0 100644 --- a/src/lib/game-chat.ts +++ b/src/lib/game-chat.ts @@ -6,18 +6,52 @@ export interface ChatMessage { message: string; } +export type ChatEntry = + | { type: 'message'; message: ChatMessage } + | { type: 'boundary'; event: 'start' | 'end' | 'reset'; time: string | null }; + 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 lines.flatMap(line => { - const match = /^\s*(\d+:[0-5]\d)\s+say;([^;]+);(\d+);([^;]*);(.*)$/.exec(line); - if (!match) return []; - const message = plainText(match[5]); - if (!message) return []; - return [{ time: match[1], guid: match[2], slot: Number(match[3]), - player: plainText(match[4]) || 'Unknown player', message }]; - }); + 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[] { + const entries: ChatEntry[] = []; + let previousSeconds: number | null = null; + let ended = false; + + for (const line of lines) { + const timestamp = /^\s*(\d+:[0-5]\d)\s+/.exec(line); + const time = timestamp?.[1] ?? null; + const seconds = time === null ? null : Number(time.split(':')[0]) * 60 + Number(time.split(':')[1]); + const event = line.slice(timestamp?.[0].length ?? 0).trimStart(); + if (/^InitGame(?:\s|:|$)/.test(event)) { + entries.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) entries.push({ type: 'boundary', event: 'end', time }); + ended = true; + } else if (seconds !== null && previousSeconds !== null && seconds < previousSeconds) { + entries.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; + entries.push({ type: 'message', message: { time: match[1], guid: match[2], slot: Number(match[3]), + player: plainText(match[4]) || 'Unknown player', message } }); + } + return entries; } diff --git a/src/lib/ui/components/ChatPanel.svelte b/src/lib/ui/components/ChatPanel.svelte index db5fa11..ff2ec94 100644 --- a/src/lib/ui/components/ChatPanel.svelte +++ b/src/lib/ui/components/ChatPanel.svelte @@ -1,11 +1,12 @@