Preserve fleet match timing across empty telemetry
CI / checks (push) Successful in 4m46s

This commit is contained in:
2026-10-09 19:41:55 -04:00
parent d1f581d6a8
commit ce6dba17e6
12 changed files with 171 additions and 53 deletions
+8
View File
@@ -119,6 +119,14 @@ describe('Fleet discovery and match freshness', () => {
(event) => {
const report = { ...gameEvent(event, 38, 4, server.name), receivedAt: iso(-1000) };
expect(currentRound(server, report, now)).toBe(38);
const unknownStart = { ...server, game_started_at: null };
expect(currentRound(unknownStart, report, now)).toBe(38);
expect(
currentRound({ ...unknownStart, telemetry_after: iso() }, report, now)
).toBeNull();
expect(
currentRound(unknownStart, { ...report, receivedAt: iso(-121_000) }, now)
).toBeNull();
for (const change of [
{ receivedAt: iso(-121_000) },
{ receivedAt: iso(-61_000) },
+3 -3
View File
@@ -434,7 +434,7 @@ describe('shared server observation', () => {
expect(freshTelemetry({ ...report(), players: [{}] } as never, now)).toBe(false);
});
it('uses game-start rosters without inventing scores and clears match times on an empty report', () => {
it('uses game-start rosters without inventing scores and preserves match times on an empty report', () => {
const start = { ...gameEvent('game_start', 0, 2, 'managed'), receivedAt: at() };
const active = selectServerObservation(server(), start, now);
expect(active.roster?.[0]).toMatchObject({ score: null, ping: null });
@@ -444,8 +444,8 @@ describe('shared server observation', () => {
).toMatchObject({
last_players: 0,
roster: [],
game_started_at: null,
game_ended_at: null
game_started_at: at(),
game_ended_at: at()
});
});
});
+52 -2
View File
@@ -608,7 +608,7 @@ describe('database fleet and container readiness', () => {
}
);
it('keeps game_end through occupied polls until game_start or an empty lobby', async () => {
it('keeps game_end through RCON polls and recovers when telemetry observes a new match', async () => {
const { inspect, rcon, fanoutStatus, getServer } = await setup();
inspect.mockResolvedValue({ ok: true, state: 'running' });
const reply = (players: number) => ({
@@ -621,9 +621,11 @@ describe('database fleet and container readiness', () => {
await postTelemetry(gameEvent('game_end', 10, 1, 'managed'));
const ended = getServer('managed')?.game_ended_at;
expect(ended).toEqual(expect.any(String));
await postTelemetry(gameEvent('heartbeat', 10, 1, 'managed'));
await fanoutStatus();
expect(getServer('managed')?.game_ended_at).toBe(ended);
await postTelemetry(gameEvent('heartbeat', 1, 1, 'managed'));
await fanoutStatus();
expect(getServer('managed')).toMatchObject({ game_started_at: null, game_ended_at: null });
await postTelemetry(gameEvent('game_start', 0, 1, 'managed'));
expect(getServer('managed')?.game_ended_at).toBeNull();
await postTelemetry(gameEvent('game_end', 10, 1, 'managed'));
@@ -638,6 +640,54 @@ describe('database fleet and container readiness', () => {
expect(getServer('managed')?.game_ended_at).toBeNull();
});
it.each([0, 1])(
'preserves a match starting with %i players through empty reports, polls, a gap, and reload',
async (startPlayers) => {
const { inspect, rcon, fanoutStatus, getServer } = await setup();
const { GET } = await import('../../src/routes/api/servers/+server.js');
const { currentRound } = await import('../../src/lib/fleet-overview.js');
const { closeDb } = await import('../../src/lib/server/db/index.js');
const beginning = Date.parse('2026-10-09T12:00:00Z');
const at = (seconds: number) => new Date(beginning + seconds * 1000).toISOString();
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(beginning);
inspect.mockResolvedValue({ ok: true, state: 'running' });
rcon.mockResolvedValue({
ok: true,
endpoint: { host: 'localhost', port: 28960 },
status: { players: 0, map: 'zombie_theater', gametype: 'zom', roster: [] }
});
const start = await (
await postTelemetry(gameEvent('game_start', 0, startPlayers, 'managed'))
).json();
expect(start.telemetry.timing.server.gameStartedAt).toBe(at(0));
vi.setSystemTime(beginning + 1000);
await postTelemetry(gameEvent('round_change', 1, 0, 'managed'));
for (const seconds of [30, 121]) {
vi.setSystemTime(beginning + seconds * 1000);
await fanoutStatus();
expect(getServer('managed')).toMatchObject({
active_since: null,
game_started_at: at(0),
game_ended_at: null
});
}
vi.setSystemTime(beginning + 180_000);
await postTelemetry(gameEvent('heartbeat', 4, 1, 'managed'));
await fanoutStatus();
closeDb();
const server = (await (await GET()).json()).servers[0];
expect(server).toMatchObject({
health: 'online',
last_players: 1,
active_since: at(180),
game_started_at: at(0),
game_ended_at: null
});
expect(currentRound(server, server.telemetry, Date.now())).toBe(4);
}
);
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');
+39
View File
@@ -18,6 +18,45 @@ afterEach(async () => fixture.cleanup());
const payload = gameEvent('round_change', 17);
it.each(['game_end', 'round reset', 'map change'])(
'drops the previous match timing when game_start is missed after %s',
async (boundary) => {
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-10-09T12:00:00Z') + seconds * 1000).toISOString();
saveTelemetry({ ...gameEvent('game_start', 0, 1), receivedAt: at(0) });
const previous = saveTelemetry({ ...gameEvent('heartbeat', 7, 1), receivedAt: at(30) });
if (boundary === 'game_end') {
const ended = saveTelemetry({ ...gameEvent('game_end', 7, 0), receivedAt: at(31) });
expect(ended.timing?.server?.gameEndedAt).toBe(at(31));
}
const next = saveTelemetry({
...gameEvent('heartbeat', boundary === 'map change' ? 8 : 1, 1),
...(boundary === 'map change' ? { map: 'zombie_moon' } : {}),
receivedAt: at(60)
});
expect(next.timing?.matchId).not.toBe(previous.timing?.matchId);
expect(next.timing?.server).toMatchObject({ gameStartedAt: null, gameEndedAt: null });
expect(getServer(payload.server)).toMatchObject({
game_started_at: null,
game_ended_at: null
});
}
);
it('repairs a previously discarded start from an observed match without inventing one', async () => {
const { saveTelemetry } = await import('../../src/lib/server/fleet/telemetry.js');
const { db } = await import('../../src/lib/server/db/index.js');
const at = (seconds: number) =>
new Date(Date.parse('2026-10-09T12:00:00Z') + seconds * 1000).toISOString();
saveTelemetry({ ...gameEvent('game_start', 0, 0), receivedAt: at(0) });
// Simulate the old ingestion behavior for an empty start.
db().prepare('UPDATE servers SET game_started_at = NULL WHERE name = ?').run(payload.server);
const report = saveTelemetry({ ...gameEvent('heartbeat', 4, 1), receivedAt: at(120) });
expect(report.timing?.server?.gameStartedAt).toBe(at(0));
});
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');
+9
View File
@@ -88,6 +88,15 @@ describe('compact server cards', () => {
expect(body).not.toContain('36ms');
});
it('shows a known round without a match clock and hides match metrics for an empty lobby', () => {
const occupied = html({ gameStartedAt: null, round: 31 });
expect(occupied).toMatch(/<dt[^>]*>Round<\/dt>\s*<dd[^>]*>31<\/dd>/);
expect(occupied).toMatch(/<dt[^>]*>Match time<\/dt>\s*<dd[^>]*>—<\/dd>/);
const empty = html({ players: 0, round: 31, compactEmpty: false });
expect(empty).toMatch(/<dt[^>]*>Round<\/dt>\s*<dd[^>]*>—<\/dd>/);
expect(empty).toMatch(/<dt[^>]*>Match time<\/dt>\s*<dd[^>]*>—<\/dd>/);
});
it('places Pin outside the only link and retains its pressed state and server name', () => {
const body = html({ pinned: true, onPin: () => {} });
expect(body).toMatch(/<article\b/);
+4 -4
View File
@@ -460,26 +460,26 @@ describe('Fleet report clock', () => {
report('heartbeat', 34, 0, {
activeSince: null,
inactiveSince: at(34),
gameStartedAt: null,
gameStartedAt: at(32),
gameEndedAt: null
})
).toMatchObject({
active_since: null,
inactive_since: at(34),
game_started_at: null,
game_started_at: at(32),
game_ended_at: null
});
expect(
report('heartbeat', 35, 1, {
activeSince: at(35),
inactiveSince: null,
gameStartedAt: null,
gameStartedAt: at(32),
gameEndedAt: null
})
).toMatchObject({
active_since: at(35),
inactive_since: null,
game_started_at: null,
game_started_at: at(32),
game_ended_at: null
});
});