Add player profiles with lifetime stats and map records
This commit is contained in:
@@ -5,6 +5,15 @@ or filter by game and map. Choose a ranking, search a name or GUID, and share th
|
|||||||
page URL with its filters intact. Results refresh while the page is visible.
|
page URL with its filters intact. Results refresh while the page is visible.
|
||||||
Active games contribute as soon as a round report arrives.
|
Active games contribute as soon as a round report arrives.
|
||||||
|
|
||||||
|
Click a player's name to open their shareable profile at
|
||||||
|
`/leaderboard/players/[game]/[guid]`. Profiles show all ten lifetime statistics,
|
||||||
|
headshots as a percentage of kills, kills per down, average kills and observed
|
||||||
|
rounds per game, and completion rate. Each map has its own complete set of
|
||||||
|
records; match history shows individual counters, server, date and completion
|
||||||
|
credit in pages of 25 games. Map names link back to that map's leaderboard.
|
||||||
|
Use **Refresh stats** to load the latest reports. Ratios with a zero denominator
|
||||||
|
display a dash. Profiles always cover all recorded maps for that game and player.
|
||||||
|
|
||||||
The leaderboard uses the existing three telemetry events; no reporter change is
|
The leaderboard uses the existing three telemetry events; no reporter change is
|
||||||
required. Each player is identified by **game + GUID**, with their latest reported
|
required. Each player is identified by **game + GUID**, with their latest reported
|
||||||
name displayed. Different names for the same GUID do not split their records;
|
name displayed. Different names for the same GUID do not split their records;
|
||||||
|
|||||||
@@ -13,6 +13,45 @@ export const leaderboardMetrics = [
|
|||||||
|
|
||||||
export type LeaderboardMetric = typeof leaderboardMetrics[number]['key'];
|
export type LeaderboardMetric = typeof leaderboardMetrics[number]['key'];
|
||||||
|
|
||||||
|
export type PlayerStats = Record<LeaderboardMetric, number>;
|
||||||
|
export interface PlayerMapRecord extends PlayerStats {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
lastSeen: string;
|
||||||
|
}
|
||||||
|
export interface PlayerMatch {
|
||||||
|
id: number;
|
||||||
|
server: string;
|
||||||
|
mapName: string;
|
||||||
|
startedAt: string;
|
||||||
|
lastSeen: string;
|
||||||
|
status: 'active' | 'completed' | 'interrupted';
|
||||||
|
completed: number;
|
||||||
|
highestRound: number;
|
||||||
|
roundsSurvived: number;
|
||||||
|
kills: number;
|
||||||
|
headshots: number;
|
||||||
|
revives: number;
|
||||||
|
downs: number;
|
||||||
|
bestScore: number;
|
||||||
|
}
|
||||||
|
export interface PlayerProfile {
|
||||||
|
game: string;
|
||||||
|
guid: string;
|
||||||
|
name: string;
|
||||||
|
firstSeen: string;
|
||||||
|
lastSeen: string;
|
||||||
|
stats: PlayerStats;
|
||||||
|
maps: PlayerMapRecord[];
|
||||||
|
matches: PlayerMatch[];
|
||||||
|
offset: number;
|
||||||
|
limit: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function playerProfileHref(game: string, guid: string): string {
|
||||||
|
return `/leaderboard/players/${encodeURIComponent(game)}/${encodeURIComponent(guid)}`;
|
||||||
|
}
|
||||||
|
|
||||||
export interface LeaderboardEntry {
|
export interface LeaderboardEntry {
|
||||||
rank: number;
|
rank: number;
|
||||||
game: string;
|
game: string;
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { db } from '../db/index.js';
|
||||||
|
import type { PlayerProfile, PlayerStats, PlayerMapRecord, PlayerMatch } from '../../leaderboard.js';
|
||||||
|
|
||||||
|
const totals = `MAX(mp.highest_round) AS highestRound, SUM(mp.kills) AS kills,
|
||||||
|
SUM(mp.rounds_survived) AS roundsSurvived, SUM(mp.headshots) AS headshots,
|
||||||
|
SUM(mp.revives) AS revives, SUM(mp.downs) AS downs, COUNT(*) AS gamesPlayed,
|
||||||
|
SUM(mp.completed) AS gamesCompleted, MAX(mp.kills) AS bestKills, MAX(mp.best_score) AS bestScore`;
|
||||||
|
const scope = `FROM leaderboard_match_players mp JOIN leaderboard_matches m ON m.id = mp.match_id
|
||||||
|
WHERE m.game = ? AND mp.guid = ? AND mp.has_stats = 1`;
|
||||||
|
|
||||||
|
export function getPlayerProfile(game: string, guid: string, requestedOffset = 0): PlayerProfile | null {
|
||||||
|
const handle = db();
|
||||||
|
const player = handle.prepare(`SELECT game, guid, name, first_seen_at AS firstSeen,
|
||||||
|
last_seen_at AS lastSeen FROM leaderboard_players WHERE game = ? AND guid = ?`)
|
||||||
|
.get(game, guid) as Pick<PlayerProfile, 'game' | 'guid' | 'name' | 'firstSeen' | 'lastSeen'> | undefined;
|
||||||
|
if (!player) return null;
|
||||||
|
const stats = handle.prepare(`SELECT ${totals} ${scope}`).get(game, guid) as PlayerStats;
|
||||||
|
if (!stats.gamesPlayed) return null;
|
||||||
|
const limit = 25;
|
||||||
|
const lastOffset = Math.floor((stats.gamesPlayed - 1) / limit) * limit;
|
||||||
|
const offset = Number.isSafeInteger(requestedOffset) ? Math.min(lastOffset, Math.max(0, requestedOffset)) : 0;
|
||||||
|
const maps = handle.prepare(`SELECT m.map_key AS key, m.map_name AS name,
|
||||||
|
MAX(mp.last_seen_at) AS lastSeen, ${totals} ${scope}
|
||||||
|
GROUP BY m.map_key ORDER BY highestRound DESC, kills DESC, name COLLATE NOCASE, key`)
|
||||||
|
.all(game, guid) as PlayerMapRecord[];
|
||||||
|
const matches = handle.prepare(`SELECT m.id, m.server, m.map_name AS mapName,
|
||||||
|
m.started_at AS startedAt, mp.last_seen_at AS lastSeen, m.status, mp.completed,
|
||||||
|
mp.highest_round AS highestRound, mp.rounds_survived AS roundsSurvived,
|
||||||
|
mp.kills, mp.headshots, mp.revives, mp.downs, mp.best_score AS bestScore
|
||||||
|
${scope} ORDER BY mp.last_seen_at DESC, m.id DESC LIMIT ? OFFSET ?`)
|
||||||
|
.all(game, guid, limit, offset) as PlayerMatch[];
|
||||||
|
return { ...player, stats, maps, matches, offset, limit };
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
import { onMount, untrack } from 'svelte';
|
import { onMount, untrack } from 'svelte';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { leaderboardMetrics, type LeaderboardMetric, type LeaderboardMap, type LeaderboardResponse } from '$lib/leaderboard.js';
|
import { leaderboardMetrics, playerProfileHref, type LeaderboardMetric, type LeaderboardMap, type LeaderboardResponse } from '$lib/leaderboard.js';
|
||||||
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
||||||
import Button from '$ui/components/primitives/Button.svelte';
|
import Button from '$ui/components/primitives/Button.svelte';
|
||||||
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
|
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
|
||||||
@@ -208,7 +208,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td class="rank"><span class:podium={entry.rank <= 3}>{number.format(entry.rank)}</span></td>
|
<td class="rank"><span class:podium={entry.rank <= 3}>{number.format(entry.rank)}</span></td>
|
||||||
<th scope="row" class="player">
|
<th scope="row" class="player">
|
||||||
<span class="player-name">{entry.name}</span>
|
<a class="player-name" href={playerProfileHref(entry.game, entry.guid)}>{entry.name}</a>
|
||||||
<div class="identity"><span class="game-label">{entry.game.toUpperCase()}</span><CopyValue value={entry.guid} label={`GUID for ${entry.name}`} /></div>
|
<div class="identity"><span class="game-label">{entry.game.toUpperCase()}</span><CopyValue value={entry.guid} label={`GUID for ${entry.name}`} /></div>
|
||||||
</th>
|
</th>
|
||||||
{#each columns as key (key)}<td class="stat" class:selected={key === metric}>{number.format(entry[key])}</td>{/each}
|
{#each columns as key (key)}<td class="stat" class:selected={key === metric}>{number.format(entry[key])}</td>{/each}
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { error } from '@sveltejs/kit';
|
||||||
|
import { getPlayerProfile } from '$lib/server/fleet/player-profile.js';
|
||||||
|
|
||||||
|
export function load({ params, url }: { params: { game: string; guid: string }; url: URL }) {
|
||||||
|
if (params.game !== 't5' && params.game !== 't6') error(404, 'Player not found');
|
||||||
|
const profile = getPlayerProfile(params.game, params.guid, Number(url.searchParams.get('offset') ?? 0));
|
||||||
|
if (!profile) error(404, 'No recorded stats found for this player');
|
||||||
|
return { profile };
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { invalidateAll } from '$app/navigation';
|
||||||
|
import { leaderboardMetrics, type PlayerProfile, type LeaderboardMetric } from '$lib/leaderboard.js';
|
||||||
|
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
||||||
|
import Button from '$ui/components/primitives/Button.svelte';
|
||||||
|
import CopyValue from '$ui/components/CopyValue.svelte';
|
||||||
|
|
||||||
|
let { data }: { data: { profile: PlayerProfile } } = $props();
|
||||||
|
const profile = $derived(data.profile);
|
||||||
|
const stats = $derived(profile.stats);
|
||||||
|
const number = new Intl.NumberFormat('en-US', { maximumFractionDigits: 1 });
|
||||||
|
const date = (value: string) => new Date(value).toLocaleString('en-US', { timeZone: 'UTC' }) + ' UTC';
|
||||||
|
const ratio = (value: number, total: number, percent = false) => total ? `${number.format(value / total * (percent ? 100 : 1))}${percent ? '%' : ''}` : '—';
|
||||||
|
const historyColumns = ['highestRound', 'kills', 'roundsSurvived', 'headshots', 'revives', 'downs', 'bestScore'] as const satisfies readonly LeaderboardMetric[];
|
||||||
|
let refreshing = $state(false);
|
||||||
|
let refreshError = $state('');
|
||||||
|
async function refresh() {
|
||||||
|
refreshing = true;
|
||||||
|
refreshError = '';
|
||||||
|
try { await invalidateAll(); }
|
||||||
|
catch { refreshError = 'Unable to refresh player stats. Please try again.'; }
|
||||||
|
finally { refreshing = false; }
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head><title>{profile.name} · Player stats · Electric Cherry</title></svelte:head>
|
||||||
|
|
||||||
|
<section class="profile">
|
||||||
|
<SectionHeader title={profile.name} eyebrow="Player profile" subtitle={`${profile.game === 't5' ? 'Black Ops I' : 'Black Ops II'} · All recorded games`}>
|
||||||
|
{#snippet crumbs()}<a href="/leaderboard">← Leaderboard</a>{/snippet}
|
||||||
|
{#snippet actions()}<Button size="sm" loading={refreshing} onclick={refresh}>Refresh stats</Button>{/snippet}
|
||||||
|
</SectionHeader>
|
||||||
|
<div class="identity"><CopyValue value={profile.guid} label="Player GUID" /><span>First seen {date(profile.firstSeen)}</span><span>Last seen {date(profile.lastSeen)}</span></div>
|
||||||
|
{#if refreshError}<p role="alert">{refreshError}</p>{/if}
|
||||||
|
|
||||||
|
<section aria-labelledby="totals-title">
|
||||||
|
<h2 id="totals-title">Lifetime stats</h2>
|
||||||
|
<div class="stats">
|
||||||
|
{#each leaderboardMetrics as metric (metric.key)}
|
||||||
|
<div class="stat" class:record={metric.key === 'highestRound'}><span>{metric.label}</span><strong>{number.format(stats[metric.key])}</strong></div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section aria-labelledby="metrics-title">
|
||||||
|
<h2 id="metrics-title">Player metrics</h2>
|
||||||
|
<div class="stats metrics">
|
||||||
|
<div class="stat"><span>Headshots / kills</span><strong>{ratio(stats.headshots, stats.kills, true)}</strong></div>
|
||||||
|
<div class="stat"><span>Kills per down</span><strong>{ratio(stats.kills, stats.downs)}</strong></div>
|
||||||
|
<div class="stat"><span>Average kills per game</span><strong>{ratio(stats.kills, stats.gamesPlayed)}</strong></div>
|
||||||
|
<div class="stat"><span>Average rounds per game</span><strong>{ratio(stats.roundsSurvived, stats.gamesPlayed)}</strong></div>
|
||||||
|
<div class="stat"><span>Completion rate</span><strong>{ratio(stats.gamesCompleted, stats.gamesPlayed, true)}</strong></div>
|
||||||
|
</div>
|
||||||
|
<p class="note">Averages include games in progress. A dash means there is no denominator for that metric.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel" aria-labelledby="maps-title">
|
||||||
|
<div class="panel-heading"><h2 id="maps-title">Per-map records</h2><p>Personal best rounds and every tracked stat, ordered by highest round.</p></div>
|
||||||
|
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Scrollable table for keyboard users.) -->
|
||||||
|
<div class="table-scroll" role="region" aria-label="Per-map player records" tabindex="0">
|
||||||
|
<table>
|
||||||
|
<thead><tr><th scope="col">Map</th>{#each leaderboardMetrics as metric}<th scope="col">{metric.label}</th>{/each}</tr></thead>
|
||||||
|
<tbody>{#each profile.maps as map (map.key)}
|
||||||
|
<tr><th scope="row"><a href={`/leaderboard?${new URLSearchParams({ game: profile.game, map: map.key })}`}>{map.name}</a><small>Last played {date(map.lastSeen)}</small></th>
|
||||||
|
{#each leaderboardMetrics as metric}<td class:best={metric.key === 'highestRound'}>{number.format(map[metric.key])}</td>{/each}</tr>
|
||||||
|
{/each}</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel" aria-labelledby="history-title">
|
||||||
|
<div class="panel-heading"><h2 id="history-title">Match history</h2><p>Most recently observed games first. Stats reflect this player's participation.</p></div>
|
||||||
|
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Scrollable table for keyboard users.) -->
|
||||||
|
<div class="table-scroll" role="region" aria-label="Player match history" tabindex="0">
|
||||||
|
<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>{match.server}</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>
|
||||||
|
{/each}</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="pagination"><span>{number.format(profile.offset + 1)}–{number.format(profile.offset + profile.matches.length)} of {number.format(stats.gamesPlayed)} games</span>
|
||||||
|
<div><Button size="sm" href={`?offset=${Math.max(0, profile.offset - profile.limit)}`} disabled={profile.offset === 0}>Previous</Button><Button size="sm" href={`?offset=${profile.offset + profile.limit}`} disabled={profile.offset + profile.limit >= stats.gamesPlayed}>Next</Button></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<details class="note"><summary>How these stats work</summary>
|
||||||
|
<p>Records cover saved telemetry, including active and interrupted games. Highest round includes mid-game joins; total rounds only counts observed round progression. Kills, headshots, revives and downs use each match's highest reported counters.</p>
|
||||||
|
<p>Highest reported score is the largest observed points balance, not total points earned. Completion requires the player to appear in the final report. Records stay with the same GUID within this game when a player changes names.</p>
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.profile { display: flex; flex-direction: column; gap: var(--space-6); min-width: 0; }
|
||||||
|
.identity { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-3); color: var(--text-tertiary); font-size: var(--text-xs); }
|
||||||
|
h2 { font-size: var(--text-lg); margin: 0 0 var(--space-3); }
|
||||||
|
a { color: var(--accent-primary-hover); }
|
||||||
|
.stats { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: var(--space-3); }
|
||||||
|
.stat { display: flex; flex-direction: column; gap: var(--space-2); padding: var(--space-5); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); background: var(--surface); }
|
||||||
|
.stat span { color: var(--text-secondary); font-size: var(--text-xs); }
|
||||||
|
.stat strong { font-family: var(--font-mono); font-size: var(--text-2xl); }
|
||||||
|
.record { border-color: var(--accent-primary); }
|
||||||
|
.record strong, .best { color: var(--accent-primary-hover); }
|
||||||
|
.metrics strong { font-size: var(--text-xl); }
|
||||||
|
.panel { border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); background: var(--surface); overflow: hidden; }
|
||||||
|
.panel-heading { padding: var(--space-5); }
|
||||||
|
.panel-heading p, .note { margin: 0; color: var(--text-tertiary); font-size: var(--text-xs); line-height: 1.7; }
|
||||||
|
section > .note { margin-top: var(--space-3); }
|
||||||
|
.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); border-bottom: 1px solid var(--border-subtle); text-align: right; 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: 200px; white-space: normal; overflow-wrap: anywhere; }
|
||||||
|
td { font-variant-numeric: tabular-nums; }
|
||||||
|
small { display: block; margin-top: 4px; font-size: var(--text-xs); color: var(--text-tertiary); font-weight: normal; }
|
||||||
|
.status { text-transform: capitalize; }
|
||||||
|
.pagination, .pagination > div { display: flex; gap: var(--space-3); align-items: center; }
|
||||||
|
.pagination { padding: var(--space-4); justify-content: space-between; flex-wrap: wrap; font-size: var(--text-xs); color: var(--text-secondary); }
|
||||||
|
summary { cursor: pointer; color: var(--text-secondary); }
|
||||||
|
@media (max-width: 1000px) { .stats { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
|
||||||
|
@media (max-width: 640px) { .stats { grid-template-columns: repeat(2, minmax(0, 1fr)); } .stat { padding: var(--space-3); } .stat strong { font-size: var(--text-xl); } }
|
||||||
|
</style>
|
||||||
@@ -107,6 +107,58 @@ describe('persisted leaderboard queries', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('player profiles', () => {
|
||||||
|
it('returns exact player totals, map records and history independently of current server registration', async () => {
|
||||||
|
await seedRecords();
|
||||||
|
const { getPlayerProfile } = await import('../../src/lib/server/fleet/player-profile.js');
|
||||||
|
const { getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js');
|
||||||
|
const profile = getPlayerProfile('t5', '101')!;
|
||||||
|
const entry = getLeaderboard({ game: 't5', q: '101' }).entries[0];
|
||||||
|
expect(profile).toMatchObject({ name: 'Ace', game: 't5', guid: '101', offset: 0, limit: 25 });
|
||||||
|
for (const [key, value] of Object.entries(profile.stats)) expect(entry[key as keyof typeof entry]).toBe(value);
|
||||||
|
expect(profile.maps).toHaveLength(2);
|
||||||
|
expect(profile.maps[0]).toMatchObject({ key: 't5:theater', highestRound: 10, kills: 250, gamesPlayed: 2, gamesCompleted: 1 });
|
||||||
|
expect(profile.matches.map(match => match.id)).toEqual([3, 2, 1]);
|
||||||
|
expect(profile.matches[1]).toMatchObject({ status: 'active', completed: 0, server: 'removed-server', kills: 150 });
|
||||||
|
expect(getPlayerProfile('t6', '101')).toMatchObject({ name: 'Other game', stats: { kills: 500, highestRound: 20 } });
|
||||||
|
expect(getPlayerProfile('t5', '404')).toBeNull();
|
||||||
|
expect(getPlayerProfile('t5', "' OR 1=1 --")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('paginates history without changing lifetime totals and clamps invalid offsets', async () => {
|
||||||
|
const handle = await seedRecords();
|
||||||
|
for (let id = 10; id < 40; id++) {
|
||||||
|
handle.prepare(`INSERT INTO leaderboard_matches
|
||||||
|
(id, server, game, map, map_key, map_name, started_at, last_received_at, status, last_event, last_fingerprint)
|
||||||
|
SELECT ?, server, game, map, map_key, map_name, started_at, last_received_at, status, last_event, last_fingerprint
|
||||||
|
FROM leaderboard_matches WHERE id = 1`).run(id);
|
||||||
|
handle.prepare(`INSERT INTO leaderboard_match_players (match_id, guid, has_stats, first_seen_at, last_seen_at)
|
||||||
|
VALUES (?, '101', 1, '2026-09-01', '2026-09-01')`).run(id);
|
||||||
|
}
|
||||||
|
const { getPlayerProfile } = await import('../../src/lib/server/fleet/player-profile.js');
|
||||||
|
const first = getPlayerProfile('t5', '101')!;
|
||||||
|
const second = getPlayerProfile('t5', '101', 25)!;
|
||||||
|
expect(first.matches).toHaveLength(25);
|
||||||
|
expect(second.matches).toHaveLength(8);
|
||||||
|
expect(second.stats).toEqual(first.stats);
|
||||||
|
expect(new Set([...first.matches, ...second.matches].map(match => match.id)).size).toBe(33);
|
||||||
|
expect(getPlayerProfile('t5', '101', 9999)?.offset).toBe(25);
|
||||||
|
for (const offset of [-1, NaN, 0.5]) expect(getPlayerProfile('t5', '101', offset)?.offset).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads direct profile URLs and returns 404 for missing players or games', async () => {
|
||||||
|
await seedRecords();
|
||||||
|
const { load } = await import('../../src/routes/leaderboard/players/[game]/[guid]/+page.server.js');
|
||||||
|
const url = new URL('http://localhost/leaderboard/players/t5/101');
|
||||||
|
expect(load({ params: { game: 't5', guid: '101' }, url }).profile.name).toBe('Ace');
|
||||||
|
for (const params of [{ game: 't7', guid: '101' }, { game: 't5', guid: 'missing' }]) {
|
||||||
|
expect(() => load({ params, url })).toThrow(expect.objectContaining({ status: 404 }));
|
||||||
|
}
|
||||||
|
const { playerProfileHref } = await import('../../src/lib/leaderboard.js');
|
||||||
|
expect(playerProfileHref('t5', '001/2?#')).toBe('/leaderboard/players/t5/001%2F2%3F%23');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('leaderboard API', () => {
|
describe('leaderboard API', () => {
|
||||||
it('returns the requested ranking and pagination', async () => {
|
it('returns the requested ranking and pagination', async () => {
|
||||||
await seedRecords();
|
await seedRecords();
|
||||||
|
|||||||
Reference in New Issue
Block a user