diff --git a/docs/guides/beta-test-plans.md b/docs/guides/beta-test-plans.md index 49286e8..e8d6788 100644 --- a/docs/guides/beta-test-plans.md +++ b/docs/guides/beta-test-plans.md @@ -190,6 +190,12 @@ an end closes the match. Receipt order determines the latest report. This small protocol does not attempt to identify delayed events from earlier games or provide exactly-once delivery across games. +Match timing is independent of player count: an empty `game_start` still records +the start, and empty rosters or RCON polls do not erase it. Fleet can display a +fresh reported round even when the start was missed; match duration stays unknown +in that case. When match history identifies a new match from subsequent gameplay, +Fleet clears the previous match's timing rather than carrying it into the new one. + Reports also feed the persistent [Leaderboard](leaderboard.md), independently of beta activity. That page documents cumulative stat accounting, round credit, and the limits of identifying matches from this protocol. diff --git a/src/lib/fleet-overview.ts b/src/lib/fleet-overview.ts index 51e2b87..33f34bb 100644 --- a/src/lib/fleet-overview.ts +++ b/src/lib/fleet-overview.ts @@ -105,14 +105,13 @@ export function currentRound( (report.event !== 'round_change' && report.event !== 'heartbeat') || !freshServer(server, now) || !server.active_since || - !server.game_started_at || server.game_ended_at || (server.last_players ?? 0) === 0 ) return null; const received = Date.parse(report.receivedAt); return freshTelemetry(report, now, server.telemetry_after) && - received >= Date.parse(server.game_started_at) && + (!server.game_started_at || received >= Date.parse(server.game_started_at)) && received >= Date.parse(server.active_since) ? report.round : null; diff --git a/src/lib/server-observation.ts b/src/lib/server-observation.ts index 1fe7fe4..b7cfd52 100644 --- a/src/lib/server-observation.ts +++ b/src/lib/server-observation.ts @@ -176,8 +176,8 @@ export function selectServerObservation( })), active_since: players > 0 ? timing.activeSince : null, inactive_since: players === 0 ? timing.inactiveSince : null, - game_started_at: players === 0 ? null : timing.gameStartedAt, - game_ended_at: players === 0 ? null : timing.gameEndedAt + game_started_at: timing.gameStartedAt, + game_ended_at: timing.gameEndedAt }; } // A current authenticated RCON reply can establish game liveness when diff --git a/src/lib/server/fleet/status-updates.ts b/src/lib/server/fleet/status-updates.ts index 653670c..a9eab8d 100644 --- a/src/lib/server/fleet/status-updates.ts +++ b/src/lib/server/fleet/status-updates.ts @@ -20,15 +20,13 @@ export function successfulStatusFields( }; } -/** Occupancy timers and match clearing follow the same source as the UI. */ +/** Occupancy follows the UI's source; only gameplay evidence changes match timing. */ export function activityFields(current: ServerRow, status: ServerObservation): Partial { if (status.health !== 'online' || status.last_players === null) return {}; return status.last_players > 0 ? { active_since: current.active_since ?? status.last_seen_at, inactive_since: null } : { active_since: null, - inactive_since: current.inactive_since ?? status.last_seen_at, - game_started_at: null, - game_ended_at: null + inactive_since: current.inactive_since ?? status.last_seen_at }; } diff --git a/src/lib/server/fleet/telemetry.ts b/src/lib/server/fleet/telemetry.ts index 0701df8..5d3b53d 100644 --- a/src/lib/server/fleet/telemetry.ts +++ b/src/lib/server/fleet/telemetry.ts @@ -25,46 +25,55 @@ export function saveTelemetry(report: GameTelemetry): GameTelemetry { ? JSON.parse(row.payload) : undefined; recordLeaderboardTelemetry(handle, report); - // Record each occupancy transition at receipt, before a heartbeat can - // replace game_start or an empty report between status polls. - const after = latestGameSubmission(report.server); - if (!after || Date.parse(report.receivedAt) > Date.parse(after)) { - if (report.players.length > 0) { - handle - .prepare( - 'UPDATE servers SET active_since = COALESCE(active_since, ?), inactive_since = NULL WHERE name = ?' - ) - .run(report.receivedAt, report.server); - if (report.event === 'game_start') { - handle - .prepare( - 'UPDATE servers SET game_started_at = ?, game_ended_at = NULL WHERE name = ?' - ) - .run(report.receivedAt, report.server); - } else if (report.event === 'game_end') { - handle - .prepare('UPDATE servers SET game_ended_at = ? WHERE name = ?') - .run(report.receivedAt, report.server); - } - } else { - handle - .prepare( - `UPDATE servers SET active_since = NULL, inactive_since = COALESCE(inactive_since, ?), - game_started_at = NULL, game_ended_at = NULL WHERE name = ?` - ) - .run(report.receivedAt, report.server); - } - } + const match = handle + .prepare( + 'SELECT id, started_at, start_observed FROM leaderboard_matches WHERE server = ? ORDER BY id DESC LIMIT 1' + ) + .get(report.server) as { id: number; started_at: string; start_observed: number }; + const sameMatch = previous?.timing?.matchId === match.id; const serverTiming = handle .prepare( `SELECT active_since AS activeSince, inactive_since AS inactiveSince, game_started_at AS gameStartedAt, game_ended_at AS gameEndedAt FROM servers WHERE name = ?` ) .get(report.server) as ServerTiming; - const match = handle - .prepare('SELECT id FROM leaderboard_matches WHERE server = ? ORDER BY id DESC LIMIT 1') - .get(report.server) as { id: number }; - const sameMatch = previous?.timing?.matchId === match.id; + // Record each occupancy transition at receipt, before a heartbeat can + // replace game_start or an empty report between status polls. + const after = latestGameSubmission(report.server); + if (!after || Date.parse(report.receivedAt) > Date.parse(after)) { + const occupied = report.players.length > 0; + serverTiming.activeSince = occupied + ? (serverTiming.activeSince ?? report.receivedAt) + : null; + serverTiming.inactiveSince = occupied + ? null + : (serverTiming.inactiveSince ?? report.receivedAt); + // Match boundaries follow history, independently of roster occupancy. + // Recover starts discarded by older receivers only when a start was + // actually observed; an inferred match has no known start time. + const startedAt = + report.event === 'game_start' + ? report.receivedAt + : ((sameMatch ? serverTiming.gameStartedAt : null) ?? + (match.start_observed ? match.started_at : null)); + serverTiming.gameStartedAt = + startedAt && (!after || Date.parse(startedAt) > Date.parse(after)) + ? startedAt + : null; + serverTiming.gameEndedAt = report.event === 'game_end' ? report.receivedAt : null; + handle + .prepare( + `UPDATE servers SET active_since = ?, inactive_since = ?, + game_started_at = ?, game_ended_at = ? WHERE name = ?` + ) + .run( + serverTiming.activeSince, + serverTiming.inactiveSince, + serverTiming.gameStartedAt, + serverTiming.gameEndedAt, + report.server + ); + } // A gap beyond the normal heartbeat window cannot establish continuity. const continuous = previous && Date.parse(report.receivedAt) - Date.parse(previous.receivedAt) <= 120_000; diff --git a/src/lib/ui/components/ServerCard.svelte b/src/lib/ui/components/ServerCard.svelte index 3aad004..9b8d09a 100644 --- a/src/lib/ui/components/ServerCard.svelte +++ b/src/lib/ui/components/ServerCard.svelte @@ -100,10 +100,10 @@ : null ); const currentRound = $derived( - !gameEndedAt ? (round !== undefined ? round : reportedRound) : null + players !== 0 && !gameEndedAt ? (round !== undefined ? round : reportedRound) : null ); const gameElapsed = $derived( - gameStartedAt && !gameEndedAt ? elapsedSince(gameStartedAt, $uiNow) : null + players !== 0 && gameStartedAt && !gameEndedAt ? elapsedSince(gameStartedAt, $uiNow) : null ); const emptyElapsed = $derived(elapsedSince(inactiveSince, $uiNow)); // These lobbies have four slots when the live status omits the limit. diff --git a/tests/unit/fleet-overview.test.ts b/tests/unit/fleet-overview.test.ts index 516ce97..a495d31 100644 --- a/tests/unit/fleet-overview.test.ts +++ b/tests/unit/fleet-overview.test.ts @@ -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) }, diff --git a/tests/unit/server-observation.test.ts b/tests/unit/server-observation.test.ts index e5ed175..f49dfd8 100644 --- a/tests/unit/server-observation.test.ts +++ b/tests/unit/server-observation.test.ts @@ -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() }); }); }); diff --git a/tests/unit/server-status.test.ts b/tests/unit/server-status.test.ts index 38e1384..79baf07 100644 --- a/tests/unit/server-status.test.ts +++ b/tests/unit/server-status.test.ts @@ -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'); diff --git a/tests/unit/telemetry.test.ts b/tests/unit/telemetry.test.ts index 402de39..334a56c 100644 --- a/tests/unit/telemetry.test.ts +++ b/tests/unit/telemetry.test.ts @@ -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'); diff --git a/tests/unit/ui-server-card.test.ts b/tests/unit/ui-server-card.test.ts index 8dc3983..c67c1f7 100644 --- a/tests/unit/ui-server-card.test.ts +++ b/tests/unit/ui-server-card.test.ts @@ -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(/]*>Round<\/dt>\s*]*>31<\/dd>/); + expect(occupied).toMatch(/]*>Match time<\/dt>\s*]*>—<\/dd>/); + const empty = html({ players: 0, round: 31, compactEmpty: false }); + expect(empty).toMatch(/]*>Round<\/dt>\s*]*>—<\/dd>/); + expect(empty).toMatch(/]*>Match time<\/dt>\s*]*>—<\/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(/ { 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 }); });