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
+41
View File
@@ -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();