Rank each player once across T5 and T6

This commit is contained in:
2026-09-28 15:22:11 -04:00
parent 61d4eebfea
commit 30030599d6
6 changed files with 144 additions and 39 deletions
-1
View File
@@ -56,7 +56,6 @@ export function playerProfileHref(guid: string): string {
export interface LeaderboardEntry {
rank: number;
game: string;
guid: string;
name: string;
highestRound: number;
+13 -9
View File
@@ -10,13 +10,13 @@ export function isLeaderboardMetric(value: string): value is LeaderboardMetric {
return metrics.has(value);
}
/** Aggregate persisted per-match records; filtering a player keeps their actual rank. */
/** Rank each GUID once within the selected matches; player search keeps their actual rank. */
export function getLeaderboard(options: LeaderboardOptions = {}): LeaderboardResponse {
const handle = db();
const metric = options.metric && isLeaderboardMetric(options.metric) ? options.metric : 'highestRound';
const limit = Number.isSafeInteger(options.limit) ? Math.min(100, Math.max(1, options.limit!)) : 50;
const offset = Number.isSafeInteger(options.offset) ? Math.min(MAX_LEADERBOARD_OFFSET, Math.max(0, options.offset!)) : 0;
const conditions = ['mp.has_stats = 1', `m.highest_round >= ${MIN_RECORDED_ROUND}`,
const conditions = ["m.game IN ('t5', 't6')", 'mp.has_stats = 1', `m.highest_round >= ${MIN_RECORDED_ROUND}`,
`mp.highest_round >= ${MIN_RECORDED_ROUND}`];
const parameters: string[] = [];
if (options.game) { conditions.push('m.game = ?'); parameters.push(options.game); }
@@ -25,34 +25,38 @@ export function getLeaderboard(options: LeaderboardOptions = {}): LeaderboardRes
JOIN leaderboard_matches m ON m.id = mp.match_id
WHERE ${conditions.join(' AND ')}`;
const aggregated = `WITH totals AS (
SELECT m.game, mp.guid, MAX(mp.highest_round) AS highestRound,
SELECT mp.guid, 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, MAX(mp.last_seen_at) AS lastSeen
${scoped} GROUP BY m.game, mp.guid
${scoped} GROUP BY mp.guid
), identities AS (
SELECT guid, name, ROW_NUMBER() OVER (PARTITION BY guid ORDER BY last_seen_at DESC, game) AS recency
FROM leaderboard_players WHERE game IN ('t5', 't6')
), ranked AS (
SELECT totals.*, p.name, RANK() OVER (ORDER BY totals.${metric} DESC) AS rank
FROM totals JOIN leaderboard_players p ON p.game = totals.game AND p.guid = totals.guid
FROM totals JOIN identities p ON p.guid = totals.guid AND p.recency = 1
)`;
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}
ORDER BY ${metric} DESC, name COLLATE NOCASE, name, game, guid LIMIT ? OFFSET ?`)
ORDER BY ${metric} DESC, name COLLATE NOCASE, name, guid LIMIT ? OFFSET ?`)
.all(...searchedParameters, limit, offset) as LeaderboardEntry[];
const { total } = handle.prepare(`${aggregated} SELECT COUNT(*) AS total FROM ranked ${search}`)
.get(...searchedParameters) as { total: number };
const summary = handle.prepare(`SELECT
(SELECT COUNT(*) FROM (SELECT m.game, mp.guid ${scoped} GROUP BY m.game, mp.guid)) AS players,
COUNT(DISTINCT mp.guid) AS players,
COUNT(DISTINCT m.id) AS matches, COALESCE(SUM(mp.kills), 0) AS kills,
COALESCE(SUM(mp.rounds_survived), 0) AS roundsSurvived,
COALESCE(MAX(mp.highest_round), 0) AS highestRound,
MIN(m.started_at) AS firstTrackedAt, MAX(m.last_received_at) AS updatedAt
${scoped}`).get(...parameters, ...parameters) as LeaderboardResponse['summary'];
${scoped}`).get(...parameters) as LeaderboardResponse['summary'];
// Keep qualifying map variants available, even after their server is removed.
const maps = handle.prepare(`SELECT map_key AS key, game, map, map_name AS name
FROM leaderboard_matches WHERE highest_round >= ${MIN_RECORDED_ROUND}${options.game ? ' AND game = ?' : ''}
FROM leaderboard_matches WHERE game IN ('t5', 't6')
AND highest_round >= ${MIN_RECORDED_ROUND}${options.game ? ' AND game = ?' : ''}
GROUP BY game, map_key HAVING id = MAX(id)
ORDER BY game, name COLLATE NOCASE, map_key`)
.all(...(options.game ? [options.game] : [])) as LeaderboardMap[];
+7 -7
View File
@@ -133,7 +133,7 @@
</svelte:head>
<section class="page">
<SectionHeader title="Leaderboard" eyebrow="Every round counts" subtitle="Personal bests and lifetime stats, across the fleet.">
<SectionHeader title="Leaderboard" eyebrow="Every round counts" subtitle="Lifetime Zombies stats across Black Ops I and Black Ops II.">
{#snippet actions()}
<Button size="sm" loading={loading} onclick={() => void refresh(apiUrl)}>Refresh</Button>
{/snippet}
@@ -180,7 +180,7 @@
<div><span>Total kills</span><strong>{number.format(data.summary.kills)}</strong><small>Across all players</small></div>
<div><span>Total rounds</span><strong>{number.format(data.summary.roundsSurvived)}</strong><small>Total player rounds</small></div>
<div><span>Games tracked</span><strong>{number.format(data.summary.matches)}</strong><small>Includes active games</small></div>
<div><span>Players</span><strong>{number.format(data.summary.players)}</strong><small>Across tracked games</small></div>
<div><span>Players</span><strong>{number.format(data.summary.players)}</strong><small>Each player counted once</small></div>
</div>
</details>
@@ -205,12 +205,12 @@
{/each}
</tr></thead>
<tbody>
{#each data.entries as entry (`${entry.game}:${entry.guid}`)}
{#each data.entries as entry (entry.guid)}
<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.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>
<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}
</tr>
@@ -237,7 +237,8 @@
<details class="tracking-notes">
<summary>How these records work</summary>
<div>
<p>Records include active games and completed games. Filters apply to totals and rankings; player search only filters the rankings. Players are identified by their GUID within each game, so name changes keep their records together.</p>
<p>Each GUID has one leaderboard entry across Black Ops I and Black Ops II, so name changes keep a player's records together. The latest reported name across both games appears here and on the player's profile, regardless of filters.</p>
<p>Records include active and completed games. Game and map filters choose which matches contribute to totals and rankings. Player search filters the ranked entries without changing their ranks. Player totals count each GUID once.</p>
<p>Highest round includes mid-game joins. Total rounds counts round progression during observed participation, excluding earlier rounds. Joining at round 8 records a highest round of 8; reaching round 9 adds one to total rounds. The fleet total adds all players’ rounds.</p>
<p>Kills, headshots, revives, and downs accumulate across recorded games. Highest reported score is the largest observed points balance, not the total points a player has earned. Equal values share a rank.</p>
<p>Records cover telemetry saved since tracking began; older unrecorded games cannot be included.{#if data?.summary.firstTrackedAt}{' '}Tracking began <time datetime={data.summary.firstTrackedAt}>{$timeFormat.dateTime(data.summary.firstTrackedAt)}</time> for this selection.{/if}{#if data?.summary.updatedAt}{' '}Latest report: <time datetime={data.summary.updatedAt}>{$timeFormat.dateTime(data.summary.updatedAt)}</time>.{/if}</p>
@@ -287,7 +288,6 @@
tbody .player { background: var(--surface); font-weight: var(--weight-medium); }
.player-name { display: block; color: var(--text-primary); overflow-wrap: anywhere; }
.identity { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2); margin-top: var(--space-1); color: var(--text-tertiary); font-size: var(--text-xs); }
.game-label { padding-right: var(--space-2); border-right: 1px solid var(--border-strong); }
.stat { text-align: right; white-space: nowrap; }
td.stat { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
.stat.selected { color: var(--accent-primary-hover); }
@@ -310,7 +310,7 @@
.mobile-view { display: block; padding: 0 16px 12px; }
.compact .stat:not(.selected) { display: none; }
.compact .player { min-width: 110px; max-width: 160px; position: static; }
.compact .identity :global(.copy) { display: none; }
.compact .identity { display: none; }
.compact .stat { white-space: normal; }
.compact .rank { width: 44px; }
.compact th, .compact td { padding: 10px 8px; }