Separate chat messages with match dividers

This commit is contained in:
2026-09-20 22:19:19 -04:00
parent 5c5e75526d
commit 50c81456c4
3 changed files with 118 additions and 20 deletions
+42 -8
View File
@@ -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;
}
+25 -11
View File
@@ -1,11 +1,12 @@
<script lang="ts">
import { onMount, tick } from 'svelte';
import { parseGameChat, type ChatMessage } from '$lib/game-chat.js';
import { parseGameChatTimeline, type ChatEntry } from '$lib/game-chat.js';
import { pollVisible } from '$ui/poll.js';
import Button from './primitives/Button.svelte';
let { server, game }: { server: string; game: string | null } = $props();
let messages = $state<ChatMessage[]>([]);
let entries = $state<ChatEntry[]>([]);
let hasMessages = $derived(entries.some(entry => entry.type === 'message'));
let loaded = $state(false);
let loading = $state(false);
let error = $state('');
@@ -25,7 +26,7 @@
const data = await response.json();
if (!response.ok || !data.ok) throw new Error(data.reason ?? 'Could not refresh chat.');
if (request.signal.aborted) return;
messages = parseGameChat(data.lines ?? []);
entries = parseGameChatTimeline(data.lines ?? []);
missing = !!data.missing;
loaded = true;
error = '';
@@ -61,17 +62,26 @@
{#if error}<p class="ui-error" role="alert">{error} {loaded ? 'Showing the last loaded messages.' : ''}</p>{/if}
{#if !loaded && !error}
<p class="empty" role="status">Loading chat…</p>
{:else if loaded && !messages.length}
{:else if loaded && !hasMessages}
<p class="empty">{missing ? 'Waiting for the game log. Player messages will appear here.' : 'No player messages in the recent game log.'}</p>
{:else if messages.length}
{:else if hasMessages}
<!-- svelte-ignore a11y_no_noninteractive_tabindex (The scrollable chat region must support keyboard scrolling.) -->
<div class="messages" bind:this={body} onscroll={onScroll} tabindex="0" role="region" aria-label="Chat messages">
<ol>
{#each messages as message}
<li>
<span class="time" title="Elapsed game-log time">{message.time}</span>
<div><strong>{message.player}</strong><p>{message.message}</p></div>
</li>
{#each entries as entry}
{#if entry.type === 'boundary'}
<li class="match-divider" class:match-end={entry.event === 'end'}>
<span class="match-label">
{entry.event === 'start' ? 'Match started' : entry.event === 'end' ? 'Match ended' : 'New match · timer reset'}
{#if entry.time}<span class="time" title="Elapsed game-log time">{entry.time}</span>{/if}
</span>
</li>
{:else}
<li class="message">
<span class="time" title="Elapsed game-log time">{entry.message.time}</span>
<div><strong>{entry.message.player}</strong><p>{entry.message.message}</p></div>
</li>
{/if}
{/each}
</ol>
</div>
@@ -85,7 +95,11 @@
.chat-head p, .note, .empty { color: var(--text-secondary); font-size: var(--text-sm); }
.messages { max-height: 60vh; overflow: auto; margin-top: 16px; }
ol { list-style: none; padding: 0; margin: 0; }
li { display: grid; grid-template-columns: 72px minmax(0, 1fr); gap: 12px; padding: 12px 0; border-bottom: 1px solid var(--border-subtle); }
.message { display: grid; grid-template-columns: 72px minmax(0, 1fr); gap: 12px; padding: 12px 0; border-bottom: 1px solid var(--border-subtle); }
.match-divider { display: flex; align-items: center; gap: 12px; padding: 20px 0; color: var(--text-primary); font-size: var(--text-sm); }
.match-divider::before, .match-divider::after { content: ''; height: 1px; flex: 1; background: var(--border-subtle); }
.match-label { display: flex; align-items: center; justify-content: center; flex-wrap: wrap; gap: 8px 12px; padding: 6px 12px; border: 1px solid var(--border-subtle); border-radius: 999px; font-weight: 600; }
.match-end { color: var(--text-secondary); }
.time { font-family: var(--font-mono); color: var(--text-tertiary); font-size: var(--text-sm); }
strong { color: var(--text-primary); overflow-wrap: anywhere; }
li p { margin: 4px 0 0; white-space: pre-wrap; overflow-wrap: anywhere; }