Capture team chat and expose moderation match archives
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user