Capture team chat and expose moderation match archives

This commit is contained in:
2026-10-17 20:11:49 -04:00
parent b678de2f6f
commit 870f13b150
28 changed files with 943 additions and 81 deletions
+32 -1
View File
@@ -51,11 +51,42 @@ describe('game chat', () => {
'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'
'1:00 sayteam;123;bad;Player;hello',
'1:00 sayteam;123;0;Player;\x15^7'
])
).toEqual([]);
});
it('keeps team and public chat in order with distinct channels and stable public identities', () => {
const messages = parseGameChat([
'1:00 say;123;0;Player;hello; café',
'1:00 sayteam;123;0;^2Player;\x15hello; café',
'1:01 sayteam;456;1;Teammate;reply'
]);
expect(messages).toEqual([
{ time: '1:00', guid: '123', slot: 0, player: 'Player', message: 'hello; café' },
{
time: '1:00',
guid: '123',
slot: 0,
player: 'Player',
message: 'hello; café',
channel: 'team'
},
{
time: '1:01',
guid: '456',
slot: 1,
player: 'Teammate',
message: 'reply',
channel: 'team'
}
]);
expect(JSON.stringify(messages[0])).toBe(
'{"time":"1:00","guid":"123","slot":0,"player":"Player","message":"hello; café"}'
);
});
it('separates matches even when elapsed time keeps increasing', () => {
const entries = parseGameChatTimeline([
'1200:00 InitGame: \\mapname\\zombie_theater',
+239
View File
@@ -0,0 +1,239 @@
import { randomUUID } from 'node:crypto';
import type Database from 'better-sqlite3';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import type { AuthUser } from '../../src/lib/auth.js';
import {
matchApiParams,
matchHistoryHref,
readMatchQuery
} from '../../src/lib/match-history-query.js';
import { newFixture, type Fixture } from '../helpers/fixture.js';
let fixture: Fixture;
let handle: Database.Database;
const server = 't5_zombie_theater_1';
const clock = '2026-10-17T12:00:00.000Z';
const end = '2026-10-17T12:01:00.000Z';
beforeEach(async () => {
vi.resetModules();
fixture = await newFixture([]);
const { db } = await import('../../src/lib/server/db/index.js');
handle = db();
handle
.prepare("INSERT INTO servers(name,game,map) VALUES(?,'t5','zombie_theater')")
.run(server);
for (const role of ['admin', 'moderator', 'super_viewer', 'operator', 'viewer'])
handle
.prepare(
`INSERT INTO auth_users(id,username,display_name,password_hash,role,created_at,updated_at)
VALUES(?,?,?,'unused',?,0,0)`
)
.run(role, role, role, role);
handle
.prepare(
"INSERT INTO leaderboard_players(game,guid,name,first_seen_at,last_seen_at) VALUES('t5','123','Player',?,?)"
)
.run(clock, end);
const insert =
handle.prepare(`INSERT INTO leaderboard_matches(id,server,game,map,map_key,map_name,started_at,ended_at,last_received_at,status,start_observed,highest_round,last_event,last_fingerprint)
VALUES(?,?,'t5','zombie_theater','t5:zombie_theater','Kino Der Toten',?,?,?,?,1,?,'game_end','test')`);
for (const [id, status, round] of [
[1, 'completed', 1],
[2, 'interrupted', 1],
[3, 'active', 0],
[4, 'end-report-missing', 2],
[5, 'completed', 8],
[6, 'completed', 1]
] as const) {
insert.run(id, id === 6 ? 'other-server' : server, clock, end, end, status, round);
handle
.prepare(
`INSERT INTO leaderboard_match_players(match_id,guid,highest_round,has_stats,completed,first_seen_at,last_seen_at)
VALUES(?,'123',?,1,?,?,?)`
)
.run(id, round, status === 'completed' ? 1 : 0, clock, end);
handle.prepare('INSERT INTO match_chat VALUES(?,?,?,?)').run(
id,
`message-${id}`,
1,
JSON.stringify({
type: 'message',
message: {
time: '1:00',
guid: '123',
slot: 1,
player: 'Player',
message: `Message from match ${id}`
},
context: { realTime: null, matchElapsedMs: null, round: null }
})
);
}
});
afterEach(async () => fixture.cleanup());
async function user(role: string): Promise<AuthUser> {
const { getUser } = await import('../../src/lib/server/auth/index.js');
return getUser(role)!;
}
async function api(actor: AuthUser | null, scoped = false, archive = '1') {
const url = new URL(`http://localhost/api/matches?archive=${archive}`);
if (scoped) {
const { GET } = await import('../../src/routes/api/servers/[name]/matches/+server.js');
return GET({ url, params: { name: server }, locals: { user: actor } });
}
const { GET } = await import('../../src/routes/api/matches/+server.js');
return GET({ url, locals: { user: actor } });
}
async function page(
actor: AuthUser | null,
surface: 'list' | 'detail' | 'instance',
id = '1',
archive = '1'
) {
const url = new URL(`http://localhost/matches/${id}?matchArchive=${archive}`);
const setHeaders = vi.fn();
let result;
if (surface === 'list') {
const { load } = await import('../../src/routes/matches/+page.server.js');
result = load({ url, locals: { user: actor }, setHeaders });
} else if (surface === 'detail') {
const { load } = await import('../../src/routes/matches/[id]/+page.server.js');
result = load({ url, params: { id }, locals: { user: actor }, setHeaders });
} else {
const { load } = await import('../../src/routes/servers/[name]/+page.server.js');
result = load({ url, params: { name: server }, locals: { user: actor }, setHeaders });
}
return { result, setHeaders };
}
it('makes archive selection explicit, strictly parses it and preserves it through namespaced navigation', () => {
expect(readMatchQuery(new URLSearchParams()).archive).toBe(false);
expect(readMatchQuery(new URLSearchParams('archive=0')).archive).toBe(false);
expect(readMatchQuery(new URLSearchParams('archive=1')).archive).toBe(true);
for (const value of ['true', 'yes', '2'])
expect(() => readMatchQuery(new URLSearchParams(`archive=${value}`))).toThrow(
'Invalid match history filters'
);
const url = new URL('http://localhost/matches?matchPage=3&matchStatus=interrupted');
const href = matchHistoryHref(url, { archive: '1' });
expect(href).toContain('matchArchive=1');
expect(href).not.toContain('matchPage');
expect(matchApiParams(new URL(href, url)).get('archive')).toBe('1');
});
it('exposes saved round-one, interrupted, active and missing-end chat only in expanded queries without changing gameplay qualification', async () => {
const { getMatchDetail, serverMatchHistory } =
await import('../../src/lib/server/fleet/match-history.js');
const { getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js');
const publicBefore = getLeaderboard();
expect(publicBefore.entries[0].gamesPlayed).toBe(1);
expect(serverMatchHistory().total).toBe(2);
const archive = readMatchQuery(new URLSearchParams('archive=1'));
expect(serverMatchHistory(undefined, 25, archive).total).toBe(6);
expect(serverMatchHistory(server, 25, archive).total).toBe(5);
expect(
serverMatchHistory(
server,
25,
readMatchQuery(new URLSearchParams('archive=1&status=interrupted'))
).matches.map((item) => item.id)
).toEqual([2]);
for (const id of [1, 2, 3, 4, 6]) {
expect(getMatchDetail(id)).toBeNull();
expect(getMatchDetail(id, true)?.chat).toMatchObject([
{ message: { message: `Message from match ${id}` }, messageId: `${id}:message-${id}:1` }
]);
}
for (const id of [2, 3, 4])
expect(getMatchDetail(id, true)).toMatchObject({
finalRound: null,
durationMs: null,
endedAt: null,
endPlayers: null,
players: [{ presentAtEnd: false }]
});
expect(getMatchDetail(5)).toMatchObject({ status: 'completed', finalRound: 8 });
expect(getLeaderboard()).toEqual(publicBefore);
handle.prepare('DELETE FROM servers WHERE name=?').run(server);
expect(getMatchDetail(2, true)?.chat).toHaveLength(1);
});
it.each(['admin', 'moderator', 'super_viewer'])(
'allows %s archive access with private responses on every API and page surface',
async (role) => {
const actor = await user(role);
for (const scoped of [false, true]) {
const response = await api(actor, scoped);
expect(response.status).toBe(200);
expect(response.headers.get('Cache-Control')).toBe('no-store');
expect((await response.json()).total).toBe(scoped ? 5 : 6);
}
for (const surface of ['list', 'detail', 'instance'] as const) {
const loaded = await page(actor, surface);
expect(loaded.setHeaders).toHaveBeenCalledWith({ 'Cache-Control': 'no-store' });
if (surface === 'detail')
expect(loaded.result).toMatchObject({
match: { id: 1, status: 'completed', finalRound: 1 }
});
}
}
);
it.each(['operator', 'viewer', null])(
'rejects %s expanded access before revealing match existence',
async (role) => {
const actor = role ? await user(role) : null;
for (const scoped of [false, true]) {
const response = await api(actor, scoped);
expect(response.status).toBe(403);
expect(response.headers.get('Cache-Control')).toBe('no-store');
}
for (const surface of ['list', 'detail', 'instance'] as const)
await expect(page(actor, surface)).rejects.toMatchObject({ status: 403 });
await expect(page(actor, 'detail', '999')).rejects.toMatchObject({ status: 403 });
expect((await api(actor, false, '0')).status).toBe(200);
await expect(page(actor, 'detail', '1', '0')).rejects.toMatchObject({ status: 404 });
expect((await page(actor, 'detail', '5', '0')).result).toMatchObject({ match: { id: 5 } });
}
);
it.each(["role='viewer'", 'disabled=1', 'must_change_password=1'])(
'revalidates stale moderator locals after %s',
async (change) => {
const actor = await user('moderator');
handle.prepare(`UPDATE auth_users SET ${change} WHERE id='moderator'`).run();
expect((await api(actor)).status).toBe(403);
expect((await api(actor, true)).status).toBe(403);
for (const surface of ['list', 'detail', 'instance'] as const)
await expect(page(actor, surface)).rejects.toMatchObject({ status: 403 });
}
);
it('returns validation and not-found errors without silently falling back to the standard history', async () => {
const actor = await user('moderator');
expect((await api(actor, false, 'true')).status).toBe(400);
for (const surface of ['list', 'detail', 'instance'] as const)
await expect(page(actor, surface, '1', 'true')).rejects.toMatchObject({ status: 400 });
for (const id of ['0', '01', '-1', '1.5', '999'])
await expect(page(actor, 'detail', id)).rejects.toMatchObject({ status: 404 });
});
it('attaches interrupted-match archive evidence and keeps the snapshot after source cleanup', async () => {
const { getMatchDetail } = await import('../../src/lib/server/fleet/match-history.js');
const { mutateModeration } = await import('../../src/lib/server/moderation/actions.js');
const { moderationCaseDetail } = await import('../../src/lib/server/moderation/cases.js');
const entry = getMatchDetail(2, true)!.chat[0];
const { id } = mutateModeration('moderator', {
op: 'create-case',
game: 't5',
guid: '123',
title: 'Interrupted match report',
evidence: { matchId: 2, messageIds: [entry.messageId] },
idempotencyKey: randomUUID()
});
handle.prepare('DELETE FROM leaderboard_matches WHERE id=2').run();
const evidence = moderationCaseDetail('moderator', id).case.evidence[0];
expect(JSON.parse(evidence.snapshot!).entry.message.message).toBe('Message from match 2');
});
+28 -2
View File
@@ -50,7 +50,7 @@ async function create(overrides: Record<string, unknown> = {}) {
});
}
async function archive(messages: Array<{ guid?: string; message: string }>) {
async function archive(messages: Array<{ guid?: string; message: string; channel?: 'team' }>) {
const { saveTelemetry } = await import('../../src/lib/server/fleet/telemetry.js');
const { matchChatTimeline } = await import('../../src/lib/server/fleet/chat.js');
saveTelemetry({
@@ -77,7 +77,8 @@ async function archive(messages: Array<{ guid?: string; message: string }>) {
guid: message.guid ?? '123',
slot: message.guid ? 2 : 1,
player: message.guid ? 'Witness' : 'Subject',
message: message.message
message: message.message,
...(message.channel ? { channel: message.channel } : {})
}
})
);
@@ -155,6 +156,31 @@ it('allows a new witness reply alongside an attached subject and deduplicates la
expect(await detail(id)).toEqual(attached);
});
it('preserves team chat separately when matching evidence saved before message identifiers', async () => {
const { matchId, entries } = await archive([
{ message: 'Same words', channel: 'team' },
{ message: 'Same words' }
]);
const { id } = await create({
evidence: { matchId, messageIds: [entries[1].messageId] }
});
// A legacy public-chat snapshot has no selected IDs or channel field.
handle
.prepare('UPDATE moderation_evidence SET snapshot=? WHERE case_id=?')
.run(JSON.stringify({ matchId, entry: entries[1] }), id);
await mutate({
op: 'attach-evidence',
id,
revision: 1,
evidence: { matchId, messageIds: [entries[0].messageId] }
});
const attached = await detail(id);
expect(attached.case.evidence).toHaveLength(2);
const snapshot = JSON.parse(attached.case.evidence[1].snapshot!);
expect(snapshot.entry.message.channel).toBe('team');
expect(snapshot.selectedMessageIds).toEqual([entries[0].messageId]);
});
it('validates selection and context bounds and rolls back opening a case with invalid evidence', async () => {
const { matchId, entries } = await archive([{ message: 'Subject statement' }]);
const messageId = entries[0].messageId!;
+107 -1
View File
@@ -1,7 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { newFixture, type Fixture } from '../helpers/fixture.js';
import type { ContextualChatEntry } from '../../src/lib/game-chat.js';
import type { TailResult } from '../../src/lib/server/fleet/logs.js';
import type { LogChunk, TailResult } from '../../src/lib/server/fleet/logs.js';
const SERVER = 't5_zombie_theater_1';
const OTHER_SERVER = 't6_zombie_town_1';
@@ -19,6 +19,7 @@ beforeEach(async () => {
db()
.prepare("UPDATE servers SET created_at=?, last_players=0, container_state='running'")
.run(new Date(baseline - 120_000).toISOString());
db().prepare("UPDATE servers SET container_state='stopped' WHERE name=?").run(OTHER_SERVER);
});
afterEach(async () => {
@@ -43,6 +44,25 @@ function messages(body: { entries: ContextualChatEntry[] }): string[] {
);
}
function chunk(content: string, offset: number, size: number, reset = false): LogChunk {
const mtime = new Date(baseline + 60_000).toISOString();
return {
contentBase64: Buffer.from(content).toString('base64'),
size,
mtime,
truncated: false,
reset,
cursor: {
fileId: 'chat-log',
offset,
size,
modified: mtime,
anchorLength: 0,
anchorHash: ''
}
};
}
function advance(milliseconds: number) {
vi.setSystemTime(Date.now() + milliseconds);
}
@@ -82,6 +102,92 @@ async function addInstance() {
}
describe('server chat refresh sharing', () => {
it('picks up surviving logs once when collection starts on an idle server', async () => {
const s = await setup();
await s.collectFleetChat();
expect(messages(await (await s.get()).json())).toEqual(['first']);
advance(60_000);
await s.collectFleetChat();
expect(s.read).toHaveBeenCalledTimes(1);
});
it('drains all remaining chunks after players leave before anchoring messages', async () => {
const s = await setup();
const lines = [
'0:30 say;abc;0;Player;first\n',
'0:40 say;abc;0;Player;middle\n',
'1:00 say;abc;0;Player;last\n'
];
const sizes = lines.map((line) => Buffer.byteLength(line));
const total = sizes.reduce((a, b) => a + b, 0);
s.read
.mockResolvedValueOnce(chunk(lines[0], sizes[0], total, true))
.mockResolvedValueOnce(chunk(lines[1], sizes[0] + sizes[1], total))
.mockResolvedValueOnce(chunk(lines[2], total, total));
s.db.prepare('UPDATE servers SET last_players=1 WHERE name=?').run(SERVER);
await s.collectFleetChat();
expect(messages(await (await s.get()).json())).toEqual([]);
s.db.prepare('UPDATE servers SET last_players=0 WHERE name=?').run(SERVER);
await s.collectFleetChat();
expect(messages(await (await s.get()).json())).toEqual([]);
await s.collectFleetChat();
expect(messages(await (await s.get()).json())).toEqual(['first', 'middle', 'last']);
await s.collectFleetChat();
expect(s.read).toHaveBeenCalledTimes(3);
expect(s.read.mock.calls.map((call) => call[1]?.offset)).toEqual([
undefined,
sizes[0],
sizes[0] + sizes[1]
]);
});
it('bounds stalled partial-line retries and resumes collection when players return', async () => {
const s = await setup();
const partial = '1:00 say;abc;0;Player;incomplete';
const size = Buffer.byteLength(partial);
s.read
.mockResolvedValueOnce(chunk(partial, size, size, true))
.mockResolvedValue(chunk('', size, size));
await s.collectFleetChat();
await s.collectFleetChat();
expect(s.read).toHaveBeenCalledTimes(2);
await s.collectFleetChat();
expect(s.read).toHaveBeenCalledTimes(2);
for (let attempt = 0; attempt < 5; attempt++) {
advance(15_000);
await s.collectFleetChat();
}
expect(s.read).toHaveBeenCalledTimes(4);
s.db.prepare('UPDATE servers SET last_players=1 WHERE name=?').run(SERVER);
s.read.mockResolvedValue(chunk('\n', size + 1, size + 1));
await s.collectFleetChat();
expect(messages(await (await s.get()).json())).toEqual(['incomplete']);
expect(s.read).toHaveBeenCalledTimes(5);
});
it('stops idle reads for missing logs and bounds retries for read failures', async () => {
const s = await setup();
s.read.mockResolvedValueOnce(null);
await s.collectFleetChat();
advance(60_000);
await s.collectFleetChat();
expect(s.read).toHaveBeenCalledTimes(1);
// A new registration gets its own initial attempt and retry budget.
s.db
.prepare('UPDATE servers SET created_at=? WHERE name=?')
.run(new Date(baseline - 60_000).toISOString(), SERVER);
s.read.mockRejectedValue(new Error('Unavailable source'));
vi.spyOn(console, 'error').mockImplementation(() => {});
await s.collectFleetChat();
await s.collectFleetChat();
expect(s.read).toHaveBeenCalledTimes(2);
for (let attempt = 0; attempt < 5; attempt++) {
advance(15_000);
await s.collectFleetChat();
}
expect(s.read).toHaveBeenCalledTimes(4);
});
it('serves worker state to repeated viewers and still collects after players leave', async () => {
const s = await setup();
s.db.prepare('UPDATE servers SET last_players=1 WHERE name=?').run(SERVER);
+33 -8
View File
@@ -139,7 +139,7 @@ describe('server chat API', () => {
).toEqual(['first', 'retry me']);
});
it('skips empty, stopped and offline servers and collects one last time after players leave', async () => {
it('checks idle servers once, skips stopped and offline servers and collects after players leave', async () => {
const { db } = await import('../../src/lib/server/db/index.js');
const { createHost } = await import('../../src/lib/server/hosts/index.js');
const logs = await import('../../src/lib/server/fleet/logs.js');
@@ -147,25 +147,27 @@ describe('server chat API', () => {
const { collectFleetChat } = await import('../../src/lib/server/fleet/chat.js');
db().prepare("UPDATE servers SET last_players=0,container_state='running'").run();
await collectFleetChat();
expect(read).not.toHaveBeenCalled();
expect(read).toHaveBeenCalledTimes(2);
await collectFleetChat();
expect(read).toHaveBeenCalledTimes(2);
db().prepare('UPDATE servers SET last_players=1 WHERE name=?').run(SERVER);
await log(['60:50 say;abc;0;Player;playing'], 50);
await collectFleetChat();
expect(read).toHaveBeenCalledTimes(1);
expect(read).toHaveBeenCalledTimes(3);
db().prepare('UPDATE servers SET last_players=0 WHERE name=?').run(SERVER);
await log(['60:50 say;abc;0;Player;playing', '61:00 say;abc;0;Player;goodbye']);
expect((await (await get()).json()).entries).toHaveLength(1);
expect(read).toHaveBeenCalledTimes(1);
expect(read).toHaveBeenCalledTimes(3);
await collectFleetChat();
await collectFleetChat();
expect(read).toHaveBeenCalledTimes(2);
expect(read).toHaveBeenCalledTimes(4);
expect((await (await get()).json()).entries).toHaveLength(2);
expect(read).toHaveBeenCalledTimes(2);
expect(read).toHaveBeenCalledTimes(4);
db()
.prepare("UPDATE servers SET last_players=1,container_state='stopped' WHERE name=?")
.run(SERVER);
await collectFleetChat();
expect(read).toHaveBeenCalledTimes(2);
expect(read).toHaveBeenCalledTimes(4);
const host = createHost({ name: 'Offline' });
db().prepare("UPDATE servers SET container_state='running' WHERE name=?").run(SERVER);
db()
@@ -175,7 +177,30 @@ describe('server chat API', () => {
)
.run(SERVER, host.id, at(0));
await collectFleetChat();
expect(read).toHaveBeenCalledTimes(2);
expect(read).toHaveBeenCalledTimes(4);
});
it('archives surviving idle match logs with distinct stable public and team message identities', async () => {
await report('game_start', 0);
await report('heartbeat', 20, 4);
await report('game_end', 60, 4);
await log(['60:30 say;abc;0;Player;hello', '60:30 sayteam;abc;0;Player;hello'], 30);
const { db } = await import('../../src/lib/server/db/index.js');
db().prepare('UPDATE servers SET last_players=0').run();
const { collectFleetChat, matchChatTimeline } =
await import('../../src/lib/server/fleet/chat.js');
await collectFleetChat();
const saved = matchChatTimeline(1);
expect(saved).toHaveLength(2);
expect(saved[0]).toMatchObject({ message: { message: 'hello' } });
expect(saved[0].type === 'message' && saved[0].message.channel).toBeUndefined();
expect(saved[1]).toMatchObject({ message: { message: 'hello', channel: 'team' } });
expect(new Set(saved.map((entry) => entry.messageId)).size).toBe(2);
expect(saved.every((entry) => typeof entry.messageId === 'string')).toBe(true);
advanceTime();
await get();
await collectFleetChat();
expect(matchChatTimeline(1)).toEqual(saved);
});
it('keeps real timestamps and historical match context stable across refreshes', async () => {
+13
View File
@@ -3,6 +3,7 @@ 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 EvidenceMessages from '../../src/lib/ui/components/moderation/EvidenceMessages.svelte';
import type { MatchDetail } from '../../src/lib/match-history.js';
vi.mock('$app/stores', () => ({
@@ -45,6 +46,18 @@ const match: MatchDetail = {
};
describe('historical match chat', () => {
it('identifies team chat in both archived messages and preserved evidence excerpts', () => {
const entry = match.chat[0];
if (entry.type !== 'message') throw new Error('Expected message fixture');
const history = [{ ...entry, message: { ...entry.message, channel: 'team' as const } }];
expect(render(ChatPanel, { props: { history } }).body).toContain('Team chat');
expect(render(EvidenceMessages, { props: { entries: history } }).body).toContain(
'Team chat'
);
expect(render(ChatPanel, { props: { history: match.chat } }).body).not.toContain(
'Team 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');