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
+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);