Leaderboards (#43)

* leaderboards

* [leaderboard] add basic leaderboards - will probably have to clean up later but data is collected now
This commit is contained in:
devin
2026-08-26 01:04:27 -04:00
committed by GitHub
parent 7ef949bfcf
commit a12ff24068
9 changed files with 845 additions and 144 deletions
@@ -0,0 +1,29 @@
-- A room's leaderboards: each player's value on each of a room's stat channels. Owned by
-- the `leaderboard` worker, which is the only reader and the only writer —
-- `POST /leaderboard/CheckAndSetStat` writes a row and the three reads
-- (`GetRanks`, `GetNearbyScores`, `GetPlayerRank`) rank them. Generated from
-- src/leaderboard-db.ts (SCHEMA_DDL) — keep in sync.
--
-- One row per (player, room, channel). A row is created the first time a player posts a
-- stat for that channel of the room; a player with no row is simply not on that board,
-- which the reads answer as an empty slice / the unranked sentinel rather than by
-- inserting on a read.
--
-- `stat_channel` is the client's `StatChannel` — which of the room's tracked stats this
-- is (a room keeps one board per channel) — and `stat_value` is what it posts as
-- `StatValue`. What a channel counts (wins, laps, a time) is the room's business; the
-- server only orders on it, highest first unless the client asks for ascending, ties
-- broken on the lower `player_id` so a rank is stable between two reads.
CREATE TABLE IF NOT EXISTS leaderboard (
player_id INTEGER NOT NULL,
room_id INTEGER NOT NULL,
stat_channel INTEGER NOT NULL,
stat_value INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (player_id, room_id, stat_channel)
);
-- The reads walk one board (room + channel) ordered by value; the primary key serves the
-- point lookup but not that scan.
CREATE INDEX IF NOT EXISTS leaderboard_board_value
ON leaderboard (room_id, stat_channel, stat_value DESC, player_id);
+3
View File
@@ -12,10 +12,13 @@
"deploy": "run-wrangler-deploy", "deploy": "run-wrangler-deploy",
"dev": "run-wrangler-dev", "dev": "run-wrangler-dev",
"fix:workers-types": "run-wrangler-types", "fix:workers-types": "run-wrangler-types",
"migrate": "run-wrangler-migrate",
"test": "run-vitest" "test": "run-vitest"
}, },
"dependencies": { "dependencies": {
"@repo/domain": "workspace:*",
"@repo/hono-helpers": "workspace:*", "@repo/hono-helpers": "workspace:*",
"@repo/jwt": "workspace:*",
"@standard-community/standard-json": "0.3.5", "@standard-community/standard-json": "0.3.5",
"@standard-community/standard-openapi": "0.2.9", "@standard-community/standard-openapi": "0.2.9",
"hono": "4.12.27", "hono": "4.12.27",
+2
View File
@@ -8,6 +8,8 @@ export type Env = SharedHonoEnv & {
* `auth` verify here. * `auth` verify here.
*/ */
JWT_SECRET: SecretsStoreSecret JWT_SECRET: SecretsStoreSecret
/** Shared `recflare` D1 database; this worker owns the `leaderboard` table. */
DB: D1Database
} }
/** Variables can be extended */ /** Variables can be extended */
+218
View File
@@ -0,0 +1,218 @@
/**
* A room's leaderboards on the shared `recflare` D1 database: each player's value on each
* of a room's stat channels.
*
* One table, one row per (player, room, channel), created the first time a player posts a
* stat for that channel of the room via `POST /leaderboard/CheckAndSetStat`. A player with
* no row is not on that board: the reads answer that as an empty slice or {@link UNRANKED}
* rather than inserting on a read.
*
* A board is a (room, channel) pair — `stat_channel` is the client's `StatChannel`, which of
* the room's tracked stats this is, and `stat_value` is what it posts as `StatValue`. What a
* channel counts (wins, laps, a time) is the room's business; the server only orders on it.
*
* Ranks are 1-based and total: the board is ordered by `stat_value` (highest first unless
* the client asks for ascending) with ties broken on the lower `player_id`, so two players
* with the same score never share a rank and a rank is stable between two reads.
*
* A board can be read through a FRIENDS filter (the client's `FilterType` 1): the same rows
* restricted to the viewer and the people they are friends with, ranked among themselves —
* so a player who is 40th globally can be 2nd among friends. Friendship is the `api`
* worker's `relationship` table on the same database, read here through a subquery rather
* than an `IN (...)` list so a player with hundreds of friends doesn't hit D1's bind limit.
*
* The `leaderboard` worker owns the schema/migrations (migrations/0001_leaderboard.sql,
* applied under its own `migrations_table` so they don't clash with the other workers'
* migrations that share the database).
*/
import { RelationshipType } from '@repo/domain'
/** Schema DDL (mirror of migrations/0001_leaderboard.sql). */
export const SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS leaderboard (
player_id INTEGER NOT NULL,
room_id INTEGER NOT NULL,
stat_channel INTEGER NOT NULL,
stat_value INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (player_id, room_id, stat_channel)
)`,
`CREATE INDEX IF NOT EXISTS leaderboard_board_value
ON leaderboard (room_id, stat_channel, stat_value DESC, player_id)`,
]
/**
* The rank a player who isn't on the board gets. `Rank` is 1-based: a 0 would render as
* first place and a negative one may not render at all. A number far past the end of any
* real board reads as last, which is what an unscored player is, and is recognisable in a
* log or a screenshot as a sentinel rather than a real standing.
*/
export const UNRANKED = 99999
/** The score behind {@link UNRANKED}: no stat has ever been stored, and 0 is "no score". */
export const NO_SCORE = 0
/**
* One row of a board as the client renders it — the same three fields `GetPlayerRank`
* answers, so a row and a standing are the one shape.
*/
export interface LeaderboardEntry {
PlayerId: number
Score: number
Rank: number
}
interface LeaderboardRow {
player_id: number
stat_value: number
}
/** Which board: one of a room's stat channels, optionally seen through one player's friends. */
export interface Board {
roomId: number
statChannel: number
/** When set, only this player and their friends are on the board. */
friendsOf?: number
}
/**
* The WHERE clause selecting a board's rows, with the room and channel bound as `?1`/`?2`
* and, for a friends board, the viewer as `?3`. Callers number any further parameters from
* {@link Scope.next}.
*/
interface Scope {
where: string
binds: number[]
next: number
}
function scope(board: Board): Scope {
if (board.friendsOf === undefined) {
return {
where: 'room_id = ?1 AND stat_channel = ?2',
binds: [board.roomId, board.statChannel],
next: 3,
}
}
return {
where: `room_id = ?1 AND stat_channel = ?2 AND (player_id = ?3 OR player_id IN (
SELECT CASE WHEN requester_id = ?3 THEN target_id ELSE requester_id END
FROM relationship
WHERE relationship_type = ${RelationshipType.Friend} AND (requester_id = ?3 OR target_id = ?3)
))`,
binds: [board.roomId, board.statChannel, board.friendsOf],
next: 4,
}
}
/** The ORDER BY for a board. Ties break on the lower player id either way. */
function order(sortAscending: boolean): string {
return sortAscending ? 'stat_value ASC, player_id ASC' : 'stat_value DESC, player_id ASC'
}
/**
* A page of a board: ranks `rankStart`..`rankEnd`, both 1-based and inclusive.
* A `rankStart` below 1 is clamped to the top; an empty or inverted range is an empty page.
*/
export async function getRanks(
db: D1Database,
board: Board,
rankStart: number,
rankEnd: number,
sortAscending: boolean
): Promise<LeaderboardEntry[]> {
const start = Math.max(1, rankStart)
const limit = rankEnd - start + 1
if (limit <= 0) return []
const s = scope(board)
const { results } = await db
.prepare(
`SELECT player_id, stat_value FROM leaderboard WHERE ${s.where}
ORDER BY ${order(sortAscending)} LIMIT ?${s.next} OFFSET ?${s.next + 1}`
)
.bind(...s.binds, limit, start - 1)
.all<LeaderboardRow>()
return results.map((r, i) => ({ PlayerId: r.player_id, Score: r.stat_value, Rank: start + i }))
}
/**
* One player's standing on a board: their score and 1-based rank, or
* {@link UNRANKED} with {@link NO_SCORE} when they have no row there.
*
* The rank is one more than the count of players placed ahead — a higher score, or the
* same score and a lower id — so it matches the position {@link getRanks} would give.
*/
export async function getPlayerRank(
db: D1Database,
board: Board,
playerId: number,
sortAscending: boolean
): Promise<LeaderboardEntry> {
const s = scope(board)
const mine = await db
.prepare(`SELECT stat_value FROM leaderboard WHERE ${s.where} AND player_id = ?${s.next}`)
.bind(...s.binds, playerId)
.first<{ stat_value: number }>()
if (!mine) return { PlayerId: playerId, Score: NO_SCORE, Rank: UNRANKED }
const [pid, val] = [s.next, s.next + 1]
const ahead = sortAscending
? `(stat_value < ?${val} OR (stat_value = ?${val} AND player_id < ?${pid}))`
: `(stat_value > ?${val} OR (stat_value = ?${val} AND player_id < ?${pid}))`
const count = await db
.prepare(`SELECT COUNT(*) AS n FROM leaderboard WHERE ${s.where} AND ${ahead}`)
.bind(...s.binds, playerId, mine.stat_value)
.first<{ n: number }>()
return { PlayerId: playerId, Score: mine.stat_value, Rank: (count?.n ?? 0) + 1 }
}
/** The most rows either side of a player `GetNearbyScores` will serve, whatever it asks. */
export const MAX_WINDOW = 10
/**
* The rows around one player on a board: `windowSize` entries on either side of their
* rank (at most {@link MAX_WINDOW}), clamped to the board. A player who isn't on the board
* gets the top of it — a full window's worth — so the screen still draws something.
*/
export async function getNearbyScores(
db: D1Database,
board: Board,
playerId: number,
windowSize: number,
sortAscending: boolean
): Promise<LeaderboardEntry[]> {
const window = Math.min(Math.max(windowSize, 1), MAX_WINDOW)
const mine = await getPlayerRank(db, board, playerId, sortAscending)
if (mine.Rank === UNRANKED) return getRanks(db, board, 1, window * 2 + 1, sortAscending)
return getRanks(db, board, mine.Rank - window, mine.Rank + window, sortAscending)
}
/**
* A compare-and-set of one player's value on one board, the write behind
* `POST /leaderboard/CheckAndSetStat`.
*
* `expected` is what the client believes is stored: with a number, the row is written only
* if it still holds that value, which is how a stale client is kept from walking a board
* backwards; with null the client believes nothing is stored, and the value is written
* regardless — a row that exists is overwritten rather than the write being refused, since
* the client's belief about a fresh room is the one it can't have checked.
*
* Returns whether the write landed.
*/
export async function checkAndSetStat(
db: D1Database,
board: Board,
playerId: number,
value: number,
expected: number | null
): Promise<boolean> {
const result = await db
.prepare(
`INSERT INTO leaderboard (player_id, room_id, stat_channel, stat_value)
VALUES (?1, ?2, ?3, ?4)
ON CONFLICT (player_id, room_id, stat_channel) DO UPDATE SET stat_value = excluded.stat_value
WHERE ?5 IS NULL OR stat_value = ?5`
)
.bind(playerId, board.roomId, board.statChannel, value, expected)
.run()
return (result.meta.changes ?? 0) > 0
}
+162 -74
View File
@@ -3,6 +3,17 @@ import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
import { useWorkersLogger } from 'workers-tagged-logger' import { useWorkersLogger } from 'workers-tagged-logger'
import { logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers' import { logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
import { validateAndGetAccountId } from '@repo/jwt'
import {
checkAndSetStat,
getNearbyScores,
getPlayerRank,
getRanks,
MAX_WINDOW,
NO_SCORE,
UNRANKED,
} from './leaderboard-db'
import { import {
CheckAndSetStatBody, CheckAndSetStatBody,
@@ -17,26 +28,54 @@ import {
} from './openapi' } from './openapi'
import type { App } from './context' import type { App } from './context'
import type { Board } from './leaderboard-db'
/** /**
* Leaderboard Worker. Nothing scores anything here yet — the routes answer the shape the * Leaderboard Worker. One board per (room, stat channel), stored in the `leaderboard` table
* client parses, with no rows, no rank and no stored stats behind them. * (see leaderboard-db.ts): `CheckAndSetStat` writes the caller's value on one, and the three
* reads rank them.
*/ */
/** /** A board selector as the client posts it. Every field is optional on the wire — a body
* The rank a player who isn't on the board gets. Nothing is scored here, so every caller is * that names nothing still gets an answer, it's just an empty one. */
* unranked — but "unranked" has to be said in the client's own vocabulary, and `Rank` is interface BoardBody {
* 1-based: a 0 would render as first place and a negative one may not render at all. A PlayerId?: number
* number far past the end of any real board reads as last, which is what an unscored player RoomId?: number
* is, and is recognisable in a log or a screenshot as a sentinel rather than a real standing. StatChannel?: number
*/ FilterType?: number
const UNRANKED = 99999 SortAscending?: boolean
RankStart?: number
RankEnd?: number
WindowSize?: number
}
/** Read a JSON body leniently: an unreadable one is `{}`, never an error — a board that
* fails to draw is worse than one that draws empty. */
async function readBody<T extends object>(c: { req: { json<U>(): Promise<U> } }): Promise<T> {
return c.req.json<T>().catch(() => ({}) as T)
}
const int = (v: unknown, fallback: number) => (Number.isInteger(v) ? (v as number) : fallback)
/** The client's `FilterType`: who a board counts. */
const enum FilterType {
Global = 0,
Friends = 1,
}
/** /**
* The score behind {@link UNRANKED}. Zero rather than a second sentinel: no stat has ever * The board a body names: `RoomId` + `StatChannel`, each 0 when absent, seen through
* been stored, and 0 is what "no score" means in the client's own units. * `PlayerId`'s friends when `FilterType` is Friends. A friends board with no `PlayerId`
* has nobody to be friends of, so it falls back to the global one rather than to nothing.
*/ */
const NO_SCORE = 0 function board(body: BoardBody): Board {
const playerId = int(body.PlayerId, 0)
return {
roomId: int(body.RoomId, 0),
statChannel: int(body.StatChannel, 0),
...(int(body.FilterType, 0) === FilterType.Friends && playerId !== 0 && { friendsOf: playerId }),
}
}
const app = new Hono<App>() const app = new Hono<App>()
.use( .use(
@@ -77,34 +116,41 @@ const app = new Hono<App>()
// a blank board instead of failing. The key must be present — a bare `{}` trips its // a blank board instead of failing. The key must be present — a bare `{}` trips its
// parser. // parser.
// //
// Nothing is ranked or stored yet, so the request body is ignored. It IS logged: the // The body is GetPlayerRank's plus `WindowSize`: the rows `WindowSize` either side of
// body's shape hasn't been recovered from the client, and this route is how it gets // the player's rank (capped at MAX_WINDOW whatever the client asks — the client asks for
// watched. Read it as text — the shape is unknown, so parsing it would only invent one — // 10), or the top of the board when they aren't on it. `FilterType` 1 restricts the
// and never fail on it, since an unreadable body must not cost the client its board. // board to the player and their friends, ranked among themselves. An unreadable body is
// answered with an empty board, never an error.
.post( .post(
'/leaderboard/GetNearbyScores', '/leaderboard/GetNearbyScores',
describeRoute({ describeRoute({
tags: ['Leaderboard'], tags: ['Leaderboard'],
summary: 'The scores around a player', summary: 'The scores around a player',
description: [ description: [
'What the client shows when it opens a leaderboard ON someone rather than at the top.', 'What the client shows when it opens a leaderboard ON someone rather than at the top:',
`the rows \`WindowSize\` (at most ${MAX_WINDOW}, the default) either side of \`PlayerId\`s`,
'rank on the board `RoomId` + `StatChannel` names, or the top of the board when the',
'player isnt on it. `FilterType` 1 (Friends) restricts the board to `PlayerId` and',
'their friends, ranked among themselves.',
'', '',
'Nothing is scored or stored on this server yet, so `Rows` is always empty — a complete', 'An empty `Rows` is a complete answer meaning "this leaderboard has no scores", which',
'answer meaning "this leaderboard has no scores", which the client renders as a blank', 'the client renders as a blank board rather than failing. The key is always present; a',
'board rather than failing. The key is always present; a bare `{}` trips its parser.', 'bare `{}` trips its parser. An unreadable body is answered with an empty board.',
'',
'The request body is IGNORED, and logged rather than parsed: its shape has not been',
'recovered from the client, so this route is how it gets watched. An unreadable body is',
'not an error either — it must not cost the client its board.',
].join(' '), ].join(' '),
requestBody: jsonBody(GetNearbyScoresBody, 'Ignored and logged; shape not yet recovered'), requestBody: jsonBody(GetNearbyScoresBody, 'The player and the board to centre on'),
responses: { 200: json(LeaderboardRows, 'The board, always with no rows') }, responses: { 200: json(LeaderboardRows, 'The rows around the player') },
}), }),
async (c) => { async (c) => {
const body = await c.req.text().catch(() => '<unreadable>') const body = await readBody<BoardBody>(c)
logger.info('GetNearbyScores', { body }) logger.info('GetNearbyScores', { body })
const rows: unknown[] = [] const rows = await getNearbyScores(
c.env.DB,
board(body),
int(body.PlayerId, 0),
int(body.WindowSize, MAX_WINDOW),
body.SortAscending === true
)
return c.json({ Rows: rows }) return c.json({ Rows: rows })
} }
) )
@@ -116,8 +162,9 @@ const app = new Hono<App>()
// //
// Same answer and same rules as GetNearbyScores: `{ Rows: [...] }`, where an EMPTY // Same answer and same rules as GetNearbyScores: `{ Rows: [...] }`, where an EMPTY
// `Rows` is a complete answer meaning "this leaderboard has no scores" and the key must // `Rows` is a complete answer meaning "this leaderboard has no scores" and the key must
// be present. Nothing is ranked or stored yet, so the body is ignored — only logged, and // be present. Ranks are 1-based; a `RankStart` of 0 is read as the top. `FilterType` 1
// read as text so an unreadable body can never cost the client its board. // ranks the viewer and their friends among themselves. An unreadable body is answered
// with an empty board, never an error.
.post( .post(
'/leaderboard/GetRanks', '/leaderboard/GetRanks',
describeRoute({ describeRoute({
@@ -129,20 +176,26 @@ const app = new Hono<App>()
'plus `StatChannel`), the viewer (`PlayerId`) and the ordering (`FilterType`,', 'plus `StatChannel`), the viewer (`PlayerId`) and the ordering (`FilterType`,',
'`SortAscending`).', '`SortAscending`).',
'', '',
'Answers exactly what `GetNearbyScores` answers, under the same rules: `Rows` is always', 'Answers the rows ranked `RankStart`..`RankEnd` on the board `RoomId` + `StatChannel`',
'empty because nothing is scored or stored here yet, and the key is always present.', 'names (1-based; 0 is read as the top), highest value first unless `SortAscending`. An',
'', 'empty `Rows` means "this leaderboard has no scores"; the key is always present.',
'The body is IGNORED — it is logged, not parsed — so the fields are documented as the', '`FilterType` 1 (Friends) restricts the board to `PlayerId` and their friends, ranked',
'record of what the client asks for rather than as anything the handler reads.', 'among themselves.',
].join(' '), ].join(' '),
requestBody: jsonBody(GetRanksBody, 'The slice and board the client is asking for'), requestBody: jsonBody(GetRanksBody, 'The slice and board the client is asking for'),
responses: { 200: json(LeaderboardRows, 'The board, always with no rows') }, responses: { 200: json(LeaderboardRows, 'The requested slice of the board') },
}), }),
async (c) => { async (c) => {
const body = await c.req.text().catch(() => '<unreadable>') const body = await readBody<BoardBody>(c)
logger.info('GetRanks', { body }) logger.info('GetRanks', { body })
const rows: unknown[] = [] const rows = await getRanks(
c.env.DB,
board(body),
int(body.RankStart, 1),
int(body.RankEnd, 10),
body.SortAscending === true
)
return c.json({ Rows: rows }) return c.json({ Rows: rows })
} }
) )
@@ -156,9 +209,9 @@ const app = new Hono<App>()
// therefore the one field read out of the body: answering with a different player's id // therefore the one field read out of the body: answering with a different player's id
// would be answering a question nobody asked. // would be answering a question nobody asked.
// //
// Nothing is scored here, so every caller is unranked and gets {@link UNRANKED} with a // A player with no row in the room gets {@link UNRANKED} with a zero score. A body that
// zero score. A body that can't be read still gets an answer — a board that fails to draw // can't be read still gets an answer — a board that fails to draw is worse than one that
// is worse than one that draws the player as unranked — so `PlayerId` falls back to 0. // draws the player as unranked — so `PlayerId` falls back to 0.
.post( .post(
'/leaderboard/GetPlayerRank', '/leaderboard/GetPlayerRank',
describeRoute({ describeRoute({
@@ -169,24 +222,28 @@ const app = new Hono<App>()
'the board — the body names the player and the board (`RoomId` + `StatChannel` +', 'the board — the body names the player and the board (`RoomId` + `StatChannel` +',
'`FilterType`: Global 0, Friends 1).', '`FilterType`: Global 0, Friends 1).',
'', '',
'Nothing is scored or stored on this server yet, so the answer is always the same:', '`Score` is the players value on the board `RoomId` + `StatChannel` names and `Rank`',
`\`Rank\` ${UNRANKED}, a sentinel meaning unranked (ranks are 1-based, so a 0 would`, `their 1-based position on it. A player with no row there answers \`Rank\` ${UNRANKED}, a sentinel meaning`,
'render as first place), and `Score` 0.', 'unranked (ranks are 1-based, so a 0 would render as first place), and `Score` 0.',
'', '',
'`PlayerId` is echoed from the request and is the only field read out of it — the', '`FilterType` 1 (Friends) ranks the player among their friends only.',
'response carries no board selectors, so the client matches the answer to its own', '',
'question. An unreadable body is answered rather than rejected, with a `PlayerId` of 0.', '`PlayerId` is echoed from the request — the response carries no board selectors, so',
'the client matches the answer to its own question. An unreadable body is answered',
'rather than rejected, with a `PlayerId` of 0.',
].join(' '), ].join(' '),
requestBody: jsonBody(GetPlayerRankBody, 'The player and the board being asked about'), requestBody: jsonBody(GetPlayerRankBody, 'The player and the board being asked about'),
responses: { 200: json(PlayerRank, 'The players standing — always unranked') }, responses: { 200: json(PlayerRank, 'The players standing') },
}), }),
async (c) => { async (c) => {
const body = await c.req const body = await readBody<BoardBody>(c)
.json<{ PlayerId?: number }>()
.catch(() => ({}) as { PlayerId?: number })
logger.info('GetPlayerRank', { body }) logger.info('GetPlayerRank', { body })
return c.json({ PlayerId: body.PlayerId ?? 0, Score: NO_SCORE, Rank: UNRANKED }) const playerId = int(body.PlayerId, 0)
if (playerId === 0) return c.json({ PlayerId: 0, Score: NO_SCORE, Rank: UNRANKED })
return c.json(
await getPlayerRank(c.env.DB, board(body), playerId, body.SortAscending === true)
)
} }
) )
@@ -196,9 +253,11 @@ const app = new Hono<App>()
// how a room's high-score board avoids being walked backwards by a stale client. There is // how a room's high-score board avoids being walked backwards by a stale client. There is
// no `PlayerId`: the stat belongs to whoever is calling. // no `PlayerId`: the stat belongs to whoever is calling.
// //
// Nothing is stored yet, so the write is accepted and dropped. The answer is a BARE `0` — // The caller comes from the bearer token — no token, 401, since a stat with no owner has
// not an envelope, not `{ value: 0 }` — which is what the live service returns and so what // nowhere to go. The write lands in `leaderboard` as the caller's value on the board
// the client's parser expects. The body is logged, not read. // `RoomId` + `StatChannel` names. The answer is a BARE `0` — not an
// envelope, not `{ value: 0 }` — which is what the live service returns and so what the
// client's parser expects; it is 0 even when the compare failed and nothing was written.
.post( .post(
'/leaderboard/CheckAndSetStat', '/leaderboard/CheckAndSetStat',
describeRoute({ describeRoute({
@@ -209,19 +268,45 @@ const app = new Hono<App>()
'wants stored, `CurrentStatValue` what it believes is stored now (null when it believes', 'wants stored, `CurrentStatValue` what it believes is stored now (null when it believes',
'nothing is). No `PlayerId` — the stat belongs to the caller.', 'nothing is). No `PlayerId` — the stat belongs to the caller.',
'', '',
'Nothing is stored on this server yet, so the write is accepted and dropped. The', 'Stores `StatValue` as the callers value on the board `RoomId` + `StatChannel` names',
'response is the BARE number `0`, not an envelope and not a `{ value }` wrapper — what', '(the caller is the Bearer token; 401 without one). With a numeric `CurrentStatValue`',
'the live service answers, and what the clients parser expects.', 'the row is written only if it still holds that value; with null it is written',
'regardless.',
'', '',
'The body is IGNORED and logged, which is how these shapes get recovered from a live', 'The response is the BARE number `0`, not an envelope and not a `{ value }` wrapper —',
'client.', 'what the live service answers, and what the clients parser expects — whether or not',
'the compare passed.',
].join(' '), ].join(' '),
requestBody: jsonBody(CheckAndSetStatBody, 'The stat, the room and the value to store'), requestBody: jsonBody(CheckAndSetStatBody, 'The stat, the room and the value to store'),
responses: { 200: json(CheckAndSetStatResponse, 'Always the bare number 0') }, responses: {
200: json(CheckAndSetStatResponse, 'Always the bare number 0'),
401: { description: 'No valid bearer token' },
},
}), }),
async (c) => { async (c) => {
const body = await c.req.text().catch(() => '<unreadable>') const accountId = await validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get())
logger.info('CheckAndSetStat', { body }) if (accountId === null) return c.body(null, 401)
const body = await readBody<{
RoomId?: number
StatChannel?: number
StatValue?: number
CurrentStatValue?: number | null
}>(c)
logger.info('CheckAndSetStat', { accountId, body })
const target = board(body)
if (target.roomId !== 0 && typeof body.StatValue === 'number') {
const expected = typeof body.CurrentStatValue === 'number' ? body.CurrentStatValue : null
const written = await checkAndSetStat(
c.env.DB,
target,
accountId,
Math.trunc(body.StatValue),
expected
)
if (!written) logger.info('CheckAndSetStat: stale, not written', { accountId, ...target })
}
return c.json(0) return c.json(0)
} }
@@ -242,17 +327,20 @@ app.get(
'Leaderboards for recflare, a private-server reimplementation of the Rec Room', 'Leaderboards for recflare, a private-server reimplementation of the Rec Room',
'backend — the boards a room keeps for the stats it tracks.', 'backend — the boards a room keeps for the stats it tracks.',
'', '',
'NOTHING IS SCORED HERE YET, and every route answers accordingly rather than', 'One board per (room, stat channel): `CheckAndSetStat` stores the callers value on',
'failing: the two board reads answer `{ "Rows": [] }`, an empty list being a', 'one, and the reads rank them — highest first unless `SortAscending`, ties broken on',
'complete answer meaning "this leaderboard has no scores" (the `Rows` key is always', 'the lower player id, ranks 1-based. `FilterType` 1 reads a board as the viewer and',
'present — a bare `{}` trips the clients parser); `GetPlayerRank` answers a rank of', 'their friends only (the `api` workers `relationship` table), ranked among',
'99999, the sentinel for unranked, with a score of 0; and `CheckAndSetStat` accepts', 'themselves.',
'a stat write, drops it, and answers the bare number `0`.',
'', '',
'Only `GetPlayerRank` reads anything out of its request body, and only the', 'The two board reads answer `{ "Rows": [ { PlayerId, Score, Rank } ] }`, an empty',
'`PlayerId` it echoes back. Every route logs the body verbatim, which is how these', 'list being a complete answer meaning "this leaderboard has no scores" (the `Rows`',
'shapes get recovered from a live client; `GetNearbyScores` body is still unknown', 'key is always present — a bare `{}` trips the clients parser); `GetPlayerRank`',
'for exactly that reason. No route needs a token today.', 'answers a player with no row a rank of 99999, the sentinel for unranked, with a',
'score of 0; and `CheckAndSetStat` answers the bare number `0`.',
'',
'Only `CheckAndSetStat` needs a token — the stat belongs to whoever is calling.',
'Unreadable bodies are answered (empty board / unranked), never rejected.',
].join('\n'), ].join('\n'),
}, },
servers: [{ url: 'https://leaderboard.recflare.net', description: 'Production' }], servers: [{ url: 'https://leaderboard.recflare.net', description: 'Production' }],
+25 -27
View File
@@ -27,8 +27,8 @@ export function json(schema: z.ZodType, description: string) {
/** /**
* Convert a zod schema to a plain OpenAPI schema for a request body. `describeRoute`'s * Convert a zod schema to a plain OpenAPI schema for a request body. `describeRoute`'s
* `requestBody` takes an OpenAPI schema (not a `resolver()`). zod's `$schema` key and * `requestBody` takes an OpenAPI schema (not a `resolver()`). zod's `$schema` key and
* `additionalProperties: false` are dropped — the handlers read nothing out of these * `additionalProperties: false` are dropped — the handlers ignore fields they don't read,
* bodies at all, so a closed object would misreport them as stricter than they are. * so a closed object would misreport them as stricter than they are.
*/ */
function toOpenApiSchema(schema: z.ZodType): OpenAPIV3_1.SchemaObject { function toOpenApiSchema(schema: z.ZodType): OpenAPIV3_1.SchemaObject {
const { $schema: _$schema, additionalProperties: _extra, ...jsonSchema } = z.toJSONSchema(schema) const { $schema: _$schema, additionalProperties: _extra, ...jsonSchema } = z.toJSONSchema(schema)
@@ -45,18 +45,15 @@ export function jsonBody(schema: z.ZodType, description: string): OpenAPIV3_1.Re
/** /**
* Both leaderboard reads answer this and nothing else: `{ Rows: [...] }`. * Both leaderboard reads answer this and nothing else: `{ Rows: [...] }`.
* *
* `Rows` is EMPTY on this server — nothing scores anything yet — and an empty list is a * An EMPTY `Rows` is a complete answer meaning "this leaderboard has no scores", which the
* complete answer meaning "this leaderboard has no scores", which the client renders as a * client renders as a blank board rather than failing. The key must be present; a bare `{}`
* blank board rather than failing. The key must be present; a bare `{}` trips its parser. * trips its parser. Each row is the same `{ PlayerId, Score, Rank }` {@link PlayerRank}
* * answers — the shape both reference servers serve.
* The row shape is therefore undocumented: no live response has ever carried one, so
* describing a row here would be inventing it. It is typed as an open object rather than
* `unknown` so a viewer shows an object in the array.
*/ */
export const LeaderboardRows = z.object({ export const LeaderboardRows = z.object({
Rows: z Rows: z
.array(z.looseObject({})) .array(z.lazy(() => PlayerRank))
.describe('The boards rows. Always empty — nothing is scored or stored yet.'), .describe('The boards rows, in rank order. Empty when the board has no scores.'),
}) })
/** /**
@@ -64,14 +61,14 @@ export const LeaderboardRows = z.object({
* `{"PlayerId":205,"Score":4200,"Rank":17}`. * `{"PlayerId":205,"Score":4200,"Rank":17}`.
* *
* Three fields only: none of the board selectors the request names are echoed back, so the * Three fields only: none of the board selectors the request names are echoed back, so the
* client matches the answer to the question by having asked it. Nothing is scored here yet, * client matches the answer to the question by having asked it. A player with no row gets
* so `Rank` is a constant sentinel and `Score` is zero — see the route for why that pairing * the 99999 sentinel with a zero score — see the route for why that rather than a rank of
* rather than a rank of 0, which would read as "first place". * 0, which would read as "first place".
*/ */
export const PlayerRank = z.object({ export const PlayerRank = z.object({
PlayerId: z.int().describe('Echoed from the request — whose rank this is'), PlayerId: z.int().describe('Echoed from the request — whose rank this is'),
Score: z.int().describe('The stat value behind the rank. Always 0 — nothing is scored yet'), Score: z.int().describe('The players value on the board; 0 when they have no row there'),
Rank: z.int().describe('1-based position on the board. Always 99999 — an unranked sentinel'), Rank: z.int().describe('1-based position on the board; 99999 when the player isnt on it'),
}) })
/** /**
@@ -91,9 +88,8 @@ export const CheckAndSetStatResponse = z
* The body the client posts to `GetRanks`, e.g. * The body the client posts to `GetRanks`, e.g.
* `{"RankStart":0,"RankEnd":9,"PlayerId":2,"StatChannel":1,"RoomId":6,"FilterType":0,"SortAscending":false}`. * `{"RankStart":0,"RankEnd":9,"PlayerId":2,"StatChannel":1,"RoomId":6,"FilterType":0,"SortAscending":false}`.
* *
* Recovered from a live client, not from a spec, and IGNORED by the handler today — the * Recovered from a live client, not from a spec. `FilterType` 1 restricts the board to
* board is empty whatever it says. It is documented because it is the record of what the * `PlayerId` and their friends.
* client asks for, which is what an implementation will have to answer.
*/ */
export const GetRanksBody = z.object({ export const GetRanksBody = z.object({
RankStart: z.int().describe('First rank of the slice, 0-based and inclusive'), RankStart: z.int().describe('First rank of the slice, 0-based and inclusive'),
@@ -110,7 +106,7 @@ export const GetRanksBody = z.object({
* `{"PlayerId":205,"StatChannel":2,"RoomId":14,"FilterType":0,"SortAscending":false}`. * `{"PlayerId":205,"StatChannel":2,"RoomId":14,"FilterType":0,"SortAscending":false}`.
* *
* The same board selectors {@link GetRanksBody} carries, minus the slice — this asks about * The same board selectors {@link GetRanksBody} carries, minus the slice — this asks about
* one player rather than a page. Only `PlayerId` is read today, to echo it back. * one player rather than a page.
*/ */
export const GetPlayerRankBody = z.object({ export const GetPlayerRankBody = z.object({
PlayerId: z.int().describe('The player whose rank is being asked for'), PlayerId: z.int().describe('The player whose rank is being asked for'),
@@ -139,11 +135,13 @@ export const CheckAndSetStatBody = z.object({
}) })
/** /**
* The body posted to `GetNearbyScores`. Its shape has NOT been recovered from the client — * The body posted to `GetNearbyScores`: {@link GetPlayerRankBody} plus `WindowSize`. This
* the handler logs the raw text precisely so it can be — so this documents an open object * is the reference servers' shape (the client's `GetNearbyScoresRequestDTO` extends its
* rather than guessing fields. Expect it to name a player and a board the way * rank request with `WindowSize`); it has not yet been watched from a live client here,
* {@link GetRanksBody} does. * which is why the handler still logs it.
*/ */
export const GetNearbyScoresBody = z export const GetNearbyScoresBody = GetPlayerRankBody.extend({
.looseObject({}) WindowSize: z
.describe('Unknown shape — logged by the handler so it can be recovered from a live client.') .int()
.describe('How many rows either side of the player to return; default and maximum 10'),
})
+377 -29
View File
@@ -1,5 +1,82 @@
import { SELF } from 'cloudflare:test' import { adminSecretsStore, env, SELF } from 'cloudflare:test'
import { expect, it } from 'vitest' import { beforeAll, describe, expect, it } from 'vitest'
import { RELATIONSHIP_SCHEMA_DDL, RelationshipType } from '@repo/domain'
import { SCHEMA_DDL } from '../../leaderboard-db'
beforeAll(async () => {
// Seed the shared JWT signing key into the local Secrets Store so .get() resolves.
await adminSecretsStore(env.JWT_SECRET).create(TEST_SECRET)
for (const stmt of SCHEMA_DDL) await env.DB.prepare(stmt).run()
// The `api` worker's relationship table — the friends filter reads it.
for (const stmt of RELATIONSHIP_SCHEMA_DDL) await env.DB.prepare(stmt).run()
})
const befriend = (a: number, b: number) =>
env.DB.prepare(
'INSERT INTO relationship (requester_id, target_id, relationship_type) VALUES (?1, ?2, ?3)'
)
.bind(a, b, RelationshipType.Friend)
.run()
// Mint a token the way the `auth` worker does, signing with the shared test key seeded
// into the JWT_SECRET store, so this worker's validation accepts it.
const TEST_SECRET = 'test-signing-key'
function b64url(input: ArrayBuffer | string): string {
const bytes = typeof input === 'string' ? new TextEncoder().encode(input) : new Uint8Array(input)
let binary = ''
for (const byte of bytes) binary += String.fromCharCode(byte)
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
async function bearer(sub: number): Promise<Record<string, string>> {
const now = Math.floor(Date.now() / 1000)
const signingInput = `${b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))}.${b64url(
JSON.stringify({ sub: String(sub), exp: now + 3600 })
)}`
const key = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(TEST_SECRET),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
)
const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(signingInput))
return { Authorization: `Bearer ${signingInput}.${b64url(sig)}` }
}
const post = (path: string, body: unknown, headers: Record<string, string> = {}) =>
SELF.fetch(`https://example.com/leaderboard/${path}`, {
method: 'POST',
headers: { 'content-type': 'application/json', ...headers },
body: JSON.stringify(body),
})
async function setStat(
player: number,
RoomId: number,
StatValue: number,
CurrentStatValue: number | null = null,
StatChannel = 2
) {
const res = await post(
'CheckAndSetStat',
{ StatChannel, RoomId, StatValue, CurrentStatValue },
await bearer(player)
)
expect(res.status).toBe(200)
// The whole body is the number — not an envelope, not `{ value: 0 }`.
expect(await res.text()).toBe('0')
}
const stored = (player: number, room: number, channel = 2) =>
env.DB.prepare(
'SELECT stat_value FROM leaderboard WHERE player_id = ?1 AND room_id = ?2 AND stat_channel = ?3'
)
.bind(player, room, channel)
.first<{ stat_value: number }>()
it('response with hello world', async () => { it('response with hello world', async () => {
const res = await SELF.fetch('https://example.com') const res = await SELF.fetch('https://example.com')
@@ -7,51 +84,43 @@ it('response with hello world', async () => {
expect(await res.text()).toMatchInlineSnapshot(`"hello, world!"`) expect(await res.text()).toMatchInlineSnapshot(`"hello, world!"`)
}) })
it('answers GetNearbyScores with an empty row list', async () => { describe('an empty board', () => {
const res = await SELF.fetch('https://example.com/leaderboard/GetNearbyScores', { it('answers GetNearbyScores with an empty row list', async () => {
method: 'POST', const res = await post('GetNearbyScores', { PlayerId: 1, RoomId: 999, StatChannel: 1 })
body: JSON.stringify({}),
})
expect(res.status).toBe(200) expect(res.status).toBe(200)
expect(await res.json()).toEqual({ Rows: [] }) expect(await res.json()).toEqual({ Rows: [] })
}) })
it('answers GetRanks with an empty row list', async () => { it('answers GetRanks with an empty row list', async () => {
const res = await SELF.fetch('https://example.com/leaderboard/GetRanks', { const res = await post('GetRanks', {
method: 'POST',
body: JSON.stringify({
RankStart: 0, RankStart: 0,
RankEnd: 9, RankEnd: 9,
PlayerId: 2, PlayerId: 2,
StatChannel: 1, StatChannel: 1,
RoomId: 6, RoomId: 999,
FilterType: 0, FilterType: 0,
SortAscending: false, SortAscending: false,
}),
}) })
expect(res.status).toBe(200) expect(res.status).toBe(200)
expect(await res.json()).toEqual({ Rows: [] }) expect(await res.json()).toEqual({ Rows: [] })
}) })
it('answers GetPlayerRank with the unranked sentinel and the callers own id', async () => { it('answers GetPlayerRank with the unranked sentinel and the callers own id', async () => {
const res = await SELF.fetch('https://example.com/leaderboard/GetPlayerRank', { const res = await post('GetPlayerRank', {
method: 'POST',
body: JSON.stringify({
PlayerId: 205, PlayerId: 205,
StatChannel: 2, StatChannel: 2,
RoomId: 14, RoomId: 999,
FilterType: 0, FilterType: 0,
SortAscending: false, SortAscending: false,
}),
}) })
expect(res.status).toBe(200) expect(res.status).toBe(200)
// Three fields, no board selectors: the client pairs the answer with its own question. // Three fields, no board selectors: the client pairs the answer with its own question.
// Rank is 1-based, so the sentinel has to be a big number rather than 0 — which would // Rank is 1-based, so the sentinel has to be a big number rather than 0 — which would
// render the unranked caller as first place. // render the unranked caller as first place.
expect(await res.json()).toEqual({ PlayerId: 205, Score: 0, Rank: 99999 }) expect(await res.json()).toEqual({ PlayerId: 205, Score: 0, Rank: 99999 })
}) })
it('answers GetPlayerRank even when the body is unreadable', async () => { it('answers GetPlayerRank even when the body is unreadable', async () => {
const res = await SELF.fetch('https://example.com/leaderboard/GetPlayerRank', { const res = await SELF.fetch('https://example.com/leaderboard/GetPlayerRank', {
method: 'POST', method: 'POST',
body: 'not json', body: 'not json',
@@ -59,16 +128,295 @@ it('answers GetPlayerRank even when the body is unreadable', async () => {
// A board that fails to draw is worse than one that draws the player as unranked. // A board that fails to draw is worse than one that draws the player as unranked.
expect(res.status).toBe(200) expect(res.status).toBe(200)
expect(await res.json()).toEqual({ PlayerId: 0, Score: 0, Rank: 99999 }) expect(await res.json()).toEqual({ PlayerId: 0, Score: 0, Rank: 99999 })
}) })
it('answers CheckAndSetStat with a bare 0', async () => { it('answers GetRanks with an empty board when the body is unreadable', async () => {
const res = await SELF.fetch('https://example.com/leaderboard/CheckAndSetStat', { const res = await SELF.fetch('https://example.com/leaderboard/GetRanks', {
method: 'POST', method: 'POST',
body: JSON.stringify({ StatChannel: 2, RoomId: 14, StatValue: 1, CurrentStatValue: null }), body: 'not json',
}) })
expect(res.status).toBe(200) expect(res.status).toBe(200)
// The whole body is the number — not an envelope, not `{ value: 0 }`. expect(await res.json()).toEqual({ Rows: [] })
expect(await res.text()).toBe('0') })
})
describe('CheckAndSetStat', () => {
it('refuses a write with no bearer token', async () => {
const res = await post('CheckAndSetStat', {
StatChannel: 2,
RoomId: 14,
StatValue: 1,
CurrentStatValue: null,
})
expect(res.status).toBe(401)
})
it('stores the callers value on the board and answers a bare 0', async () => {
await setStat(42, 14, 3)
expect(await stored(42, 14)).toEqual({ stat_value: 3 })
})
it('overwrites when the client believes nothing is stored', async () => {
await setStat(43, 14, 3)
await setStat(43, 14, 7, null)
expect(await stored(43, 14)).toEqual({ stat_value: 7 })
})
it('writes only when CurrentStatValue matches what is stored', async () => {
await setStat(44, 14, 3)
await setStat(44, 14, 4, 3)
expect(await stored(44, 14)).toEqual({ stat_value: 4 })
// A stale client that still believes 3 is stored doesn't walk the board backwards.
await setStat(44, 14, 1, 3)
expect(await stored(44, 14)).toEqual({ stat_value: 4 })
})
it('keeps rooms and channels apart', async () => {
await setStat(45, 14, 3)
await setStat(45, 15, 9)
await setStat(45, 14, 5, null, 7)
expect(await stored(45, 14)).toEqual({ stat_value: 3 })
expect(await stored(45, 15)).toEqual({ stat_value: 9 })
expect(await stored(45, 14, 7)).toEqual({ stat_value: 5 })
})
})
describe('a scored board', () => {
// Room 100: player 1 has 10, player 2 has 30, player 3 has 20, player 4 has 20 (ties
// break on the lower id, so 3 ranks ahead of 4).
const ROOM = 100
beforeAll(async () => {
await setStat(1, ROOM, 10)
await setStat(2, ROOM, 30)
await setStat(3, ROOM, 20)
await setStat(4, ROOM, 20)
})
it('answers another channel of the same room as its own, empty board', async () => {
const res = await post('GetRanks', {
RankStart: 0,
RankEnd: 9,
PlayerId: 1,
StatChannel: 3,
RoomId: ROOM,
FilterType: 0,
SortAscending: false,
})
expect(await res.json()).toEqual({ Rows: [] })
})
it('ranks highest value first with 1-based ranks', async () => {
const res = await post('GetRanks', {
RankStart: 0,
RankEnd: 9,
PlayerId: 1,
StatChannel: 2,
RoomId: ROOM,
FilterType: 0,
SortAscending: false,
})
expect(await res.json()).toEqual({
Rows: [
{ PlayerId: 2, Score: 30, Rank: 1 },
{ PlayerId: 3, Score: 20, Rank: 2 },
{ PlayerId: 4, Score: 20, Rank: 3 },
{ PlayerId: 1, Score: 10, Rank: 4 },
],
})
})
it('pages the board by rank, inclusive at both ends', async () => {
const res = await post('GetRanks', {
RankStart: 2,
RankEnd: 3,
PlayerId: 1,
StatChannel: 2,
RoomId: ROOM,
FilterType: 0,
SortAscending: false,
})
expect(await res.json()).toEqual({
Rows: [
{ PlayerId: 3, Score: 20, Rank: 2 },
{ PlayerId: 4, Score: 20, Rank: 3 },
],
})
})
it('ranks lowest first when asked to sort ascending', async () => {
const res = await post('GetRanks', {
RankStart: 1,
RankEnd: 2,
PlayerId: 1,
StatChannel: 2,
RoomId: ROOM,
FilterType: 0,
SortAscending: true,
})
expect(await res.json()).toEqual({
Rows: [
{ PlayerId: 1, Score: 10, Rank: 1 },
{ PlayerId: 3, Score: 20, Rank: 2 },
],
})
})
it('answers a players own rank consistently with the page', async () => {
const res = await post('GetPlayerRank', {
PlayerId: 4,
StatChannel: 2,
RoomId: ROOM,
FilterType: 0,
SortAscending: false,
})
expect(await res.json()).toEqual({ PlayerId: 4, Score: 20, Rank: 3 })
})
it('answers a player with no row in the room as unranked', async () => {
const res = await post('GetPlayerRank', {
PlayerId: 77,
StatChannel: 2,
RoomId: ROOM,
FilterType: 0,
SortAscending: false,
})
expect(await res.json()).toEqual({ PlayerId: 77, Score: 0, Rank: 99999 })
})
it('answers the rows around a player, clamped to the board', async () => {
const res = await post('GetNearbyScores', {
PlayerId: 4,
StatChannel: 2,
RoomId: ROOM,
FilterType: 0,
SortAscending: false,
WindowSize: 1,
})
expect(await res.json()).toEqual({
Rows: [
{ PlayerId: 3, Score: 20, Rank: 2 },
{ PlayerId: 4, Score: 20, Rank: 3 },
{ PlayerId: 1, Score: 10, Rank: 4 },
],
})
})
it('answers the top of the board around a player who isnt on it', async () => {
const res = await post('GetNearbyScores', {
PlayerId: 77,
StatChannel: 2,
RoomId: ROOM,
FilterType: 0,
SortAscending: false,
WindowSize: 1,
})
expect(await res.json()).toEqual({
Rows: [
{ PlayerId: 2, Score: 30, Rank: 1 },
{ PlayerId: 3, Score: 20, Rank: 2 },
{ PlayerId: 4, Score: 20, Rank: 3 },
],
})
})
})
describe('the friends filter', () => {
// Room 200, channel 2: players 11..15 score 50, 40, 30, 20, 10. Player 14 is friends
// with 11 (14 requested) and 15 (15 requested); 12 and 13 are strangers, and 16 is a
// friend with no score. Globally 14 is 4th; among friends 2nd.
const ROOM = 200
const board = (extra: object) => ({
StatChannel: 2,
RoomId: ROOM,
FilterType: 1,
SortAscending: false,
...extra,
})
beforeAll(async () => {
await setStat(11, ROOM, 50)
await setStat(12, ROOM, 40)
await setStat(13, ROOM, 30)
await setStat(14, ROOM, 20)
await setStat(15, ROOM, 10)
await befriend(14, 11)
await befriend(15, 14)
await befriend(14, 16)
// A pending request is not a friendship.
await env.DB.prepare(
'INSERT INTO relationship (requester_id, target_id, relationship_type) VALUES (14, 12, ?1)'
)
.bind(RelationshipType.FriendRequestSent)
.run()
})
it('ranks the viewer among their friends on GetRanks', async () => {
const res = await post('GetRanks', board({ PlayerId: 14, RankStart: 1, RankEnd: 10 }))
expect(await res.json()).toEqual({
Rows: [
{ PlayerId: 11, Score: 50, Rank: 1 },
{ PlayerId: 14, Score: 20, Rank: 2 },
{ PlayerId: 15, Score: 10, Rank: 3 },
],
})
})
it('gives the friends rank on GetPlayerRank', async () => {
const res = await post('GetPlayerRank', board({ PlayerId: 14 }))
expect(await res.json()).toEqual({ PlayerId: 14, Score: 20, Rank: 2 })
const global = await post('GetPlayerRank', board({ PlayerId: 14, FilterType: 0 }))
expect(await global.json()).toEqual({ PlayerId: 14, Score: 20, Rank: 4 })
})
it('centres GetNearbyScores on the viewer within their friends', async () => {
const res = await post('GetNearbyScores', board({ PlayerId: 14, WindowSize: 10 }))
expect(await res.json()).toEqual({
Rows: [
{ PlayerId: 11, Score: 50, Rank: 1 },
{ PlayerId: 14, Score: 20, Rank: 2 },
{ PlayerId: 15, Score: 10, Rank: 3 },
],
})
})
it('shows a player with no friends only themself', async () => {
const res = await post('GetRanks', board({ PlayerId: 13, RankStart: 1, RankEnd: 10 }))
expect(await res.json()).toEqual({ Rows: [{ PlayerId: 13, Score: 30, Rank: 1 }] })
})
})
describe('the nearby window', () => {
// Room 300: players 21..45 score 25..1, so 25 rows with player 33 in the middle (rank 13).
const ROOM = 300
beforeAll(async () => {
for (let p = 21; p <= 45; p++) await setStat(p, ROOM, 46 - p)
})
it('caps WindowSize at 10 either side', async () => {
const res = await post('GetNearbyScores', {
PlayerId: 33,
StatChannel: 2,
RoomId: ROOM,
FilterType: 0,
SortAscending: false,
WindowSize: 100,
})
const { Rows } = (await res.json()) as { Rows: { Rank: number }[] }
expect(Rows).toHaveLength(21)
expect(Rows[0]?.Rank).toBe(3)
expect(Rows[20]?.Rank).toBe(23)
})
it('defaults WindowSize to 10 when absent', async () => {
const res = await post('GetNearbyScores', {
PlayerId: 33,
StatChannel: 2,
RoomId: ROOM,
FilterType: 0,
SortAscending: false,
})
const { Rows } = (await res.json()) as { Rows: unknown[] }
expect(Rows).toHaveLength(21)
})
}) })
it('serves an openapi spec with no dangling refs', async () => { it('serves an openapi spec with no dangling refs', async () => {
+12
View File
@@ -4,6 +4,18 @@
"main": "src/leaderboard.app.ts", "main": "src/leaderboard.app.ts",
"compatibility_date": "2026-06-16", "compatibility_date": "2026-06-16",
"compatibility_flags": ["nodejs_compat"], "compatibility_flags": ["nodejs_compat"],
// Shared `recflare` D1 database. This worker owns the `leaderboard` table
// (migrations/), applied under its own migrations table so they don't clash with the
// other workers' migrations that share the database.
"d1_databases": [
{
"binding": "DB",
"database_name": "recflare",
"database_id": "local",
"migrations_dir": "migrations",
"migrations_table": "d1_migrations_leaderboard"
}
],
// Shared Secrets Store holding the HS256 JWT signing key. Every worker binds the // Shared Secrets Store holding the HS256 JWT signing key. Every worker binds the
// same store as JWT_SECRET so tokens signed by `auth` verify here. The "local" // same store as JWT_SECRET so tokens signed by `auth` verify here. The "local"
// store_id placeholder is replaced with RECFLARE_SECRETS_STORE at deploy time. // store_id placeholder is replaced with RECFLARE_SECRETS_STORE at deploy time.
+3
View File
@@ -669,6 +669,9 @@ importers:
apps/leaderboard: apps/leaderboard:
dependencies: dependencies:
'@repo/domain':
specifier: workspace:*
version: link:../../packages/domain
'@repo/hono-helpers': '@repo/hono-helpers':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/hono-helpers version: link:../../packages/hono-helpers