Add completed match detail pages
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})) };
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 }));
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user