Separate chat messages with match dividers
This commit is contained in:
+42
-8
@@ -6,18 +6,52 @@ export interface ChatMessage {
|
|||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ChatEntry =
|
||||||
|
| { type: 'message'; message: ChatMessage }
|
||||||
|
| { type: 'boundary'; event: 'start' | 'end' | 'reset'; time: string | null };
|
||||||
|
|
||||||
function plainText(value: string): string {
|
function plainText(value: string): string {
|
||||||
return value.replace(/\^[0-9]/g, '').replace(/[\x00-\x1f\x7f]/g, '').trim();
|
return value.replace(/\^[0-9]/g, '').replace(/[\x00-\x1f\x7f]/g, '').trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Preserve log order: elapsed timestamps can reset between matches. */
|
/** Preserve log order: elapsed timestamps can reset between matches. */
|
||||||
export function parseGameChat(lines: readonly string[]): ChatMessage[] {
|
export function parseGameChat(lines: readonly string[]): ChatMessage[] {
|
||||||
return lines.flatMap(line => {
|
return parseGameChatTimeline(lines).flatMap(entry => entry.type === 'message' ? [entry.message] : []);
|
||||||
const match = /^\s*(\d+:[0-5]\d)\s+say;([^;]+);(\d+);([^;]*);(.*)$/.exec(line);
|
}
|
||||||
if (!match) return [];
|
|
||||||
const message = plainText(match[5]);
|
/** Keep match boundaries alongside messages, including when the log clock resets. */
|
||||||
if (!message) return [];
|
export function parseGameChatTimeline(lines: readonly string[]): ChatEntry[] {
|
||||||
return [{ time: match[1], guid: match[2], slot: Number(match[3]),
|
const entries: ChatEntry[] = [];
|
||||||
player: plainText(match[4]) || 'Unknown player', message }];
|
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">
|
<script lang="ts">
|
||||||
import { onMount, tick } from 'svelte';
|
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 { pollVisible } from '$ui/poll.js';
|
||||||
import Button from './primitives/Button.svelte';
|
import Button from './primitives/Button.svelte';
|
||||||
|
|
||||||
let { server, game }: { server: string; game: string | null } = $props();
|
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 loaded = $state(false);
|
||||||
let loading = $state(false);
|
let loading = $state(false);
|
||||||
let error = $state('');
|
let error = $state('');
|
||||||
@@ -25,7 +26,7 @@
|
|||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (!response.ok || !data.ok) throw new Error(data.reason ?? 'Could not refresh chat.');
|
if (!response.ok || !data.ok) throw new Error(data.reason ?? 'Could not refresh chat.');
|
||||||
if (request.signal.aborted) return;
|
if (request.signal.aborted) return;
|
||||||
messages = parseGameChat(data.lines ?? []);
|
entries = parseGameChatTimeline(data.lines ?? []);
|
||||||
missing = !!data.missing;
|
missing = !!data.missing;
|
||||||
loaded = true;
|
loaded = true;
|
||||||
error = '';
|
error = '';
|
||||||
@@ -61,17 +62,26 @@
|
|||||||
{#if error}<p class="ui-error" role="alert">{error} {loaded ? 'Showing the last loaded messages.' : ''}</p>{/if}
|
{#if error}<p class="ui-error" role="alert">{error} {loaded ? 'Showing the last loaded messages.' : ''}</p>{/if}
|
||||||
{#if !loaded && !error}
|
{#if !loaded && !error}
|
||||||
<p class="empty" role="status">Loading chat…</p>
|
<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>
|
<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.) -->
|
<!-- 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">
|
<div class="messages" bind:this={body} onscroll={onScroll} tabindex="0" role="region" aria-label="Chat messages">
|
||||||
<ol>
|
<ol>
|
||||||
{#each messages as message}
|
{#each entries as entry}
|
||||||
<li>
|
{#if entry.type === 'boundary'}
|
||||||
<span class="time" title="Elapsed game-log time">{message.time}</span>
|
<li class="match-divider" class:match-end={entry.event === 'end'}>
|
||||||
<div><strong>{message.player}</strong><p>{message.message}</p></div>
|
<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>
|
</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}
|
{/each}
|
||||||
</ol>
|
</ol>
|
||||||
</div>
|
</div>
|
||||||
@@ -85,7 +95,11 @@
|
|||||||
.chat-head p, .note, .empty { color: var(--text-secondary); font-size: var(--text-sm); }
|
.chat-head p, .note, .empty { color: var(--text-secondary); font-size: var(--text-sm); }
|
||||||
.messages { max-height: 60vh; overflow: auto; margin-top: 16px; }
|
.messages { max-height: 60vh; overflow: auto; margin-top: 16px; }
|
||||||
ol { list-style: none; padding: 0; margin: 0; }
|
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); }
|
.time { font-family: var(--font-mono); color: var(--text-tertiary); font-size: var(--text-sm); }
|
||||||
strong { color: var(--text-primary); overflow-wrap: anywhere; }
|
strong { color: var(--text-primary); overflow-wrap: anywhere; }
|
||||||
li p { margin: 4px 0 0; white-space: pre-wrap; 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 { 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', () => {
|
describe('game chat', () => {
|
||||||
it('extracts say messages and ignores joins', () => {
|
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 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([]);
|
'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