Unify player profiles across T5 and T6

This commit is contained in:
2026-09-28 15:04:57 -04:00
parent a4a5298408
commit 61d4eebfea
11 changed files with 190 additions and 90 deletions
+12 -7
View File
@@ -10,18 +10,23 @@ This also excludes existing round 1 records. Early telemetry is retained so
qualifying games keep their start time, roster and cumulative counters.
Click a player's name to open their shareable profile at
`/leaderboard/players/[game]/[guid]`. Profiles show all ten lifetime statistics,
`/leaderboard/players/[guid]`. Profiles combine Black Ops I (T5) and Black Ops II
(T6) records for the same GUID. Existing `/leaderboard/players/[game]/[guid]`
links redirect to the combined profile. 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.
records; map records and match history identify their game. Match history shows
individual counters, server, date and completion credit in pages of 25 matches
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 for that game and player.
display a dash. Profiles always cover all recorded maps across both games.
The leaderboard uses the existing three telemetry events; no reporter change is
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;
identical names with different GUIDs remain separate. GUIDs stay strings.
required. Profiles identify a player by **GUID**, displaying the latest reported
name across both games. Different names for the same GUID do not split their
profile; identical names with different GUIDs remain separate. Rankings remain
scoped to each game and its map filters, and stored player records remain keyed
by **game + GUID**. GUIDs stay strings.
## Recorded statistics
+4 -3
View File
@@ -15,12 +15,14 @@ export type LeaderboardMetric = typeof leaderboardMetrics[number]['key'];
export type PlayerStats = Record<LeaderboardMetric, number>;
export interface PlayerMapRecord extends PlayerStats {
game: string;
key: string;
name: string;
lastSeen: string;
}
export interface PlayerMatch {
id: number;
game: string;
server: string;
server_deleted_at?: string | null;
mapName: string;
@@ -37,7 +39,6 @@ export interface PlayerMatch {
bestScore: number;
}
export interface PlayerProfile {
game: string;
guid: string;
name: string;
firstSeen: string;
@@ -49,8 +50,8 @@ export interface PlayerProfile {
limit: number;
}
export function playerProfileHref(game: string, guid: string): string {
return `/leaderboard/players/${encodeURIComponent(game)}/${encodeURIComponent(guid)}`;
export function playerProfileHref(guid: string): string {
return `/leaderboard/players/${encodeURIComponent(guid)}`;
}
export interface LeaderboardEntry {
+13 -11
View File
@@ -7,29 +7,31 @@ const totals = `MAX(mp.highest_round) AS highestRound, SUM(mp.kills) AS kills,
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
WHERE m.game IN ('t5', 't6') AND mp.guid = ? AND mp.has_stats = 1
AND m.highest_round >= ${MIN_RECORDED_ROUND} AND mp.highest_round >= ${MIN_RECORDED_ROUND}`;
export function getPlayerProfile(game: string, guid: string, requestedOffset = 0): PlayerProfile | null {
export function getPlayerProfile(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;
// Keep game-specific ingestion identities, but show one identity with the latest reported name.
const player = handle.prepare(`SELECT guid, name, MIN(first_seen_at) OVER () AS firstSeen,
MAX(last_seen_at) OVER () AS lastSeen FROM leaderboard_players
WHERE game IN ('t5', 't6') AND guid = ? ORDER BY last_seen_at DESC, game LIMIT 1`)
.get(guid) as Pick<PlayerProfile, 'guid' | 'name' | 'firstSeen' | 'lastSeen'> | undefined;
if (!player) return null;
const stats = handle.prepare(`SELECT ${totals} ${scope}`).get(game, guid) as PlayerStats;
const stats = handle.prepare(`SELECT ${totals} ${scope}`).get(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,
const maps = handle.prepare(`SELECT m.game, 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.server_deleted_at, m.map_name AS mapName,
GROUP BY m.game, m.map_key ORDER BY highestRound DESC, kills DESC, name COLLATE NOCASE, m.game, key`)
.all(guid) as PlayerMapRecord[];
const matches = handle.prepare(`SELECT m.id, m.game, m.server, m.server_deleted_at, 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[];
.all(guid, limit, offset) as PlayerMatch[];
return { ...player, stats, maps, matches, offset, limit };
}
@@ -10,7 +10,7 @@
import Badge from './primitives/Badge.svelte';
import EmptyState from './primitives/EmptyState.svelte';
let { report, mapLabel, game, current = true, after = null }: { report: GameTelemetry | undefined; mapLabel?: string; game: string; current?: boolean; after?: string | null } = $props();
let { report, mapLabel, current = true, after = null }: { report: GameTelemetry | undefined; mapLabel?: string; current?: boolean; after?: string | null } = $props();
const fresh = $derived(current && freshTelemetry(report, $uiNow, after));
const timingNow = $derived(report && (report.event === 'game_end' || !fresh)
? Date.parse(report.receivedAt) : $uiNow);
@@ -89,7 +89,7 @@
{@const timing = report.timing?.players[player.guid]}
<tr>
<th scope="row" class="player">
<a class="player-name" href={playerProfileHref(game.toLowerCase(), player.guid)}>{player.name}</a>
<a class="player-name" href={playerProfileHref(player.guid)}>{player.name}</a>
<div class="guid"><span>GUID</span><CopyValue value={player.guid} label={`GUID for ${player.name}`} /></div>
</th>
<td class="stat">{timing ? elapsedSince(timing.serverJoinedAt, timingNow) : '—'}</td>
+1 -1
View File
@@ -209,7 +209,7 @@
<tr>
<td class="rank"><span class:podium={entry.rank <= 3}>{number.format(entry.rank)}</span></td>
<th scope="row" class="player">
<a class="player-name" href={playerProfileHref(entry.game, entry.guid)}>{entry.name}</a>
<a class="player-name" href={playerProfileHref(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>
</th>
{#each columns as key (key)}<td class="stat" class:selected={key === metric}>{number.format(entry[key])}</td>{/each}
@@ -1,9 +1,7 @@
import { error } from '@sveltejs/kit';
import { getPlayerProfile } from '$lib/server/fleet/player-profile.js';
import { error, redirect } from '@sveltejs/kit';
import { playerProfileHref } from '$lib/leaderboard.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 };
redirect(308, playerProfileHref(params.guid) + url.search);
}
@@ -0,0 +1,8 @@
import { error } from '@sveltejs/kit';
import { getPlayerProfile } from '$lib/server/fleet/player-profile.js';
export function load({ params, url }: { params: { guid: string }; url: URL }) {
const profile = getPlayerProfile(params.guid, Number(url.searchParams.get('offset') ?? 0));
if (!profile) error(404, 'No recorded stats found for this player');
return { profile };
}
@@ -12,6 +12,7 @@
const stats = $derived(profile.stats);
const number = new Intl.NumberFormat('en-US', { maximumFractionDigits: 1 });
const date = (value: string) => $timeFormat.dateTime(value);
const gameName = (game: string) => game === 't5' ? 'Black Ops I' : game === 't6' ? 'Black Ops II' : game.toUpperCase();
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);
@@ -28,7 +29,7 @@
<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`}>
<SectionHeader title={profile.name} eyebrow="Player profile" subtitle="Black Ops I & Black Ops II · Lifetime Zombies stats">
{#snippet crumbs()}<a href="/leaderboard">← Leaderboard</a>{/snippet}
{#snippet actions()}<Button size="sm" loading={refreshing} onclick={refresh}>Refresh stats</Button>{/snippet}
</SectionHeader>
@@ -57,13 +58,13 @@
</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>
<div class="panel-heading"><h2 id="maps-title">Per-map records</h2><p>Personal best rounds and every tracked stat across both games, 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>
<tbody>{#each profile.maps as map (`${map.game}:${map.key}`)}
<tr><th scope="row"><a href={`/leaderboard?${new URLSearchParams({ game: map.game, map: map.key })}`}>{map.name}</a><small>{gameName(map.game)}</small><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>
@@ -71,13 +72,13 @@
</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>
<div class="panel-heading"><h2 id="history-title">Match history</h2><p>Matches from both games, most recently observed 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} {#if match.server_deleted_at}<Badge>Deleted</Badge>{/if}</small></th><td>{date(match.startedAt)}</td>
<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>
<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>
@@ -89,8 +90,8 @@
</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>
<p>Lifetime stats combine saved telemetry from Black Ops I and Black Ops II, 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 across both games when a player changes names, and the latest reported name is displayed.</p>
</details>
</section>
+1 -1
View File
@@ -323,7 +323,7 @@
{#if tabError}<div class="ui-error" role="alert">{tabError}<Button size="sm" onclick={() => loadTab(name, activeTab)}>Retry</Button></div>{/if}
{#if activeTab === 'scoreboard'}
<Card padding="lg">
{#key name}<PlayerScoreboard current={fresh} after={server?.telemetry_after ?? null} game={fleetIdentity(server).game} report={scoreboard} mapLabel={scoreboard ? fleetIdentity({ ...server, last_map: scoreboard.map }).map : undefined} />{/key}
{#key name}<PlayerScoreboard current={fresh} after={server?.telemetry_after ?? null} report={scoreboard} mapLabel={scoreboard ? fleetIdentity({ ...server, last_map: scoreboard.map }).map : undefined} />{/key}
</Card>
{:else if activeTab === 'chat'}
<Card padding="lg">
+17 -7
View File
@@ -42,16 +42,16 @@ it('starts counting at round 2, preserves early stats, and excludes players who
await report('game_start', 0, [player(), departed]);
await report('round_change', 1, [player(undefined, { kills: 6, score: 1000 }), departed]);
expect(getLeaderboard()).toMatchObject({ entries: [], maps: [], total: 0, summary: { players: 0, matches: 0, kills: 0 } });
expect(getPlayerProfile('t5', player().guid)).toBeNull();
expect(getPlayerProfile(player().guid)).toBeNull();
expect(serverMatchHistory()).toEqual({ total: 0, matches: [] });
await report('round_change', 2, [player(undefined, { kills: 6, score: 500 })]);
expect(getLeaderboard()).toMatchObject({ total: 1, entries: [expect.objectContaining({
highestRound: 2, kills: 6, bestScore: 1000, roundsSurvived: 1, gamesPlayed: 1, gamesCompleted: 0
})], summary: { players: 1, matches: 1, kills: 6 } });
expect(getPlayerProfile('t5', departed.guid)).toBeNull();
expect(getPlayerProfile(departed.guid)).toBeNull();
await report('game_end', 2, [player(undefined, { kills: 10 })]);
const board = getLeaderboard();
const profile = getPlayerProfile('t5', player().guid);
const profile = getPlayerProfile(player().guid);
const history = serverMatchHistory();
expect(history).toMatchObject({ total: 1, matches: [{ startPlayers: 2, uniquePlayers: 2, durationMs: 3000, finalRound: 2 }] });
await report('game_start');
@@ -62,7 +62,7 @@ it('starts counting at round 2, preserves early stats, and excludes players who
closeDb();
expect(getLeaderboard()).toEqual(board);
// Identity timestamps still reflect live presence, but gameplay records do not change.
expect(getPlayerProfile('t5', player().guid)).toMatchObject({ stats: profile!.stats, maps: profile!.maps, matches: profile!.matches });
expect(getPlayerProfile(player().guid)).toMatchObject({ stats: profile!.stats, maps: profile!.maps, matches: profile!.matches });
expect(serverMatchHistory()).toEqual(history);
const { GET } = await import('../../src/routes/api/matches/+server.js');
expect(await GET({ url: new URL('http://localhost/api/matches?limit=1') }).json()).toEqual(history);
@@ -165,13 +165,13 @@ it('persists independent stat high-water marks without adding cumulative snapsho
})]);
});
it('keeps a latest player identity across games on different servers without merging different game GUIDs', async () => {
it('retains per-game identities while combining the same GUID in one profile', async () => {
await report('round_change', 8, [player(undefined, { kills: 111 })]);
await report('game_end', 8, [player(undefined, { kills: 120, name: 'New name' })]);
await report('game_start');
await report('game_end', 2, [player(undefined, { kills: 20, name: 'Latest name' })]);
await report('game_end', 5, [player(undefined, { kills: 200, name: 'Other game' })], 't6_zombie_town_1', 'zm_transit');
const { db } = await import('../../src/lib/server/db/index.js');
const { db, closeDb } = await import('../../src/lib/server/db/index.js');
expect(db().prepare('SELECT game, guid, name FROM leaderboard_players ORDER BY game').all()).toEqual([
{ game: 't5', guid: '2741626', name: 'Latest name' },
{ game: 't6', guid: '2741626', name: 'Other game' }
@@ -180,6 +180,16 @@ it('keeps a latest player identity across games on different servers without mer
expect(db().prepare(`SELECT SUM(p.rounds_survived) AS total FROM leaderboard_match_players p
JOIN leaderboard_matches m ON m.id = p.match_id WHERE m.game = 't5' AND p.guid = ?`).get('2741626'))
.toEqual({ total: 1 });
closeDb();
const { getPlayerProfile } = await import('../../src/lib/server/fleet/player-profile.js');
const profile = getPlayerProfile(player().guid)!;
expect(profile).toMatchObject({ guid: player().guid, name: 'Other game',
firstSeen: '2026-09-18T20:00:00.000Z', lastSeen: '2026-09-18T20:00:04.000Z',
stats: { gamesPlayed: 3, gamesCompleted: 3, kills: 340, highestRound: 8, bestKills: 200, roundsSurvived: 1 } });
expect(profile.matches.map(match => match.game)).toEqual(['t6', 't5', 't5']);
await report('game_start', 0, [player(undefined, { name: 'Live name' })]);
expect(getPlayerProfile(player().guid)).toMatchObject({ name: 'Live name', lastSeen: '2026-09-18T20:00:05.000Z',
stats: profile.stats, maps: profile.maps, matches: profile.matches });
});
it('credits the highest round but only observed survival for a late join or a return after an absent roster', async () => {
@@ -312,7 +322,7 @@ it('keeps deleted match identities and starts a separate match when the server n
server, server_deleted_at: expect.any(String), status: 'interrupted'
});
expect(history.find(match => match.id !== original.id)).toMatchObject({ server, server_deleted_at: null, status: 'active' });
const profile = getPlayerProfile('t5', player().guid)!;
const profile = getPlayerProfile(player().guid)!;
expect(profile.stats.gamesPlayed).toBe(2);
expect(profile.matches.find(match => match.id === original.id)?.server_deleted_at).toEqual(expect.any(String));
expect(profile.stats.kills).toBe(240);
+120 -45
View File
@@ -51,31 +51,33 @@ describe('persisted leaderboard queries', () => {
const { serverMatchHistory } = await import('../../src/lib/server/fleet/match-history.js');
const { leaderboardMetrics } = await import('../../src/lib/leaderboard.js');
const boards = leaderboardMetrics.map(({ key }) => getLeaderboard({ metric: key, limit: 2, offset: 1 }));
const profile = getPlayerProfile('t5', '101');
const profile = getPlayerProfile('101');
const history = serverMatchHistory(undefined, 2);
const scopedHistory = serverMatchHistory('removed-server', 2);
const scopedBoard = getLeaderboard({ game: 't5', map: 't5:theater' });
let id = 10;
for (const status of ['active', 'completed', 'interrupted']) {
for (const round of [0, 1]) {
handle.prepare(`INSERT INTO leaderboard_matches
(id, server, game, map, map_key, map_name, started_at, last_received_at,
status, highest_round, last_event, last_fingerprint)
VALUES (?, 'removed-server', 't5', 'reset_map', 't5:reset', 'Reset Map',
'2026-09-20', '2026-09-20', ?, ?, 'game_end', 'reset')`).run(id, status, round);
// A historical record with large counters must not change rankings or bests.
handle.prepare(`INSERT INTO leaderboard_match_players
(match_id, guid, highest_round, kills, headshots, revives, downs, best_score,
has_stats, completed, first_seen_at, last_seen_at)
VALUES (?, '101', ?, 99999, 99999, 99999, 99999, 99999, 1, 1, '2026-09-20', '2026-09-20')`)
.run(id++, round);
for (const game of ['t5', 't6']) {
for (const status of ['active', 'completed', 'interrupted']) {
for (const round of [0, 1]) {
handle.prepare(`INSERT INTO leaderboard_matches
(id, server, game, map, map_key, map_name, started_at, last_received_at,
status, highest_round, last_event, last_fingerprint)
VALUES (?, 'removed-server', ?, 'reset_map', ?, 'Reset Map',
'2026-09-20', '2026-09-20', ?, ?, 'game_end', 'reset')`).run(id, game, `${game}:reset`, status, round);
// A historical record with large counters must not change rankings or bests.
handle.prepare(`INSERT INTO leaderboard_match_players
(match_id, guid, highest_round, kills, headshots, revives, downs, best_score,
has_stats, completed, first_seen_at, last_seen_at)
VALUES (?, '101', ?, 99999, 99999, 99999, 99999, 99999, 1, 1, '2026-09-20', '2026-09-20')`)
.run(id++, round);
}
}
}
expect(leaderboardMetrics.map(({ key }) => getLeaderboard({ metric: key, limit: 2, offset: 1 }))).toEqual(boards);
expect(getLeaderboard({ game: 't5', map: 't5:theater' })).toEqual(scopedBoard);
expect(getLeaderboard({ map: 't5:reset' })).toMatchObject({ entries: [], total: 0,
summary: { players: 0, matches: 0, kills: 0, highestRound: 0, firstTrackedAt: null, updatedAt: null } });
expect(getPlayerProfile('t5', '101')).toEqual(profile);
expect(getPlayerProfile('101')).toEqual(profile);
expect(serverMatchHistory(undefined, 2)).toEqual(history);
expect(serverMatchHistory('removed-server', 2)).toEqual(scopedHistory);
});
@@ -144,54 +146,127 @@ describe('persisted leaderboard queries', () => {
});
describe('player profiles', () => {
it('returns exact player totals, map records and history independently of current server registration', async () => {
it('combines totals, map records and history across T5 and T6 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();
const profile = getPlayerProfile('101')!;
expect(profile).toMatchObject({ name: 'Ace', guid: '101', offset: 0, limit: 25 });
expect(profile).not.toHaveProperty('game');
expect(profile.stats).toEqual({ highestRound: 20, kills: 770, roundsSurvived: 38,
headshots: 320, revives: 8, downs: 4, gamesPlayed: 4, gamesCompleted: 3,
bestKills: 500, bestScore: 5000 });
expect(profile.maps).toHaveLength(3);
expect(profile.maps[0]).toMatchObject({ game: 't6', key: 't6:town', highestRound: 20, kills: 500, gamesPlayed: 1, gamesCompleted: 1 });
expect(profile.maps[1]).toMatchObject({ game: 't5', key: 't5:theater', highestRound: 10, kills: 250, gamesPlayed: 2, gamesCompleted: 1 });
expect(profile.matches.map(match => [match.id, match.game])).toEqual([[4, 't6'], [3, 't5'], [2, 't5'], [1, 't5']]);
expect(profile.matches[2]).toMatchObject({ status: 'active', completed: 0, server: 'removed-server', kills: 150 });
expect(getPlayerProfile('505')).toMatchObject({ name: 'Eve', stats: { kills: 200, gamesPlayed: 1 },
maps: [expect.objectContaining({ game: 't6' })], matches: [expect.objectContaining({ game: 't6' })] });
expect(getPlayerProfile('404')).toBeNull();
expect(getPlayerProfile("' OR 1=1 --")).toBeNull();
});
it('paginates history without changing lifetime totals and clamps invalid offsets', async () => {
it('uses the latest reported name and the full identity date range across supported games', async () => {
const handle = await seedRecords();
handle.prepare(`UPDATE leaderboard_players SET first_seen_at = '2026-08-01', last_seen_at = '2026-09-07'
WHERE game = 't5' AND guid = '101'`).run();
handle.prepare(`UPDATE leaderboard_players SET first_seen_at = '2026-07-01', last_seen_at = '2026-09-08'
WHERE game = 't6' AND guid = '101'`).run();
const { getPlayerProfile } = await import('../../src/lib/server/fleet/player-profile.js');
expect(getPlayerProfile('101')).toMatchObject({ name: 'Other game', firstSeen: '2026-07-01', lastSeen: '2026-09-08' });
handle.prepare("UPDATE leaderboard_players SET last_seen_at = '2026-09-08' WHERE game = 't5' AND guid = '101'").run();
expect(getPlayerProfile('101')?.name).toBe('Ace');
handle.prepare("UPDATE leaderboard_players SET last_seen_at = '2026-09-09' WHERE game = 't5' AND guid = '101'").run();
expect(getPlayerProfile('101')).toMatchObject({ name: 'Ace', firstSeen: '2026-07-01', lastSeen: '2026-09-09' });
});
it('keeps map records separate across games even when their stored keys are equal', async () => {
const handle = await seedRecords();
handle.prepare("UPDATE leaderboard_matches SET map_key = 'shared-map' WHERE id IN (1, 2, 4)").run();
const { getPlayerProfile } = await import('../../src/lib/server/fleet/player-profile.js');
const records = getPlayerProfile('101')!.maps.filter(map => map.key === 'shared-map');
expect(records).toEqual([
expect.objectContaining({ game: 't6', highestRound: 20, kills: 500, gamesPlayed: 1 }),
expect.objectContaining({ game: 't5', highestRound: 10, kills: 250, gamesPlayed: 2 })
]);
});
it('excludes unsupported games and players without qualifying stats from every part of a profile', async () => {
const handle = await seedRecords();
const { getPlayerProfile } = await import('../../src/lib/server/fleet/player-profile.js');
const original = getPlayerProfile('101');
const match = handle.prepare(`INSERT INTO leaderboard_matches
(id, server, game, map, map_key, map_name, started_at, last_received_at, status, highest_round, last_event, last_fingerprint)
VALUES (?, 'removed-server', ?, 'excluded', 'excluded', 'Excluded', '2026-09-20', '2026-09-20', 'completed', ?, 'game_end', 'test')`);
const record = handle.prepare(`INSERT INTO leaderboard_match_players
(match_id, guid, kills, highest_round, has_stats, first_seen_at, last_seen_at)
VALUES (?, ?, 99999, ?, ?, '2026-09-20', '2026-09-20')`);
match.run(10, 't7', 99);
record.run(10, '101', 99, 1);
record.run(10, 'unsupported-only', 99, 1);
for (const guid of ['101', 'unsupported-only']) {
handle.prepare(`INSERT INTO leaderboard_players (game, guid, name, first_seen_at, last_seen_at)
VALUES ('t7', ?, 'Unsupported name', '2020-01-01', '2030-01-01')`).run(guid);
}
match.run(11, 't5', 99);
record.run(11, '101', 1, 1);
match.run(12, 't6', 99);
record.run(12, '101', 99, 0);
match.run(13, 't6', 1);
record.run(13, '101', 99, 1);
expect(getPlayerProfile('101')).toEqual(original);
expect(getPlayerProfile('unsupported-only')).toBeNull();
});
it('paginates one history across both games 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, highest_round, last_event, last_fingerprint)
SELECT ?, server, game, map, map_key, map_name, started_at, last_received_at, status, highest_round, last_event, last_fingerprint
FROM leaderboard_matches WHERE id = 1`).run(id);
FROM leaderboard_matches WHERE id = ?`).run(id, id % 2 === 0 ? 1 : 4);
handle.prepare(`INSERT INTO leaderboard_match_players (match_id, guid, has_stats, highest_round, first_seen_at, last_seen_at)
VALUES (?, '101', 1, 2, '2026-09-01', '2026-09-01')`).run(id);
VALUES (?, '101', 1, 2, '2026-09-01', '2026-09-06')`).run(id);
}
const { getPlayerProfile } = await import('../../src/lib/server/fleet/player-profile.js');
const first = getPlayerProfile('t5', '101')!;
const second = getPlayerProfile('t5', '101', 25)!;
const first = getPlayerProfile('101')!;
const second = getPlayerProfile('101', 25)!;
expect(first.matches).toHaveLength(25);
expect(second.matches).toHaveLength(8);
expect(second.matches).toHaveLength(9);
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);
expect(first.stats).toMatchObject({ gamesPlayed: 34, kills: 770 });
expect(first.matches.map(match => [match.id, match.game])).toEqual(Array.from({ length: 25 }, (_, index) => {
const id = 39 - index;
return [id, id % 2 === 0 ? 't5' : 't6'];
}));
expect(second.matches.map(match => match.id)).toEqual([14, 13, 12, 11, 10, 4, 3, 2, 1]);
expect(new Set([...first.matches, ...second.matches].map(match => match.id)).size).toBe(34);
expect(getPlayerProfile('101', 9999)?.offset).toBe(25);
for (const offset of [-1, NaN, 0.5]) expect(getPlayerProfile('101', offset)?.offset).toBe(0);
const { load } = await import('../../src/routes/leaderboard/players/[guid]/+page.server.js');
expect(load({ params: { guid: '101' }, url: new URL('http://localhost/leaderboard/players/101?offset=25') }).profile).toEqual(second);
});
it('loads direct profile URLs and returns 404 for missing players or games', async () => {
it('loads canonical profile URLs, encodes GUIDs and returns 404 for missing players', 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 { load } = await import('../../src/routes/leaderboard/players/[guid]/+page.server.js');
const url = new URL('http://localhost/leaderboard/players/101');
expect(load({ params: { guid: '101' }, url }).profile).toMatchObject({ name: 'Ace', stats: { kills: 770, gamesPlayed: 4 } });
expect(() => load({ params: { guid: 'missing' }, 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');
expect(playerProfileHref('001/2?#')).toBe('/leaderboard/players/001%2F2%3F%23');
});
it('permanently redirects both legacy game URLs while preserving the GUID and pagination query', async () => {
const { load } = await import('../../src/routes/leaderboard/players/[game]/[guid]/+page.server.js');
for (const game of ['t5', 't6']) {
const url = new URL(`http://localhost/leaderboard/players/${game}/001%2F2%3F%23?offset=25&source=old%20link`);
expect(() => load({ params: { game, guid: '001/2?#' }, url })).toThrow(expect.objectContaining({
status: 308, location: '/leaderboard/players/001%2F2%3F%23?offset=25&source=old%20link'
}));
}
expect(() => load({ params: { game: 't7', guid: '101' }, url: new URL('http://localhost/leaderboard/players/t7/101') }))
.toThrow(expect.objectContaining({ status: 404 }));
});
});