Fix active and match timer drift between status polls

Persist occupancy transitions with telemetry and carry timer anchors through live updates. Cover delayed polling, lobby rejoining, and lifecycle boundaries with regression tests.

Fixes #47
This commit is contained in:
2026-09-27 23:29:04 -04:00
parent b904ddbc48
commit 2fa3792482
8 changed files with 210 additions and 15 deletions
+90 -1
View File
@@ -17,7 +17,7 @@ beforeEach(async () => {
vi.clearAllMocks();
fixture = await newFixture(['unregistered-folder']);
});
afterEach(async () => { await fixture.cleanup(); });
afterEach(async () => { vi.useRealTimers(); await fixture.cleanup(); });
async function setup() {
const { db } = await import('../../src/lib/server/db/index.js');
@@ -85,6 +85,95 @@ describe('database fleet and container readiness', () => {
expect(getServer('managed')?.game_ended_at).toBeNull();
});
it('keeps the first occupied report as the active timer when more telemetry arrives before polling', async () => {
const { inspect, rcon, fanoutStatus, getServer } = await setup();
const { GET } = await import('../../src/routes/api/servers/+server.js');
const { subscribe } = await import('../../src/lib/server/pipeline/bus.js');
const startedAt = Date.parse('2026-09-27T12:00:00Z');
const at = (seconds: number) => new Date(startedAt + seconds * 1000).toISOString();
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(startedAt);
inspect.mockResolvedValue({ ok: true, state: 'running' });
expect((await postTelemetry(gameEvent('game_start', 0, 1, 'managed'))).status).toBe(200);
expect(getServer('managed')).toMatchObject({ active_since: at(0), inactive_since: null, game_started_at: at(0) });
vi.setSystemTime(startedAt + 19_000);
const heartbeat = await (await postTelemetry(gameEvent('heartbeat', 2, 1, 'managed'))).json();
expect(heartbeat.telemetry.timing.server).toEqual({
activeSince: at(0), inactiveSince: null, gameStartedAt: at(0), gameEndedAt: null
});
vi.setSystemTime(startedAt + 30_000);
const events: unknown[] = [];
const unsubscribe = subscribe(event => events.push(event));
try { await fanoutStatus(); } finally { unsubscribe(); }
expect(rcon).not.toHaveBeenCalled();
expect((await (await GET()).json()).servers[0]).toMatchObject({
active_since: at(0), game_started_at: at(0), last_seen_at: at(19)
});
expect(events).toContainEqual(expect.objectContaining({ type: 'server-status', entries: [expect.objectContaining({
activeSince: at(0), gameStartedAt: at(0), lastSeenAt: at(19)
})] }));
const { closeDb } = await import('../../src/lib/server/db/index.js');
closeDb();
expect((await (await GET()).json()).servers[0]).toMatchObject({ active_since: at(0), game_started_at: at(0) });
});
it('preserves occupancy observed by RCON before game-start telemetry', async () => {
const { inspect, rcon, fanoutStatus, getServer } = await setup();
const startedAt = Date.parse('2026-09-27T12:00:00Z');
const at = (seconds: number) => new Date(startedAt + seconds * 1000).toISOString();
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(startedAt - 20_000);
inspect.mockResolvedValue({ ok: true, state: 'running' });
rcon.mockResolvedValue({ ok: true, endpoint: { host: 'localhost', port: 28960 }, rawReply: 'status',
status: { players: 1, map: 'zombie_theater', gametype: 'zom', roster: [] } });
await fanoutStatus();
expect(getServer('managed')).toMatchObject({ active_since: at(-20), game_started_at: null });
vi.setSystemTime(startedAt);
const start = await (await postTelemetry(gameEvent('game_start', 0, 1, 'managed'))).json();
expect(start.telemetry.timing.server).toEqual({
activeSince: at(-20), inactiveSince: null, gameStartedAt: at(0), gameEndedAt: null
});
expect(getServer('managed')).toMatchObject({ active_since: at(-20), inactive_since: null, game_started_at: at(0) });
});
it('records an empty lobby and rejoin between status polls without restoring the old match', async () => {
const { inspect, fanoutStatus, getServer } = await setup();
const { GET } = await import('../../src/routes/api/servers/+server.js');
const startedAt = Date.parse('2026-09-27T12:00:00Z');
const at = (seconds: number) => new Date(startedAt + seconds * 1000).toISOString();
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(startedAt);
inspect.mockResolvedValue({ ok: true, state: 'running' });
await postTelemetry(gameEvent('game_start', 0, 1, 'managed'));
vi.setSystemTime(startedAt + 10_000);
await postTelemetry(gameEvent('game_end', 2, 1, 'managed'));
vi.setSystemTime(startedAt + 11_000);
const empty = await (await postTelemetry(gameEvent('heartbeat', 0, 0, 'managed'))).json();
expect(empty.telemetry.timing.server).toEqual({
activeSince: null, inactiveSince: at(11), gameStartedAt: null, gameEndedAt: null
});
expect(getServer('managed')).toMatchObject({
active_since: null, inactive_since: at(11), game_started_at: null, game_ended_at: null
});
vi.setSystemTime(startedAt + 15_000);
const rejoin = await (await postTelemetry(gameEvent('heartbeat', 1, 1, 'managed'))).json();
expect(rejoin.telemetry.timing.server).toEqual({
activeSince: at(15), inactiveSince: null, gameStartedAt: null, gameEndedAt: null
});
expect(getServer('managed')).toMatchObject({
active_since: at(15), inactive_since: null, game_started_at: null, game_ended_at: null
});
vi.setSystemTime(startedAt + 30_000);
await fanoutStatus();
expect((await (await GET()).json()).servers[0]).toMatchObject({
active_since: at(15), inactive_since: null, game_started_at: null, game_ended_at: null
});
});
it('waits for game_start telemetry after log initialization and player joins', async () => {
const { inspect, rcon, fanoutStatus, getServer } = await setup();
const { GET } = await import('../../src/routes/api/servers/+server.js');