Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 28 additions & 2 deletions src/lib/components/LeaderboardTable.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof setTimeout> | 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)))
Expand Down Expand Up @@ -116,8 +131,18 @@
/>
<span class="pointer-events-none btn btn-sm">min played</span>
</label>
<input
class="h-[30px] input w-full py-0 sm:w-56"
type="search"
placeholder={showIds ? 'Name, alias or SteamID…' : 'Name…'}
aria-label="Search the leaderboard"
title="Narrows the rows; each player keeps their place on the whole board"
bind:value={search}
oninput={searchSoon}
/>
<span class="ml-auto text-[12.5px] text-mist-600">
{#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}
</span>
{#if exportHref}
Expand Down Expand Up @@ -176,7 +201,8 @@
{:else}
<tr>
<td colspan="15" class="py-6 text-center text-mist-600">
{#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}
</td>
Expand Down
14 changes: 13 additions & 1 deletion src/lib/leaderboard.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, test } from 'bun:test';
import {
BOARD_SEARCH_MAX,
boardQueryParams,
DEFAULT_BOARD_QUERY,
DEFAULT_FLOOR_MINUTES,
Expand Down Expand Up @@ -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'))
Expand All @@ -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');
Expand Down
14 changes: 12 additions & 2 deletions src/lib/leaderboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: ''
};

/**
Expand Down Expand Up @@ -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()
};
}

Expand All @@ -144,6 +153,7 @@ export function boardQueryParams(q: BoardQuery): Record<string, string> {
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;
}

Expand Down
63 changes: 50 additions & 13 deletions src/lib/server/leaderboards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,8 @@ const METRIC_SQL: Record<BoardMetric, ReturnType<typeof sql>> = {
};

interface BaseRow extends Record<string, unknown> {
/** 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;
Expand Down Expand Up @@ -207,27 +209,57 @@ async function anyFeed(env: Env, ids: string[]): Promise<boolean> {
/** 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<BaseRow[]> {
const from = rangeStart(q.range) ?? EPOCH;
const order = q.dir === 'asc' ? sql`ASC NULLS LAST` : sql`DESC NULLS LAST`;
return (await env.db.execute<BaseRow>(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,
Expand All @@ -236,17 +268,22 @@ async function boardSlice(
FROM page r`)) as BaseRow[];
}

export async function loadBoard(env: Env, ids: string[], q: BoardQuery): Promise<BoardView> {
export async function loadBoard(
env: Env,
ids: string[],
q: BoardQuery,
audience: BoardAudience = {}
): Promise<BoardView> {
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
Expand All @@ -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<BoardRow[]> {
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)),
Expand Down
2 changes: 1 addition & 1 deletion src/routes/(public)/s/[id]/leaderboard/+page.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
};
Expand Down
2 changes: 1 addition & 1 deletion src/routes/api/public/servers/[id]/leaderboard/+server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
);
Expand Down
121 changes: 121 additions & 0 deletions src/test/board-search.test.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
Loading