diff --git a/src/lib/game-chat.ts b/src/lib/game-chat.ts new file mode 100644 index 0000000..05a1328 --- /dev/null +++ b/src/lib/game-chat.ts @@ -0,0 +1,23 @@ +export interface ChatMessage { + time: string; + guid: string; + slot: number; + player: string; + message: string; +} + +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 }]; + }); +} diff --git a/src/lib/ui/components/ChatPanel.svelte b/src/lib/ui/components/ChatPanel.svelte new file mode 100644 index 0000000..db5fa11 --- /dev/null +++ b/src/lib/ui/components/ChatPanel.svelte @@ -0,0 +1,94 @@ + + +
+
+

Chat

Recent messages from the game log · refreshes every 5 seconds

+
+ {#if !follow}{/if} + +
+
+ {#if error}{/if} + {#if !loaded && !error} +

Loading chat…

+ {:else if loaded && !messages.length} +

{missing ? 'Waiting for the game log. Player messages will appear here.' : 'No player messages in the recent game log.'}

+ {:else if messages.length} + +
+
    + {#each messages as message} +
  1. + {message.time} +
    {message.player}

    {message.message}

    +
  2. + {/each} +
+
+ {/if} +

Times are elapsed game-log times. Older messages may fall outside the recent log window.

+
+ + diff --git a/src/lib/ui/components/ServerCard.svelte b/src/lib/ui/components/ServerCard.svelte index a803785..1b9dabf 100644 --- a/src/lib/ui/components/ServerCard.svelte +++ b/src/lib/ui/components/ServerCard.svelte @@ -67,7 +67,7 @@ (roster ?? []) .slice() .sort((a, b) => b.score - a.score) - .slice(0, 3) + .slice(0, 4) ); let showRoster = $derived(health === 'online' || health === 'stale'); let title = $derived(serverTitle(name)); diff --git a/src/routes/servers/[name]/+page.svelte b/src/routes/servers/[name]/+page.svelte index db29a6b..1967eab 100644 --- a/src/routes/servers/[name]/+page.svelte +++ b/src/routes/servers/[name]/+page.svelte @@ -18,6 +18,7 @@ import { elapsedSince } from '$ui/duration.js'; import PlayerScoreboard from '$ui/components/PlayerScoreboard.svelte'; import LogPanel from '$ui/components/LogPanel.svelte'; + import ChatPanel from '$ui/components/ChatPanel.svelte'; import { gametypeLabel } from '$ui/gametype.js'; import { decodeRconError, rconFixHint } from '$ui/rconError.js'; import { komodoFixHint, type KomodoPortFailure } from '$ui/komodoError.js'; @@ -101,7 +102,7 @@ let rconProbe = $state(null); let probingRcon = $state(false); let destroyOpen = $state(false); - const sectionIds = ['scoreboard', 'logs', 'config', 'inventory', 'diagnostics', 'history']; + const sectionIds = ['scoreboard', 'chat', 'logs', 'config', 'inventory', 'diagnostics', 'history']; const activeTab = $derived(sectionIds.includes($page.url.searchParams.get('tab') ?? '') ? $page.url.searchParams.get('tab')! : 'scoreboard'); function changeTab(tab: string) { const url = new URL($page.url); @@ -212,6 +213,7 @@ // Tab config — Diagnostics gets a warning badge when there's an issue. let tabs = $derived([ { id: 'scoreboard', label: 'Scoreboard', badge: scoreboard?.players.length ?? null }, + { id: 'chat', label: 'Chat' }, { id: 'logs', label: 'Logs' }, { id: 'config', label: 'Config' }, { id: 'inventory', label: 'Inventory', badge: inventory.length || null }, @@ -308,6 +310,10 @@ {#key name}{/key} + {:else if activeTab === 'chat'} + + {#key name}{/key} + {:else if activeTab === 'logs'} { + it('extracts say messages and ignores joins', () => { + expect(parseGameChat([ + ' 61:19 J;6493949;3;Conda1989', + ' 63:09 say;4229696;0;alienfr;\x15just noticed i didnt have ANY perks go figure', + ' 63:22 say;2989140;1;mikakyu;\x15whoopsie' + ])).toEqual([ + { time: '63:09', guid: '4229696', slot: 0, player: 'alienfr', message: 'just noticed i didnt have ANY perks go figure' }, + { time: '63:22', guid: '2989140', slot: 1, player: 'mikakyu', message: 'whoopsie' } + ]); + }); + + it('preserves semicolons, Unicode, repeated messages and log order across timer resets', () => { + const lines = ['120:59 say;abc;2;^2Player;\x15hello; café friends!', + '0:01 say;abc;2;Player;hello', '0:01 say;abc;2;Player;hello']; + const messages = parseGameChat(lines); + expect(messages.map(message => message.time)).toEqual(['120:59', '0:01', '0:01']); + expect(messages[0]).toMatchObject({ player: 'Player', message: 'hello; café friends!' }); + expect(messages).toHaveLength(3); + }); + + it('ignores malformed, unrelated and empty entries', () => { + expect(parseGameChat(['', 'say;123;0;Player;no timestamp', '1:00 say;123;0;Player', + '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([]); + }); +});