Add completed match detail pages

This commit is contained in:
2026-10-01 11:45:23 -04:00
parent aa0223c35d
commit a3cd3ee585
9 changed files with 409 additions and 13 deletions
+6
View File
@@ -373,6 +373,12 @@ same reports, including highest round, cumulative kills, rounds survived,
headshots, revives, downs, and games played. See [Leaderboard](docs/leaderboard.md)
for counting rules, history import, and the read API.
In **Match History**, select **View match** on a completed match to open its
shareable results page. It shows the map, server, final round, timing, and every
recorded player's stats, including players absent from the final report. Completed
matches also link from server history and player profiles. Results survive server
deletion; missing starts and unrecorded stats remain explicitly unknown.
## Running tests
```bash
+9
View File
@@ -23,6 +23,15 @@ across both games. Map names link back to that map's leaderboard in its game.
Use **Refresh stats** to load the latest reports. Ratios with a zero denominator
display a dash. Profiles always cover all recorded maps across both games.
Completed matches link to their own results page at `/matches/[id]` from global
Match History, server history, and player profiles. The page shows the recorded
map and server, final round, start/end times, duration when the start was observed,
player counts, and the full saved roster. Players missing from the final report
retain their earlier counters; players with no stats display dashes. Counters and
score are the highest reported values within the match, not necessarily its final
scoreboard. Active and interrupted matches remain listed without results links.
Match pages remain available after the original server is removed.
The leaderboard uses the existing three telemetry events; no reporter change is
required. The leaderboard and profiles identify a player by **GUID**, displaying
the latest reported name across both games, regardless of game or map filters.
+27
View File
@@ -15,3 +15,30 @@ export interface ServerMatch {
durationMs: number | null;
finalRound: number | null;
}
export interface MatchPlayer {
guid: string;
name: string;
hasStats: boolean;
presentAtEnd: boolean;
canViewProfile: boolean;
firstSeenAt: string;
lastSeenAt: string;
// Combat counters and score are the highest reported values; rounds survived
// counts observed progression. These are not a final scoreboard snapshot.
// A player seen only at game_start has no observed gameplay stats.
highestRound: number | null;
roundsSurvived: number | null;
kills: number | null;
headshots: number | null;
downs: number | null;
revives: number | null;
bestScore: number | null;
}
export interface MatchDetail extends ServerMatch {
status: 'completed';
game: string;
startObserved: boolean;
players: MatchPlayer[];
}
+54 -9
View File
@@ -1,12 +1,7 @@
import { db } from '../db/index.js';
import { MIN_RECORDED_ROUND, type ServerMatch } from '../../match-history.js';
import { MIN_RECORDED_ROUND, type MatchDetail, type MatchPlayer, type ServerMatch } from '../../match-history.js';
export function serverMatchHistory(server?: string, limit = 25): { matches: ServerMatch[]; total: number } {
const handle = db();
const where = `WHERE m.highest_round >= ${MIN_RECORDED_ROUND}${server === undefined ? '' : ' AND m.server = ?'}`;
const parameters = server === undefined ? [] : [server];
const total = (handle.prepare(`SELECT COUNT(*) AS total FROM leaderboard_matches m ${where}`).get(...parameters) as { total: number }).total;
const matches = handle.prepare(`SELECT m.id, m.server, m.server_deleted_at, m.map_name AS map, m.started_at AS startedAt, m.status,
const matchSummary = `m.id, m.server, m.server_deleted_at, m.map_name AS map, m.started_at AS startedAt, m.status,
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,
@@ -15,8 +10,58 @@ export function serverMatchHistory(server?: string, limit = 25): { matches: Serv
END AS endPlayers,
CASE WHEN m.status = 'completed' AND m.start_observed = 1 THEN
MAX(0, ROUND((julianday(m.ended_at) - julianday(m.started_at)) * 86400000)) END AS durationMs,
CASE WHEN m.status = 'completed' THEN m.highest_round END AS finalRound
FROM leaderboard_matches m ${where} ORDER BY m.started_at DESC, m.id DESC LIMIT ?`)
CASE WHEN m.status = 'completed' THEN m.highest_round END AS finalRound`;
export function serverMatchHistory(server?: string, limit = 25): { matches: ServerMatch[]; total: number } {
const handle = db();
const where = `WHERE m.highest_round >= ${MIN_RECORDED_ROUND}${server === undefined ? '' : ' AND m.server = ?'}`;
const parameters = server === undefined ? [] : [server];
const total = (handle.prepare(`SELECT COUNT(*) AS total FROM leaderboard_matches m ${where}`).get(...parameters) as { total: number }).total;
const matches = handle.prepare(`SELECT ${matchSummary} FROM leaderboard_matches m
${where} ORDER BY m.started_at DESC, m.id DESC LIMIT ?`)
.all(...parameters, limit) as ServerMatch[];
return { matches, total };
}
/** Read durable match records without depending on a current server or scoreboard. */
export function getMatchDetail(id: number): MatchDetail | null {
if (!Number.isSafeInteger(id) || id < 1) return null;
const handle = db();
const match = handle.prepare(`SELECT ${matchSummary}, m.game, m.start_observed AS startObserved
FROM leaderboard_matches m WHERE m.id = ? AND m.status = 'completed'
AND m.highest_round >= ${MIN_RECORDED_ROUND}`)
.get(id) as (Omit<MatchDetail, 'players' | 'startObserved'> & { startObserved: number }) | undefined;
if (!match) return null;
const players = handle.prepare(`SELECT p.guid,
COALESCE((SELECT identity.name FROM leaderboard_players identity
WHERE identity.guid = p.guid AND identity.game IN ('t5', 't6')
ORDER BY identity.last_seen_at DESC, identity.game LIMIT 1),
(SELECT identity.name FROM leaderboard_players identity
WHERE identity.guid = p.guid AND identity.game = ?), p.guid) AS name,
p.has_stats AS hasStats, p.present AS presentAtEnd,
p.first_seen_at AS firstSeenAt, p.last_seen_at AS lastSeenAt,
CASE WHEN p.has_stats = 1 THEN p.highest_round END AS highestRound,
CASE WHEN p.has_stats = 1 THEN p.rounds_survived END AS roundsSurvived,
CASE WHEN p.has_stats = 1 THEN p.kills END AS kills,
CASE WHEN p.has_stats = 1 THEN p.headshots END AS headshots,
CASE WHEN p.has_stats = 1 THEN p.downs END AS downs,
CASE WHEN p.has_stats = 1 THEN p.revives END AS revives,
CASE WHEN p.has_stats = 1 THEN p.best_score END AS bestScore,
(EXISTS (SELECT 1 FROM leaderboard_players identity
WHERE identity.guid = p.guid AND identity.game IN ('t5', 't6'))
AND EXISTS (SELECT 1 FROM leaderboard_match_players recorded
JOIN leaderboard_matches history ON history.id = recorded.match_id
WHERE recorded.guid = p.guid AND history.game IN ('t5', 't6') AND recorded.has_stats = 1
AND history.highest_round >= ${MIN_RECORDED_ROUND}
AND recorded.highest_round >= ${MIN_RECORDED_ROUND})) AS canViewProfile
FROM leaderboard_match_players p WHERE p.match_id = ?
ORDER BY hasStats DESC, kills DESC, highestRound DESC, name COLLATE NOCASE, p.guid`)
.all(match.game, id) as Array<Omit<MatchPlayer, 'hasStats' | 'presentAtEnd' | 'canViewProfile'> & {
hasStats: number; presentAtEnd: number; canViewProfile: number;
}>;
return { ...match, startObserved: Boolean(match.startObserved), players: players.map(player => ({
...player, hasStats: Boolean(player.hasStats), presentAtEnd: Boolean(player.presentAtEnd),
canViewProfile: Boolean(player.canViewProfile)
})) };
}
+14 -3
View File
@@ -41,7 +41,7 @@
<Card padding="lg">
<h2>Match history</h2>
<p>Newest matches first. Unique players are counted across all match reports. — means the start or end wasn’t recorded, or the match is still in progress.</p>
<p>Newest matches first. Open a completed match to see its player results. Unique players are counted across all match reports. — means the start or end wasn’t recorded, or the match is still in progress.</p>
{#if error}
<div role="alert">{error} <Button onclick={() => refresh(server, limit)}>Retry</Button></div>
{/if}
@@ -57,11 +57,17 @@
<thead><tr><th>Started</th><th>Ended</th>{#if server === undefined}<th>Server</th>{/if}<th>Map</th><th>Status</th><th>Players at start</th><th>Unique players</th><th>Players at end</th><th>Game time</th><th>Final round</th></tr></thead>
<tbody>
{#each matches as match (match.id)}
<tr>
<tr class:completed={match.status === 'completed'}>
<td><time datetime={match.startedAt}>{$timeFormat.dateTime(match.startedAt)}</time></td>
<td>{#if match.endedAt}<time datetime={match.endedAt}>{$timeFormat.dateTime(match.endedAt)}</time>{:else}—{/if}</td>
{#if server === undefined}<td><HistoricalServerIdentity name={match.server} deletedAt={match.server_deleted_at} href={`/servers/${encodeURIComponent(match.server)}?tab=matches`} /></td>{/if}
<td>{match.map}</td>
<td>
{#if match.status === 'completed'}
<a class="match-link" href={`/matches/${match.id}`} aria-label={`View ${match.map} match from ${$timeFormat.dateTime(match.startedAt)}`}>
{match.map}<small>View match →</small>
</a>
{:else}{match.map}{/if}
</td>
<td>{match.status === 'active' ? 'In progress' : match.status === 'completed' ? 'Completed' : 'Interrupted'}</td>
<td>{match.startPlayers ?? '—'}</td><td>{match.uniquePlayers}</td><td>{match.endPlayers ?? '—'}</td>
<td>{match.durationMs === null ? '—' : formatDuration(match.durationMs)}</td><td>{match.finalRound ?? '—'}</td>
@@ -84,4 +90,9 @@
th, td { padding: var(--space-3); text-align: left; border-bottom: 1px solid var(--border-subtle); white-space: nowrap; }
th { color: var(--text-secondary); font-size: var(--text-xs); }
td { font-variant-numeric: tabular-nums; }
.completed:hover, .completed:focus-within { background: var(--surface-raised); }
.match-link { display: block; color: var(--accent-primary-hover); text-decoration: none; }
.match-link:hover { text-decoration: underline; }
.match-link:focus-visible { outline: none; box-shadow: var(--focus-ring); border-radius: var(--radius-sm); }
.match-link small { display: block; margin-top: var(--space-1); font-size: var(--text-xs); }
</style>
@@ -78,7 +78,7 @@
<table>
<thead><tr><th scope="col">Map / server</th><th scope="col">Match started</th><th scope="col">Match status</th>{#each historyColumns as key}<th scope="col">{leaderboardMetrics.find(metric => metric.key === key)!.label}</th>{/each}</tr></thead>
<tbody>{#each profile.matches as match (match.id)}
<tr><th scope="row">{match.mapName}<small>{gameName(match.game)}</small><small>{match.server} {#if match.server_deleted_at}<Badge>Deleted</Badge>{/if}</small></th><td>{date(match.startedAt)}</td>
<tr><th scope="row">{#if match.status === 'completed'}<a href={`/matches/${match.id}`} aria-label={`View ${match.mapName} match from ${date(match.startedAt)}`}>{match.mapName}</a>{:else}{match.mapName}{/if}<small>{gameName(match.game)}</small><small>{match.server} {#if match.server_deleted_at}<Badge>Deleted</Badge>{/if}</small></th><td>{date(match.startedAt)}</td>
<td class="status">{match.status}<small>{match.completed ? 'Completion credited' : 'No completion credit'}</small></td>
{#each historyColumns as key}<td class:best={key === 'highestRound'}>{number.format(match[key])}</td>{/each}
</tr>
+9
View File
@@ -0,0 +1,9 @@
import { error } from '@sveltejs/kit';
import { getMatchDetail } from '$lib/server/fleet/match-history.js';
export function load({ params }: { params: { id: string } }) {
if (!/^[1-9]\d*$/.test(params.id)) error(404, 'Match not found');
const match = getMatchDetail(Number(params.id));
if (!match) error(404, 'Match not found');
return { match };
}
+121
View File
@@ -0,0 +1,121 @@
<script lang="ts">
import type { MatchDetail } from '$lib/match-history.js';
import { playerProfileHref } from '$lib/leaderboard.js';
import { timeFormat } from '$ui/stores/time-zone.js';
import { formatDuration } from '$ui/duration.js';
import HistoricalServerIdentity from '$ui/components/HistoricalServerIdentity.svelte';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import Badge from '$ui/components/primitives/Badge.svelte';
import Card from '$ui/components/primitives/Card.svelte';
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
import Stat from '$ui/components/primitives/Stat.svelte';
let { data }: { data: { match: MatchDetail } } = $props();
const match = $derived(data.match);
const gameName = $derived(match.game === 't5' ? 'Black Ops I' : match.game === 't6' ? 'Black Ops II' : match.game.toUpperCase());
const number = new Intl.NumberFormat('en-US');
const stats = [
{ key: 'highestRound', label: 'Highest round' },
{ key: 'kills', label: 'Kills' },
{ key: 'headshots', label: 'Headshots' },
{ key: 'revives', label: 'Revives' },
{ key: 'downs', label: 'Downs' },
{ key: 'roundsSurvived', label: 'Rounds survived' },
{ key: 'bestScore', label: 'Highest reported score' }
] as const;
</script>
<svelte:head><title>{match.map} · Match #{match.id} · Electric Cherry</title></svelte:head>
<div class="match-page">
<SectionHeader title={match.map} eyebrow={`Match #${match.id}`} subtitle={`${gameName} · Recorded match results`}>
{#snippet crumbs()}<a href="/matches">← Match History</a>{/snippet}
{#snippet actions()}<Badge variant="online" size="md">Completed</Badge>{/snippet}
</SectionHeader>
<section class="summary" aria-label="Match summary">
<Card><Stat label="Final round" value={match.finalRound} tone="accent" size="lg" tween={false} /></Card>
<Card><Stat label="Game time" value={match.durationMs === null ? null : formatDuration(match.durationMs)} size="lg" tween={false} /></Card>
<Card><Stat label="Unique players" value={match.uniquePlayers} size="lg" tween={false} /></Card>
<Card><Stat label="Players at end" value={match.endPlayers} size="lg" tween={false} /></Card>
</section>
<Card padding="lg">
<h2>Match details</h2>
<dl class="details">
<div class="server"><dt>Server</dt><dd><HistoricalServerIdentity name={match.server} deletedAt={match.server_deleted_at} href={`/servers/${encodeURIComponent(match.server)}?tab=matches`} /></dd></div>
<div><dt>{match.startObserved ? 'Started' : 'First observed'}</dt><dd><time datetime={match.startedAt}>{$timeFormat.dateTime(match.startedAt)}</time></dd></div>
<div><dt>Ended</dt><dd>{#if match.endedAt}<time datetime={match.endedAt}>{$timeFormat.dateTime(match.endedAt)}</time>{:else}—{/if}</dd></div>
<div><dt>Players at start</dt><dd>{match.startPlayers ?? '—'}</dd></div>
</dl>
{#if !match.startObserved}<p class="note">The match start was not recorded. The first observation is shown above; the full game time is unknown.</p>{/if}
</Card>
<section class="results" aria-labelledby="players-title">
<div class="results-heading">
<h2 id="players-title">Player results</h2>
<p>Every recorded player, including those absent from the final report. Stats are the highest reported counters for this match.</p>
</div>
{#if match.players.length === 0}
<EmptyState title="No player results recorded" description="This match has no saved player reports." />
{:else}
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Scrollable table for keyboard users.) -->
<div class="table-scroll" role="region" aria-label="Match player results" tabindex="0">
<table>
<thead><tr><th scope="col">Player</th><th scope="col">Participation</th>{#each stats as stat}<th scope="col">{stat.label}</th>{/each}<th scope="col">Last seen</th></tr></thead>
<tbody>
{#each match.players as player (player.guid)}
<tr>
<th scope="row">
{#if player.canViewProfile}<a href={playerProfileHref(player.guid)}>{player.name}</a>{:else}{player.name}{/if}
<small class="guid">{player.guid}</small>
</th>
<td class="participation"><Badge variant={player.presentAtEnd ? 'online' : 'neutral'}>{player.presentAtEnd ? 'At match end' : 'Not at match end'}</Badge>{#if !player.hasStats}<small>No stats recorded</small>{/if}</td>
{#each stats as stat}<td class:round={stat.key === 'highestRound'}>{player[stat.key] === null ? '—' : number.format(player[stat.key]!)}</td>{/each}
<td><time datetime={player.lastSeenAt}>{$timeFormat.dateTime(player.lastSeenAt)}</time></td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</section>
<details class="note">
<summary>About these results</summary>
<p>Kills, headshots, revives, downs, and score retain each player's highest reported value. Highest reported score is a points balance, not total points earned or necessarily the final score.</p>
<p>Highest round includes late joins. Rounds survived counts only observed round progression while the player was present. “At match end” means the player appeared in the final report; other players keep their earlier stats. A dash means no value was recorded.</p>
<p>Player names use the latest recorded identity. Times reflect when reports were received and use your selected timezone. These results remain available after the server is deleted.</p>
</details>
</div>
<style>
.match-page { display: grid; gap: var(--space-6); min-width: 0; }
a { color: var(--accent-primary-hover); }
a:focus-visible, .table-scroll:focus-visible, summary:focus-visible { outline: none; box-shadow: var(--focus-ring); border-radius: var(--radius-sm); }
h2 { margin: 0; font-size: var(--text-md); }
.summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--space-4); }
.details { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--space-5); margin: var(--space-5) 0 0; }
.server { grid-column: 1 / -1; }
dt { margin-bottom: var(--space-2); color: var(--text-secondary); font-size: var(--text-xs); }
dd { margin: 0; font-size: var(--text-sm); overflow-wrap: anywhere; font-variant-numeric: tabular-nums; }
.results { min-width: 0; border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); background: var(--surface); overflow: hidden; }
.results-heading { padding: var(--space-6); }
.results-heading p { margin: var(--space-2) 0 0; color: var(--text-secondary); font-size: var(--text-sm); line-height: var(--leading-body); }
.table-scroll { max-width: 100%; overflow-x: auto; }
table { width: 100%; border-collapse: collapse; font-size: var(--text-sm); }
th, td { padding: var(--space-4); text-align: right; border-bottom: 1px solid var(--border-subtle); white-space: nowrap; }
thead th { background: var(--surface-sunken); color: var(--text-secondary); font-size: var(--text-xs); }
th:first-child { text-align: left; min-width: 180px; max-width: 280px; white-space: normal; overflow-wrap: anywhere; }
th:nth-child(2), .participation { text-align: left; }
tbody tr:last-child th, tbody tr:last-child td { border-bottom: none; }
td { font-variant-numeric: tabular-nums; }
.round { color: var(--accent-primary-hover); }
small { display: block; margin-top: var(--space-1); font-size: var(--text-xs); color: var(--text-tertiary); font-weight: normal; }
.guid { font-family: var(--font-mono); }
.note { color: var(--text-tertiary); font-size: var(--text-xs); line-height: 1.7; }
p.note { margin: var(--space-4) 0 0; }
summary { cursor: pointer; color: var(--text-secondary); }
@media (max-width: 800px) { .summary { grid-template-columns: repeat(2, minmax(0, 1fr)); } .details { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 480px) { .summary { gap: var(--space-3); } .details { grid-template-columns: minmax(0, 1fr); } .results-heading { padding: var(--space-4); } }
</style>
+168
View File
@@ -0,0 +1,168 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { GameEvent, TelemetryPlayerStats } from '../../src/lib/telemetry.js';
import { newFixture, type Fixture } from '../helpers/fixture.js';
let fixture: Fixture;
let received = 0;
const server = 't5_zombie_theater_1';
const timestamp = (minute: number) => new Date(Date.UTC(2026, 8, 18, 20, minute)).toISOString();
const player = (guid: string, stats: Partial<TelemetryPlayerStats> = {}): TelemetryPlayerStats => ({
guid, name: `Player ${guid}`, kills: 0, headshots: 0, downs: 0, revives: 0, score: 0, ...stats
});
beforeEach(async () => {
vi.resetModules();
received = 0;
fixture = await newFixture([]);
const { db } = await import('../../src/lib/server/db/index.js');
db().prepare("INSERT INTO servers (name, komodo_name, game, map) VALUES (?, ?, 't5', 'zombie_theater')")
.run(server, server);
});
afterEach(async () => fixture.cleanup());
async function report(event: GameEvent['event'], round = 0, players = [player('101')]) {
const { db } = await import('../../src/lib/server/db/index.js');
const { recordLeaderboardTelemetry } = await import('../../src/lib/server/fleet/leaderboard-ingest.js');
const common = { server, map: 'zombie_theater', receivedAt: timestamp(received++) };
recordLeaderboardTelemetry(db(), event === 'game_start'
? { ...common, event, players: players.map(({ guid, name }) => ({ guid, name })) }
: { ...common, event, round, players });
}
describe('completed match details', () => {
it('returns the history summary and every participant with recorded counters, including departed and late players', async () => {
await report('game_start', 0, [player('101'), player('202'), player('303')]);
await report('round_change', 2, [player('101', { kills: 30, headshots: 10, downs: 1, revives: 2, score: 2000 }),
player('202', { kills: 15, score: 1500 })]);
await report('heartbeat', 2, [player('101', { kills: 40, headshots: 12, downs: 1, revives: 2, score: 1000 })]);
await report('game_end', 4, [player('101', { kills: 50, headshots: 20, downs: 2, revives: 3, score: 500 }), player('404')]);
const { getMatchDetail, serverMatchHistory } = await import('../../src/lib/server/fleet/match-history.js');
const match = getMatchDetail(1)!;
const { game, startObserved, players, ...summary } = match;
expect(summary).toEqual(serverMatchHistory(server).matches[0]);
expect({ ...summary, game, startObserved }).toMatchObject({
id: 1, game: 't5', startObserved: true, server, status: 'completed',
startedAt: timestamp(0), endedAt: timestamp(3), durationMs: 180_000,
startPlayers: 3, uniquePlayers: 4, endPlayers: 2, finalRound: 4
});
expect(players.map(entry => entry.guid)).toEqual(['101', '202', '404', '303']);
expect(players[0]).toEqual({ guid: '101', name: 'Player 101', hasStats: true, presentAtEnd: true,
canViewProfile: true, firstSeenAt: timestamp(0), lastSeenAt: timestamp(3),
highestRound: 4, roundsSurvived: 3, kills: 50, headshots: 20, downs: 2, revives: 3, bestScore: 2000 });
expect(players[1]).toEqual({ guid: '202', name: 'Player 202', hasStats: true, presentAtEnd: false,
canViewProfile: true, firstSeenAt: timestamp(0), lastSeenAt: timestamp(1),
highestRound: 2, roundsSurvived: 1, kills: 15, headshots: 0, downs: 0, revives: 0, bestScore: 1500 });
expect(players[2]).toMatchObject({ guid: '404', presentAtEnd: true, highestRound: 4, roundsSurvived: 0,
firstSeenAt: timestamp(3), lastSeenAt: timestamp(3), kills: 0, bestScore: 0 });
expect(players[3]).toEqual({ guid: '303', name: 'Player 303', hasStats: false, presentAtEnd: false,
canViewProfile: false, firstSeenAt: timestamp(0), lastSeenAt: timestamp(0),
highestRound: null, roundsSurvived: null, kills: null, headshots: null, downs: null, revives: null, bestScore: null });
});
it('preserves an unknown start and an observed empty ending roster across database reopen', async () => {
await report('heartbeat', 5, [player('101', { kills: 40 })]);
await report('game_end', 6, []);
const { closeDb } = await import('../../src/lib/server/db/index.js');
closeDb();
const { getMatchDetail } = await import('../../src/lib/server/fleet/match-history.js');
expect(getMatchDetail(1)).toMatchObject({ startObserved: false, startedAt: timestamp(0),
startPlayers: null, durationMs: null, endedAt: timestamp(1), endPlayers: 0,
uniquePlayers: 1, finalRound: 6, players: [expect.objectContaining({
guid: '101', presentAtEnd: false, highestRound: 5, kills: 40, roundsSurvived: 0
})] });
});
it('loads an empty completed record without inventing player rows', async () => {
await report('game_start', 0, []);
await report('game_end', 2, []);
const { getMatchDetail } = await import('../../src/lib/server/fleet/match-history.js');
expect(getMatchDetail(1)).toMatchObject({ startPlayers: 0, uniquePlayers: 0, endPlayers: 0,
startObserved: true, durationMs: 60_000, finalRound: 2, players: [] });
});
it('preserves original match and server identity after deletion and reuse of the server name', async () => {
await report('game_start');
await report('game_end', 8, [player('101', { kills: 80 })]);
const { db } = await import('../../src/lib/server/db/index.js');
const { getMatchDetail } = await import('../../src/lib/server/fleet/match-history.js');
const before = getMatchDetail(1)!;
db().prepare('DELETE FROM servers WHERE name = ?').run(server);
const deleted = getMatchDetail(1)!;
expect(deleted).toEqual({ ...before, server_deleted_at: expect.any(String) });
db().prepare("INSERT INTO servers (name, komodo_name, game, map) VALUES (?, 'replacement', 't6', 'zm_transit')").run(server);
const { recordLeaderboardTelemetry } = await import('../../src/lib/server/fleet/leaderboard-ingest.js');
recordLeaderboardTelemetry(db(), { event: 'game_end', server, map: 'zm_transit', round: 20,
players: [player('999', { kills: 999 })], receivedAt: timestamp(4) });
expect(getMatchDetail(1)).toEqual(deleted);
expect(getMatchDetail(2)).toMatchObject({ id: 2, game: 't6', server_deleted_at: null,
finalRound: 20, players: [expect.objectContaining({ guid: '999', kills: 999 })] });
});
it('uses the latest shared player name and only offers profiles that actually exist', async () => {
await report('game_start', 0, [player('101'), player('202'), player('303')]);
await report('round_change', 1, [player('101'), player('202')]);
await report('game_end', 2, [player('101')]);
const { db } = await import('../../src/lib/server/db/index.js');
const handle = db();
handle.prepare(`INSERT INTO leaderboard_players (game, guid, name, first_seen_at, last_seen_at)
VALUES ('t6', '101', 'Latest name', ?, ?), ('t7', '101', 'Unsupported name', ?, ?)`)
.run(timestamp(0), timestamp(4), timestamp(0), timestamp(5));
const { getMatchDetail } = await import('../../src/lib/server/fleet/match-history.js');
const { getPlayerProfile } = await import('../../src/lib/server/fleet/player-profile.js');
let participants = getMatchDetail(1)!.players;
expect(participants.find(entry => entry.guid === '101')).toMatchObject({ name: 'Latest name', canViewProfile: true });
expect(participants.find(entry => entry.guid === '202')).toMatchObject({ hasStats: true, highestRound: 1, canViewProfile: false });
expect(participants.find(entry => entry.guid === '303')).toMatchObject({ hasStats: false, canViewProfile: false });
for (const participant of participants) expect(participant.canViewProfile).toBe(getPlayerProfile(participant.guid) !== null);
// A start-only participant can still have a valid profile from another match.
await report('game_start', 0, [player('303')]);
await report('game_end', 3, [player('303')]);
participants = getMatchDetail(1)!.players;
expect(participants.find(entry => entry.guid === '303')).toMatchObject({ hasStats: false, canViewProfile: true });
// Missing identities must not discard a saved roster row or produce a dead link.
handle.prepare("DELETE FROM leaderboard_players WHERE guid = '101'").run();
expect(getMatchDetail(1)!.players.find(entry => entry.guid === '101')).toMatchObject({ name: '101', canViewProfile: false });
handle.prepare("UPDATE leaderboard_matches SET game = 'custom' WHERE id = 2").run();
handle.prepare("UPDATE leaderboard_players SET game = 'custom' WHERE guid = '303'").run();
expect(getMatchDetail(2)!.players[0]).toMatchObject({ name: 'Player 303', hasStats: true, canViewProfile: false });
});
it('only exposes completed records at round 2 or higher and rejects invalid IDs', async () => {
await report('game_start');
await report('game_end', 1);
await report('game_start');
await report('round_change', 2);
await report('game_start');
await report('round_change', 3);
const { getMatchDetail } = await import('../../src/lib/server/fleet/match-history.js');
for (const id of [1, 2, 3, 999, 0, -1, 1.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1]) {
expect(getMatchDetail(id), String(id)).toBeNull();
}
await report('game_end', 3);
expect(getMatchDetail(3)).toMatchObject({ id: 3, status: 'completed', finalRound: 3 });
});
});
describe('match detail page load', () => {
it('loads a completed match directly by its durable ID', async () => {
await report('game_end', 2);
const { load } = await import('../../src/routes/matches/[id]/+page.server.js');
const { getMatchDetail } = await import('../../src/lib/server/fleet/match-history.js');
expect(load({ params: { id: '1' } })).toEqual({ match: getMatchDetail(1) });
});
it('returns 404 for malformed, missing, active, interrupted, and nonqualifying matches', async () => {
await report('game_end', 1);
await report('game_start');
await report('round_change', 2);
await report('game_start');
await report('round_change', 2);
const { load } = await import('../../src/routes/matches/[id]/+page.server.js');
for (const id of ['1', '2', '3', '999', '', '0', '-1', '1.0', '01', '1e0', '1x', ' 1', 'Infinity',
'9007199254740992', "1' OR 1=1 --"]) {
expect(() => load({ params: { id } }), id).toThrow(expect.objectContaining({ status: 404 }));
}
});
});