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:
@@ -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');
|
||||
|
||||
@@ -13,6 +13,30 @@ afterEach(async () => fixture.cleanup());
|
||||
|
||||
const payload = gameEvent('round_change', 17);
|
||||
|
||||
it('does not let a request received before a lifecycle submission restore cleared timers', async () => {
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
const { saveTelemetry } = await import('../../src/lib/server/fleet/telemetry.js');
|
||||
const { getServer } = await import('../../src/lib/server/fleet/servers.js');
|
||||
const at = (seconds: number) => new Date(Date.parse('2026-09-20T12:00:00Z') + seconds * 1000).toISOString();
|
||||
db().prepare('INSERT INTO restart_rollout (server, requested_at) VALUES (?, ?)').run(payload.server, at(10));
|
||||
for (const seconds of [9, 10]) {
|
||||
saveTelemetry({ ...gameEvent('game_start'), receivedAt: at(seconds) });
|
||||
expect(getServer(payload.server)).toMatchObject({ active_since: null, game_started_at: null });
|
||||
}
|
||||
const current = saveTelemetry({ ...gameEvent('heartbeat', 2), receivedAt: at(11) });
|
||||
expect(current.timing?.server).toEqual({ activeSince: at(11), inactiveSince: null, gameStartedAt: null, gameEndedAt: null });
|
||||
});
|
||||
|
||||
it('generates timer anchors on the server instead of accepting client-supplied timing', async () => {
|
||||
const response = await post({ ...gameEvent('game_start'), receivedAt: '2000-01-01T00:00:00Z',
|
||||
timing: { server: { activeSince: '2000-01-01T00:00:00Z' } } });
|
||||
const { telemetry } = await response.json();
|
||||
expect(response.status).toBe(200);
|
||||
expect(telemetry.receivedAt).not.toBe('2000-01-01T00:00:00Z');
|
||||
expect(telemetry.timing.server).toEqual({ activeSince: telemetry.receivedAt, inactiveSince: null,
|
||||
gameStartedAt: telemetry.receivedAt, gameEndedAt: null });
|
||||
});
|
||||
|
||||
it('persists player timing across matches and resets after absence or a telemetry gap', async () => {
|
||||
const { saveTelemetry, listTelemetry } = await import('../../src/lib/server/fleet/telemetry.js');
|
||||
const { closeDb } = await import('../../src/lib/server/db/index.js');
|
||||
|
||||
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { get } from 'svelte/store';
|
||||
import { freshServer, matchesFleet, type FleetServer } from '../../src/lib/fleet-overview.js';
|
||||
import type { ServerStatus } from '../../src/lib/ui/stores/servers.js';
|
||||
import type { GameEvent, ServerTiming } from '../../src/lib/telemetry.js';
|
||||
import { gameEvent } from '../helpers/telemetry.js';
|
||||
|
||||
vi.mock('$app/environment', () => ({ browser: true }));
|
||||
@@ -66,6 +67,46 @@ async function setup() {
|
||||
}
|
||||
|
||||
describe('Fleet report clock', () => {
|
||||
it('keeps persisted occupancy and match anchors through live reports before the next status poll', async () => {
|
||||
const at = (seconds: number) => new Date(epoch + seconds * 1000).toISOString();
|
||||
snapshot.servers = [server(epoch, { container_state: 'running', container_checked_at: at(0) })];
|
||||
const { fleetServers } = await setup();
|
||||
const report = (event: GameEvent['event'], seconds: number, players: number, timing: ServerTiming) => {
|
||||
source.dispatchEvent(new MessageEvent('server-telemetry', { data: JSON.stringify({ serverNow: epoch + seconds * 1000,
|
||||
entries: [{ ...gameEvent(event, 2, players), receivedAt: at(seconds),
|
||||
timing: { matchId: 1, players: {}, server: timing } }] }) }));
|
||||
return get(fleetServers)[0];
|
||||
};
|
||||
const active = { activeSince: at(1), inactiveSince: null, gameStartedAt: at(1), gameEndedAt: null };
|
||||
report('game_start', 1, 1, active);
|
||||
expect(report('heartbeat', 20, 1, active)).toMatchObject({
|
||||
active_since: at(1), inactive_since: null, game_started_at: at(1), game_ended_at: null
|
||||
});
|
||||
const ended = { ...active, gameEndedAt: at(30) };
|
||||
report('game_end', 30, 1, ended);
|
||||
expect(report('heartbeat', 31, 1, ended).game_ended_at).toBe(at(30));
|
||||
const nextMatch = { ...active, gameStartedAt: at(32) };
|
||||
report('game_start', 32, 1, nextMatch);
|
||||
expect(report('heartbeat', 33, 1, nextMatch)).toMatchObject({
|
||||
active_since: at(1), game_started_at: at(32), game_ended_at: null
|
||||
});
|
||||
expect(report('heartbeat', 34, 0, { activeSince: null, inactiveSince: at(34), gameStartedAt: null, gameEndedAt: null }))
|
||||
.toMatchObject({ active_since: null, inactive_since: at(34), game_started_at: null, game_ended_at: null });
|
||||
expect(report('heartbeat', 35, 1, { activeSince: at(35), inactiveSince: null, gameStartedAt: null, gameEndedAt: null }))
|
||||
.toMatchObject({ active_since: at(35), inactive_since: null, game_started_at: null, game_ended_at: null });
|
||||
});
|
||||
|
||||
it('restores timers from a telemetry snapshot even when the browser missed game_start', async () => {
|
||||
const startedAt = new Date(epoch - 19_000).toISOString();
|
||||
snapshot.servers = [server(epoch, { container_state: 'running', container_checked_at: new Date(epoch).toISOString() })];
|
||||
const { fleetServers } = await setup();
|
||||
source.dispatchEvent(new MessageEvent('server-telemetry', { data: JSON.stringify({ serverNow: epoch, snapshot: true,
|
||||
entries: [{ ...gameEvent('heartbeat', 2, 1), receivedAt: new Date(epoch).toISOString(),
|
||||
timing: { matchId: 1, players: {}, server: { activeSince: startedAt, inactiveSince: null,
|
||||
gameStartedAt: startedAt, gameEndedAt: null } } }] }) }));
|
||||
expect(get(fleetServers)[0]).toMatchObject({ active_since: startedAt, game_started_at: startedAt });
|
||||
});
|
||||
|
||||
it('updates count and roster immediately from telemetry and expires them together', async () => {
|
||||
snapshot.servers = [server(epoch, { container_state: 'running', container_checked_at: new Date(epoch).toISOString(), last_players: 4 })];
|
||||
const { fleetServers, uiNow } = await setup();
|
||||
|
||||
Reference in New Issue
Block a user