From 2b8e102b8684a55a58675b7775995e74cb3ed43c Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Wed, 7 Oct 2026 00:40:20 -0400 Subject: [PATCH] Scope live chat to the current match and save match history --- src/lib/game-chat.ts | 5 +- src/lib/match-history.ts | 3 + src/lib/server/db/schema.sql | 9 ++ src/lib/server/fleet/chat.ts | 93 ++++++++++++++++++- src/lib/server/fleet/match-history.ts | 5 +- src/lib/server/pipeline/worker.ts | 4 +- src/lib/ui/components/ChatPanel.svelte | 30 +++--- src/routes/api/servers/[name]/chat/+server.ts | 7 +- src/routes/matches/[id]/+page.svelte | 3 + src/routes/servers/[name]/+page.svelte | 2 +- tests/unit/match-detail.test.ts | 3 +- tests/unit/server-chat.test.ts | 70 +++++++++++++- tests/unit/ui-match-chat.test.ts | 38 ++++++++ tests/unit/worker-scheduling.test.ts | 24 +++++ 14 files changed, 266 insertions(+), 30 deletions(-) create mode 100644 tests/unit/ui-match-chat.test.ts diff --git a/src/lib/game-chat.ts b/src/lib/game-chat.ts index 03ea07d..4f9e97e 100644 --- a/src/lib/game-chat.ts +++ b/src/lib/game-chat.ts @@ -17,9 +17,10 @@ export interface ChatContext { round: number | null; } -export type ContextualChatEntry = ChatEntry & { context: ChatContext }; +export type ContextualChatEntry = ChatEntry & { context: ChatContext; matchId?: number }; export interface ChatMatch { + id?: number; startedAt: string; endedAt: string | null; lastReceivedAt: string; @@ -174,6 +175,6 @@ export function enrichGameChatTimeline(lines: readonly string[], options: { } } if (entry.type === 'boundary' && entry.event === 'end') logEnded = true; - return { ...entry, context }; + return { ...entry, context, ...(match?.id === undefined ? {} : { matchId: match.id }) }; }); } diff --git a/src/lib/match-history.ts b/src/lib/match-history.ts index 8b4b283..74c7240 100644 --- a/src/lib/match-history.ts +++ b/src/lib/match-history.ts @@ -1,3 +1,5 @@ +import type { ContextualChatEntry } from './game-chat.js'; + // Reset/abandoned games at round 1 do not qualify as gameplay records. export const MIN_RECORDED_ROUND = 2; @@ -43,6 +45,7 @@ export interface MatchDetail extends ServerMatch { game: string; startObserved: boolean; players: MatchPlayer[]; + chat: ContextualChatEntry[]; } export interface MatchHistoryResponse { diff --git a/src/lib/server/db/schema.sql b/src/lib/server/db/schema.sql index 33443d4..4fbc43b 100644 --- a/src/lib/server/db/schema.sql +++ b/src/lib/server/db/schema.sql @@ -40,6 +40,15 @@ CREATE INDEX IF NOT EXISTS idx_leaderboard_server ON leaderboard_matches(server, CREATE INDEX IF NOT EXISTS idx_leaderboard_started ON leaderboard_matches(started_at DESC, id DESC); CREATE INDEX IF NOT EXISTS idx_leaderboard_map ON leaderboard_matches(game, map_key); +-- Chat follows match retention and survives deletion of the live server. +CREATE TABLE IF NOT EXISTS match_chat ( + match_id INTEGER NOT NULL REFERENCES leaderboard_matches(id) ON DELETE CASCADE, + message_key TEXT NOT NULL, + occurrence INTEGER NOT NULL, + entry TEXT NOT NULL, + PRIMARY KEY (match_id, message_key, occurrence) +); + CREATE TABLE IF NOT EXISTS leaderboard_players ( game TEXT NOT NULL, guid TEXT NOT NULL, diff --git a/src/lib/server/fleet/chat.ts b/src/lib/server/fleet/chat.ts index 794aefb..c5fe608 100644 --- a/src/lib/server/fleet/chat.ts +++ b/src/lib/server/fleet/chat.ts @@ -1,14 +1,15 @@ +import { createHash } from 'node:crypto'; import { db } from '../db/index.js'; import { enrichGameChatTimeline, type ChatMatch, type ChatRoundReport, type ContextualChatEntry } from '../../game-chat.js'; import { parseGameEvent } from '../../telemetry.js'; import { telemetryRetention, telemetryCutoff } from './telemetry-requests.js'; -import type { ServerRow } from './servers.js'; -import type { TailResult } from './logs.js'; +import { getServer, listServers, type ServerRow } from './servers.js'; +import { gameLogKey, resolveLogPath, tailLog, type TailResult } from './logs.js'; /** Historical context is scoped to this registration, including round-one matches. */ export function serverChatTimeline(server: ServerRow, tail: TailResult): ContextualChatEntry[] { const handle = db(); - const matches = handle.prepare(`SELECT started_at AS startedAt, ended_at AS endedAt, + const matches = handle.prepare(`SELECT id, started_at AS startedAt, ended_at AS endedAt, last_received_at AS lastReceivedAt, status, start_observed AS startObserved FROM leaderboard_matches WHERE server = ? AND server_deleted_at IS NULL ORDER BY id DESC LIMIT 2000`).all(server.name) as Array & { startObserved: number }>; @@ -47,3 +48,89 @@ export function serverChatTimeline(server: ServerRow, tail: TailResult): Context reports }); } + +export function matchChatTimeline(matchId: number): ContextualChatEntry[] { + return (db().prepare('SELECT entry FROM match_chat WHERE match_id = ? ORDER BY rowid') + .all(matchId) as { entry: string }[]).map(row => JSON.parse(row.entry)); +} + +/** Save before filtering the live view so completed matches retain their messages. */ +function archiveChat(entries: ContextualChatEntry[]): void { + const handle = db(); + const insert = handle.prepare(`INSERT INTO match_chat (match_id, message_key, occurrence, entry) + VALUES (?, ?, ?, ?) ON CONFLICT DO NOTHING`); + const occurrences = new Map(); + handle.transaction(() => { + for (const entry of entries) { + if (entry.type !== 'message' || entry.matchId === undefined) continue; + // Engine time is stable across reads; estimated wall time is not. + // Count identical messages separately, including repeats in one second. + const key = createHash('sha256').update(JSON.stringify(entry.message)).digest('hex'); + const scope = `${entry.matchId}:${key}`; + const occurrence = (occurrences.get(scope) ?? 0) + 1; + occurrences.set(scope, occurrence); + insert.run(entry.matchId, key, occurrence, JSON.stringify(entry)); + } + })(); +} + +export function currentMatchChat(server: ServerRow, tail: TailResult | null): ContextualChatEntry[] { + const entries = tail ? serverChatTimeline(server, tail) : []; + archiveChat(entries); + const match = db().prepare(`SELECT id, started_at AS startedAt FROM leaderboard_matches + WHERE server = ? AND server_deleted_at IS NULL ORDER BY id DESC LIMIT 1`) + .get(server.name) as { id: number; startedAt: string } | undefined; + let startIndex = entries.length - 1; + while (startIndex >= 0) { + const entry = entries[startIndex]; + if (entry.type === 'boundary' && entry.event !== 'end') break; + startIndex--; + } + const current = entries.slice(Math.max(0, startIndex)); + if (!match) { + // Servers without gameplay telemetry still have log-defined sessions. + return current.filter(entry => entry.context.realTime !== null + && Date.parse(entry.context.realTime) >= Date.parse(server.created_at)); + } + const start = entries[startIndex]; + // A log restart can arrive before game_start telemetry. Clear immediately + // instead of falling back to messages from the previous recorded match. + if (start && !current.some(entry => entry.matchId === match.id) + && (!start.context.realTime || Date.parse(start.context.realTime) > Date.parse(match.startedAt) + 1000)) return []; + return matchChatTimeline(match.id); +} + +const pending = new Map>(); + +/** Shared by the live API and the worker; collection does not require a viewer. */ +export function collectServerChat(server: ServerRow): Promise<{ entries: ContextualChatEntry[]; missing: boolean }> { + const key = `${server.name}:${server.created_at}:${server.native_instance_id}:${server.instance_generation}`; + const existing = pending.get(key); + if (existing) return existing; + const work = (async () => { + const path = resolveLogPath(server.name, gameLogKey(server.name)); + if (!path) throw new Error('Invalid server log path'); + const tail = await tailLog(path, 2000); + const current = getServer(server.name); + // A remote read may finish after destruction or replacement. + if (!current || current.destroyed_at || current.created_at !== server.created_at + || current.native_instance_id !== server.native_instance_id || current.instance_generation !== server.instance_generation) { + return { entries: [], missing: true }; + } + return { entries: currentMatchChat(current, tail), missing: tail === null }; + })().finally(() => pending.delete(key)); + pending.set(key, work); + return work; +} + +export async function collectFleetChat(): Promise { + const servers = listServers().filter(server => !server.destroyed_at); + // Bound agent traffic and isolate failures to their own server. + async function collect() { + for (let server = servers.shift(); server; server = servers.shift()) { + try { await collectServerChat(server); } + catch (error) { console.error(`[chat] Could not collect chat for ${server.name}`, error); } + } + } + await Promise.all(Array.from({ length: Math.min(4, servers.length) }, collect)); +} diff --git a/src/lib/server/fleet/match-history.ts b/src/lib/server/fleet/match-history.ts index a057c04..443ad8b 100644 --- a/src/lib/server/fleet/match-history.ts +++ b/src/lib/server/fleet/match-history.ts @@ -1,4 +1,5 @@ import { db } from '../db/index.js'; +import { matchChatTimeline } from './chat.js'; import { MIN_RECORDED_ROUND, type MatchDetail, type MatchPlayer, type MatchHistoryResponse, type ServerMatch } from '../../match-history.js'; import { readMatchQuery, type MatchHistoryQuery } from '../../match-history-query.js'; @@ -48,7 +49,7 @@ export function getMatchDetail(id: number): MatchDetail | null { const match = handle.prepare(`SELECT ${matchSummary}, m.start_observed AS startObserved FROM leaderboard_matches m WHERE m.id = ? AND m.status = 'completed' AND m.highest_round >= ${MIN_RECORDED_ROUND}`) - .get(id) as (Omit & { startObserved: number }) | undefined; + .get(id) as (Omit & { startObserved: number }) | undefined; if (!match) return null; const players = handle.prepare(`SELECT p.guid, @@ -79,7 +80,7 @@ export function getMatchDetail(id: number): MatchDetail | null { .all(match.game, id) as Array & { hasStats: number; presentAtEnd: number; canViewProfile: number; }>; - return { ...match, startObserved: Boolean(match.startObserved), players: players.map(player => ({ + return { ...match, chat: matchChatTimeline(id), startObserved: Boolean(match.startObserved), players: players.map(player => ({ ...player, hasStats: Boolean(player.hasStats), presentAtEnd: Boolean(player.presentAtEnd), canViewProfile: Boolean(player.canViewProfile) })) }; diff --git a/src/lib/server/pipeline/worker.ts b/src/lib/server/pipeline/worker.ts index 40215eb..bf06ed7 100644 --- a/src/lib/server/pipeline/worker.ts +++ b/src/lib/server/pipeline/worker.ts @@ -1,4 +1,5 @@ import { pruneHistory, pruneSampleHistory } from '../retention.js'; +import { collectFleetChat } from '../fleet/chat.js'; import { runAutoscaling, pruneAutoscaling } from '../autoscaling/index.js'; import { config } from '../config.js'; import { processProvisioning } from '../provisioning/worker.js'; @@ -57,6 +58,7 @@ function reconcileRequesters(): void { ['maintenance', 'Checking maintenance and recovery', runMaintenance], ['autoscaling', 'Evaluating lobby autoscaling', runAutoscaling], ['game-files', 'Removing unused game files', cleanupControllerLibraries], + ['chat', 'Saving match chat', collectFleetChat], ['retention', 'Cleaning expired history', async () => { pruneHistory(); pruneSampleHistory(); pruneAutoscaling(); }] ]; for (const [key, label, work] of requesters) { @@ -67,7 +69,7 @@ function reconcileRequesters(): void { } const flight = trackActivity(key, label, work) .catch(err => { console.error(`[worker] ${key} error:`, err); }) - .then(async result => { if (key !== 'game-files' && key !== 'retention' && (key !== 'autoscaling' || result === true)) await tick(true); }) + .then(async result => { if (key !== 'game-files' && key !== 'retention' && key !== 'chat' && (key !== 'autoscaling' || result === true)) await tick(true); }) .finally(() => { requesterFlights.delete(key); }); requesterFlights.set(key, flight); } diff --git a/src/lib/ui/components/ChatPanel.svelte b/src/lib/ui/components/ChatPanel.svelte index 360443b..5d5c7fe 100644 --- a/src/lib/ui/components/ChatPanel.svelte +++ b/src/lib/ui/components/ChatPanel.svelte @@ -7,11 +7,13 @@ import { timeFormat } from '$ui/stores/time-zone.js'; import Button from './primitives/Button.svelte'; - let { server }: { server: string } = $props(); - let entries = $state([]); + let { server, history }: { server?: string; history?: ContextualChatEntry[] } = $props(); + let liveEntries = $state([]); + const entries = $derived(history ?? liveEntries); let timeline = $derived(groupChatTimeline(entries)); let hasMessages = $derived(entries.some(entry => entry.type === 'message')); - let loaded = $state(false); + let liveLoaded = $state(false); + const loaded = $derived(history !== undefined || liveLoaded); let loading = $state(false); let manualRefreshing = $state(false); let error = $state(''); @@ -22,6 +24,7 @@ let controller: AbortController | null = null; async function refresh(manual = false) { + if (!server) return; if (loading) { if (manual) manualRefreshing = true; return; @@ -36,9 +39,9 @@ if (!response.ok || !data?.ok) throw new Error('Could not refresh chat. Try again shortly.'); if (request.signal.aborted) return; if (paused && !manual) return; - entries = data.entries ?? []; + liveEntries = data.entries ?? []; missing = !!data.missing; - loaded = true; + liveLoaded = true; error = ''; await tick(); if (follow && body) body.scrollTop = body.scrollHeight; @@ -56,6 +59,7 @@ if (body) body.scrollTop = body.scrollHeight; } onMount(() => { + if (!server) return; const stop = pollVisible(() => paused ? Promise.resolve() : refresh(), 5000); return () => { stop(); controller?.abort(); }; }); @@ -89,18 +93,20 @@
-

Chat

{paused ? 'Paused' : 'Live · 5s'}
-
- {#if hasMessages}{/if} - {#if !follow}{/if} - -
+

Chat

{server ? paused ? 'Paused' : 'Live · 5s' : 'Match history'}
+ {#if server} +
+ {#if hasMessages}{/if} + {#if !follow}{/if} + +
+ {/if}
{#if error}{/if} {#if !loaded && !error}

Loading chat…

{:else if loaded && !hasMessages} -

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

+

{history !== undefined ? 'No chat messages were saved for this match.' : missing ? 'Waiting for the game log. Player messages will appear here.' : 'No player messages in this match yet.'}

{:else if hasMessages}
diff --git a/src/routes/api/servers/[name]/chat/+server.ts b/src/routes/api/servers/[name]/chat/+server.ts index 8453b61..b58df14 100644 --- a/src/routes/api/servers/[name]/chat/+server.ts +++ b/src/routes/api/servers/[name]/chat/+server.ts @@ -1,7 +1,7 @@ import { json } from '@sveltejs/kit'; import { getServer } from '$lib/server/fleet/servers.js'; -import { gameLogKey, resolveLogPath, tailLog } from '$lib/server/fleet/logs.js'; -import { serverChatTimeline } from '$lib/server/fleet/chat.js'; +import { gameLogKey, resolveLogPath } from '$lib/server/fleet/logs.js'; +import { collectServerChat } from '$lib/server/fleet/chat.js'; export async function GET({ params }: { params: { name: string } }) { const headers = { 'Cache-Control': 'no-store' }; @@ -13,8 +13,7 @@ export async function GET({ params }: { params: { name: string } }) { if (!path) return json({ ok: false, reason: 'Path traversal rejected' }, { status: 400, headers }); try { - const tail = await tailLog(path, 2000); - return json({ ok: true, entries: tail ? serverChatTimeline(server, tail) : [], missing: tail === null }, { headers }); + return json({ ok: true, ...await collectServerChat(server) }, { headers }); } catch (error) { console.error('Could not read server chat', error); return json({ ok: false, reason: 'Could not read server chat' }, { status: 500, headers }); diff --git a/src/routes/matches/[id]/+page.svelte b/src/routes/matches/[id]/+page.svelte index cabc2ff..f43ccd6 100644 --- a/src/routes/matches/[id]/+page.svelte +++ b/src/routes/matches/[id]/+page.svelte @@ -13,6 +13,7 @@ import Card from '$ui/components/primitives/Card.svelte'; import EmptyState from '$ui/components/primitives/EmptyState.svelte'; import Stat from '$ui/components/primitives/Stat.svelte'; + import ChatPanel from '$ui/components/ChatPanel.svelte'; let { data }: { data: { match: MatchDetail } } = $props(); const match = $derived(data.match); @@ -86,6 +87,8 @@ {/if}
+ +
About these results

Kills, headshots, revives, downs, and score retain each player's highest reported value. Highest reported score is a points balance, not total points earned or necessarily the final score.

diff --git a/src/routes/servers/[name]/+page.svelte b/src/routes/servers/[name]/+page.svelte index ecce4bc..0c46731 100644 --- a/src/routes/servers/[name]/+page.svelte +++ b/src/routes/servers/[name]/+page.svelte @@ -259,7 +259,7 @@
- {#key name}{/key} + {#key `${name}:${scoreboard?.timing?.matchId ?? ''}:${effectiveGameStartedAt ?? ''}`}{/key}
{:else if activeSection === 'configuration'} diff --git a/tests/unit/match-detail.test.ts b/tests/unit/match-detail.test.ts index 5918c67..926fc0c 100644 --- a/tests/unit/match-detail.test.ts +++ b/tests/unit/match-detail.test.ts @@ -38,7 +38,8 @@ describe('completed match details', () => { await report('game_end', 4, [player('101', { kills: 50, headshots: 20, downs: 2, revives: 3, score: 500 }), player('404')]); const { getMatchDetail, serverMatchHistory } = await import('../../src/lib/server/fleet/match-history.js'); const match = getMatchDetail(1)!; - const { game, startObserved, players, ...summary } = match; + const { game, startObserved, players, chat, ...summary } = match; + expect(chat).toEqual([]); expect({ ...summary, game }).toEqual(serverMatchHistory(server).matches[0]); expect({ ...summary, game, startObserved }).toMatchObject({ id: 1, game: 't5', startObserved: true, server, status: 'completed', diff --git a/tests/unit/server-chat.test.ts b/tests/unit/server-chat.test.ts index 81c0f91..10b9981 100644 --- a/tests/unit/server-chat.test.ts +++ b/tests/unit/server-chat.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { mkdir, utimes, writeFile } from 'node:fs/promises'; +import { mkdir, rm, utimes, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { newFixture, type Fixture } from '../helpers/fixture.js'; import { gameEvent } from '../helpers/telemetry.js'; @@ -137,8 +137,70 @@ describe('server chat API', () => { await log(['60:50 say;abc;0;Player;previous server', '61:40 say;abc;0;Player;current server'], 100); const body = await (await get()).json(); const messages = body.entries.filter((entry: { type: string }) => entry.type === 'message'); - expect(messages).toHaveLength(2); - expect(messages[0].context).toEqual({ realTime: at(50), matchElapsedMs: null, round: null }); - expect(messages[1].context).toEqual({ realTime: at(100), matchElapsedMs: 20_000, round: 2 }); + expect(messages).toHaveLength(1); + expect(messages[0].message.message).toBe('current server'); + expect(messages[0].context).toEqual({ realTime: at(100), matchElapsedMs: 20_000, round: 2 }); + }); + + it('clears at a new match before it writes to the log and keeps the old chat with its match', async () => { + await report('game_start', 0); + await report('round_change', 20, 4); + await log(['60:30 say;abc;0;Player;old match'], 30); + expect((await (await get()).json()).entries[0].message.message).toBe('old match'); + await report('game_end', 60, 4); + await report('game_start', 80); + expect((await (await get()).json()).entries).toEqual([]); + await report('heartbeat', 90, 2); + await log(['60:30 say;abc;0;Player;old match', '61:40 say;abc;0;Player;new match'], 100); + const body = await (await get()).json(); + expect(body.entries.map((entry: any) => entry.message.message)).toEqual(['new match']); + const { getMatchDetail } = await import('../../src/lib/server/fleet/match-history.js'); + expect(getMatchDetail(1)!.chat.map(entry => entry.type === 'message' && entry.message.message)).toEqual(['old match']); + }); + + it('clears on log boundaries and clock resets before the next telemetry report', async () => { + for (const boundary of ['61:00 InitGame:', '0:00 InitGame:', '0:00 J;abc;0;Player', 'InitGame:']) { + await report('game_start', 0); + await report('heartbeat', 20, 4); + await log(['60:30 say;abc;0;Player;old match'], 30); + expect((await (await get()).json()).entries).toHaveLength(1); + await log(['60:30 say;abc;0;Player;old match', boundary], 60); + expect((await (await get()).json()).entries, boundary).toEqual([]); + } + }); + + it('shows only the latest log session without telemetry', async () => { + await log(['80:00 say;abc;0;Player;old match', '0:00 InitGame:', '0:30 say;abc;0;Player;new match'], 60); + const entries = (await (await get()).json()).entries; + expect(entries.filter((entry: any) => entry.type === 'message').map((entry: any) => entry.message.message)) + .toEqual(['new match']); + await log(['80:00 say;abc;0;Player;old match', '0:00 InitGame:'], 60); + expect((await (await get()).json()).entries.some((entry: any) => entry.type === 'message')).toBe(false); + }); + + it('collects without a viewer and retains repeated messages after log rotation, restart and server deletion', async () => { + await report('game_start', 0); + await report('heartbeat', 20, 4); + const lines = ['60:30 say;abc;0;Player;hello', '60:30 say;abc;0;Player;hello']; + await log(lines, 30); + const { collectFleetChat } = await import('../../src/lib/server/fleet/chat.js'); + await collectFleetChat(); + await collectFleetChat(); + await report('game_end', 60, 4); + await log([...lines, '61:00 ExitLevel:'], 60); + await collectFleetChat(); + const { closeDb, db } = await import('../../src/lib/server/db/index.js'); + const { getMatchDetail } = await import('../../src/lib/server/fleet/match-history.js'); + const saved = getMatchDetail(1)!.chat; + expect(saved).toHaveLength(2); + expect(saved[0]).toMatchObject({ message: { message: 'hello' }, context: { realTime: at(30), round: 4 } }); + await rm(join(fixture.t5, SERVER, 'logs'), { recursive: true }); + closeDb(); + expect((await (await get()).json()).entries).toEqual(saved); + db().prepare('DELETE FROM servers WHERE name = ?').run(SERVER); + expect(getMatchDetail(1)!.chat).toEqual(saved); + // Match retention also removes its chat, with no orphaned history. + db().prepare('DELETE FROM leaderboard_matches WHERE id = 1').run(); + expect(db().prepare('SELECT * FROM match_chat').all()).toEqual([]); }); }); diff --git a/tests/unit/ui-match-chat.test.ts b/tests/unit/ui-match-chat.test.ts new file mode 100644 index 0000000..47d68c9 --- /dev/null +++ b/tests/unit/ui-match-chat.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it, vi } from 'vitest'; +import { render } from 'svelte/server'; +import { writable } from 'svelte/store'; +import MatchPage from '../../src/routes/matches/[id]/+page.svelte'; +import ChatPanel from '../../src/lib/ui/components/ChatPanel.svelte'; +import type { MatchDetail } from '../../src/lib/match-history.js'; + +vi.mock('$app/stores', () => ({ page: writable({ url: new URL('http://localhost/matches/1'), data: { user: null } }) })); + +const match: MatchDetail = { + id: 1, server: 't5_zombie_theater_1', game: 't5', map: 'Kino Der Toten', + observedRound: 4, finalRound: 4, status: 'completed', startObserved: true, + startedAt: '2026-10-07T00:00:00Z', endedAt: '2026-10-07T00:01:00Z', durationMs: 60_000, + startPlayers: 1, uniquePlayers: 1, endPlayers: 1, players: [], + chat: [{ type: 'message', matchId: 1, + message: { time: '60:30', guid: 'abc', slot: 0, player: 'Player', message: '' }, + context: { realTime: '2026-10-07T00:00:30Z', matchElapsedMs: 30_000, round: 4 } }] +}; + +describe('historical match chat', () => { + it('renders saved messages with their context and no live controls on the match page', () => { + const body = render(MatchPage, { props: { data: { match } } }).body; + expect(body).toContain('Match history'); + expect(body).toContain('<script>hello</script>'); + expect(body).toContain('Message context'); + expect(body).toContain('30s'); + expect(body.replace(//gs, '')).toMatch(/
Round<\/dt>]*>4<\/dd>/); + expect(body).not.toContain('Refresh chat'); + expect(body).not.toContain('Live · 5s'); + expect(body).not.toContain('Loading chat'); + }); + + it('explains when a historical match has no saved messages', () => { + const body = render(ChatPanel, { props: { history: [] } }).body; + expect(body).toContain('No chat messages were saved for this match.'); + expect(body).not.toContain('Waiting for the game log'); + }); +}); diff --git a/tests/unit/worker-scheduling.test.ts b/tests/unit/worker-scheduling.test.ts index af12164..e1a5e60 100644 --- a/tests/unit/worker-scheduling.test.ts +++ b/tests/unit/worker-scheduling.test.ts @@ -7,6 +7,7 @@ vi.mock('../../src/lib/server/maintenance/index.js', () => ({ runMaintenance: vi vi.mock('../../src/lib/server/game-files/index.js', () => ({ cleanupControllerLibraries: vi.fn(async () => {}) })); vi.mock('../../src/lib/server/provisioning/worker.js', () => ({ processProvisioning: vi.fn(async () => {}) })); vi.mock('../../src/lib/server/retention.js', () => ({ pruneHistory: vi.fn(), pruneSampleHistory: vi.fn() })); +vi.mock('../../src/lib/server/fleet/chat.js', () => ({ collectFleetChat: vi.fn(async () => {}) })); function deferred() { let resolve!: () => void; @@ -32,6 +33,29 @@ async function setup() { } describe('independent provisioning scheduling', () => { + it('collects match chat on worker ticks without overlapping a slow collection', async () => { + const s = await setup(); + const { collectFleetChat } = await import('../../src/lib/server/fleet/chat.js'); + const collection = deferred(); + vi.mocked(collectFleetChat).mockImplementationOnce(() => collection.promise); + vi.useFakeTimers(); + try { + s.startWorker(); + expect(collectFleetChat).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(30_000); + expect(collectFleetChat).toHaveBeenCalledTimes(1); + collection.resolve(); + await vi.advanceTimersByTimeAsync(0); + await vi.advanceTimersByTimeAsync(15_000); + expect(collectFleetChat).toHaveBeenCalledTimes(2); + } finally { + s.stopWorker(); collection.resolve(); s.background.resolve(); + await vi.advanceTimersByTimeAsync(0); + vi.useRealTimers(); + await vi.waitFor(() => expect(s.workerActivity()).toEqual([])); + } + }); + it('runs retention at most every five minutes across ticks and explicit kicks', async () => { const s = await setup(); const { pruneHistory, pruneSampleHistory } = await import('../../src/lib/server/retention.js');