Link personal bests to their recorded matches
This commit is contained in:
@@ -25,6 +25,14 @@ 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
|
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.
|
display a dash. Profiles always cover all recorded maps across both games.
|
||||||
|
|
||||||
|
Highest-round, best-game-kill and highest-score records link to the completed
|
||||||
|
match that earned them on the leaderboard and in lifetime/per-map profile stats.
|
||||||
|
Each source shows its map, game, completion time, server, and whether the match
|
||||||
|
start was recorded. **Run details** shows when the player was first observed:
|
||||||
|
at the recorded start, at a later round, or unknown for older stored records.
|
||||||
|
Presence is not proof of continuous survival. Equal personal bests link to the
|
||||||
|
earliest recorded completion, then the lowest match ID when times tie.
|
||||||
|
|
||||||
Completed matches link to their own results page at `/matches/[id]` from global
|
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
|
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,
|
map and server, final round, start/end times, duration when the start was observed,
|
||||||
|
|||||||
@@ -14,11 +14,27 @@ export const leaderboardMetrics = [
|
|||||||
export type LeaderboardMetric = typeof leaderboardMetrics[number]['key'];
|
export type LeaderboardMetric = typeof leaderboardMetrics[number]['key'];
|
||||||
|
|
||||||
export type PlayerStats = Record<LeaderboardMetric, number>;
|
export type PlayerStats = Record<LeaderboardMetric, number>;
|
||||||
|
export type PersonalBestMetric = 'highestRound' | 'bestKills' | 'bestScore';
|
||||||
|
export interface RecordEvidence {
|
||||||
|
matchId: number;
|
||||||
|
game: string;
|
||||||
|
mapKey: string;
|
||||||
|
mapName: string;
|
||||||
|
server: string;
|
||||||
|
startedAt: string;
|
||||||
|
endedAt: string | null;
|
||||||
|
startObserved: boolean;
|
||||||
|
presentAtStart: boolean | null;
|
||||||
|
firstObservedRound: number | null;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
export type PersonalBestRecords = Record<PersonalBestMetric, RecordEvidence>;
|
||||||
export interface PlayerMapRecord extends PlayerStats {
|
export interface PlayerMapRecord extends PlayerStats {
|
||||||
game: string;
|
game: string;
|
||||||
key: string;
|
key: string;
|
||||||
name: string;
|
name: string;
|
||||||
lastSeen: string;
|
lastSeen: string;
|
||||||
|
records: PersonalBestRecords;
|
||||||
}
|
}
|
||||||
export interface PlayerMatch {
|
export interface PlayerMatch {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -44,6 +60,7 @@ export interface PlayerProfile {
|
|||||||
firstSeen: string;
|
firstSeen: string;
|
||||||
lastSeen: string;
|
lastSeen: string;
|
||||||
stats: PlayerStats;
|
stats: PlayerStats;
|
||||||
|
records: PersonalBestRecords;
|
||||||
maps: PlayerMapRecord[];
|
maps: PlayerMapRecord[];
|
||||||
matches: PlayerMatch[];
|
matches: PlayerMatch[];
|
||||||
offset: number;
|
offset: number;
|
||||||
@@ -69,6 +86,7 @@ export interface LeaderboardEntry {
|
|||||||
bestKills: number;
|
bestKills: number;
|
||||||
bestScore: number;
|
bestScore: number;
|
||||||
lastSeen: string;
|
lastSeen: string;
|
||||||
|
records: PersonalBestRecords;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LeaderboardMap {
|
export interface LeaderboardMap {
|
||||||
|
|||||||
@@ -360,6 +360,10 @@ export function db(): Database.Database {
|
|||||||
assertSnapshotSchemeSafe(handle);
|
assertSnapshotSchemeSafe(handle);
|
||||||
migrateGameTelemetry(handle);
|
migrateGameTelemetry(handle);
|
||||||
migrateTable(handle, 'leaderboard_matches', [{ name: 'start_players', ddl: 'ALTER TABLE leaderboard_matches ADD COLUMN start_players INTEGER' }]);
|
migrateTable(handle, 'leaderboard_matches', [{ name: 'start_players', ddl: 'ALTER TABLE leaderboard_matches ADD COLUMN start_players INTEGER' }]);
|
||||||
|
migrateTable(handle, 'leaderboard_match_players', [
|
||||||
|
{ name: 'first_observed_round', ddl: 'ALTER TABLE leaderboard_match_players ADD COLUMN first_observed_round INTEGER' },
|
||||||
|
{ name: 'present_at_start', ddl: 'ALTER TABLE leaderboard_match_players ADD COLUMN present_at_start INTEGER' }
|
||||||
|
]);
|
||||||
backfillLeaderboard(handle);
|
backfillLeaderboard(handle);
|
||||||
initializeFleetSettings(handle);
|
initializeFleetSettings(handle);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ CREATE TABLE IF NOT EXISTS leaderboard_match_players (
|
|||||||
completed INTEGER NOT NULL DEFAULT 0,
|
completed INTEGER NOT NULL DEFAULT 0,
|
||||||
first_seen_at TEXT NOT NULL,
|
first_seen_at TEXT NOT NULL,
|
||||||
last_seen_at TEXT NOT NULL,
|
last_seen_at TEXT NOT NULL,
|
||||||
|
first_observed_round INTEGER, -- null for legacy participation
|
||||||
|
present_at_start INTEGER, -- null for legacy participation; one only when first seen in game_start
|
||||||
PRIMARY KEY (match_id, guid)
|
PRIMARY KEY (match_id, guid)
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_leaderboard_player_matches ON leaderboard_match_players(guid, match_id);
|
CREATE INDEX IF NOT EXISTS idx_leaderboard_player_matches ON leaderboard_match_players(guid, match_id);
|
||||||
|
|||||||
@@ -91,8 +91,8 @@ export function recordLeaderboardTelemetry(handle: Database.Database, report: Ga
|
|||||||
last_seen_at = MAX(last_seen_at, excluded.last_seen_at)`);
|
last_seen_at = MAX(last_seen_at, excluded.last_seen_at)`);
|
||||||
const savePlayer = handle.prepare(`INSERT INTO leaderboard_match_players
|
const savePlayer = handle.prepare(`INSERT INTO leaderboard_match_players
|
||||||
(match_id, guid, highest_round, rounds_survived, kills, headshots, downs, revives, best_score,
|
(match_id, guid, highest_round, rounds_survived, kills, headshots, downs, revives, best_score,
|
||||||
last_round, present, has_stats, completed, first_seen_at, last_seen_at)
|
last_round, present, has_stats, completed, first_seen_at, last_seen_at, first_observed_round, present_at_start)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?)
|
||||||
ON CONFLICT(match_id, guid) DO UPDATE SET
|
ON CONFLICT(match_id, guid) DO UPDATE SET
|
||||||
highest_round = MAX(highest_round, excluded.highest_round),
|
highest_round = MAX(highest_round, excluded.highest_round),
|
||||||
rounds_survived = rounds_survived + excluded.rounds_survived,
|
rounds_survived = rounds_survived + excluded.rounds_survived,
|
||||||
@@ -113,7 +113,7 @@ export function recordLeaderboardTelemetry(handle: Database.Database, report: Ga
|
|||||||
const stats = 'kills' in player ? player : null;
|
const stats = 'kills' in player ? player : null;
|
||||||
savePlayer.run(match.id, player.guid, round, survived,
|
savePlayer.run(match.id, player.guid, round, survived,
|
||||||
stats?.kills ?? 0, stats?.headshots ?? 0, stats?.downs ?? 0, stats?.revives ?? 0, stats?.score ?? 0,
|
stats?.kills ?? 0, stats?.headshots ?? 0, stats?.downs ?? 0, stats?.revives ?? 0, stats?.score ?? 0,
|
||||||
completedRounds, Number(!isStart), Number(report.event === 'game_end'), report.receivedAt, report.receivedAt);
|
completedRounds, Number(!isStart), Number(report.event === 'game_end'), report.receivedAt, report.receivedAt, round, Number(isStart));
|
||||||
}
|
}
|
||||||
handle.prepare(`UPDATE leaderboard_matches SET highest_round = MAX(highest_round, ?),
|
handle.prepare(`UPDATE leaderboard_matches SET highest_round = MAX(highest_round, ?),
|
||||||
last_received_at = ?, last_event = ?, last_fingerprint = ?,
|
last_received_at = ?, last_event = ?, last_fingerprint = ?,
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import type Database from 'better-sqlite3';
|
||||||
|
import type { PersonalBestMetric, PersonalBestRecords, RecordEvidence } from '../../leaderboard.js';
|
||||||
|
import { MIN_RECORDED_ROUND } from '../../match-history.js';
|
||||||
|
|
||||||
|
interface RecordRequest extends Record<PersonalBestMetric, number> {
|
||||||
|
guid: string;
|
||||||
|
game?: string;
|
||||||
|
map?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetch evidence only for requested players and scopes, never rank the whole fleet's history. */
|
||||||
|
export function getPersonalBestRecords(handle: Database.Database, requests: RecordRequest[]): PersonalBestRecords[] {
|
||||||
|
const result: PersonalBestRecords[] = [];
|
||||||
|
// Page requests are at most 100 players; chunk unusually large profile map collections too.
|
||||||
|
for (let offset = 0; offset < requests.length; offset += 100) {
|
||||||
|
const batch = requests.slice(offset, offset + 100);
|
||||||
|
const parameters = batch.flatMap((request, index) => [index, request.guid, request.game ?? null,
|
||||||
|
request.map ?? null, request.highestRound, request.bestKills, request.bestScore]);
|
||||||
|
const rows = handle.prepare(`WITH requested (requestId, guid, game, map, highestRound, bestKills, bestScore) AS (
|
||||||
|
VALUES ${batch.map(() => '(?, ?, ?, ?, ?, ?, ?)').join(', ')}
|
||||||
|
), candidates AS (
|
||||||
|
SELECT r.requestId, m.id AS matchId, m.game, m.map_key AS mapKey, m.map_name AS mapName,
|
||||||
|
m.server, m.started_at AS startedAt, m.ended_at AS endedAt, m.start_observed AS startObserved,
|
||||||
|
mp.first_observed_round AS firstObservedRound, mp.present_at_start AS presentAtStart,
|
||||||
|
COALESCE(m.ended_at, m.last_received_at) AS completionOrder,
|
||||||
|
mp.highest_round AS highestRound, mp.kills AS bestKills, mp.best_score AS bestScore,
|
||||||
|
r.highestRound AS targetRound, r.bestKills AS targetKills, r.bestScore AS targetScore
|
||||||
|
FROM requested r JOIN leaderboard_match_players mp ON mp.guid = r.guid
|
||||||
|
JOIN leaderboard_matches m ON m.id = mp.match_id
|
||||||
|
WHERE m.game IN ('t5', 't6') AND m.status = 'completed' AND mp.has_stats = 1
|
||||||
|
AND m.highest_round >= ${MIN_RECORDED_ROUND} AND mp.highest_round >= ${MIN_RECORDED_ROUND}
|
||||||
|
AND (r.game IS NULL OR m.game = r.game) AND (r.map IS NULL OR m.map_key = r.map)
|
||||||
|
AND (mp.highest_round = r.highestRound OR mp.kills = r.bestKills OR mp.best_score = r.bestScore)
|
||||||
|
), valuesByMetric AS (
|
||||||
|
SELECT *, 'highestRound' AS metric, highestRound AS value FROM candidates WHERE highestRound = targetRound
|
||||||
|
UNION ALL SELECT *, 'bestKills', bestKills FROM candidates WHERE bestKills = targetKills
|
||||||
|
UNION ALL SELECT *, 'bestScore', bestScore FROM candidates WHERE bestScore = targetScore
|
||||||
|
), ranked AS (
|
||||||
|
SELECT *, ROW_NUMBER() OVER (PARTITION BY requestId, metric ORDER BY completionOrder, matchId) AS choice
|
||||||
|
FROM valuesByMetric
|
||||||
|
) SELECT requestId, metric, value, matchId, game, mapKey, mapName, server, startedAt, endedAt,
|
||||||
|
startObserved, firstObservedRound, presentAtStart FROM ranked WHERE choice = 1`)
|
||||||
|
.all(...parameters) as Array<Omit<RecordEvidence, 'startObserved' | 'presentAtStart'> & {
|
||||||
|
requestId: number; metric: PersonalBestMetric; startObserved: number; presentAtStart: number | null;
|
||||||
|
}>;
|
||||||
|
const records = batch.map(() => ({} as PersonalBestRecords));
|
||||||
|
for (const { requestId, metric, startObserved, presentAtStart, ...record } of rows) {
|
||||||
|
records[requestId][metric] = { ...record, startObserved: Boolean(startObserved),
|
||||||
|
presentAtStart: presentAtStart === null ? null : Boolean(presentAtStart) };
|
||||||
|
}
|
||||||
|
result.push(...records);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { db } from '../db/index.js';
|
import { db } from '../db/index.js';
|
||||||
import { MIN_RECORDED_ROUND } from '../../match-history.js';
|
import { MIN_RECORDED_ROUND } from '../../match-history.js';
|
||||||
|
import { getPersonalBestRecords } from './leaderboard-records.js';
|
||||||
import { leaderboardMetrics, type LeaderboardEntry, type LeaderboardMap, type LeaderboardMetric,
|
import { leaderboardMetrics, type LeaderboardEntry, type LeaderboardMap, type LeaderboardMetric,
|
||||||
type LeaderboardOptions, type LeaderboardResponse } from '../../leaderboard.js';
|
type LeaderboardOptions, type LeaderboardResponse } from '../../leaderboard.js';
|
||||||
|
|
||||||
@@ -41,9 +42,13 @@ export function getLeaderboard(options: LeaderboardOptions = {}): LeaderboardRes
|
|||||||
const query = options.q?.trim() ?? '';
|
const query = options.q?.trim() ?? '';
|
||||||
const search = query ? 'WHERE instr(lower(name), lower(?)) > 0 OR instr(lower(guid), lower(?)) > 0' : '';
|
const search = query ? 'WHERE instr(lower(name), lower(?)) > 0 OR instr(lower(guid), lower(?)) > 0' : '';
|
||||||
const searchedParameters = query ? [...parameters, query, query] : parameters;
|
const searchedParameters = query ? [...parameters, query, query] : parameters;
|
||||||
const entries = handle.prepare(`${aggregated} SELECT * FROM ranked ${search}
|
const rankedEntries = handle.prepare(`${aggregated} SELECT * FROM ranked ${search}
|
||||||
ORDER BY ${metric} DESC, name COLLATE NOCASE, name, guid LIMIT ? OFFSET ?`)
|
ORDER BY ${metric} DESC, name COLLATE NOCASE, name, guid LIMIT ? OFFSET ?`)
|
||||||
.all(...searchedParameters, limit, offset) as LeaderboardEntry[];
|
.all(...searchedParameters, limit, offset) as Array<Omit<LeaderboardEntry, 'records'>>;
|
||||||
|
const records = getPersonalBestRecords(handle, rankedEntries.map(entry => ({
|
||||||
|
...entry, game: options.game || undefined, map: options.map || undefined
|
||||||
|
})));
|
||||||
|
const entries = rankedEntries.map((entry, index) => ({ ...entry, records: records[index] }));
|
||||||
const { total } = handle.prepare(`${aggregated} SELECT COUNT(*) AS total FROM ranked ${search}`)
|
const { total } = handle.prepare(`${aggregated} SELECT COUNT(*) AS total FROM ranked ${search}`)
|
||||||
.get(...searchedParameters) as { total: number };
|
.get(...searchedParameters) as { total: number };
|
||||||
const summary = handle.prepare(`SELECT
|
const summary = handle.prepare(`SELECT
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { db } from '../db/index.js';
|
import { db } from '../db/index.js';
|
||||||
import { MIN_RECORDED_ROUND } from '../../match-history.js';
|
import { MIN_RECORDED_ROUND } from '../../match-history.js';
|
||||||
|
import { getPersonalBestRecords } from './leaderboard-records.js';
|
||||||
import type { PlayerProfile, PlayerStats, PlayerMapRecord, PlayerMatch } from '../../leaderboard.js';
|
import type { PlayerProfile, PlayerStats, PlayerMapRecord, PlayerMatch } from '../../leaderboard.js';
|
||||||
|
|
||||||
const totals = `MAX(mp.highest_round) AS highestRound, SUM(mp.kills) AS kills,
|
const totals = `MAX(mp.highest_round) AS highestRound, SUM(mp.kills) AS kills,
|
||||||
@@ -23,15 +24,18 @@ export function getPlayerProfile(guid: string, requestedOffset = 0): PlayerProfi
|
|||||||
const limit = 25;
|
const limit = 25;
|
||||||
const lastOffset = Math.floor((stats.gamesPlayed - 1) / limit) * limit;
|
const lastOffset = Math.floor((stats.gamesPlayed - 1) / limit) * limit;
|
||||||
const offset = Number.isSafeInteger(requestedOffset) ? Math.min(lastOffset, Math.max(0, requestedOffset)) : 0;
|
const offset = Number.isSafeInteger(requestedOffset) ? Math.min(lastOffset, Math.max(0, requestedOffset)) : 0;
|
||||||
const maps = handle.prepare(`SELECT m.game, m.map_key AS key, m.map_name AS name,
|
const mapStats = handle.prepare(`SELECT m.game, m.map_key AS key, m.map_name AS name,
|
||||||
MAX(mp.last_seen_at) AS lastSeen, ${totals} ${scope}
|
MAX(mp.last_seen_at) AS lastSeen, ${totals} ${scope}
|
||||||
GROUP BY m.game, m.map_key ORDER BY highestRound DESC, kills DESC, name COLLATE NOCASE, m.game, key`)
|
GROUP BY m.game, m.map_key ORDER BY highestRound DESC, kills DESC, name COLLATE NOCASE, m.game, key`)
|
||||||
.all(guid) as PlayerMapRecord[];
|
.all(guid) as Array<Omit<PlayerMapRecord, 'records'>>;
|
||||||
|
const [records, ...mapRecords] = getPersonalBestRecords(handle, [{ guid, ...stats },
|
||||||
|
...mapStats.map(map => ({ guid, ...map, map: map.key }))]);
|
||||||
|
const maps = mapStats.map((map, index) => ({ ...map, records: mapRecords[index] }));
|
||||||
const matches = handle.prepare(`SELECT m.id, m.game, m.server, m.server_deleted_at, m.map_name AS mapName,
|
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,
|
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.highest_round AS highestRound, mp.rounds_survived AS roundsSurvived,
|
||||||
mp.kills, mp.headshots, mp.revives, mp.downs, mp.best_score AS bestScore
|
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 ?`)
|
${scope} ORDER BY mp.last_seen_at DESC, m.id DESC LIMIT ? OFFSET ?`)
|
||||||
.all(guid, limit, offset) as PlayerMatch[];
|
.all(guid, limit, offset) as PlayerMatch[];
|
||||||
return { ...player, stats, maps, matches, offset, limit };
|
return { ...player, stats, records, maps, matches, offset, limit };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { RecordEvidence } from '$lib/leaderboard.js';
|
||||||
|
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||||
|
|
||||||
|
let { evidence }: { evidence: RecordEvidence } = $props();
|
||||||
|
const gameName = $derived(evidence.game === 't5' ? 'Black Ops I' : 'Black Ops II');
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="record-source">
|
||||||
|
<a href={`/matches/${evidence.matchId}`}>{evidence.mapName} · {gameName}</a>
|
||||||
|
<span>{#if evidence.endedAt}Completed <time datetime={evidence.endedAt}>{$timeFormat.dateTime(evidence.endedAt)}</time>{:else}Completed · End time unknown{/if}</span>
|
||||||
|
<details>
|
||||||
|
<summary>Run details</summary>
|
||||||
|
<dl>
|
||||||
|
<div><dt>Server</dt><dd>{evidence.server}</dd></div>
|
||||||
|
<div><dt>{evidence.startObserved ? 'Match started' : 'Match first observed'}</dt><dd><time datetime={evidence.startedAt}>{$timeFormat.dateTime(evidence.startedAt)}</time></dd></div>
|
||||||
|
<div><dt>Player participation</dt><dd>{evidence.presentAtStart ? 'Present at recorded start' : evidence.firstObservedRound === null ? 'Participation start unknown' : `First observed at round ${evidence.firstObservedRound}`}</dd></div>
|
||||||
|
</dl>
|
||||||
|
<p>Recorded presence does not establish continuous survival.</p>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.record-source { margin-top: var(--space-2); max-width: 24rem; white-space: normal; overflow-wrap: anywhere; font-family: var(--font-body); font-size: var(--text-xs); font-weight: normal; line-height: 1.5; color: var(--text-secondary); }
|
||||||
|
a { color: var(--accent-primary-hover); }
|
||||||
|
span { display: block; margin-top: 3px; color: var(--text-tertiary); }
|
||||||
|
details { margin-top: var(--space-1); }
|
||||||
|
summary { cursor: pointer; }
|
||||||
|
dl { margin: var(--space-2) 0; }
|
||||||
|
dl > div + div { margin-top: var(--space-2); }
|
||||||
|
dt { color: var(--text-tertiary); }
|
||||||
|
dd { margin: 0; }
|
||||||
|
p { margin: var(--space-2) 0 0; color: var(--text-tertiary); }
|
||||||
|
a:focus-visible, summary:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 3px; border-radius: var(--radius-sm); }
|
||||||
|
</style>
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
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';
|
||||||
import CopyValue from '$ui/components/CopyValue.svelte';
|
import CopyValue from '$ui/components/CopyValue.svelte';
|
||||||
|
import RecordSource from '$ui/components/RecordSource.svelte';
|
||||||
|
|
||||||
const number = new Intl.NumberFormat('en-US');
|
const number = new Intl.NumberFormat('en-US');
|
||||||
const pageSize = 50;
|
const pageSize = 50;
|
||||||
@@ -212,7 +213,7 @@
|
|||||||
<a class="player-name" href={playerProfileHref(entry.guid)}>{entry.name}</a>
|
<a class="player-name" href={playerProfileHref(entry.guid)}>{entry.name}</a>
|
||||||
<div class="identity"><CopyValue value={entry.guid} label={`GUID for ${entry.name}`} /></div>
|
<div class="identity"><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])}{#if key === 'highestRound' || key === 'bestKills' || key === 'bestScore'}<RecordSource evidence={entry.records[key]} />{/if}</td>{/each}
|
||||||
</tr>
|
</tr>
|
||||||
{/each}
|
{/each}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
import Button from '$ui/components/primitives/Button.svelte';
|
import Button from '$ui/components/primitives/Button.svelte';
|
||||||
import CopyValue from '$ui/components/CopyValue.svelte';
|
import CopyValue from '$ui/components/CopyValue.svelte';
|
||||||
import Badge from '$ui/components/primitives/Badge.svelte';
|
import Badge from '$ui/components/primitives/Badge.svelte';
|
||||||
|
import RecordSource from '$ui/components/RecordSource.svelte';
|
||||||
|
|
||||||
let { data }: { data: { profile: PlayerProfile } } = $props();
|
let { data }: { data: { profile: PlayerProfile } } = $props();
|
||||||
const profile = $derived(data.profile);
|
const profile = $derived(data.profile);
|
||||||
@@ -40,7 +41,7 @@
|
|||||||
<h2 id="totals-title">Lifetime stats</h2>
|
<h2 id="totals-title">Lifetime stats</h2>
|
||||||
<div class="stats">
|
<div class="stats">
|
||||||
{#each leaderboardMetrics as metric (metric.key)}
|
{#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>
|
<div class="stat" class:record={metric.key === 'highestRound'}><span>{metric.label}</span><strong>{number.format(stats[metric.key])}</strong>{#if metric.key === 'highestRound' || metric.key === 'bestKills' || metric.key === 'bestScore'}<RecordSource evidence={profile.records[metric.key]} />{/if}</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -65,7 +66,7 @@
|
|||||||
<thead><tr><th scope="col">Map</th>{#each leaderboardMetrics as metric}<th scope="col">{metric.label}</th>{/each}</tr></thead>
|
<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.game}:${map.key}`)}
|
<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>
|
<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 leaderboardMetrics as metric}<td class:best={metric.key === 'highestRound'}>{number.format(map[metric.key])}{#if metric.key === 'highestRound' || metric.key === 'bestKills' || metric.key === 'bestScore'}<RecordSource evidence={map.records[metric.key]} />{/if}</td>{/each}</tr>
|
||||||
{/each}</tbody>
|
{/each}</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -123,7 +123,11 @@ describe('persisted leaderboard queries', () => {
|
|||||||
rank: 1, guid: '101', name: 'Ace', highestRound: 20, kills: 770,
|
rank: 1, guid: '101', name: 'Ace', highestRound: 20, kills: 770,
|
||||||
roundsSurvived: 38, headshots: 320, revives: 8, downs: 4,
|
roundsSurvived: 38, headshots: 320, revives: 8, downs: 4,
|
||||||
gamesPlayed: 4, gamesCompleted: 3, bestKills: 500, bestScore: 5000,
|
gamesPlayed: 4, gamesCompleted: 3, bestKills: 500, bestScore: 5000,
|
||||||
lastSeen: '2026-09-05T00:00:00.000Z'
|
lastSeen: '2026-09-05T00:00:00.000Z', records: {
|
||||||
|
highestRound: expect.objectContaining({ matchId: 4, value: 20 }),
|
||||||
|
bestKills: expect.objectContaining({ matchId: 4, value: 500 }),
|
||||||
|
bestScore: expect.objectContaining({ matchId: 4, value: 5000 })
|
||||||
|
}
|
||||||
}]);
|
}]);
|
||||||
expect(board.total).toBe(4);
|
expect(board.total).toBe(4);
|
||||||
expect(board.entries.map(entry => [entry.guid, entry.rank])).toEqual([['101', 1], ['505', 2], ['202', 3], ['303', 4]]);
|
expect(board.entries.map(entry => [entry.guid, entry.rank])).toEqual([['101', 1], ['505', 2], ['202', 3], ['303', 4]]);
|
||||||
@@ -141,14 +145,22 @@ describe('persisted leaderboard queries', () => {
|
|||||||
expect(t5.entries.find(entry => entry.guid === '101')).toEqual({
|
expect(t5.entries.find(entry => entry.guid === '101')).toEqual({
|
||||||
rank: 1, guid: '101', name: 'Ace', highestRound: 10, kills: 270,
|
rank: 1, guid: '101', name: 'Ace', highestRound: 10, kills: 270,
|
||||||
roundsSurvived: 19, headshots: 120, revives: 4, downs: 3, gamesPlayed: 3,
|
roundsSurvived: 19, headshots: 120, revives: 4, downs: 3, gamesPlayed: 3,
|
||||||
gamesCompleted: 2, bestKills: 150, bestScore: 3000, lastSeen: '2026-09-04T00:00:00.000Z'
|
gamesCompleted: 2, bestKills: 150, bestScore: 3000, lastSeen: '2026-09-04T00:00:00.000Z', records: {
|
||||||
|
highestRound: expect.objectContaining({ matchId: 1, value: 10 }),
|
||||||
|
bestKills: expect.objectContaining({ matchId: 2, value: 150 }),
|
||||||
|
bestScore: expect.objectContaining({ matchId: 2, value: 3000 })
|
||||||
|
}
|
||||||
});
|
});
|
||||||
expect(t5).toMatchObject({ total: 3, summary: { players: 3, matches: 3, kills: 440, roundsSurvived: 35 } });
|
expect(t5).toMatchObject({ total: 3, summary: { players: 3, matches: 3, kills: 440, roundsSurvived: 35 } });
|
||||||
const t6 = getLeaderboard({ game: 't6' });
|
const t6 = getLeaderboard({ game: 't6' });
|
||||||
expect(t6.entries.find(entry => entry.guid === '101')).toEqual({
|
expect(t6.entries.find(entry => entry.guid === '101')).toEqual({
|
||||||
rank: 1, guid: '101', name: 'Ace', highestRound: 20, kills: 500,
|
rank: 1, guid: '101', name: 'Ace', highestRound: 20, kills: 500,
|
||||||
roundsSurvived: 19, headshots: 200, revives: 4, downs: 1, gamesPlayed: 1,
|
roundsSurvived: 19, headshots: 200, revives: 4, downs: 1, gamesPlayed: 1,
|
||||||
gamesCompleted: 1, bestKills: 500, bestScore: 5000, lastSeen: '2026-09-05T00:00:00.000Z'
|
gamesCompleted: 1, bestKills: 500, bestScore: 5000, lastSeen: '2026-09-05T00:00:00.000Z', records: {
|
||||||
|
highestRound: expect.objectContaining({ matchId: 4, value: 20 }),
|
||||||
|
bestKills: expect.objectContaining({ matchId: 4, value: 500 }),
|
||||||
|
bestScore: expect.objectContaining({ matchId: 4, value: 5000 })
|
||||||
|
}
|
||||||
});
|
});
|
||||||
expect(t6).toMatchObject({ total: 2, summary: { players: 2, matches: 2, kills: 700, roundsSurvived: 33 } });
|
expect(t6).toMatchObject({ total: 2, summary: { players: 2, matches: 2, kills: 700, roundsSurvived: 33 } });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
||||||
|
import type Database from 'better-sqlite3';
|
||||||
|
import type { GameTelemetry, TelemetryPlayerStats } from '../../src/lib/telemetry.js';
|
||||||
|
import { newFixture, type Fixture } from '../helpers/fixture.js';
|
||||||
|
|
||||||
|
let fixture: Fixture;
|
||||||
|
beforeEach(async () => { vi.resetModules(); fixture = await newFixture([]); });
|
||||||
|
afterEach(async () => fixture.cleanup());
|
||||||
|
const timestamp = (minute: number) => new Date(Date.UTC(2026, 8, 18, 20, minute)).toISOString();
|
||||||
|
const player = (guid: string, kills = 0): TelemetryPlayerStats => ({
|
||||||
|
guid, name: guid, kills, headshots: 0, downs: 0, revives: 0, score: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
function seed(handle: Database.Database, id: number, options: {
|
||||||
|
game?: string; map?: string; guid?: string; round?: number; kills?: number; score?: number;
|
||||||
|
ended?: string | null; received?: string; status?: string; hasStats?: number; observed?: number | null;
|
||||||
|
} = {}) {
|
||||||
|
const { game = 't5', map = 'theater', guid = '101', round = 10, kills = 100, score = 1000,
|
||||||
|
ended = timestamp(id), received = timestamp(id), status = 'completed', hasStats = 1, observed = null } = options;
|
||||||
|
handle.prepare(`INSERT INTO leaderboard_matches (id, server, game, map, map_key, map_name, started_at,
|
||||||
|
ended_at, last_received_at, status, highest_round, last_event, last_fingerprint)
|
||||||
|
VALUES (?, 't5_zombie_theater_1', ?, ?, ?, ?, ?, ?, ?, ?, ?, 'game_end', ?)`)
|
||||||
|
.run(id, game, map, `${game}:${map}`, `${map} name`, timestamp(0), ended, received, status, round, String(id));
|
||||||
|
handle.prepare(`INSERT OR IGNORE INTO leaderboard_players (game, guid, name, first_seen_at, last_seen_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?)`).run(game, guid, guid, timestamp(0), received);
|
||||||
|
handle.prepare(`INSERT INTO leaderboard_match_players (match_id, guid, highest_round, kills, best_score,
|
||||||
|
has_stats, completed, first_seen_at, last_seen_at, first_observed_round)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?, ?)`).run(id, guid, round, kills, score, hasStats, timestamp(0), received, observed);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('links each best to its exact player/game/map scope, including profile map records', async () => {
|
||||||
|
const { db } = await import('../../src/lib/server/db/index.js');
|
||||||
|
const handle = db();
|
||||||
|
seed(handle, 1, { round: 20, kills: 100, score: 500 });
|
||||||
|
seed(handle, 2, { round: 10, kills: 200, score: 2000 });
|
||||||
|
seed(handle, 3, { map: 'moon', round: 30, kills: 300, score: 1000 });
|
||||||
|
seed(handle, 4, { game: 't6', map: 'town', round: 40, kills: 400, score: 4000 });
|
||||||
|
seed(handle, 5, { guid: '202', round: 50, kills: 500, score: 5000 });
|
||||||
|
const { getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js');
|
||||||
|
const { getPlayerProfile } = await import('../../src/lib/server/fleet/player-profile.js');
|
||||||
|
const overall = getLeaderboard({ q: '101' }).entries[0];
|
||||||
|
expect(overall.rank).toBe(2);
|
||||||
|
expect(overall.records.highestRound).toEqual({ matchId: 4, game: 't6', mapKey: 't6:town', mapName: 'town name',
|
||||||
|
server: 't5_zombie_theater_1', startedAt: timestamp(0), endedAt: timestamp(4), startObserved: false,
|
||||||
|
presentAtStart: null, firstObservedRound: null, value: 40 });
|
||||||
|
for (const metric of ['highestRound', 'bestKills', 'bestScore'] as const) {
|
||||||
|
expect(overall.records[metric]).toMatchObject({ matchId: 4, value: overall[metric] });
|
||||||
|
}
|
||||||
|
const t5 = getLeaderboard({ game: 't5', q: '101' }).entries[0].records;
|
||||||
|
expect(t5.highestRound).toMatchObject({ matchId: 3, value: 30 });
|
||||||
|
expect(t5.bestKills).toMatchObject({ matchId: 3, value: 300 });
|
||||||
|
expect(t5.bestScore).toMatchObject({ matchId: 2, value: 2000 });
|
||||||
|
const theater = getLeaderboard({ map: 't5:theater', q: '101' }).entries[0].records;
|
||||||
|
expect(theater.highestRound).toMatchObject({ matchId: 1, value: 20 });
|
||||||
|
expect(theater.bestKills).toMatchObject({ matchId: 2, value: 200 });
|
||||||
|
expect(theater.bestScore).toMatchObject({ matchId: 2, value: 2000 });
|
||||||
|
expect(getLeaderboard({ offset: 1, limit: 1 }).entries[0].records).toEqual(overall.records);
|
||||||
|
const profile = getPlayerProfile('101')!;
|
||||||
|
expect(profile.records).toEqual(overall.records);
|
||||||
|
expect(profile.maps.find(map => map.key === 't5:theater')!.records).toEqual(theater);
|
||||||
|
expect(profile.maps.find(map => map.key === 't5:moon')!.records.bestScore).toMatchObject({ matchId: 3, value: 1000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves tied bests by earliest completion, receipt time for unknown endings, then match ID', async () => {
|
||||||
|
const { db } = await import('../../src/lib/server/db/index.js');
|
||||||
|
const handle = db();
|
||||||
|
seed(handle, 1, { ended: timestamp(5) });
|
||||||
|
seed(handle, 2, { ended: timestamp(4), received: timestamp(1) });
|
||||||
|
seed(handle, 3, { ended: null, received: timestamp(2) });
|
||||||
|
seed(handle, 4, { ended: timestamp(2), received: timestamp(0) });
|
||||||
|
const { getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js');
|
||||||
|
const { getPlayerProfile } = await import('../../src/lib/server/fleet/player-profile.js');
|
||||||
|
for (const records of [getLeaderboard().entries[0].records, getPlayerProfile('101')!.records]) {
|
||||||
|
for (const record of Object.values(records)) expect(record).toMatchObject({ matchId: 3, endedAt: null });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never uses unfinished, unsupported, nonqualifying or unrecorded stats as evidence', async () => {
|
||||||
|
const { db } = await import('../../src/lib/server/db/index.js');
|
||||||
|
const handle = db();
|
||||||
|
seed(handle, 1);
|
||||||
|
const { getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js');
|
||||||
|
const { getPlayerProfile } = await import('../../src/lib/server/fleet/player-profile.js');
|
||||||
|
const board = getLeaderboard();
|
||||||
|
const profile = getPlayerProfile('101');
|
||||||
|
seed(handle, 2, { status: 'active', round: 99, kills: 9999, score: 9999 });
|
||||||
|
seed(handle, 3, { status: 'interrupted', round: 99, kills: 9999, score: 9999 });
|
||||||
|
seed(handle, 4, { game: 'custom', round: 99, kills: 9999, score: 9999 });
|
||||||
|
seed(handle, 5, { round: 1, kills: 9999, score: 9999 });
|
||||||
|
seed(handle, 6, { hasStats: 0, round: 99, kills: 9999, score: 9999 });
|
||||||
|
expect(getLeaderboard()).toEqual(board);
|
||||||
|
expect(getPlayerProfile('101')).toEqual(profile);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records first observed rounds for start participants and late arrivals without overwriting on return', async () => {
|
||||||
|
const { db, closeDb } = await import('../../src/lib/server/db/index.js');
|
||||||
|
const { recordLeaderboardTelemetry } = await import('../../src/lib/server/fleet/leaderboard-ingest.js');
|
||||||
|
const common = { server: 't5_zombie_theater_1', map: 'zombie_theater' };
|
||||||
|
const report = (event: GameTelemetry['event'], round: number, players: TelemetryPlayerStats[], minute: number) => {
|
||||||
|
recordLeaderboardTelemetry(db(), event === 'game_start'
|
||||||
|
? { ...common, event, players, receivedAt: timestamp(minute) }
|
||||||
|
: { ...common, event, round, players, receivedAt: timestamp(minute) });
|
||||||
|
};
|
||||||
|
report('game_start', 0, [player('101')], 0);
|
||||||
|
report('heartbeat', 0, [player('101'), player('303')], 1);
|
||||||
|
report('heartbeat', 50, [player('101', 100), player('202', 20)], 2);
|
||||||
|
report('heartbeat', 51, [], 3);
|
||||||
|
report('game_end', 52, [player('101', 200), player('202', 40), player('303')], 4);
|
||||||
|
closeDb();
|
||||||
|
const { getPlayerProfile } = await import('../../src/lib/server/fleet/player-profile.js');
|
||||||
|
expect(getPlayerProfile('101')!.records.highestRound).toMatchObject({ value: 52, startObserved: true,
|
||||||
|
firstObservedRound: 0, presentAtStart: true });
|
||||||
|
expect(getPlayerProfile('202')!.records.highestRound).toMatchObject({ value: 52, startObserved: true,
|
||||||
|
firstObservedRound: 50, presentAtStart: false });
|
||||||
|
expect(getPlayerProfile('303')!.records.highestRound).toMatchObject({ value: 52, startObserved: true,
|
||||||
|
firstObservedRound: 0, presentAtStart: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('migrates legacy participation as unknown before snapshot backfill and preserves it across updates and reopens', async () => {
|
||||||
|
const { db, closeDb } = await import('../../src/lib/server/db/index.js');
|
||||||
|
const handle = db();
|
||||||
|
seed(handle, 1, { status: 'active' });
|
||||||
|
handle.exec('ALTER TABLE leaderboard_match_players DROP COLUMN first_observed_round');
|
||||||
|
handle.exec('ALTER TABLE leaderboard_match_players DROP COLUMN present_at_start');
|
||||||
|
handle.prepare(`INSERT INTO servers (name, komodo_name, game, map)
|
||||||
|
VALUES ('t5_zombie_moon_1', 'moon', 't5', 'zombie_moon')`).run();
|
||||||
|
const saved: GameTelemetry = { event: 'game_end', server: 't5_zombie_moon_1', map: 'zombie_moon', round: 8,
|
||||||
|
players: [player('202', 80)], receivedAt: timestamp(2) };
|
||||||
|
handle.prepare('INSERT INTO server_telemetry (server, round, players, received_at, payload) VALUES (?, ?, ?, ?, ?)')
|
||||||
|
.run(saved.server, 8, 1, saved.receivedAt, JSON.stringify(saved));
|
||||||
|
handle.prepare("DELETE FROM settings WHERE key = 'leaderboard_v1'").run();
|
||||||
|
closeDb();
|
||||||
|
expect(db().prepare('SELECT guid, first_observed_round, present_at_start FROM leaderboard_match_players ORDER BY guid').all()).toEqual([
|
||||||
|
{ guid: '101', first_observed_round: null, present_at_start: null },
|
||||||
|
{ guid: '202', first_observed_round: 8, present_at_start: 0 }
|
||||||
|
]);
|
||||||
|
const { recordLeaderboardTelemetry } = await import('../../src/lib/server/fleet/leaderboard-ingest.js');
|
||||||
|
recordLeaderboardTelemetry(db(), { event: 'game_end', server: 't5_zombie_theater_1', map: 'theater', round: 11,
|
||||||
|
players: [player('101', 150)], receivedAt: timestamp(3) });
|
||||||
|
closeDb();
|
||||||
|
const { getPlayerProfile } = await import('../../src/lib/server/fleet/player-profile.js');
|
||||||
|
expect(getPlayerProfile('101')!.records.bestKills).toMatchObject({ matchId: 1, value: 150,
|
||||||
|
firstObservedRound: null, presentAtStart: null });
|
||||||
|
expect(getPlayerProfile('202')!.records.highestRound).toMatchObject({ firstObservedRound: 8,
|
||||||
|
startObserved: false, presentAtStart: false });
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user