Scope live chat to the current match and save match history
This commit is contained in:
@@ -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 }) };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Omit<ChatMatch, 'startObserved'> & { 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<string, number>();
|
||||
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<string, Promise<{ entries: ContextualChatEntry[]; missing: boolean }>>();
|
||||
|
||||
/** 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<void> {
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -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<MatchDetail, 'players' | 'startObserved'> & { startObserved: number }) | undefined;
|
||||
.get(id) as (Omit<MatchDetail, 'players' | 'chat' | 'startObserved'> & { 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<Omit<MatchPlayer, 'hasStats' | 'presentAtEnd' | 'canViewProfile'> & {
|
||||
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)
|
||||
})) };
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<ContextualChatEntry[]>([]);
|
||||
let { server, history }: { server?: string; history?: ContextualChatEntry[] } = $props();
|
||||
let liveEntries = $state<ContextualChatEntry[]>([]);
|
||||
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 @@
|
||||
|
||||
<section aria-label="Player chat">
|
||||
<div class="chat-head">
|
||||
<div class="chat-title"><h2>Chat</h2><span class="update-cadence">{paused ? 'Paused' : 'Live · 5s'}</span></div>
|
||||
<div class="actions">
|
||||
{#if hasMessages}<Button size="sm" variant="ghost" onclick={() => paused = !paused}>{paused ? 'Resume' : 'Pause'}</Button>{/if}
|
||||
{#if !follow}<Button size="sm" variant="ghost" onclick={latest}>Latest messages</Button>{/if}
|
||||
<Button size="sm" onclick={() => refresh(true)} loading={manualRefreshing} ariaLabel={manualRefreshing ? 'Refreshing chat' : 'Refresh chat'}>Refresh</Button>
|
||||
</div>
|
||||
<div class="chat-title"><h2>Chat</h2><span class="update-cadence">{server ? paused ? 'Paused' : 'Live · 5s' : 'Match history'}</span></div>
|
||||
{#if server}
|
||||
<div class="actions">
|
||||
{#if hasMessages}<Button size="sm" variant="ghost" onclick={() => paused = !paused}>{paused ? 'Resume' : 'Pause'}</Button>{/if}
|
||||
{#if !follow}<Button size="sm" variant="ghost" onclick={latest}>Latest messages</Button>{/if}
|
||||
<Button size="sm" onclick={() => refresh(true)} loading={manualRefreshing} ariaLabel={manualRefreshing ? 'Refreshing chat' : 'Refresh chat'}>Refresh</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#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 && !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">{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.'}</p>
|
||||
{: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">
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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}
|
||||
</section>
|
||||
|
||||
<Card padding="lg"><ChatPanel history={match.chat} /></Card>
|
||||
|
||||
<details class="note">
|
||||
<summary>About these results</summary>
|
||||
<p>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.</p>
|
||||
|
||||
@@ -259,7 +259,7 @@
|
||||
</section>
|
||||
<section id="chat" aria-label="Chat">
|
||||
<Card>
|
||||
{#key name}<ChatPanel server={name} />{/key}
|
||||
{#key `${name}:${scoreboard?.timing?.matchId ?? ''}:${effectiveGameStartedAt ?? ''}`}<ChatPanel server={name} />{/key}
|
||||
</Card>
|
||||
</section>
|
||||
{:else if activeSection === 'configuration'}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: '<script>hello</script>' },
|
||||
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(/<dt>Round<\/dt><dd[^>]*>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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user