Add player profiles with lifetime stats and map records
This commit is contained in:
@@ -13,6 +13,45 @@ export const leaderboardMetrics = [
|
||||
|
||||
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 {
|
||||
rank: number;
|
||||
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 { 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 @@
|
||||
<tr>
|
||||
<td class="rank"><span class:podium={entry.rank <= 3}>{number.format(entry.rank)}</span></td>
|
||||
<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>
|
||||
</th>
|
||||
{#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>
|
||||
Reference in New Issue
Block a user