diff --git a/src/lib/match-history.ts b/src/lib/match-history.ts index 74c7240..584bb2e 100644 --- a/src/lib/match-history.ts +++ b/src/lib/match-history.ts @@ -11,6 +11,7 @@ export interface ServerMatch { server_deleted_at?: string | null; map: string; startedAt: string; + lastReportedAt: string | null; endedAt: string | null; status: 'active' | 'completed' | 'interrupted'; startPlayers: number | null; diff --git a/src/lib/server/fleet/leaderboard-ingest.ts b/src/lib/server/fleet/leaderboard-ingest.ts index f8f7967..8362207 100644 --- a/src/lib/server/fleet/leaderboard-ingest.ts +++ b/src/lib/server/fleet/leaderboard-ingest.ts @@ -67,7 +67,15 @@ export function recordLeaderboardTelemetry(handle: Database.Database, report: Ga 'SELECT * FROM leaderboard_matches WHERE server = ? AND server_deleted_at IS NULL ORDER BY id DESC LIMIT 1' ) .get(report.server) as Match | undefined; - if (match?.last_fingerprint === signature && match.map_key === identity.key) return; + if (match?.last_fingerprint === signature && match.map_key === identity.key) { + // Unchanged gameplay still proves an open match is reporting. Keep the + // duplicate from changing participation, counters, or completed results. + if (match.status === 'active') + handle + .prepare('UPDATE leaderboard_matches SET last_received_at = ? WHERE id = ?') + .run(report.receivedAt, match.id); + if (match.status !== 'interrupted') return; + } const isStart = report.event === 'game_start'; const round = isStart ? 0 : report.round; diff --git a/src/lib/server/fleet/match-history.ts b/src/lib/server/fleet/match-history.ts index 14f983c..f7a541b 100644 --- a/src/lib/server/fleet/match-history.ts +++ b/src/lib/server/fleet/match-history.ts @@ -11,6 +11,7 @@ import { import { readMatchQuery, type MatchHistoryQuery } from '../../match-history-query.js'; const matchSummary = `m.id, m.game, m.highest_round AS observedRound, m.server, m.server_deleted_at, m.map_name AS map, m.started_at AS startedAt, m.status, + m.last_received_at AS lastReportedAt, CASE WHEN m.status = 'completed' THEN m.ended_at END AS endedAt, m.start_players AS startPlayers, (SELECT COUNT(*) FROM leaderboard_match_players p WHERE p.match_id = m.id) AS uniquePlayers, diff --git a/src/lib/server/provisioning/native.ts b/src/lib/server/provisioning/native.ts index 82b5779..968cadf 100644 --- a/src/lib/server/provisioning/native.ts +++ b/src/lib/server/provisioning/native.ts @@ -1551,12 +1551,20 @@ async function processJob(job: Job, eligibility: (job: Job) => string | null) { hold(job, 'Waiting for the agent to verify a stopped container.'); return; } - db() - .prepare( - 'UPDATE servers SET container_state=?,last_players=NULL,last_roster=NULL WHERE name=?' - ) - .run(health.state, job.server); - complete(job); + db().transaction(() => { + db() + .prepare( + 'UPDATE servers SET container_state=?,last_players=NULL,last_roster=NULL WHERE name=?' + ) + .run(health.state, job.server); + db() + .prepare( + `UPDATE leaderboard_matches SET status='interrupted', ended_at=? + WHERE server=? AND status='active' AND server_deleted_at IS NULL` + ) + .run(containerObservedAt, job.server); + complete(job); + })(); return; } check( diff --git a/src/lib/ui/components/MatchHistory.svelte b/src/lib/ui/components/MatchHistory.svelte index 9574b6b..e6d315d 100644 --- a/src/lib/ui/components/MatchHistory.svelte +++ b/src/lib/ui/components/MatchHistory.svelte @@ -8,6 +8,7 @@ import { browser } from '$app/environment'; import { contextHref } from '$lib/navigation-context.js'; import { timestampISO } from '$lib/time-zone.js'; + import { freshStatusTimestamp } from '$lib/server-freshness.js'; import { goto } from '$app/navigation'; import { timeFormat, timeZone } from '$ui/stores/time-zone.js'; import { uiNow } from '$ui/stores/servers.js'; @@ -331,9 +332,23 @@ > {#each data.matches as match (match.id)} + {@const reporting = + match.status === 'active' && + !match.server_deleted_at && + freshStatusTimestamp(timestampISO(match.lastReportedAt) ?? null, now)} + {@const notReporting = match.status === 'active' && !reporting} + {@const status = notReporting + ? 'Not reporting' + : match.status === 'active' + ? 'In progress' + : match.status === 'completed' + ? 'Completed' + : 'Interrupted'} {@const duration = match.status === 'active' - ? elapsedSince(timestampISO(match.startedAt) ?? null, now) + ? reporting + ? elapsedSince(timestampISO(match.startedAt) ?? null, now) + : null : match.durationMs === null ? null : formatDuration(match.durationMs)} @@ -346,7 +361,7 @@ aria-label={`View ${match.map} match ${match.id} from ${$timeFormat.dateTime(match.startedAt)}`} >{match.map} · Match #{match.id} - {:else if match.status === 'active' && !match.server_deleted_at} {:else}{match.map}{/if} · {match.status === - 'active' - ? 'In progress' - : match.status === 'completed' - ? 'Completed' - : 'Interrupted'}{duration !== null + > · {status}{duration !== + null ? ` · ${duration}` : ''} + {#if notReporting}Last report {/if} {#if server === undefined}
{matchInstanceLabel(match)}{#if match.server_deleted_at} @@ -376,14 +393,15 @@ {match.status === 'active' - ? 'In progress' - : match.status === 'completed' - ? 'Completed' - : 'Interrupted'}{status}{#if notReporting}Last report {/if} {match.uniquePlayers} Players counts unique players across all reports. A dash means the value is unavailable, not zero. In-progress matches show elapsed time since their recorded start and link to - the live server. Completed durations require a recorded start and completed ending. + the live server while reports are current. Otherwise, they show Not reporting with the + last report time. Completed durations require a recorded start and completed ending. Round shows the final round for completed matches and the highest observed round for unfinished matches. Open a completed match for exact timestamps and starting and ending player counts. diff --git a/tests/unit/leaderboard-ingest.test.ts b/tests/unit/leaderboard-ingest.test.ts index a1a0f74..ad9b619 100644 --- a/tests/unit/leaderboard-ingest.test.ts +++ b/tests/unit/leaderboard-ingest.test.ts @@ -369,6 +369,39 @@ it('refreshes stats during a round without adding rounds or completing the match expect((await matches()).map((match) => match.status)).toEqual(['completed', 'active']); }); +it('refreshes the last report for identical heartbeats without changing participation or completed results', async () => { + const { serverMatchHistory } = await import('../../src/lib/server/fleet/match-history.js'); + await report('game_start'); + await report('heartbeat', 5, [player(undefined, { kills: 100 })]); + const participants = await records(); + received += 120; + await report('heartbeat', 5, [player(undefined, { kills: 100 })]); + expect(await matches()).toHaveLength(1); + expect(await records()).toEqual(participants); + expect(serverMatchHistory(server).matches[0]).toMatchObject({ + status: 'active', + lastReportedAt: '2026-09-18T20:02:02.000Z' + }); + await report('game_end', 5); + const completed = serverMatchHistory(server).matches[0]; + await report('game_end', 5); + expect(serverMatchHistory(server).matches[0]).toEqual(completed); +}); + +it('starts a new match when an identical heartbeat follows an interrupted match', async () => { + await report('heartbeat', 5); + const { db } = await import('../../src/lib/server/db/index.js'); + db() + .prepare("UPDATE leaderboard_matches SET status='interrupted', ended_at=last_received_at") + .run(); + const interrupted = (await matches())[0]; + await report('heartbeat', 5); + expect(await matches()).toEqual([ + interrupted, + expect.objectContaining({ status: 'active', highest_round: 5, start_observed: 0 }) + ]); +}); + it('persists independent stat high-water marks without adding cumulative snapshots or retries', async () => { await report('game_start'); await report('round_change', 1); diff --git a/tests/unit/match-history-query.test.ts b/tests/unit/match-history-query.test.ts index d088132..54a1167 100644 --- a/tests/unit/match-history-query.test.ts +++ b/tests/unit/match-history-query.test.ts @@ -91,6 +91,7 @@ it('keeps unknown durations last in either direction and retains historical and expect(data.matches[0].server_deleted_at).toBeTruthy(); expect(data.matches[1]).toMatchObject({ status: 'active', + lastReportedAt: '2026-10-04T20:02:00.000Z', observedRound: 3, finalRound: null, durationMs: null diff --git a/tests/unit/native-provisioning.test.ts b/tests/unit/native-provisioning.test.ts index 2185976..ea6508b 100644 --- a/tests/unit/native-provisioning.test.ts +++ b/tests/unit/native-provisioning.test.ts @@ -4000,6 +4000,72 @@ describe('additional concurrency admission', () => { ); }); +describe('match history on server stop', () => { + async function setupStop() { + const job = await queue(); + await advance(); + await acknowledge(job.deploymentId); + const { db } = await import('../../src/lib/server/db/index.js'); + const handle = db(); + const insert = handle.prepare(`INSERT INTO leaderboard_matches + (server, game, map, map_key, map_name, started_at, last_received_at, status, + ended_at, server_deleted_at, highest_round, last_event, last_fingerprint) + VALUES (?, 't5', 'zombie_theater', 't5:zombie_theater', 'Kino Der Toten', + '2026-10-01T00:00:00Z', '2026-10-01T01:00:00Z', ?, ?, ?, 20, 'heartbeat', 'test')`); + insert.run(job.name, 'active', null, null); + insert.run(job.name, 'completed', '2026-10-01T01:00:00Z', null); + insert.run(job.name, 'interrupted', '2026-10-01T01:00:00Z', null); + insert.run(job.name, 'active', null, '2026-10-01T01:00:00Z'); + insert.run('another-server', 'active', null, null); + const matches = () => handle.prepare('SELECT * FROM leaderboard_matches ORDER BY id').all(); + const before = matches(); + const { requestLifecycle } = await import('../../src/lib/server/provisioning/requests.js'); + const stopped = requestLifecycle(job.name, 'stop'); + if (!stopped.ok) throw new Error(stopped.error); + await advance(); + expect(matches()).toEqual(before); + return { stopped, matches, before }; + } + + it.each(['stopped', 'exited', 'created', 'dead'])( + 'interrupts only the current open match after verifying the container is %s', + async (state) => { + const { stopped, matches, before } = await setupStop(); + // A successful command receipt alone does not prove the container stopped. + await acknowledge(stopped.deploymentId); + expect(matches()).toEqual(before); + remote.state = state; + const verifiedAfter = Date.now(); + await advance(); + expect((await detail(stopped.deploymentId)).state).toBe('done'); + const after = matches(); + expect(after[0]).toEqual({ + ...(before[0] as object), + status: 'interrupted', + ended_at: expect.any(String) + }); + const endedAt = Date.parse((after[0] as { ended_at: string }).ended_at); + expect(endedAt).toBeGreaterThanOrEqual(verifiedAfter); + expect(endedAt).toBeLessThanOrEqual(Date.now()); + expect(after.slice(1)).toEqual(before.slice(1)); + await advance(); + expect(matches()).toEqual(after); + } + ); + + it.each(['failed', 'unknown'] as const)( + 'keeps the match open when the stop command is %s', + async (state) => { + const { stopped, matches, before } = await setupStop(); + const operation = (await operationFor(stopped.deploymentId))!; + const { reportHostOperation } = await import('../../src/lib/server/hosts/index.js'); + reportHostOperation(hostId, operation.id, { state, error: 'Stop not confirmed' }); + await advance(); + expect(matches()).toEqual(before); + } + ); +}); + describe('game key pool launch lifecycle', () => { it('reserves across maps atomically, rejects duplicate credentials, and reuses a cancelled launch key', async () => { await prepareLibrary(); diff --git a/tests/unit/ui-match-chat.test.ts b/tests/unit/ui-match-chat.test.ts index d6902d4..d9a0f30 100644 --- a/tests/unit/ui-match-chat.test.ts +++ b/tests/unit/ui-match-chat.test.ts @@ -19,6 +19,7 @@ const match: MatchDetail = { status: 'completed', startObserved: true, startedAt: '2026-10-07T00:00:00Z', + lastReportedAt: '2026-10-07T00:01:00Z', endedAt: '2026-10-07T00:01:00Z', durationMs: 60_000, startPlayers: 1, diff --git a/tests/unit/ui-match-history.test.ts b/tests/unit/ui-match-history.test.ts index 6f7aa3b..ab5982f 100644 --- a/tests/unit/ui-match-history.test.ts +++ b/tests/unit/ui-match-history.test.ts @@ -17,6 +17,7 @@ const match: ServerMatch = { finalRound: 20, status: 'completed', startedAt: '2026-10-06T17:26:00Z', + lastReportedAt: '2026-10-06T19:59:59Z', endedAt: '2026-10-06T18:04:00Z', durationMs: 2280000, startPlayers: 2, @@ -153,6 +154,54 @@ it('labels unfinished rounds and missing durations without inventing final resul expect(body).toContain('>—'); expect(body).toContain('>0'); }); +it.each([ + '2026-10-06T19:58:29Z', + '2026-10-05T18:00:00Z', + '2026-10-06 19:58:29', + '2026-10-06T20:00:02Z', + null, + 'invalid' +])('shows a non-live match with its last report when %s is not current', (lastReportedAt) => { + const data = report(); + data.matches = [{ ...match, status: 'active', lastReportedAt, durationMs: null }]; + const body = render(MatchHistory, { props: { initial: data } }).body.replaceAll( + //g, + '' + ); + const rows = body.slice(body.indexOf('')); + expect(rows.match(/Not reporting/g)).toHaveLength(2); + expect(rows.match(/Last report /g)).toHaveLength(2); + expect(rows).not.toContain('In progress'); + expect(rows).not.toContain('View live'); + expect(rows).not.toContain('—'); + if (lastReportedAt && lastReportedAt !== 'invalid') { + expect(rows).toMatch(/Last report