Link personal bests to their recorded matches
This commit is contained in:
@@ -14,11 +14,27 @@ export const leaderboardMetrics = [
|
||||
export type LeaderboardMetric = typeof leaderboardMetrics[number]['key'];
|
||||
|
||||
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 {
|
||||
game: string;
|
||||
key: string;
|
||||
name: string;
|
||||
lastSeen: string;
|
||||
records: PersonalBestRecords;
|
||||
}
|
||||
export interface PlayerMatch {
|
||||
id: number;
|
||||
@@ -44,6 +60,7 @@ export interface PlayerProfile {
|
||||
firstSeen: string;
|
||||
lastSeen: string;
|
||||
stats: PlayerStats;
|
||||
records: PersonalBestRecords;
|
||||
maps: PlayerMapRecord[];
|
||||
matches: PlayerMatch[];
|
||||
offset: number;
|
||||
@@ -69,6 +86,7 @@ export interface LeaderboardEntry {
|
||||
bestKills: number;
|
||||
bestScore: number;
|
||||
lastSeen: string;
|
||||
records: PersonalBestRecords;
|
||||
}
|
||||
|
||||
export interface LeaderboardMap {
|
||||
|
||||
@@ -360,6 +360,10 @@ export function db(): Database.Database {
|
||||
assertSnapshotSchemeSafe(handle);
|
||||
migrateGameTelemetry(handle);
|
||||
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);
|
||||
initializeFleetSettings(handle);
|
||||
} catch (err) {
|
||||
|
||||
@@ -55,6 +55,8 @@ CREATE TABLE IF NOT EXISTS leaderboard_match_players (
|
||||
completed INTEGER NOT NULL DEFAULT 0,
|
||||
first_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)
|
||||
);
|
||||
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)`);
|
||||
const savePlayer = handle.prepare(`INSERT INTO leaderboard_match_players
|
||||
(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)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?)
|
||||
last_round, present, has_stats, completed, first_seen_at, last_seen_at, first_observed_round, present_at_start)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(match_id, guid) DO UPDATE SET
|
||||
highest_round = MAX(highest_round, excluded.highest_round),
|
||||
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;
|
||||
savePlayer.run(match.id, player.guid, round, survived,
|
||||
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, ?),
|
||||
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 { MIN_RECORDED_ROUND } from '../../match-history.js';
|
||||
import { getPersonalBestRecords } from './leaderboard-records.js';
|
||||
import { leaderboardMetrics, type LeaderboardEntry, type LeaderboardMap, type LeaderboardMetric,
|
||||
type LeaderboardOptions, type LeaderboardResponse } from '../../leaderboard.js';
|
||||
|
||||
@@ -41,9 +42,13 @@ export function getLeaderboard(options: LeaderboardOptions = {}): LeaderboardRes
|
||||
const query = options.q?.trim() ?? '';
|
||||
const search = query ? 'WHERE instr(lower(name), lower(?)) > 0 OR instr(lower(guid), lower(?)) > 0' : '';
|
||||
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 ?`)
|
||||
.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}`)
|
||||
.get(...searchedParameters) as { total: number };
|
||||
const summary = handle.prepare(`SELECT
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { db } from '../db/index.js';
|
||||
import { MIN_RECORDED_ROUND } from '../../match-history.js';
|
||||
import { getPersonalBestRecords } from './leaderboard-records.js';
|
||||
import type { PlayerProfile, PlayerStats, PlayerMapRecord, PlayerMatch } from '../../leaderboard.js';
|
||||
|
||||
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 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.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}
|
||||
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,
|
||||
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(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 EmptyState from '$ui/components/primitives/EmptyState.svelte';
|
||||
import CopyValue from '$ui/components/CopyValue.svelte';
|
||||
import RecordSource from '$ui/components/RecordSource.svelte';
|
||||
|
||||
const number = new Intl.NumberFormat('en-US');
|
||||
const pageSize = 50;
|
||||
@@ -212,7 +213,7 @@
|
||||
<a class="player-name" href={playerProfileHref(entry.guid)}>{entry.name}</a>
|
||||
<div class="identity"><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}
|
||||
{#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>
|
||||
{/each}
|
||||
</tbody>
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import Button from '$ui/components/primitives/Button.svelte';
|
||||
import CopyValue from '$ui/components/CopyValue.svelte';
|
||||
import Badge from '$ui/components/primitives/Badge.svelte';
|
||||
import RecordSource from '$ui/components/RecordSource.svelte';
|
||||
|
||||
let { data }: { data: { profile: PlayerProfile } } = $props();
|
||||
const profile = $derived(data.profile);
|
||||
@@ -40,7 +41,7 @@
|
||||
<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>
|
||||
<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}
|
||||
</div>
|
||||
</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>
|
||||
<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 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>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user