Rank each player once across T5 and T6
This commit is contained in:
+18
-9
@@ -1,8 +1,10 @@
|
||||
# Leaderboard
|
||||
|
||||
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
|
||||
page URL with its filters intact. Results refresh while the page is visible.
|
||||
with one entry per GUID combining Black Ops I (T5) and Black Ops II (T6). Game
|
||||
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
|
||||
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.
|
||||
@@ -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.
|
||||
|
||||
The leaderboard uses the existing three telemetry events; no reporter change is
|
||||
required. Profiles identify a player by **GUID**, displaying the latest reported
|
||||
name across both games. Different names for the same GUID do not split their
|
||||
profile; identical names with different GUIDs remain separate. Rankings remain
|
||||
scoped to each game and its map filters, and stored player records remain keyed
|
||||
by **game + GUID**. GUIDs stay strings.
|
||||
required. The leaderboard and profiles identify a player by **GUID**, displaying
|
||||
the latest reported name across both games, regardless of game or map filters.
|
||||
Different names for the same GUID do not split their leaderboard entry or
|
||||
profile; identical names with different GUIDs remain separate. Stored player
|
||||
records remain keyed by **game + GUID** for telemetry ingestion; the combined
|
||||
leaderboard and profiles require no storage migration. GUIDs stay strings.
|
||||
|
||||
## Recorded statistics
|
||||
|
||||
@@ -42,8 +45,10 @@ by **game + GUID**. GUIDs stay strings.
|
||||
|
||||
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.
|
||||
Searching preserves the player's rank within the selected game/map. Summary
|
||||
cards describe that game/map, regardless of the search text. Summary rounds are
|
||||
Searching preserves the player's rank for the selected game/map filters, or
|
||||
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.
|
||||
Highest-round records always use the round reached, including the final round
|
||||
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),
|
||||
`limit` (1–100, default 50), and `offset` (default 0). It returns ranked `entries`,
|
||||
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`,
|
||||
`downs`, `gamesPlayed`, `gamesCompleted`, `bestKills`, and `bestScore`.
|
||||
|
||||
@@ -56,7 +56,6 @@ export function playerProfileHref(guid: string): string {
|
||||
|
||||
export interface LeaderboardEntry {
|
||||
rank: number;
|
||||
game: string;
|
||||
guid: string;
|
||||
name: string;
|
||||
highestRound: number;
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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('game_end', 8, [player(undefined, { kills: 120, name: 'New name' })]);
|
||||
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 });
|
||||
closeDb();
|
||||
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)!;
|
||||
expect(profile).toMatchObject({ guid: player().guid, name: 'Other game',
|
||||
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 } });
|
||||
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' })]);
|
||||
expect(getPlayerProfile(player().guid)).toMatchObject({ name: 'Live name', lastSeen: '2026-09-18T20:00:05.000Z',
|
||||
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 () => {
|
||||
|
||||
@@ -82,21 +82,42 @@ describe('persisted leaderboard queries', () => {
|
||||
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();
|
||||
const { getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js');
|
||||
const board = getLeaderboard();
|
||||
expect(board.entries.find(entry => entry.game === 't5' && entry.guid === '101')).toEqual({
|
||||
rank: 3, game: 't5', 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(board.entries.find(entry => entry.game === 't6' && entry.guid === '101'))
|
||||
.toMatchObject({ name: 'Other game', kills: 500, gamesPlayed: 1, rank: 1 });
|
||||
expect(board.entries.filter(entry => entry.guid === '101')).toEqual([{
|
||||
rank: 1, guid: '101', name: 'Ace', highestRound: 20, kills: 770,
|
||||
roundsSurvived: 38, headshots: 320, revives: 8, downs: 4,
|
||||
gamesPlayed: 4, gamesCompleted: 3, bestKills: 500, bestScore: 5000,
|
||||
lastSeen: '2026-09-05T00:00:00.000Z'
|
||||
}]);
|
||||
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.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' });
|
||||
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 () => {
|
||||
@@ -107,7 +128,8 @@ describe('persisted leaderboard queries', () => {
|
||||
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']);
|
||||
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' });
|
||||
});
|
||||
|
||||
@@ -122,7 +144,58 @@ describe('persisted leaderboard queries', () => {
|
||||
expect(search.summary).toEqual(board.summary);
|
||||
expect(getLeaderboard({ game: 't5', metric: 'kills', limit: 1, offset: 1 }).entries[0])
|
||||
.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 () => {
|
||||
@@ -271,6 +344,19 @@ describe('player profiles', () => {
|
||||
});
|
||||
|
||||
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 () => {
|
||||
await seedRecords();
|
||||
const { GET } = await import('../../src/routes/api/leaderboard/+server.js');
|
||||
|
||||
Reference in New Issue
Block a user