From 6cfce3b5c5f858b6cda3d489f5a8b4637ab7a85b Mon Sep 17 00:00:00 2001 From: jbob0 Date: Fri, 25 Sep 2026 19:10:35 -0400 Subject: [PATCH] Leaderboards: search the board by name, alias or SteamID The Leaderboards tab and the public board get a search field, like the Players tab's: typing pauses for 300 ms, then the board reloads from page one with `q` in the query (and the URL, so a search can be linked). A search narrows the rows and never their ranks. Every row is ranked on the whole board first (ROW_NUMBER over the same order the board already used), then the search filters, then the page is sliced, so a player in 37th place is found as #37, not #1. The total and the pager count the matches. What matches depends on who is looking, as with the rest of the board: - the panel matches any name a player used on these servers, anywhere in it, and digits also match the start of a SteamID (the Players tab's rule); - a public board matches only the name it shows, the last one, so a search there cannot tie a SteamID or an old name to a player. `%` and `_` are escaped, as in seen.ts. The export follows the search too, since it writes "the board as it is set". With no search the query is the one it was, and ranks come out the same. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_011wkaN3kHsCQGhxCxeAqERQ --- src/lib/components/LeaderboardTable.svelte | 30 ++++- src/lib/leaderboard.test.ts | 14 +- src/lib/leaderboard.ts | 14 +- src/lib/server/leaderboards.ts | 63 +++++++-- .../s/[id]/leaderboard/+page.server.ts | 2 +- .../servers/[id]/leaderboard/+server.ts | 2 +- src/test/board-search.test.ts | 121 ++++++++++++++++++ 7 files changed, 226 insertions(+), 20 deletions(-) create mode 100644 src/test/board-search.test.ts diff --git a/src/lib/components/LeaderboardTable.svelte b/src/lib/components/LeaderboardTable.svelte index 2ecfef0c..35b66ab2 100644 --- a/src/lib/components/LeaderboardTable.svelte +++ b/src/lib/components/LeaderboardTable.svelte @@ -69,6 +69,21 @@ const n = Math.max(0, Math.round(Number(floor) || 0)); if (n !== query.minMinutes) set({ minMinutes: n }); } + // The search, the same way: the field holds what is typed and follows the query; a pause in + // the typing applies it (as the Players tab's search does), so a name is one read, not ten. + let search = $state(''); + $effect(() => { + search = query.q; + }); + let searchTimer: ReturnType | undefined; + function searchSoon() { + clearTimeout(searchTimer); + searchTimer = setTimeout(() => { + const v = search.trim(); + if (v !== query.q) set({ q: v }); + }, 300); + } + $effect(() => () => clearTimeout(searchTimer)); let pages = $derived( board ? Math.min(board.maxPage ?? Infinity, Math.max(1, Math.ceil(board.total / board.pageSize))) @@ -116,8 +131,18 @@ /> min played + - {#if board}{fmtNum(board.total)} player{board.total === 1 ? '' : 's'}{#if loading} + {#if board}{fmtNum(board.total)} player{board.total === 1 ? '' : 's'}{#if query.q} + matching{/if}{#if loading} · loading…{/if}{:else}Loading…{/if} {#if exportHref} @@ -176,7 +201,8 @@ {:else} - {#if !board || loading}Loading…{:else if board.total === 0 && query.minMinutes > 0}Nobody + {#if !board || loading}Loading…{:else if board.total === 0 && query.q}Nobody on this + board matches “{query.q}”.{:else if board.total === 0 && query.minMinutes > 0}Nobody has {fmtMinutes(query.minMinutes)} of playtime in this range yet.{:else}No players in this range yet.{/if} diff --git a/src/lib/leaderboard.test.ts b/src/lib/leaderboard.test.ts index 605575d2..32c8641a 100644 --- a/src/lib/leaderboard.test.ts +++ b/src/lib/leaderboard.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from 'bun:test'; import { + BOARD_SEARCH_MAX, boardQueryParams, DEFAULT_BOARD_QUERY, DEFAULT_FLOOR_MINUTES, @@ -108,7 +109,8 @@ describe('board query', () => { sort: 'winRate', dir: 'asc', page: 3, - minMinutes: 120 + minMinutes: 120, + q: '' }); expect( parseBoardQuery(new URLSearchParams('scope=x&range=1y&sort=luck&dir=up&page=0&minMinutes=-5')) @@ -120,6 +122,16 @@ describe('board query', () => { expect(boardQueryParams(q)).toEqual({ range: 'all', minMinutes: '0', page: '2' }); expect(parseBoardQuery(new URLSearchParams(boardQueryParams(q)))).toEqual(q); }); + test('a search is trimmed, kept to BOARD_SEARCH_MAX, and left out when empty', () => { + expect(parseBoardQuery(new URLSearchParams('q=%20%20Viper%20')).q).toBe('Viper'); + expect(parseBoardQuery(new URLSearchParams(`q=${'x'.repeat(250)}`)).q).toHaveLength( + BOARD_SEARCH_MAX + ); + expect(parseBoardQuery(new URLSearchParams('q=%20%20')).q).toBe(''); + const q = { ...DEFAULT_BOARD_QUERY, q: 'ARTEC' }; + expect(boardQueryParams(q)).toEqual({ q: 'ARTEC' }); + expect(parseBoardQuery(new URLSearchParams(boardQueryParams(q)))).toEqual(q); + }); test('ranges start where they say; all time has no start', () => { const now = Date.parse('2026-09-17T12:00:00Z'); expect(rangeStart('7d', now)?.toISOString()).toBe('2026-09-10T12:00:00.000Z'); diff --git a/src/lib/leaderboard.ts b/src/lib/leaderboard.ts index 0d12e4c8..c9e3570b 100644 --- a/src/lib/leaderboard.ts +++ b/src/lib/leaderboard.ts @@ -58,15 +58,23 @@ export interface BoardQuery { page: number; /** the playtime floor, in minutes */ minMinutes: number; + /** narrows the rows shown, never their ranks: a match keeps its place on the whole board. + * The panel matches any name a player used here or their SteamID; a public board only the + * name it shows. Empty shows everyone. */ + q: string; } +/** How much of a search is kept, as the Players tab's search keeps it. */ +export const BOARD_SEARCH_MAX = 100; + export const DEFAULT_BOARD_QUERY: BoardQuery = { scope: 'server', range: '30d', sort: 'kills', dir: 'desc', page: 1, - minMinutes: DEFAULT_FLOOR_MINUTES + minMinutes: DEFAULT_FLOOR_MINUTES, + q: '' }; /** @@ -131,7 +139,8 @@ export function parseBoardQuery(params: URLSearchParams, maxPage = 100_000): Boa sort: METRIC_KEYS.has(sort) ? (sort as BoardMetric) : DEFAULT_BOARD_QUERY.sort, dir: params.get('dir') === 'asc' ? 'asc' : 'desc', page: clampInt(params.get('page'), 1, 1, maxPage), - minMinutes: clampInt(params.get('minMinutes'), DEFAULT_FLOOR_MINUTES, 0, MAX_FLOOR_MINUTES) + minMinutes: clampInt(params.get('minMinutes'), DEFAULT_FLOOR_MINUTES, 0, MAX_FLOOR_MINUTES), + q: (params.get('q') ?? '').trim().slice(0, BOARD_SEARCH_MAX).trim() }; } @@ -144,6 +153,7 @@ export function boardQueryParams(q: BoardQuery): Record { if (q.dir !== 'desc') out.dir = q.dir; if (q.page !== 1) out.page = String(q.page); if (q.minMinutes !== DEFAULT_FLOOR_MINUTES) out.minMinutes = String(q.minMinutes); + if (q.q) out.q = q.q; return out; } diff --git a/src/lib/server/leaderboards.ts b/src/lib/server/leaderboards.ts index aa138b2c..b8950d20 100644 --- a/src/lib/server/leaderboards.ts +++ b/src/lib/server/leaderboards.ts @@ -172,6 +172,8 @@ const METRIC_SQL: Record> = { }; interface BaseRow extends Record { + /** the place on the whole board as it is set, before any search narrows the rows */ + rank: string; steamId: string; name: string | null; minutes: string; @@ -207,27 +209,57 @@ async function anyFeed(env: Env, ids: string[]): Promise { /** The most rows an export writes: the top ten thousand of the board as it is set. */ export const EXPORT_ROWS = 10_000; +/** Who may be found by what: a public board shows only names, so only its shown name matches. */ +export interface BoardAudience { + /** a public board: match the name it shows, never an alias or a SteamID it does not show */ + public?: boolean; +} + +const likeEscape = (s: string) => s.replace(/[\\%_]/g, (c) => `\\${c}`); + +/** + * The rows a search keeps, as a condition on a row of `ranked`. On the panel a name the player + * used on these servers matches anywhere in it, and digits also match the start of a SteamID, as + * the Players tab's search does; a public board matches only the name it shows (the last one), + * so a search there cannot tie a SteamID or an old name to a player. + */ +function searchFilter(ids: string[], q: string, audience: BoardAudience) { + if (!q) return sql`TRUE`; + const like = '%' + likeEscape(q) + '%'; + if (audience.public) + return sql`(SELECT ps.name FROM player_sessions ps WHERE ps.steam_id = ranked.steam_id AND ps.server_id IN ${ids} + ORDER BY ps.last_seen DESC LIMIT 1) ILIKE ${like}`; + const named = sql`EXISTS (SELECT 1 FROM player_sessions ps WHERE ps.steam_id = ranked.steam_id + AND ps.server_id IN ${ids} AND ps.name ILIKE ${like})`; + return /^\d+$/.test(q) ? sql`(ranked.steam_id LIKE ${q + '%'} OR ${named})` : named; +} + /** - * `limit` rows of the board as `q` sets it (scope, range, sort, floor), from `offset`: the - * aggregate covers the whole range either way, then the page, or an export, takes its slice. + * `limit` rows of the board as `q` sets it (scope, range, sort, floor, search), from `offset`: the + * aggregate covers the whole range either way and every row is ranked on it, then the search + * narrows the rows (their ranks stay), and the page, or an export, takes its slice. */ async function boardSlice( env: Env, ids: string[], q: BoardQuery, limit: number, - offset: number + offset: number, + audience: BoardAudience ): Promise { const from = rangeStart(q.range) ?? EPOCH; const order = q.dir === 'asc' ? sql`ASC NULLS LAST` : sql`DESC NULLS LAST`; return (await env.db.execute(sql` WITH ${base(ids, from)}, + ranked AS ( + SELECT *, ROW_NUMBER() OVER (ORDER BY ${METRIC_SQL[q.sort]} ${order}, kills DESC, steam_id) AS rank + FROM base WHERE minutes >= ${q.minMinutes}), page AS ( - SELECT *, COUNT(*) OVER () AS total FROM base - WHERE minutes >= ${q.minMinutes} - ORDER BY ${METRIC_SQL[q.sort]} ${order}, kills DESC, steam_id + SELECT *, COUNT(*) OVER () AS total FROM ranked + WHERE ${searchFilter(ids, q.q, audience)} + ORDER BY rank LIMIT ${limit} OFFSET ${offset}) - SELECT r.steam_id AS "steamId", r.minutes, r.seed_minutes AS "seedMinutes", r.cash, r.last_seen AS "lastSeen", + SELECT r.rank, r.steam_id AS "steamId", r.minutes, r.seed_minutes AS "seedMinutes", r.cash, r.last_seen AS "lastSeen", r.kills, r.headshots, r.team_kills AS "teamKills", r.deaths, r.suicides, r.vehicle_kills AS "vehicleKills", r.kill_streak AS "killStreak", r.death_streak AS "deathStreak", r.matches, r.wins, r.losses, r.draws, r.total, @@ -236,17 +268,22 @@ async function boardSlice( FROM page r`)) as BaseRow[]; } -export async function loadBoard(env: Env, ids: string[], q: BoardQuery): Promise { +export async function loadBoard( + env: Env, + ids: string[], + q: BoardQuery, + audience: BoardAudience = {} +): Promise { const empty: BoardView = { query: q, rows: [], total: 0, pageSize: BOARD_PAGE, hasFeed: false }; if (!ids.length) return empty; const offset = (q.page - 1) * BOARD_PAGE; const [rows, hasFeed] = await Promise.all([ - boardSlice(env, ids, q, BOARD_PAGE, offset), + boardSlice(env, ids, q, BOARD_PAGE, offset, audience), anyFeed(env, ids) ]); return { query: q, - rows: rows.map((r, i) => shapeRow(r, offset + i + 1)), + rows: rows.map(shapeRow), total: rows.length ? num(rows[0].total) : 0, pageSize: BOARD_PAGE, hasFeed @@ -256,11 +293,11 @@ export async function loadBoard(env: Env, ids: string[], q: BoardQuery): Promise /** The board as `q` sets it from the top, every page of it up to EXPORT_ROWS; `q.page` is ignored. */ export async function exportBoard(env: Env, ids: string[], q: BoardQuery): Promise { if (!ids.length) return []; - return (await boardSlice(env, ids, q, EXPORT_ROWS, 0)).map((r, i) => shapeRow(r, i + 1)); + return (await boardSlice(env, ids, q, EXPORT_ROWS, 0, {})).map(shapeRow); } -const shapeRow = (r: BaseRow, rank: number): BoardRow => ({ - rank, +const shapeRow = (r: BaseRow): BoardRow => ({ + rank: num(r.rank), steamId: r.steamId, name: r.name || r.steamId, minutes: Math.round(num(r.minutes)), diff --git a/src/routes/(public)/s/[id]/leaderboard/+page.server.ts b/src/routes/(public)/s/[id]/leaderboard/+page.server.ts index 4cde6265..0bf9996f 100644 --- a/src/routes/(public)/s/[id]/leaderboard/+page.server.ts +++ b/src/routes/(public)/s/[id]/leaderboard/+page.server.ts @@ -19,7 +19,7 @@ export const load: PageServerLoad = (event) => const orgServers = await publicOrgServers(env, ps.org, 'leaderboards'); const ids = q.scope === 'org' ? orgServers.map((s) => s.id) : [ps.server.id]; return { - board: { ...(await loadBoard(env, ids, q)), maxPage: PUBLIC_MAX_PAGE }, + board: { ...(await loadBoard(env, ids, q, { public: true })), maxPage: PUBLIC_MAX_PAGE }, orgScope: orgServers.length > 1, heading: publicHeading(ps) }; diff --git a/src/routes/api/public/servers/[id]/leaderboard/+server.ts b/src/routes/api/public/servers/[id]/leaderboard/+server.ts index a3fccdf8..7c8f11ba 100644 --- a/src/routes/api/public/servers/[id]/leaderboard/+server.ts +++ b/src/routes/api/public/servers/[id]/leaderboard/+server.ts @@ -21,7 +21,7 @@ export const GET = route(async (event) => { ? (await publicOrgServers(env, ps.org, 'leaderboards')).map((s) => s.id) : [ps.server.id]; return apiJson( - { ok: true, ...(await loadBoard(env, ids, q)), maxPage: PUBLIC_MAX_PAGE }, + { ok: true, ...(await loadBoard(env, ids, q, { public: true })), maxPage: PUBLIC_MAX_PAGE }, 200, publicHeaders(30) ); diff --git a/src/test/board-search.test.ts b/src/test/board-search.test.ts new file mode 100644 index 00000000..c2c64c89 --- /dev/null +++ b/src/test/board-search.test.ts @@ -0,0 +1,121 @@ +// Searching a leaderboard narrows its rows and leaves their ranks alone, and a public board can be +// searched only by the names it shows; these check the reads against a database. +import { beforeAll, describe, expect, test } from 'bun:test'; +import type { Env } from '$lib/server/env'; +import { matches, matchPlayers, playerSessions } from '$lib/server/db/schema'; +import { loadBoard } from '$lib/server/leaderboards'; +import { parseBoardQuery } from '$lib/leaderboard'; +import { hasTestDb, testEnv } from './db'; +import { seedWorld } from './world'; + +const VIPER = '76561198000000171'; +const ARTEC = '76561198000000172'; +const PERCENT = '76561198000000173'; +const HOUR = 3_600_000; + +describe.skipIf(!hasTestDb)('searching a leaderboard', () => { + let env: Env; + + beforeAll(async () => { + env = await testEnv(); + }); + + /** One ended match: VIPER 30 kills, ARTEC 20, PERCENT 10, so by kills they rank 1, 2, 3. + * VIPER played an earlier session as "OldTag". */ + async function seed() { + const w = await seedWorld(env); + const t0 = Date.now() - 10 * HOUR; + const [m] = await env.db + .insert(matches) + .values({ + serverId: w.server.id, + startedAt: new Date(t0), + endedAt: new Date(t0 + HOUR), + map: 'Europe', + finalScores: [{ name: 'Lonestar', score: 100 }], + winner: 'Lonestar' + }) + .returning({ id: matches.id }); + const line = (steamId: string, kills: number) => ({ + matchId: m.id, + serverId: w.server.id, + steamId, + name: steamId, + faction: 'Lonestar', + seconds: 3600, + kills, + deaths: 1 + }); + await env.db.insert(matchPlayers).values([line(VIPER, 30), line(ARTEC, 20), line(PERCENT, 10)]); + const session = (steamId: string, name: string, start: number) => ({ + serverId: w.server.id, + steamId, + name, + faction: 'Lonestar', + joinedAt: new Date(start), + lastSeen: new Date(start + HOUR), + leftAt: new Date(start + HOUR) + }); + await env.db + .insert(playerSessions) + .values([ + session(VIPER, 'OldTag', t0), + session(VIPER, 'Viper', t0 + 2 * HOUR), + session(ARTEC, 'ARTEC', t0), + session(PERCENT, '100%Real', t0) + ]); + return w; + } + + const board = (serverId: string, search: string, audience = {}) => + loadBoard( + env, + [serverId], + parseBoardQuery(new URLSearchParams({ minMinutes: '0', q: search })), + audience + ); + + test('no search ranks everyone from the top', async () => { + const w = await seed(); + const b = await board(w.server.id, ''); + expect(b.rows.map((r) => [r.rank, r.steamId])).toEqual([ + [1, VIPER], + [2, ARTEC], + [3, PERCENT] + ]); + expect(b.total).toBe(3); + }); + + test('a match keeps its place on the whole board, and the total counts the matches', async () => { + const w = await seed(); + const b = await board(w.server.id, 'artec'); + expect(b.rows.map((r) => [r.rank, r.steamId, r.name])).toEqual([[2, ARTEC, 'ARTEC']]); + expect(b.total).toBe(1); + }); + + test('the panel finds an old name and a SteamID, or its start', async () => { + const w = await seed(); + expect((await board(w.server.id, 'oldtag')).rows.map((r) => r.steamId)).toEqual([VIPER]); + expect((await board(w.server.id, VIPER)).rows.map((r) => r.steamId)).toEqual([VIPER]); + expect((await board(w.server.id, '7656119800000017')).rows.map((r) => r.steamId)).toEqual([ + VIPER, + ARTEC, + PERCENT + ]); + }); + + test('a public board matches only the name it shows', async () => { + const w = await seed(); + const pub = { public: true }; + expect((await board(w.server.id, 'viper', pub)).rows.map((r) => r.steamId)).toEqual([VIPER]); + // the old name and the SteamID are not on the public board, so they find nobody there + expect((await board(w.server.id, 'oldtag', pub)).rows).toEqual([]); + expect((await board(w.server.id, VIPER, pub)).rows).toEqual([]); + }); + + test('a percent sign or an underscore is a letter, not a wildcard', async () => { + const w = await seed(); + expect((await board(w.server.id, '%')).rows.map((r) => r.steamId)).toEqual([PERCENT]); + expect((await board(w.server.id, '_')).rows).toEqual([]); + }); +});