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([]); + }); +});