Rank each player once across T5 and T6
This commit is contained in:
+18
-9
@@ -1,8 +1,10 @@
|
|||||||
# Leaderboard
|
# Leaderboard
|
||||||
|
|
||||||
Open **Leaderboard** in the main navigation to compare players across the fleet,
|
Open **Leaderboard** in the main navigation to compare players across the fleet,
|
||||||
or filter by game and map. Choose a ranking, search a name or GUID, and share the
|
with one entry per GUID combining Black Ops I (T5) and Black Ops II (T6). Game
|
||||||
page URL with its filters intact. Results refresh while the page is visible.
|
and map filters limit which matches contribute to each player's statistics and
|
||||||
|
rank. Choose a ranking, search a name or GUID, and share the page URL with its
|
||||||
|
filters intact. Results refresh while the page is visible.
|
||||||
Matches only appear in server/global history and leaderboard map options once
|
Matches only appear in server/global history and leaderboard map options once
|
||||||
they reach round 2. Player records only contribute to leaderboards, summaries,
|
they reach round 2. Player records only contribute to leaderboards, summaries,
|
||||||
profiles and player history once that player is observed at round 2 or later.
|
profiles and player history once that player is observed at round 2 or later.
|
||||||
@@ -22,11 +24,12 @@ 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.
|
||||||
|
|
||||||
The leaderboard uses the existing three telemetry events; no reporter change is
|
The leaderboard uses the existing three telemetry events; no reporter change is
|
||||||
required. Profiles identify a player by **GUID**, displaying the latest reported
|
required. The leaderboard and profiles identify a player by **GUID**, displaying
|
||||||
name across both games. Different names for the same GUID do not split their
|
the latest reported name across both games, regardless of game or map filters.
|
||||||
profile; identical names with different GUIDs remain separate. Rankings remain
|
Different names for the same GUID do not split their leaderboard entry or
|
||||||
scoped to each game and its map filters, and stored player records remain keyed
|
profile; identical names with different GUIDs remain separate. Stored player
|
||||||
by **game + GUID**. GUIDs stay strings.
|
records remain keyed by **game + GUID** for telemetry ingestion; the combined
|
||||||
|
leaderboard and profiles require no storage migration. GUIDs stay strings.
|
||||||
|
|
||||||
## Recorded statistics
|
## Recorded statistics
|
||||||
|
|
||||||
@@ -42,8 +45,10 @@ by **game + GUID**. GUIDs stay strings.
|
|||||||
|
|
||||||
Every ranking sorts highest first, including downs (a count, not an efficiency
|
Every ranking sorts highest first, including downs (a count, not an efficiency
|
||||||
rating). Equal values share a rank; name and GUID provide a stable display order.
|
rating). Equal values share a rank; name and GUID provide a stable display order.
|
||||||
Searching preserves the player's rank within the selected game/map. Summary
|
Searching preserves the player's rank for the selected game/map filters, or
|
||||||
cards describe that game/map, regardless of the search text. Summary rounds are
|
across both games when unfiltered. Summary cards describe the same selection,
|
||||||
|
regardless of the search text. Player counts include each qualifying GUID once,
|
||||||
|
including when they have records in both games. Summary rounds are
|
||||||
player-rounds: four players progressing from game start to round 8 contribute 28.
|
player-rounds: four players progressing from game start to round 8 contribute 28.
|
||||||
Highest-round records always use the round reached, including the final round
|
Highest-round records always use the round reached, including the final round
|
||||||
and players who joined mid-game.
|
and players who joined mid-game.
|
||||||
@@ -94,6 +99,10 @@ would require additional reporter identity/ordering information.
|
|||||||
key returned in `maps`), `metric` (default `highestRound`), `q` (name/GUID search),
|
key returned in `maps`), `metric` (default `highestRound`), `q` (name/GUID search),
|
||||||
`limit` (1–100, default 50), and `offset` (default 0). It returns ranked `entries`,
|
`limit` (1–100, default 50), and `offset` (default 0). It returns ranked `entries`,
|
||||||
available `maps`, the matching player `total`, pagination values, and `summary`.
|
available `maps`, the matching player `total`, pagination values, and `summary`.
|
||||||
|
Each entry represents one GUID and combines its qualifying matches within the
|
||||||
|
game/map filters. Entries have no game field; map options retain their game
|
||||||
|
identity. Both `total` and `summary.players` count unique GUIDs; only `total` is
|
||||||
|
limited by the player search.
|
||||||
|
|
||||||
Metric keys are `highestRound`, `kills`, `roundsSurvived`, `headshots`, `revives`,
|
Metric keys are `highestRound`, `kills`, `roundsSurvived`, `headshots`, `revives`,
|
||||||
`downs`, `gamesPlayed`, `gamesCompleted`, `bestKills`, and `bestScore`.
|
`downs`, `gamesPlayed`, `gamesCompleted`, `bestKills`, and `bestScore`.
|
||||||
|
|||||||
@@ -56,7 +56,6 @@ export function playerProfileHref(guid: string): string {
|
|||||||
|
|
||||||
export interface LeaderboardEntry {
|
export interface LeaderboardEntry {
|
||||||
rank: number;
|
rank: number;
|
||||||
game: string;
|
|
||||||
guid: string;
|
guid: string;
|
||||||
name: string;
|
name: string;
|
||||||
highestRound: number;
|
highestRound: number;
|
||||||
|
|||||||
@@ -10,13 +10,13 @@ export function isLeaderboardMetric(value: string): value is LeaderboardMetric {
|
|||||||
return metrics.has(value);
|
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 {
|
export function getLeaderboard(options: LeaderboardOptions = {}): LeaderboardResponse {
|
||||||
const handle = db();
|
const handle = db();
|
||||||
const metric = options.metric && isLeaderboardMetric(options.metric) ? options.metric : 'highestRound';
|
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 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 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}`];
|
`mp.highest_round >= ${MIN_RECORDED_ROUND}`];
|
||||||
const parameters: string[] = [];
|
const parameters: string[] = [];
|
||||||
if (options.game) { conditions.push('m.game = ?'); parameters.push(options.game); }
|
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
|
JOIN leaderboard_matches m ON m.id = mp.match_id
|
||||||
WHERE ${conditions.join(' AND ')}`;
|
WHERE ${conditions.join(' AND ')}`;
|
||||||
const aggregated = `WITH totals AS (
|
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.kills) AS kills, SUM(mp.rounds_survived) AS roundsSurvived,
|
||||||
SUM(mp.headshots) AS headshots, SUM(mp.revives) AS revives, SUM(mp.downs) AS downs,
|
SUM(mp.headshots) AS headshots, SUM(mp.revives) AS revives, SUM(mp.downs) AS downs,
|
||||||
COUNT(*) AS gamesPlayed, SUM(mp.completed) AS gamesCompleted,
|
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
|
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 (
|
), ranked AS (
|
||||||
SELECT totals.*, p.name, RANK() OVER (ORDER BY totals.${metric} DESC) AS rank
|
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 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 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[];
|
.all(...searchedParameters, limit, offset) as LeaderboardEntry[];
|
||||||
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
|
||||||
(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,
|
COUNT(DISTINCT m.id) AS matches, COALESCE(SUM(mp.kills), 0) AS kills,
|
||||||
COALESCE(SUM(mp.rounds_survived), 0) AS roundsSurvived,
|
COALESCE(SUM(mp.rounds_survived), 0) AS roundsSurvived,
|
||||||
COALESCE(MAX(mp.highest_round), 0) AS highestRound,
|
COALESCE(MAX(mp.highest_round), 0) AS highestRound,
|
||||||
MIN(m.started_at) AS firstTrackedAt, MAX(m.last_received_at) AS updatedAt
|
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.
|
// 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
|
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)
|
GROUP BY game, map_key HAVING id = MAX(id)
|
||||||
ORDER BY game, name COLLATE NOCASE, map_key`)
|
ORDER BY game, name COLLATE NOCASE, map_key`)
|
||||||
.all(...(options.game ? [options.game] : [])) as LeaderboardMap[];
|
.all(...(options.game ? [options.game] : [])) as LeaderboardMap[];
|
||||||
|
|||||||
@@ -133,7 +133,7 @@
|
|||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<section class="page">
|
<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()}
|
{#snippet actions()}
|
||||||
<Button size="sm" loading={loading} onclick={() => void refresh(apiUrl)}>Refresh</Button>
|
<Button size="sm" loading={loading} onclick={() => void refresh(apiUrl)}>Refresh</Button>
|
||||||
{/snippet}
|
{/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 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>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>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>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
@@ -205,12 +205,12 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</tr></thead>
|
</tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{#each data.entries as entry (`${entry.game}:${entry.guid}`)}
|
{#each data.entries as entry (entry.guid)}
|
||||||
<tr>
|
<tr>
|
||||||
<td class="rank"><span class:podium={entry.rank <= 3}>{number.format(entry.rank)}</span></td>
|
<td class="rank"><span class:podium={entry.rank <= 3}>{number.format(entry.rank)}</span></td>
|
||||||
<th scope="row" class="player">
|
<th scope="row" class="player">
|
||||||
<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"><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>
|
</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])}</td>{/each}
|
||||||
</tr>
|
</tr>
|
||||||
@@ -237,7 +237,8 @@
|
|||||||
<details class="tracking-notes">
|
<details class="tracking-notes">
|
||||||
<summary>How these records work</summary>
|
<summary>How these records work</summary>
|
||||||
<div>
|
<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>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>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>
|
<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); }
|
tbody .player { background: var(--surface); font-weight: var(--weight-medium); }
|
||||||
.player-name { display: block; color: var(--text-primary); overflow-wrap: anywhere; }
|
.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); }
|
.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; }
|
.stat { text-align: right; white-space: nowrap; }
|
||||||
td.stat { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
|
td.stat { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
|
||||||
.stat.selected { color: var(--accent-primary-hover); }
|
.stat.selected { color: var(--accent-primary-hover); }
|
||||||
@@ -310,7 +310,7 @@
|
|||||||
.mobile-view { display: block; padding: 0 16px 12px; }
|
.mobile-view { display: block; padding: 0 16px 12px; }
|
||||||
.compact .stat:not(.selected) { display: none; }
|
.compact .stat:not(.selected) { display: none; }
|
||||||
.compact .player { min-width: 110px; max-width: 160px; position: static; }
|
.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 .stat { white-space: normal; }
|
||||||
.compact .rank { width: 44px; }
|
.compact .rank { width: 44px; }
|
||||||
.compact th, .compact td { padding: 10px 8px; }
|
.compact th, .compact td { padding: 10px 8px; }
|
||||||
|
|||||||
@@ -165,7 +165,7 @@ it('persists independent stat high-water marks without adding cumulative snapsho
|
|||||||
})]);
|
})]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('retains per-game identities while combining the same GUID in one profile', async () => {
|
it('combines the same GUID into one profile and ranking across games', async () => {
|
||||||
await report('round_change', 8, [player(undefined, { kills: 111 })]);
|
await report('round_change', 8, [player(undefined, { kills: 111 })]);
|
||||||
await report('game_end', 8, [player(undefined, { kills: 120, name: 'New name' })]);
|
await report('game_end', 8, [player(undefined, { kills: 120, name: 'New name' })]);
|
||||||
await report('game_start');
|
await report('game_start');
|
||||||
@@ -182,14 +182,21 @@ it('retains per-game identities while combining the same GUID in one profile', a
|
|||||||
.toEqual({ total: 1 });
|
.toEqual({ total: 1 });
|
||||||
closeDb();
|
closeDb();
|
||||||
const { getPlayerProfile } = await import('../../src/lib/server/fleet/player-profile.js');
|
const { getPlayerProfile } = await import('../../src/lib/server/fleet/player-profile.js');
|
||||||
|
const { getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js');
|
||||||
const profile = getPlayerProfile(player().guid)!;
|
const profile = getPlayerProfile(player().guid)!;
|
||||||
expect(profile).toMatchObject({ guid: player().guid, name: 'Other game',
|
expect(profile).toMatchObject({ guid: player().guid, name: 'Other game',
|
||||||
firstSeen: '2026-09-18T20:00:00.000Z', lastSeen: '2026-09-18T20:00:04.000Z',
|
firstSeen: '2026-09-18T20:00:00.000Z', lastSeen: '2026-09-18T20:00:04.000Z',
|
||||||
stats: { gamesPlayed: 3, gamesCompleted: 3, kills: 340, highestRound: 8, bestKills: 200, roundsSurvived: 1 } });
|
stats: { gamesPlayed: 3, gamesCompleted: 3, kills: 340, highestRound: 8, bestKills: 200, roundsSurvived: 1 } });
|
||||||
expect(profile.matches.map(match => match.game)).toEqual(['t6', 't5', 't5']);
|
expect(profile.matches.map(match => match.game)).toEqual(['t6', 't5', 't5']);
|
||||||
|
const board = getLeaderboard();
|
||||||
|
expect(board).toMatchObject({ total: 1, summary: { players: 1, matches: 3, kills: 340 } });
|
||||||
|
expect(board.entries).toEqual([expect.objectContaining({ ...profile.stats, guid: player().guid, name: profile.name, rank: 1 })]);
|
||||||
|
expect(getLeaderboard({ game: 't5' }).entries).toEqual([expect.objectContaining({ guid: player().guid, name: 'Other game', kills: 140, gamesPlayed: 2 })]);
|
||||||
|
expect(getLeaderboard({ game: 't6' }).entries).toEqual([expect.objectContaining({ guid: player().guid, name: 'Other game', kills: 200, gamesPlayed: 1 })]);
|
||||||
await report('game_start', 0, [player(undefined, { name: 'Live name' })]);
|
await report('game_start', 0, [player(undefined, { name: 'Live name' })]);
|
||||||
expect(getPlayerProfile(player().guid)).toMatchObject({ name: 'Live name', lastSeen: '2026-09-18T20:00:05.000Z',
|
expect(getPlayerProfile(player().guid)).toMatchObject({ name: 'Live name', lastSeen: '2026-09-18T20:00:05.000Z',
|
||||||
stats: profile.stats, maps: profile.maps, matches: profile.matches });
|
stats: profile.stats, maps: profile.maps, matches: profile.matches });
|
||||||
|
expect(getLeaderboard().entries).toEqual([expect.objectContaining({ ...profile.stats, name: 'Live name' })]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('credits the highest round but only observed survival for a late join or a return after an absent roster', async () => {
|
it('credits the highest round but only observed survival for a late join or a return after an absent roster', async () => {
|
||||||
|
|||||||
@@ -82,21 +82,42 @@ describe('persisted leaderboard queries', () => {
|
|||||||
expect(serverMatchHistory('removed-server', 2)).toEqual(scopedHistory);
|
expect(serverMatchHistory('removed-server', 2)).toEqual(scopedHistory);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('sums counters across matches, retains personal bests and separates games for the same GUID', async () => {
|
it('ranks each GUID once with combined game totals and personal bests', async () => {
|
||||||
await seedRecords();
|
await seedRecords();
|
||||||
const { getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js');
|
const { getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js');
|
||||||
const board = getLeaderboard();
|
const board = getLeaderboard();
|
||||||
expect(board.entries.find(entry => entry.game === 't5' && entry.guid === '101')).toEqual({
|
expect(board.entries.filter(entry => entry.guid === '101')).toEqual([{
|
||||||
rank: 3, game: 't5', guid: '101', name: 'Ace', highestRound: 10, kills: 270,
|
rank: 1, guid: '101', name: 'Ace', highestRound: 20, kills: 770,
|
||||||
roundsSurvived: 19, headshots: 120, revives: 4, downs: 3,
|
roundsSurvived: 38, headshots: 320, revives: 8, downs: 4,
|
||||||
gamesPlayed: 3, gamesCompleted: 2, bestKills: 150, bestScore: 3000,
|
gamesPlayed: 4, gamesCompleted: 3, bestKills: 500, bestScore: 5000,
|
||||||
lastSeen: '2026-09-04T00:00:00.000Z'
|
lastSeen: '2026-09-05T00:00:00.000Z'
|
||||||
});
|
}]);
|
||||||
expect(board.entries.find(entry => entry.game === 't6' && entry.guid === '101'))
|
expect(board.total).toBe(4);
|
||||||
.toMatchObject({ name: 'Other game', kills: 500, gamesPlayed: 1, rank: 1 });
|
expect(board.entries.map(entry => [entry.guid, entry.rank])).toEqual([['101', 1], ['505', 2], ['202', 3], ['303', 4]]);
|
||||||
expect(board.entries.some(entry => entry.guid === '404')).toBe(false);
|
expect(board.entries.some(entry => entry.guid === '404')).toBe(false);
|
||||||
expect(board.summary).toEqual({ players: 5, matches: 5, kills: 1140, roundsSurvived: 68,
|
expect(board.summary).toEqual({ players: 4, matches: 5, kills: 1140, roundsSurvived: 68,
|
||||||
highestRound: 20, firstTrackedAt: '2026-09-01T00:00:00.000Z', updatedAt: '2026-09-06T00:00:00.000Z' });
|
highestRound: 20, firstTrackedAt: '2026-09-01T00:00:00.000Z', updatedAt: '2026-09-06T00:00:00.000Z' });
|
||||||
|
const { getPlayerProfile } = await import('../../src/lib/server/fleet/player-profile.js');
|
||||||
|
expect(board.entries[0]).toMatchObject(getPlayerProfile('101')!.stats);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scopes contributions by game while keeping one shared player identity', async () => {
|
||||||
|
await seedRecords();
|
||||||
|
const { getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js');
|
||||||
|
const t5 = getLeaderboard({ game: 't5' });
|
||||||
|
expect(t5.entries.find(entry => entry.guid === '101')).toEqual({
|
||||||
|
rank: 1, guid: '101', name: 'Ace', highestRound: 10, kills: 270,
|
||||||
|
roundsSurvived: 19, headshots: 120, revives: 4, downs: 3, gamesPlayed: 3,
|
||||||
|
gamesCompleted: 2, bestKills: 150, bestScore: 3000, lastSeen: '2026-09-04T00:00:00.000Z'
|
||||||
|
});
|
||||||
|
expect(t5).toMatchObject({ total: 3, summary: { players: 3, matches: 3, kills: 440, roundsSurvived: 35 } });
|
||||||
|
const t6 = getLeaderboard({ game: 't6' });
|
||||||
|
expect(t6.entries.find(entry => entry.guid === '101')).toEqual({
|
||||||
|
rank: 1, guid: '101', name: 'Ace', highestRound: 20, kills: 500,
|
||||||
|
roundsSurvived: 19, headshots: 200, revives: 4, downs: 1, gamesPlayed: 1,
|
||||||
|
gamesCompleted: 1, bestKills: 500, bestScore: 5000, lastSeen: '2026-09-05T00:00:00.000Z'
|
||||||
|
});
|
||||||
|
expect(t6).toMatchObject({ total: 2, summary: { players: 2, matches: 2, kills: 700, roundsSurvived: 33 } });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('filters exact map variants and retains historical and custom map options independently of selection', async () => {
|
it('filters exact map variants and retains historical and custom map options independently of selection', async () => {
|
||||||
@@ -107,7 +128,8 @@ describe('persisted leaderboard queries', () => {
|
|||||||
expect(theater.summary).toMatchObject({ players: 3, matches: 2, kills: 420, roundsSurvived: 32, highestRound: 10 });
|
expect(theater.summary).toMatchObject({ players: 3, matches: 2, kills: 420, roundsSurvived: 32, highestRound: 10 });
|
||||||
expect(theater.maps.map(map => map.key)).toEqual(['t5:coast', 't5:custom_map', 't5:theater']);
|
expect(theater.maps.map(map => map.key)).toEqual(['t5:coast', 't5:custom_map', 't5:theater']);
|
||||||
const town = getLeaderboard({ map: 't6:town' });
|
const town = getLeaderboard({ map: 't6:town' });
|
||||||
expect(town.entries.map(entry => entry.name)).toEqual(['Other game']);
|
expect(town.entries.map(entry => [entry.guid, entry.name, entry.kills])).toEqual([['101', 'Ace', 500]]);
|
||||||
|
expect(getLeaderboard({ game: 't5', map: 't6:town' })).toMatchObject({ entries: [], total: 0, summary: { players: 0, kills: 0 } });
|
||||||
expect(town.maps).toContainEqual({ key: 't6:farm', game: 't6', map: 'zm_transit', name: 'Farm Survival' });
|
expect(town.maps).toContainEqual({ key: 't6:farm', game: 't6', map: 'zm_transit', name: 'Farm Survival' });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -122,7 +144,58 @@ describe('persisted leaderboard queries', () => {
|
|||||||
expect(search.summary).toEqual(board.summary);
|
expect(search.summary).toEqual(board.summary);
|
||||||
expect(getLeaderboard({ game: 't5', metric: 'kills', limit: 1, offset: 1 }).entries[0])
|
expect(getLeaderboard({ game: 't5', metric: 'kills', limit: 1, offset: 1 }).entries[0])
|
||||||
.toMatchObject({ name: 'Blake', rank: 2 });
|
.toMatchObject({ name: 'Blake', rank: 2 });
|
||||||
expect(getLeaderboard({ q: '303' }).entries[0]).toMatchObject({ name: 'Casey', rank: 5 });
|
expect(getLeaderboard({ q: '303' }).entries[0]).toMatchObject({ name: 'Casey', rank: 4 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses and searches the latest shared name regardless of game or map filters without merging namesakes', async () => {
|
||||||
|
const handle = await seedRecords();
|
||||||
|
handle.prepare("UPDATE leaderboard_players SET last_seen_at = '2026-09-09' WHERE game = 't6' AND guid = '101'").run();
|
||||||
|
const { getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js');
|
||||||
|
for (const options of [{}, { game: 't5' }, { game: 't6' }, { map: 't5:theater' }, { game: 't6', map: 't6:town' }]) {
|
||||||
|
const board = getLeaderboard(options);
|
||||||
|
expect(board.entries.find(entry => entry.guid === '101')?.name).toBe('Other game');
|
||||||
|
expect(getLeaderboard({ ...options, q: 'other GAME' }).entries.map(entry => entry.guid)).toEqual(['101']);
|
||||||
|
expect(getLeaderboard({ ...options, q: 'Ace' }).entries).toEqual([]);
|
||||||
|
}
|
||||||
|
handle.prepare("UPDATE leaderboard_players SET name = 'Other game' WHERE guid = '202'").run();
|
||||||
|
const namesakes = getLeaderboard({ q: 'Other game' });
|
||||||
|
expect(namesakes.entries.map(entry => [entry.guid, entry.name, entry.rank])).toEqual([
|
||||||
|
['101', 'Other game', 1], ['202', 'Other game', 3]
|
||||||
|
]);
|
||||||
|
expect(namesakes).toMatchObject({ total: 2, summary: { players: 4 } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('paginates unique players after ranking their combined totals', async () => {
|
||||||
|
await seedRecords();
|
||||||
|
const { getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js');
|
||||||
|
const first = getLeaderboard({ metric: 'kills', limit: 2 });
|
||||||
|
const second = getLeaderboard({ metric: 'kills', limit: 2, offset: 2 });
|
||||||
|
expect(first.entries.map(entry => [entry.guid, entry.kills, entry.rank])).toEqual([['101', 770, 1], ['505', 200, 2]]);
|
||||||
|
expect(second.entries.map(entry => [entry.guid, entry.kills, entry.rank])).toEqual([['202', 100, 3], ['303', 70, 4]]);
|
||||||
|
expect(first.total).toBe(4);
|
||||||
|
expect(second.total).toBe(4);
|
||||||
|
expect(second.summary).toEqual(first.summary);
|
||||||
|
expect(getLeaderboard({ metric: 'kills', limit: 2, offset: 4 }).entries).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores unsupported games in identities, totals, rankings and map options', async () => {
|
||||||
|
const handle = await seedRecords();
|
||||||
|
const { getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js');
|
||||||
|
const original = getLeaderboard();
|
||||||
|
handle.prepare(`INSERT INTO leaderboard_matches
|
||||||
|
(id, server, game, map, map_key, map_name, started_at, last_received_at, status, highest_round, last_event, last_fingerprint)
|
||||||
|
VALUES (7, 'removed-server', 't7', 'excluded', 't7:excluded', 'Excluded', '2020-01-01', '2030-01-01', 'completed', 99, 'game_end', 'test')`).run();
|
||||||
|
for (const guid of ['101', 'unsupported-only']) {
|
||||||
|
handle.prepare(`INSERT INTO leaderboard_match_players
|
||||||
|
(match_id, guid, kills, highest_round, has_stats, first_seen_at, last_seen_at)
|
||||||
|
VALUES (7, ?, 99999, 99, 1, '2020-01-01', '2030-01-01')`).run(guid);
|
||||||
|
handle.prepare(`INSERT INTO leaderboard_players (game, guid, name, first_seen_at, last_seen_at)
|
||||||
|
VALUES ('t7', ?, 'Unsupported name', '2020-01-01', '2030-01-01')`).run(guid);
|
||||||
|
}
|
||||||
|
expect(getLeaderboard()).toEqual(original);
|
||||||
|
expect(getLeaderboard({ q: 'Unsupported' }).entries).toEqual([]);
|
||||||
|
expect(getLeaderboard({ game: 't7' })).toMatchObject({ entries: [], maps: [], total: 0,
|
||||||
|
summary: { players: 0, matches: 0, kills: 0 } });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('treats SQL and LIKE punctuation as literal input and allows only known ranking columns', async () => {
|
it('treats SQL and LIKE punctuation as literal input and allows only known ranking columns', async () => {
|
||||||
@@ -271,6 +344,19 @@ describe('player profiles', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('leaderboard API', () => {
|
describe('leaderboard API', () => {
|
||||||
|
it('returns one combined ranking row when a search matches the same player in both games', async () => {
|
||||||
|
const handle = await seedRecords();
|
||||||
|
handle.prepare("UPDATE leaderboard_players SET name = 'JoshWright' WHERE guid = '101'").run();
|
||||||
|
const { GET } = await import('../../src/routes/api/leaderboard/+server.js');
|
||||||
|
const response = GET({ url: new URL('http://localhost/api/leaderboard?q=JoshWright&metric=kills') });
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
const result = await response.json() as LeaderboardResponse;
|
||||||
|
expect(result).toMatchObject({ total: 1, summary: { players: 4, matches: 5, kills: 1140 } });
|
||||||
|
expect(result.entries).toEqual([expect.objectContaining({ guid: '101', name: 'JoshWright', rank: 1,
|
||||||
|
kills: 770, gamesPlayed: 4, gamesCompleted: 3, highestRound: 20, bestKills: 500, bestScore: 5000 })]);
|
||||||
|
expect(result.entries[0]).not.toHaveProperty('game');
|
||||||
|
});
|
||||||
|
|
||||||
it('returns the requested ranking and pagination', async () => {
|
it('returns the requested ranking and pagination', async () => {
|
||||||
await seedRecords();
|
await seedRecords();
|
||||||
const { GET } = await import('../../src/routes/api/leaderboard/+server.js');
|
const { GET } = await import('../../src/routes/api/leaderboard/+server.js');
|
||||||
|
|||||||
Reference in New Issue
Block a user