Files
recflare/apps/leaderboard/src/leaderboard.app.ts
T
2026-08-26 12:12:44 -04:00

357 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Hono } from 'hono'
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
import { useWorkersLogger } from 'workers-tagged-logger'
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 {
CheckAndSetStatBody,
CheckAndSetStatResponse,
GetNearbyScoresBody,
GetPlayerRankBody,
GetRanksBody,
json,
jsonBody,
LeaderboardRows,
PlayerRank,
} from './openapi'
import type { App } from './context'
import type { Board } from './leaderboard-db'
/**
* Leaderboard Worker. One board per (room, stat channel), stored in the `leaderboard` table
* (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
* that names nothing still gets an answer, it's just an empty one. */
interface BoardBody {
PlayerId?: number
RoomId?: number
StatChannel?: number
FilterType?: number
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 board a body names: `RoomId` + `StatChannel`, each 0 when absent, seen through
* `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.
*/
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>()
.use(
'*',
// middleware
(c, next) =>
useWorkersLogger(c.env.NAME, {
environment: c.env.ENVIRONMENT,
release: c.env.SENTRY_RELEASE,
})(c, next)
)
.onError(withOnError())
.notFound(withNotFound())
.get(
'/',
describeRoute({
tags: ['Service'],
summary: 'Health check',
description:
'Liveness probe for the leaderboard worker. Answers `text/plain`, not JSON, unlike the other workers health checks. No auth.',
responses: {
200: {
description: 'Service is up',
content: { 'text/plain': { schema: { type: 'string' } } },
},
},
}),
async (c) => {
return c.text('hello, world!')
}
)
// The scores around a player — what the client shows when it opens a leaderboard on
// someone rather than at the top. Answers `{ Rows: [...] }`; an EMPTY `Rows` is a
// complete answer meaning "this leaderboard has no scores", which the client renders as
// a blank board instead of failing. The key must be present — a bare `{}` trips its
// parser.
//
// The body is GetPlayerRank's plus `WindowSize`: the rows `WindowSize` either side of
// the player's rank (capped at MAX_WINDOW whatever the client asks — the client asks for
// 10), or the top of the board when they aren't on it. `FilterType` 1 restricts the
// board to the player and their friends, ranked among themselves. An unreadable body is
// answered with an empty board, never an error.
.post(
'/leaderboard/GetNearbyScores',
describeRoute({
tags: ['Leaderboard'],
summary: 'The scores around a player',
description: [
'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.',
'',
'An empty `Rows` is a complete answer meaning "this leaderboard has no scores", which',
'the client renders as a blank board rather than failing. The key is always present; a',
'bare `{}` trips its parser. An unreadable body is answered with an empty board.',
].join(' '),
requestBody: jsonBody(GetNearbyScoresBody, 'The player and the board to centre on'),
responses: { 200: json(LeaderboardRows, 'The rows around the player') },
}),
async (c) => {
const body = await readBody<BoardBody>(c)
logger.info('GetNearbyScores', { body })
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 })
}
)
// A page of the board itself — what the client shows when it opens a leaderboard at the
// top rather than on a player. The body names the slice (`RankStart`/`RankEnd`, both
// inclusive), the board (`RoomId` + `StatChannel`), the viewer (`PlayerId`) and the
// ordering (`FilterType`, `SortAscending`).
//
// 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
// be present. Ranks are 0-based, as the client's own slice is (it asks for the first ten
// as `RankStart` 0, `RankEnd` 9) and as it renders them (it draws `Rank` 0 as "#1").
// `FilterType` 1 ranks the viewer and their friends among themselves. An unreadable body
// is answered with an empty board, never an error.
.post(
'/leaderboard/GetRanks',
describeRoute({
tags: ['Leaderboard'],
summary: 'A page of the board',
description: [
'What the client shows when it opens a leaderboard at the TOP rather than on a player.',
'The body names the slice (`RankStart`/`RankEnd`, both inclusive), the board (`RoomId`',
'plus `StatChannel`), the viewer (`PlayerId`) and the ordering (`FilterType`,',
'`SortAscending`).',
'',
'Answers the rows ranked `RankStart`..`RankEnd` on the board `RoomId` + `StatChannel`',
'names (0-based, so 0..9 is the first ten), highest value first unless `SortAscending`.',
'An empty `Rows` means "this leaderboard has no scores"; the key is always present.',
'`FilterType` 1 (Friends) restricts the board to `PlayerId` and their friends, ranked',
'among themselves.',
].join(' '),
requestBody: jsonBody(GetRanksBody, 'The slice and board the client is asking for'),
responses: { 200: json(LeaderboardRows, 'The requested slice of the board') },
}),
async (c) => {
const body = await readBody<BoardBody>(c)
logger.info('GetRanks', { body })
const rows = await getRanks(
c.env.DB,
board(body),
int(body.RankStart, 0),
int(body.RankEnd, 9),
body.SortAscending === true
)
return c.json({ Rows: rows })
}
)
// One player's standing, rather than a page of the board — what the client asks when it
// needs to show "you: #17" next to a leaderboard. The body names the player and the board
// (`RoomId` + `StatChannel` + `FilterType`, where FilterType is Global 0 / Friends 1).
//
// The answer is three fields — `{ PlayerId, Score, Rank }` — and notably does NOT echo the
// board back, so the client pairs the answer with its question itself. `PlayerId` is
// therefore the one field read out of the body: answering with a different player's id
// would be answering a question nobody asked.
//
// A player with no row in the room gets {@link UNRANKED} with a zero score. A body that
// can't be read still gets an answer — a board that fails to draw is worse than one that
// draws the player as unranked — so `PlayerId` falls back to 0.
.post(
'/leaderboard/GetPlayerRank',
describeRoute({
tags: ['Leaderboard'],
summary: 'One players rank',
description: [
'What the client asks when it needs a single players standing rather than a page of',
'the board — the body names the player and the board (`RoomId` + `StatChannel` +',
'`FilterType`: Global 0, Friends 1).',
'',
'`Score` is the players value on the board `RoomId` + `StatChannel` names and `Rank`',
`their 0-based position on it — the client adds one before it draws, so \`Rank\` 0 is`,
`shown as "#1". A player with no row there answers \`Rank\` ${UNRANKED}, a sentinel meaning`,
'unranked (0 being a real rank, first place, the sentinel has to be a big number), and',
'`Score` 0.',
'',
'`FilterType` 1 (Friends) ranks the player among their friends only.',
'',
'`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(' '),
requestBody: jsonBody(GetPlayerRankBody, 'The player and the board being asked about'),
responses: { 200: json(PlayerRank, 'The players standing') },
}),
async (c) => {
const body = await readBody<BoardBody>(c)
logger.info('GetPlayerRank', { body })
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)
)
}
)
// A stat write: the client posts the value it wants stored for a room's stat channel,
// along with `CurrentStatValue` — what it believes is stored now, null when it believes
// nothing is. That pairing makes it a compare-and-set rather than a plain write, which 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.
//
// The caller comes from the bearer token — no token, 401, since a stat with no owner has
// nowhere to go. The write lands in `leaderboard` as the caller's value on the board
// `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(
'/leaderboard/CheckAndSetStat',
describeRoute({
tags: ['Leaderboard'],
summary: 'Write a players stat',
description: [
'A compare-and-set on one of a rooms tracked stats: `StatValue` is what the client',
'wants stored, `CurrentStatValue` what it believes is stored now (null when it believes',
'nothing is). No `PlayerId` — the stat belongs to the caller.',
'',
'Stores `StatValue` as the callers value on the board `RoomId` + `StatChannel` names',
'(the caller is the Bearer token; 401 without one). With a numeric `CurrentStatValue`',
'the row is written only if it still holds that value; with null it is written',
'regardless.',
'',
'The response is the BARE number `0`, not an envelope and not a `{ value }` wrapper —',
'what the live service answers, and what the clients parser expects — whether or not',
'the compare passed.',
].join(' '),
requestBody: jsonBody(CheckAndSetStatBody, 'The stat, the room and the value to store'),
responses: {
200: json(CheckAndSetStatResponse, 'Always the bare number 0'),
401: { description: 'No valid bearer token' },
},
}),
async (c) => {
const accountId = await validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get())
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)
}
)
// The generated spec. Documentation only — no request is validated against it (see
// openapi.ts). `hide: true` keeps this route out of its own output.
app.get(
'/openapi.json',
describeRoute({ hide: true }),
withCleanSpec(
openAPIRouteHandler(app, {
documentation: {
info: {
title: 'recflare leaderboard',
version: '1.0.0',
description: [
'Leaderboards for recflare, a private-server reimplementation of the Rec Room',
'backend — the boards a room keeps for the stats it tracks.',
'',
'One board per (room, stat channel): `CheckAndSetStat` stores the callers value on',
'one, and the reads rank them — highest first unless `SortAscending`, ties broken on',
'the lower player id, ranks 0-based (the client adds one before it draws, so `Rank` 0',
'is shown as "#1"). `FilterType` 1 reads a board as the viewer and',
'their friends only (the `api` workers `relationship` table), ranked among',
'themselves.',
'',
'The two board reads answer `{ "Rows": [ { PlayerId, Score, Rank } ] }`, an empty',
'list being a complete answer meaning "this leaderboard has no scores" (the `Rows`',
'key is always present — a bare `{}` trips the clients parser); `GetPlayerRank`',
'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'),
},
servers: [{ url: 'https://leaderboard.recflare.net', description: 'Production' }],
},
})
)
)
export default app