Scope live chat to the current match and save match history

This commit is contained in:
2026-10-07 00:40:20 -04:00
parent 8fcabe27fc
commit 2b8e102b86
14 changed files with 266 additions and 30 deletions
+2 -1
View File
@@ -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',
+66 -4
View File
@@ -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([]);
});
});
+38
View File
@@ -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('&lt;script>hello&lt;/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');
});
});
+24
View File
@@ -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');