diff --git a/docs/leaderboard.md b/docs/leaderboard.md index f409d13..ffcf2ce 100644 --- a/docs/leaderboard.md +++ b/docs/leaderboard.md @@ -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. 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 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; diff --git a/src/lib/leaderboard.ts b/src/lib/leaderboard.ts index 7b770fe..6997069 100644 --- a/src/lib/leaderboard.ts +++ b/src/lib/leaderboard.ts @@ -13,6 +13,45 @@ export const leaderboardMetrics = [ export type LeaderboardMetric = typeof leaderboardMetrics[number]['key']; +export type PlayerStats = Record; +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 { rank: number; game: string; diff --git a/src/lib/server/fleet/player-profile.ts b/src/lib/server/fleet/player-profile.ts new file mode 100644 index 0000000..59a96be --- /dev/null +++ b/src/lib/server/fleet/player-profile.ts @@ -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 | 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 }; +} diff --git a/src/routes/leaderboard/+page.svelte b/src/routes/leaderboard/+page.svelte index b397a44..df66414 100644 --- a/src/routes/leaderboard/+page.svelte +++ b/src/routes/leaderboard/+page.svelte @@ -2,7 +2,7 @@ import { onMount, untrack } from 'svelte'; import { goto } from '$app/navigation'; 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 Button from '$ui/components/primitives/Button.svelte'; import EmptyState from '$ui/components/primitives/EmptyState.svelte'; @@ -208,7 +208,7 @@ {number.format(entry.rank)} - {entry.name} + {entry.name}
{entry.game.toUpperCase()}
{#each columns as key (key)}{number.format(entry[key])}{/each} diff --git a/src/routes/leaderboard/players/[game]/[guid]/+page.server.ts b/src/routes/leaderboard/players/[game]/[guid]/+page.server.ts new file mode 100644 index 0000000..17cebf6 --- /dev/null +++ b/src/routes/leaderboard/players/[game]/[guid]/+page.server.ts @@ -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 }; +} diff --git a/src/routes/leaderboard/players/[game]/[guid]/+page.svelte b/src/routes/leaderboard/players/[game]/[guid]/+page.svelte new file mode 100644 index 0000000..c4973d6 --- /dev/null +++ b/src/routes/leaderboard/players/[game]/[guid]/+page.svelte @@ -0,0 +1,124 @@ + + +{profile.name} · Player stats · Electric Cherry + +
+ + {#snippet crumbs()}← Leaderboard{/snippet} + {#snippet actions()}{/snippet} + +
First seen {date(profile.firstSeen)}Last seen {date(profile.lastSeen)}
+ {#if refreshError}

{refreshError}

{/if} + +
+

Lifetime stats

+
+ {#each leaderboardMetrics as metric (metric.key)} +
{metric.label}{number.format(stats[metric.key])}
+ {/each} +
+
+ +
+

Player metrics

+
+
Headshots / kills{ratio(stats.headshots, stats.kills, true)}
+
Kills per down{ratio(stats.kills, stats.downs)}
+
Average kills per game{ratio(stats.kills, stats.gamesPlayed)}
+
Average rounds per game{ratio(stats.roundsSurvived, stats.gamesPlayed)}
+
Completion rate{ratio(stats.gamesCompleted, stats.gamesPlayed, true)}
+
+

Averages include games in progress. A dash means there is no denominator for that metric.

+
+ +
+

Per-map records

Personal best rounds and every tracked stat, ordered by highest round.

+ +
+ + {#each leaderboardMetrics as metric}{/each} + {#each profile.maps as map (map.key)} + + {#each leaderboardMetrics as metric}{/each} + {/each} +
Map{metric.label}
{map.name}Last played {date(map.lastSeen)}{number.format(map[metric.key])}
+
+
+ +
+

Match history

Most recently observed games first. Stats reflect this player's participation.

+ +
+ + {#each historyColumns as key}{/each} + {#each profile.matches as match (match.id)} + + + {#each historyColumns as key}{/each} + + {/each} +
Map / serverMatch startedMatch status{leaderboardMetrics.find(metric => metric.key === key)!.label}
{match.mapName}{match.server}{date(match.startedAt)}{match.status}{match.completed ? 'Completion credited' : 'No completion credit'}{number.format(match[key])}
+
+ +
+
How these stats work +

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.

+

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.

+
+
+ + diff --git a/tests/unit/leaderboard-query.test.ts b/tests/unit/leaderboard-query.test.ts index 65c82e2..ff54691 100644 --- a/tests/unit/leaderboard-query.test.ts +++ b/tests/unit/leaderboard-query.test.ts @@ -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', () => { it('returns the requested ranking and pagination', async () => { await seedRecords();