diff --git a/README.md b/README.md index 2c82fa8..ce43293 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/leaderboard.md b/docs/leaderboard.md index 10ee285..0218d92 100644 --- a/docs/leaderboard.md +++ b/docs/leaderboard.md @@ -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. diff --git a/src/lib/match-history.ts b/src/lib/match-history.ts index 994ae9a..f6bbc9d 100644 --- a/src/lib/match-history.ts +++ b/src/lib/match-history.ts @@ -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[]; +} diff --git a/src/lib/server/fleet/match-history.ts b/src/lib/server/fleet/match-history.ts index 2c25beb..5a4e6d2 100644 --- a/src/lib/server/fleet/match-history.ts +++ b/src/lib/server/fleet/match-history.ts @@ -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 & { 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 & { + 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) + })) }; +} diff --git a/src/lib/ui/components/MatchHistory.svelte b/src/lib/ui/components/MatchHistory.svelte index 0c0a81d..f64c80b 100644 --- a/src/lib/ui/components/MatchHistory.svelte +++ b/src/lib/ui/components/MatchHistory.svelte @@ -41,7 +41,7 @@

Match history

-

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.

+

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.

{#if error}
{error}
{/if} @@ -57,11 +57,17 @@ StartedEnded{#if server === undefined}Server{/if}MapStatusPlayers at startUnique playersPlayers at endGame timeFinal round {#each matches as match (match.id)} - + {#if match.endedAt}{:else}—{/if} {#if server === undefined}{/if} - {match.map} + + {#if match.status === 'completed'} + + {match.map}View match → + + {:else}{match.map}{/if} + {match.status === 'active' ? 'In progress' : match.status === 'completed' ? 'Completed' : 'Interrupted'} {match.startPlayers ?? '—'}{match.uniquePlayers}{match.endPlayers ?? '—'} {match.durationMs === null ? '—' : formatDuration(match.durationMs)}{match.finalRound ?? '—'} @@ -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); } diff --git a/src/routes/leaderboard/players/[guid]/+page.svelte b/src/routes/leaderboard/players/[guid]/+page.svelte index 1e7209b..a3bb682 100644 --- a/src/routes/leaderboard/players/[guid]/+page.svelte +++ b/src/routes/leaderboard/players/[guid]/+page.svelte @@ -78,7 +78,7 @@ {#each historyColumns as key}{/each}{#each profile.matches as match (match.id)} - + {#each historyColumns as key}{/each} diff --git a/src/routes/matches/[id]/+page.server.ts b/src/routes/matches/[id]/+page.server.ts new file mode 100644 index 0000000..7143a8b --- /dev/null +++ b/src/routes/matches/[id]/+page.server.ts @@ -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 }; +} diff --git a/src/routes/matches/[id]/+page.svelte b/src/routes/matches/[id]/+page.svelte new file mode 100644 index 0000000..48eba58 --- /dev/null +++ b/src/routes/matches/[id]/+page.svelte @@ -0,0 +1,121 @@ + + +{match.map} · Match #{match.id} · Electric Cherry + +
+ + {#snippet crumbs()}← Match History{/snippet} + {#snippet actions()}Completed{/snippet} + + +
+ + + + +
+ + +

Match details

+
+
Server
+
{match.startObserved ? 'Started' : 'First observed'}
+
Ended
{#if match.endedAt}{:else}—{/if}
+
Players at start
{match.startPlayers ?? '—'}
+
+ {#if !match.startObserved}

The match start was not recorded. The first observation is shown above; the full game time is unknown.

{/if} +
+ +
+
+

Player results

+

Every recorded player, including those absent from the final report. Stats are the highest reported counters for this match.

+
+ {#if match.players.length === 0} + + {:else} + +
+
Map / serverMatch startedMatch status{leaderboardMetrics.find(metric => metric.key === key)!.label}
{match.mapName}{gameName(match.game)}{match.server} {#if match.server_deleted_at}Deleted{/if}{date(match.startedAt)}
{#if match.status === 'completed'}{match.mapName}{:else}{match.mapName}{/if}{gameName(match.game)}{match.server} {#if match.server_deleted_at}Deleted{/if}{date(match.startedAt)} {match.status}{match.completed ? 'Completion credited' : 'No completion credit'}{number.format(match[key])}
+ {#each stats as stat}{/each} + + {#each match.players as player (player.guid)} + + + + {#each stats as stat}{/each} + + + {/each} + +
PlayerParticipation{stat.label}Last seen
+ {#if player.canViewProfile}{player.name}{:else}{player.name}{/if} + {player.guid} + {player.presentAtEnd ? 'At match end' : 'Not at match end'}{#if !player.hasStats}No stats recorded{/if}{player[stat.key] === null ? '—' : number.format(player[stat.key]!)}
+ + {/if} + + +
+ About these results +

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.

+

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.

+

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.

+
+ + + diff --git a/tests/unit/match-detail.test.ts b/tests/unit/match-detail.test.ts new file mode 100644 index 0000000..87c9191 --- /dev/null +++ b/tests/unit/match-detail.test.ts @@ -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 => ({ + 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 })); + } + }); +});