Separate chat messages with match dividers
This commit is contained in:
+42
-8
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user