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>
|
||||
{#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; }
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { parseGameChat } from '../../src/lib/game-chat.js';
|
||||
import { parseGameChat, parseGameChatTimeline } from '../../src/lib/game-chat.js';
|
||||
|
||||
describe('game chat', () => {
|
||||
it('extracts say messages and ignores joins', () => {
|
||||
@@ -27,4 +27,54 @@ describe('game chat', () => {
|
||||
'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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user