Default leaderboard to all maps

This commit is contained in:
2026-10-08 00:22:10 -04:00
parent 2f67482521
commit b42cbf1894
5 changed files with 14 additions and 61 deletions
+4 -4
View File
@@ -4,11 +4,11 @@ Open **Leaderboard** in the main navigation to compare players across the fleet,
with one entry per GUID combining Black Ops I (T5) and Black Ops II (T6). Game, map, with one entry per GUID combining Black Ops I (T5) and Black Ops II (T6). Game, map,
and starting squad-size filters limit which matches contribute to each player's statistics and and starting squad-size 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 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. The page opens on a filters intact. Results refresh while the page is visible. The page defaults to
recorded map by default; choose **All maps** for a fleet-wide comparison. A URL **All maps** for a fleet-wide comparison; choose a map to narrow the results. A URL
with `map=` explicitly selects all maps. **Players** selects Solo, Duo, Trio or with no `map` parameter or with `map=` selects all maps. **Players** selects Solo, Duo, Trio or
Four players using the recorded starting roster; unknown starting counts appear Four players using the recorded starting roster; unknown starting counts appear
only under All squad sizes. The JSON API still defaults to all maps. only under All squad sizes. The JSON API also defaults to all maps.
## Qualifying matches ## Qualifying matches
-14
View File
@@ -32,20 +32,6 @@ export function isLeaderboardMetric(value: string): value is LeaderboardMetric {
return metrics.has(value); return metrics.has(value);
} }
/** Pick a usable initial map without ranking every player across the fleet first. */
export function defaultLeaderboardMap(options: Pick<LeaderboardOptions, 'game' | 'players'> = {}): string | null {
const conditions = ["m.game IN ('t5', 't6')", "m.status = 'completed'", `m.highest_round >= ${MIN_RECORDED_ROUND}`,
`EXISTS (SELECT 1 FROM leaderboard_match_players mp WHERE mp.match_id = m.id
AND mp.has_stats = 1 AND mp.highest_round >= ${MIN_RECORDED_ROUND})`];
const parameters: Array<string | number> = [];
if (options.game) { conditions.push('m.game = ?'); parameters.push(options.game); }
if (options.players) { conditions.push('m.start_players = ?'); parameters.push(options.players); }
const row = db().prepare(`SELECT m.map_key AS key FROM leaderboard_matches m
WHERE ${conditions.join(' AND ')} ORDER BY m.game, m.map_name COLLATE NOCASE, m.map_key, m.id DESC LIMIT 1`)
.get(...parameters) as { key: string } | undefined;
return row?.key ?? null;
}
/** Rank each GUID once within the selected matches; player search 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 {
return readLeaderboard(options); return readLeaderboard(options);
+2 -19
View File
@@ -1,26 +1,9 @@
import { GET } from '../api/leaderboard/+server.js'; import { GET } from '../api/leaderboard/+server.js';
import { error, redirect } from '@sveltejs/kit'; import { error } from '@sveltejs/kit';
import type { LeaderboardPlayerCount, LeaderboardResponse } from '$lib/leaderboard.js'; import type { LeaderboardResponse } from '$lib/leaderboard.js';
import { leaderboardPageSize } from '$lib/leaderboard-updates.js'; import { leaderboardPageSize } from '$lib/leaderboard-updates.js';
import { defaultLeaderboardMap } from '$lib/server/fleet/leaderboard.js';
export async function load({ url }: { url: URL }) { export async function load({ url }: { url: URL }) {
// A present empty map is the explicit All maps view. The API remains
// unfiltered by default for callers such as global player search.
if (!url.searchParams.has('map')) {
const game = url.searchParams.get('game');
const players = url.searchParams.get('players');
if (game !== null && game !== 't5' && game !== 't6' || players !== null && !/^[1-4]$/.test(players)) {
error(400, 'Use game t5 or t6 and players 1–4.');
}
const map = defaultLeaderboardMap({ game: game ?? undefined, players: players === null ? undefined : Number(players) as LeaderboardPlayerCount });
if (map) {
const destination = new URL(url);
destination.searchParams.set('map', map);
destination.searchParams.delete('offset');
redirect(307, destination.pathname + destination.search);
}
}
const reportUrl = new URL(url); const reportUrl = new URL(url);
reportUrl.searchParams.set('limit', String(leaderboardPageSize)); reportUrl.searchParams.set('limit', String(leaderboardPageSize));
const response = await GET({ url: reportUrl }); const response = await GET({ url: reportUrl });
+8 -11
View File
@@ -1,9 +1,7 @@
import { beforeEach, expect, it, vi } from 'vitest'; import { beforeEach, expect, it, vi } from 'vitest';
import { GET } from '../../src/routes/api/leaderboard/+server.js'; import { GET } from '../../src/routes/api/leaderboard/+server.js';
import { load } from '../../src/routes/leaderboard/+page.server.js'; import { load } from '../../src/routes/leaderboard/+page.server.js';
import { defaultLeaderboardMap } from '../../src/lib/server/fleet/leaderboard.js';
vi.mock('../../src/routes/api/leaderboard/+server.js', () => ({ GET: vi.fn() })); vi.mock('../../src/routes/api/leaderboard/+server.js', () => ({ GET: vi.fn() }));
vi.mock('../../src/lib/server/fleet/leaderboard.js', () => ({ defaultLeaderboardMap: vi.fn() }));
beforeEach(() => { vi.clearAllMocks(); }); beforeEach(() => { vi.clearAllMocks(); });
it('requests the same 25-player page on initial load and preserves filters without mutating the page URL', async () => { it('requests the same 25-player page on initial load and preserves filters without mutating the page URL', async () => {
vi.mocked(GET).mockResolvedValueOnce(new Response(JSON.stringify({ entries: [], limit: 25, offset: 25 }))); vi.mocked(GET).mockResolvedValueOnce(new Response(JSON.stringify({ entries: [], limit: 25, offset: 25 })));
@@ -17,22 +15,21 @@ it('requests the same 25-player page on initial load and preserves filters witho
expect(data.leaderboard.limit).toBe(25); expect(data.leaderboard.limit).toBe(25);
}); });
it('opens a missing map at a recorded map in the selected game and squad without a whole-fleet report', async () => { it.each(['', '?game=t6&players=2&metric=kills&q=Cherry&offset=25'])('defaults to All maps without redirecting or changing other filters: %s', async query => {
vi.mocked(defaultLeaderboardMap).mockReturnValue('t6:town'); vi.mocked(GET).mockResolvedValueOnce(new Response(JSON.stringify({ entries: [], maps: [{ key: 't6:town' }], limit: 25 })));
const url = new URL('http://localhost/leaderboard?game=t6&players=2&metric=kills&q=Cherry&offset=25'); const url = new URL(`http://localhost/leaderboard${query}`);
const original = url.href; const original = url.href;
await expect(load({ url })).rejects.toMatchObject({ status: 307, location: '/leaderboard?game=t6&players=2&metric=kills&q=Cherry&map=t6%3Atown' }); expect(await load({ url })).toMatchObject({ leaderboard: { entries: [], limit: 25 } });
expect(defaultLeaderboardMap).toHaveBeenCalledWith({ game: 't6', players: 2 }); const requested = vi.mocked(GET).mock.calls[0][0].url;
expect(GET).not.toHaveBeenCalled(); expect(url.href).toBe(original); expect(requested.searchParams.has('map')).toBe(false);
for (const key of ['game', 'players', 'metric', 'q', 'offset']) expect(requested.searchParams.get(key)).toBe(url.searchParams.get(key));
expect(url.href).toBe(original);
}); });
it('honors explicit All maps and handles an empty recorded-map catalog without redirecting', async () => { it('honors explicit All maps and handles an empty recorded-map catalog without redirecting', async () => {
vi.mocked(GET).mockResolvedValue(new Response(JSON.stringify({ entries: [], maps: [], limit: 25 }))); vi.mocked(GET).mockResolvedValue(new Response(JSON.stringify({ entries: [], maps: [], limit: 25 })));
await load({ url: new URL('http://localhost/leaderboard?map=&players=3') }); await load({ url: new URL('http://localhost/leaderboard?map=&players=3') });
expect(defaultLeaderboardMap).not.toHaveBeenCalled();
expect(vi.mocked(GET).mock.calls[0][0].url.searchParams.get('map')).toBe(''); expect(vi.mocked(GET).mock.calls[0][0].url.searchParams.get('map')).toBe('');
vi.mocked(defaultLeaderboardMap).mockReturnValue(null);
vi.mocked(GET).mockResolvedValueOnce(new Response(JSON.stringify({ entries: [], maps: [], limit: 25 }))); vi.mocked(GET).mockResolvedValueOnce(new Response(JSON.stringify({ entries: [], maps: [], limit: 25 })));
expect(await load({ url: new URL('http://localhost/leaderboard') })).toMatchObject({ leaderboard: { entries: [], maps: [] } }); expect(await load({ url: new URL('http://localhost/leaderboard') })).toMatchObject({ leaderboard: { entries: [], maps: [] } });
expect(defaultLeaderboardMap).toHaveBeenCalledOnce();
}); });
-13
View File
@@ -67,19 +67,6 @@ describe('leaderboard starting-player scopes', () => {
scope: { players: '2', entry: { gamesPlayed: 30, highestRound: 20, records: { highestRound: { matchId: 1 } } } } }); scope: { players: '2', entry: { gamesPlayed: 30, highestRound: 20, records: { highestRound: { matchId: 1 } } } } });
}); });
it('chooses a deterministic usable default map within game and squad, ignoring unfinished and statless records', async () => {
const { db } = await import('../../src/lib/server/db/index.js');
seed(db(), 1, 2); seed(db(), 2, 3, { map: 'moon', mapName: 'Moon' });
seed(db(), 3, 4, { game: 't6', map: 'town', mapName: 'Town' });
seed(db(), 4, 2, { map: 'empty', mapName: 'A statless map', hasStats: 0 });
seed(db(), 5, 2, { map: 'active', mapName: 'A match in progress', status: 'active' });
const { defaultLeaderboardMap } = await import('../../src/lib/server/fleet/leaderboard.js');
expect(defaultLeaderboardMap()).toBe('t5:theater');
expect(defaultLeaderboardMap({ players: 3 })).toBe('t5:moon');
expect(defaultLeaderboardMap({ game: 't6' })).toBe('t6:town');
expect(defaultLeaderboardMap({ game: 't5', players: 4 })).toBeNull();
});
it('keeps API absent-map searches global while filtering all contributions by the selected squad', async () => { it('keeps API absent-map searches global while filtering all contributions by the selected squad', async () => {
const { db } = await import('../../src/lib/server/db/index.js'); const { db } = await import('../../src/lib/server/db/index.js');
seed(db(), 1, 2); seed(db(), 2, 2, { map: 'moon', mapName: 'Moon' }); seed(db(), 3, null, { round: 99 }); seed(db(), 1, 2); seed(db(), 2, 2, { map: 'moon', mapName: 'Moon' }); seed(db(), 3, null, { round: 99 });