Focus player records and add squad leaderboards

This commit is contained in:
2026-10-07 19:14:33 -04:00
parent bab78038dc
commit bcecc42dd1
22 changed files with 569 additions and 430 deletions
+12 -1
View File
@@ -22,11 +22,22 @@ describe('leaderboard profile navigation', () => {
expect(next.searchParams.get('map')).toBe('t5:theater');
const filtered = new URL(profileViewHref(current, { game: 't6', map: '' }), current);
expect(filtered.searchParams.has('offset')).toBe(false);
expect(filtered.searchParams.has('map')).toBe(false);
expect(filtered.searchParams.get('map')).toBe('');
expect(filtered.searchParams.get('game')).toBe('t6');
expect(filtered.searchParams.get('returnTo')).toBe('/leaderboard?q=Ace');
});
it('preserves explicit all maps and squad scope through profile links and paging', () => {
const source = '/leaderboard?map=&players=2&metric=highestRound';
expect(leaderboardReturnHref(source)).toBe(source);
const profile = new URL(playerContextHref('101', source), 'http://localhost');
expect(profile.searchParams.get('map')).toBe('');
expect(profile.searchParams.get('players')).toBe('2');
const next = new URL(profileViewHref(profile, { offset: '25' }), profile);
expect(next.searchParams.get('map')).toBe('');
expect(next.searchParams.get('players')).toBe('2');
});
it('restricts supplied return destinations to the local leaderboard', () => {
for (const value of [null, '', 'https://example.com/leaderboard', '//example.com/leaderboard', '/leaderboard/players/101', '/leaderboard/../settings', '/leaderboard\\@example.com']) {
expect(leaderboardReturnHref(value)).toBe('/leaderboard');
+26 -3
View File
@@ -1,15 +1,38 @@
import { expect, it, vi } from 'vitest';
import { beforeEach, expect, it, vi } from 'vitest';
import { GET } from '../../src/routes/api/leaderboard/+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/lib/server/fleet/leaderboard.js', () => ({ defaultLeaderboardMap: vi.fn() }));
beforeEach(() => { vi.clearAllMocks(); });
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 })));
const url = new URL('http://localhost/leaderboard?game=t5&map=theater&metric=kills&q=123&offset=25&limit=100');
const url = new URL('http://localhost/leaderboard?game=t5&map=theater&players=2&metric=kills&q=123&offset=25&limit=100');
const original = url.href;
const data = await load({ url });
const requested = vi.mocked(GET).mock.calls[0][0].url;
expect(requested.searchParams.get('limit')).toBe('25');
for (const key of ['game', 'map', 'metric', 'q', 'offset']) expect(requested.searchParams.get(key)).toBe(url.searchParams.get(key));
for (const key of ['game', 'map', 'players', 'metric', 'q', 'offset']) expect(requested.searchParams.get(key)).toBe(url.searchParams.get(key));
expect(url.href).toBe(original);
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 () => {
vi.mocked(defaultLeaderboardMap).mockReturnValue('t6:town');
const url = new URL('http://localhost/leaderboard?game=t6&players=2&metric=kills&q=Cherry&offset=25');
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(defaultLeaderboardMap).toHaveBeenCalledWith({ game: 't6', players: 2 });
expect(GET).not.toHaveBeenCalled(); expect(url.href).toBe(original);
});
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 })));
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('');
vi.mocked(defaultLeaderboardMap).mockReturnValue(null);
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(defaultLeaderboardMap).toHaveBeenCalledOnce();
});
+99
View File
@@ -0,0 +1,99 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type Database from 'better-sqlite3';
import { newFixture, type Fixture } from '../helpers/fixture.js';
let fixture: Fixture;
beforeEach(async () => { vi.resetModules(); fixture = await newFixture([]); });
afterEach(async () => { await fixture.cleanup(); });
function seed(handle: Database.Database, id: number, players: number | null, options: {
guid?: string; round?: number; game?: string; map?: string; mapName?: string; status?: string; hasStats?: number;
} = {}) {
const { guid = 'ace', round = 20, game = 't5', map = 'theater', mapName = 'Kino Der Toten', status = 'completed', hasStats = 1 } = options;
const timestamp = new Date(Date.UTC(2026, 9, 1, 0, id)).toISOString();
handle.prepare(`INSERT INTO leaderboard_matches
(id,server,game,map,map_key,map_name,started_at,ended_at,last_received_at,status,start_players,highest_round,last_event,last_fingerprint)
VALUES (?,'removed-server',?,?,?,?,?,?,?,?,?,?,'game_end','fixture')`)
.run(id, game, map, `${game}:${map}`, mapName, timestamp, timestamp, timestamp, status, players, round);
handle.prepare(`INSERT OR IGNORE INTO leaderboard_players(game,guid,name,first_seen_at,last_seen_at) VALUES(?,?,?,?,?)`)
.run(game, guid, guid, timestamp, timestamp);
handle.prepare(`INSERT INTO leaderboard_match_players
(match_id,guid,highest_round,kills,best_score,rounds_survived,has_stats,completed,first_seen_at,last_seen_at)
VALUES(?,?,?,100,1000,?, ?,1,?,?)`).run(id, guid, round, round, hasStats, timestamp, timestamp);
}
describe('leaderboard starting-player scopes', () => {
it('separates each squad and its cache, with unknown starts available only in All players', async () => {
const { db } = await import('../../src/lib/server/db/index.js');
seed(db(), 1, null);
for (const players of [1, 2, 3, 4] as const) seed(db(), players + 1, players, { round: players < 3 ? 20 : players * 10 });
seed(db(), 6, 1, { guid: 'blake', round: 30 });
seed(db(), 7, 2, { guid: 'blake', round: 10 });
seed(db(), 8, null, { guid: 'legacy', round: 99 });
const { getLeaderboard, getPlayerLeaderboardEntry } = await import('../../src/lib/server/fleet/leaderboard.js');
const all = getLeaderboard();
expect(all).toMatchObject({ total: 3, summary: { matches: 8, highestRound: 99 } });
for (const players of [1, 2, 3, 4, 2, 1] as const) {
const board = getLeaderboard({ players });
expect(board.summary.matches).toBe(players < 3 ? 2 : 1);
expect(board.entries.some(entry => entry.guid === 'legacy')).toBe(false);
const ace = board.entries.find(entry => entry.guid === 'ace')!;
expect(ace.gamesPlayed).toBe(1);
expect(ace.rank).toBe(players === 1 ? 2 : 1);
// All three equal-valued records must come from this squad, not
// the earlier unknown-start or solo match with matching values.
for (const record of Object.values(ace.records)) expect(record.matchId).toBe(players + 1);
expect(getPlayerLeaderboardEntry('ace', { players })).toEqual(ace);
}
expect(getLeaderboard()).toEqual(all);
});
it('filters profile history and rank while keeping explicitly lifetime records intact', async () => {
const { db } = await import('../../src/lib/server/db/index.js');
for (let id = 1; id <= 30; id++) seed(db(), id, 2);
seed(db(), 31, 1, { round: 40 }); seed(db(), 32, null, { round: 99 });
const { getPlayerProfile } = await import('../../src/lib/server/fleet/player-profile.js');
const lifetime = getPlayerProfile('ace')!;
const first = getPlayerProfile('ace', 0, { players: 2 })!;
const last = getPlayerProfile('ace', 999, { players: 2, map: 't5:theater', game: 't5' })!;
expect(first).toMatchObject({ matchesTotal: 30, offset: 0 }); expect(first.matches).toHaveLength(25);
expect(last).toMatchObject({ matchesTotal: 30, offset: 25 }); expect(last.matches).toHaveLength(5);
expect(new Set([...first.matches, ...last.matches].map(match => match.id))).toEqual(new Set(Array.from({ length: 30 }, (_, i) => i + 1)));
expect(first.stats).toEqual(lifetime.stats); expect(first.records).toEqual(lifetime.records); expect(first.maps).toEqual(lifetime.maps);
expect(getPlayerProfile('ace', 25, { players: 4 })).toMatchObject({ matchesTotal: 0, matches: [], offset: 0, stats: lifetime.stats });
const { load } = await import('../../src/routes/leaderboard/players/[guid]/+page.server.js');
const scoped = await load({ params: { guid: 'ace' }, url: new URL('http://localhost/leaderboard/players/ace?players=2&map=t5%3Atheater') });
expect(scoped).toMatchObject({ profile: { matchesTotal: 30, stats: { gamesPlayed: 32, highestRound: 99 } },
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 () => {
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 });
const { GET } = await import('../../src/routes/api/leaderboard/+server.js');
expect(await (await GET({ url: new URL('http://localhost/api/leaderboard?players=2') })).json())
.toMatchObject({ summary: { matches: 2, highestRound: 20 }, entries: [{ gamesPlayed: 2 }] });
expect(await (await GET({ url: new URL('http://localhost/api/leaderboard') })).json())
.toMatchObject({ summary: { matches: 3, highestRound: 99 } });
for (const players of ['0', '5', '', '2.0', '-1', 'two', '01']) {
const response = await GET({ url: new URL(`http://localhost/api/leaderboard?players=${players}`) });
expect(response.status).toBe(400);
}
const { load } = await import('../../src/routes/leaderboard/players/[guid]/+page.server.js');
await expect(load({ params: { guid: 'ace' }, url: new URL('http://localhost/leaderboard/players/ace?players=5') }))
.rejects.toMatchObject({ status: 400 });
});
});
+29 -6
View File
@@ -17,14 +17,16 @@ function scope(search = '') {
(page as unknown as Writable<{ url: URL }>).set({ url: new URL(`http://localhost/leaderboard${search}`) });
}
function html(data = report()) { return render(Leaderboard, { props: { data: { leaderboard: data, user: null, setupRequired: false } } }).body; }
beforeEach(() => scope());
beforeEach(() => scope('?map='));
it('shows prominent totals and restored stat columns, preserving ranks and collapsed identity and evidence', () => {
it('shows compact totals and core stat columns, preserving ranks without repeated row disclosures', () => {
const body = html();
const headers = body.slice(body.indexOf('<thead'), body.indexOf('</thead>'));
expect(headers.match(/scope="col"/g)).toHaveLength(9);
expect(headers.match(/scope="col"/g)).toHaveLength(6);
expect(headers).toContain('Highest round'); expect(headers).toContain('Total kills'); expect(headers).toContain('Games played');
for (const label of ['Total rounds', 'Headshots', 'Revives', 'Downs']) expect(headers).toContain(label);
expect(headers).toContain('Total rounds');
for (const label of ['Headshots', 'Revives', 'Downs', 'Games completed', 'Best game kills', 'Highest reported score']) expect(body).toContain(label);
expect(body).toContain('Show all stats');
expect(body).toMatch(/<span[^>]*>27<\/span>/);
expect(body).toContain('26–26 of 60 players');
const overview = body.slice(body.indexOf('class="overview'), body.indexOf('class="rankings'));
@@ -34,7 +36,12 @@ it('shows prominent totals and restored stat columns, preserving ranks and colla
expect(overview).not.toContain('<details');
expect(body).not.toMatch(/<details[^>]*open/);
expect(body).not.toContain('Copy GUID'); expect(body).not.toContain('Every round counts');
expect(body).toContain('Record details'); expect(body).toContain('First observed at round 8');
expect(body).not.toContain('Record details'); expect(body).not.toContain('Hide details');
const rows = body.slice(body.indexOf('<tbody'), body.indexOf('</tbody>'));
expect(rows.match(/<a\b/g)).toHaveLength(2);
expect(rows).not.toContain('<details');
expect(rows).toContain('Match #71');
expect(body).toContain('including players who joined mid-game');
expect(body).not.toContain('>Search</button>');
});
@@ -43,7 +50,7 @@ it('deduplicates the selected metric and preserves scope and pagination on playe
const data = report(); data.metric = 'kills';
const body = html(data);
const headers = body.slice(body.indexOf('<thead'), body.indexOf('</thead>'));
expect(headers.match(/scope="col"/g)).toHaveLength(8);
expect(headers.match(/scope="col"/g)).toHaveLength(5);
expect(body).toContain('offset%3D25'); expect(body).toContain('q%3DCherry');
scope('?game=t5&map=t5%3Atheater&metric=highestRound&q=Cherry&offset=25');
const recordBody = html();
@@ -52,6 +59,22 @@ it('deduplicates the selected metric and preserves scope and pagination on playe
expect(recordBody).toContain('Selection record'); expect(recordBody).not.toContain('Fleet record');
});
it('provides separate mobile filter controls and preserves explicit all-map and squad context', () => {
scope('?map=&players=2&metric=highestRound&offset=25');
const body = html();
expect(body).toContain('Leaderboard filters');
expect(body).toContain('id="leaderboard-players-mobile"');
expect(body).toContain('value="2" selected=""');
expect(body).toContain('All squad sizes');
expect(body).toContain('Solo'); expect(body).toContain('Duo'); expect(body).toContain('Trio'); expect(body).toContain('Four players');
expect(body).toContain('unknown starting counts appear only under All squad sizes');
expect(body).toContain('players%3D2');
expect(body).toContain('map%3D');
const ids = [...body.matchAll(/\bid="([^"]+)"/g)].map(match => match[1]);
expect(new Set(ids).size).toBe(ids.length);
expect(body).not.toMatch(/>Refresh<\/span>/);
});
it('keeps qualification and denominator visible for efficiency rankings and missing values distinct', () => {
scope('?metric=headshotRate');
const data = report(); data.metric = 'headshotRate';
+16 -2
View File
@@ -15,7 +15,7 @@ beforeEach(() => { scope(); vi.useFakeTimers(); vi.setSystemTime(new Date('2026-
afterEach(() => vi.useRealTimers());
it('renders one page heading, six columns, a combined identity, and compact dates', () => {
const body = render(MatchHistoryPage, { props: { data: { history: report(), user: null, setupRequired: false } } }).body;
expect(body.match(/<h[12][ >]/g)).toHaveLength(1);
expect(body.match(/<h1[ >]/g)).toHaveLength(1);
expect(body.match(/scope="col"/g)).toHaveLength(6);
expect(body).toContain('Today, 1:26 PM'); expect(body).toContain('Lobby 1 · Black Ops II');
expect(body).not.toContain('>Server</th>'); expect(body).not.toContain('Show 25 more');
@@ -28,7 +28,21 @@ it('preserves filters and page in match links and avoids linking deleted instanc
expect(body).toContain('/matches/1?returnTo=%2Fmatches'); expect(body).toContain('matchPage%3D2');
expect(body).toContain('matchSort%3Dround'); expect(body).toContain('Historical instance');
expect(body).not.toContain('href="/servers/'); expect(body).not.toContain('>Deleted<');
expect(body).toContain('26–26 of 40 matches'); expect(body).toContain('More filters (1)');
expect(body).toContain('26–26 of 40 matches'); expect(body).toContain('Filters · 2');
});
it('keeps mobile filters and sorting in one labelled sheet with distinct controls and one named match link', () => {
const body = render(MatchHistory, { props: { initial: report() } }).body;
expect(body).toContain('Match filters');
expect(body).toContain('id="match-map-mobile"');
expect(body).toContain('id="match-sort-mobile"');
expect(body).toContain('id="match-order-mobile"');
expect(body).toContain('Show results');
const rows = body.slice(body.indexOf('<tbody'), body.indexOf('</tbody>'));
expect(rows.match(/<a\b/g)).toHaveLength(1);
expect(rows).toContain('Town Survival · Match #1');
const ids = [...body.matchAll(/\bid="([^"]+)"/g)].map(match => match[1]);
expect(new Set(ids).size).toBe(ids.length);
expect(body).toContain('Updates every 10 seconds');
});
it('labels unfinished rounds and missing durations without inventing final results or detail links', () => {
const data = report(); data.matches = [{ ...match, status: 'interrupted', finalRound: null, durationMs: null, uniquePlayers: 0 }];