[leaderboard] stub 2 endpoints

This commit is contained in:
Devin Zuczek
2026-08-25 16:43:00 -04:00
parent c5421539c3
commit 751e1f28c2
3 changed files with 222 additions and 13 deletions
+118 -9
View File
@@ -4,14 +4,40 @@ 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 { GetNearbyScoresBody, GetRanksBody, json, jsonBody, LeaderboardRows } from './openapi' import {
CheckAndSetStatBody,
CheckAndSetStatResponse,
GetNearbyScoresBody,
GetPlayerRankBody,
GetRanksBody,
json,
jsonBody,
LeaderboardRows,
PlayerRank,
} from './openapi'
import type { App } from './context' import type { App } from './context'
/** /**
* Leaderboard Worker. Nothing scores anything here yet — the routes answer the shape the * Leaderboard Worker. Nothing scores anything here yet — the routes answer the shape the
* client parses, with no rows in them. * client parses, with no rows, no rank and no stored stats behind them.
*/ */
/**
* The rank a player who isn't on the board gets. Nothing is scored here, so every caller is
* unranked — but "unranked" has to be said in the client's own vocabulary, and `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.
*/
const UNRANKED = 99999
/**
* The score behind {@link UNRANKED}. Zero rather than a second sentinel: no stat has ever
* been stored, and 0 is what "no score" means in the client's own units.
*/
const NO_SCORE = 0
const app = new Hono<App>() const app = new Hono<App>()
.use( .use(
'*', '*',
@@ -121,6 +147,86 @@ const app = new Hono<App>()
} }
) )
// 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.
//
// Nothing is scored here, so every caller is unranked and 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).',
'',
'Nothing is scored or stored on this server yet, so the answer is always the same:',
`\`Rank\` ${UNRANKED}, a sentinel meaning 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',
'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 — always unranked') },
}),
async (c) => {
const body = await c.req
.json<{ PlayerId?: number }>()
.catch(() => ({}) as { PlayerId?: number })
logger.info('GetPlayerRank', { body })
return c.json({ PlayerId: body.PlayerId ?? 0, Score: NO_SCORE, Rank: UNRANKED })
}
)
// 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.
//
// Nothing is stored yet, so the write is accepted and dropped. 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. The body is logged, not read.
.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.',
'',
'Nothing is stored on this server yet, so the write is accepted and dropped. 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.',
'',
'The body is IGNORED and logged, which is how these shapes get recovered from a live',
'client.',
].join(' '),
requestBody: jsonBody(CheckAndSetStatBody, 'The stat, the room and the value to store'),
responses: { 200: json(CheckAndSetStatResponse, 'Always the bare number 0') },
}),
async (c) => {
const body = await c.req.text().catch(() => '<unreadable>')
logger.info('CheckAndSetStat', { body })
return c.json(0)
}
)
// The generated spec. Documentation only — no request is validated against it (see // The generated spec. Documentation only — no request is validated against it (see
// openapi.ts). `hide: true` keeps this route out of its own output. // openapi.ts). `hide: true` keeps this route out of its own output.
app.get( app.get(
@@ -136,14 +242,17 @@ 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. Both reads answer `{ "Rows": [] }`, which is a complete', 'NOTHING IS SCORED HERE YET, and every route answers accordingly rather than',
'answer rather than an error: an empty list means "this leaderboard has no scores"', 'failing: the two board reads answer `{ "Rows": [] }`, an empty list being a',
'and the client renders a blank board. The `Rows` key is always present — a bare', 'complete answer meaning "this leaderboard has no scores" (the `Rows` key is always',
'`{}` trips the clients parser.', 'present — a bare `{}` trips the clients parser); `GetPlayerRank` answers a rank of',
'99999, the sentinel for unranked, with a score of 0; and `CheckAndSetStat` accepts',
'a stat write, drops it, and answers the bare number `0`.',
'', '',
'Neither route reads its request body. Both log it verbatim instead, which is how', 'Only `GetPlayerRank` reads anything out of its request body, and only the',
'the shapes below get recovered from a live client; `GetNearbyScores` body is', '`PlayerId` it echoes back. Every route logs the body verbatim, which is how these',
'still unknown for exactly that reason. No route needs a token today.', 'shapes get recovered from a live client; `GetNearbyScores` body is still unknown',
'for exactly that reason. No route needs a token today.',
].join('\n'), ].join('\n'),
}, },
servers: [{ url: 'https://leaderboard.recflare.net', description: 'Production' }], servers: [{ url: 'https://leaderboard.recflare.net', description: 'Production' }],
+64 -4
View File
@@ -9,8 +9,9 @@ import type { OpenAPIV3_1 } from 'openapi-types'
* IMPORTANT: these are DESCRIPTIVE ONLY. They are passed to `describeRoute` to generate * IMPORTANT: these are DESCRIPTIVE ONLY. They are passed to `describeRoute` to generate
* the spec and are never wired into `hono-openapi`'s `validator()`. Same rationale as the * the spec and are never wired into `hono-openapi`'s `validator()`. Same rationale as the
* auth/accounts/econ/match workers: a reverse-engineered protocol, lenient handlers, no * auth/accounts/econ/match workers: a reverse-engineered protocol, lenient handlers, no
* runtime validation. Here it matters more than usual — the handlers do not parse their * runtime validation. Here it matters more than usual — three of the four handlers do not
* bodies at all yet, so a body that contradicts the schema below is still answered. * parse their bodies at all, and the fourth reads one field, so a body that contradicts the
* schema below is still answered.
* *
* Do NOT add `.meta({ id })` to these schemas — with this hono-openapi + zod v4 setup a * Do NOT add `.meta({ id })` to these schemas — with this hono-openapi + zod v4 setup a
* meta'd schema used in a response emits a `$ref` the framework doesn't always hoist into * meta'd schema used in a response emits a `$ref` the framework doesn't always hoist into
@@ -34,7 +35,7 @@ function toOpenApiSchema(schema: z.ZodType): OpenAPIV3_1.SchemaObject {
return jsonSchema as OpenAPIV3_1.SchemaObject return jsonSchema as OpenAPIV3_1.SchemaObject
} }
/** An `application/json` request body — what the client posts to both reads. */ /** An `application/json` request body — every leaderboard route takes one. */
export function jsonBody(schema: z.ZodType, description: string): OpenAPIV3_1.RequestBodyObject { export function jsonBody(schema: z.ZodType, description: string): OpenAPIV3_1.RequestBodyObject {
return { description, content: { 'application/json': { schema: toOpenApiSchema(schema) } } } return { description, content: { 'application/json': { schema: toOpenApiSchema(schema) } } }
} }
@@ -58,6 +59,32 @@ export const LeaderboardRows = z.object({
.describe('The boards rows. Always empty — nothing is scored or stored yet.'), .describe('The boards rows. Always empty — nothing is scored or stored yet.'),
}) })
/**
* `POST /leaderboard/GetPlayerRank` — one player's standing on one board, e.g.
* `{"PlayerId":205,"Score":4200,"Rank":17}`.
*
* 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,
* so `Rank` is a constant sentinel and `Score` is zero — see the route for why that pairing
* rather than a rank of 0, which would read as "first place".
*/
export const PlayerRank = z.object({
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'),
Rank: z.int().describe('1-based position on the board. Always 99999 — an unranked sentinel'),
})
/**
* `POST /leaderboard/CheckAndSetStat` — a BARE JSON number, not an envelope and not a
* `{ value }` wrapper. The whole body is `0`.
*
* What the number means beyond "not an error" hasn't been recovered from the client; `0` is
* what the live service answers, so it is what this answers.
*/
export const CheckAndSetStatResponse = z
.literal(0)
.describe('Always the bare number 0 — the result code the live service returns')
// ---- Request schemas ------------------------------------------------------- // ---- Request schemas -------------------------------------------------------
/** /**
@@ -74,10 +101,43 @@ export const GetRanksBody = z.object({
PlayerId: z.int().describe('The player reading the board'), PlayerId: z.int().describe('The player reading the board'),
StatChannel: z.int().describe('Which of the rooms tracked stats to rank on'), StatChannel: z.int().describe('Which of the rooms tracked stats to rank on'),
RoomId: z.int().describe('The room whose board is being read'), RoomId: z.int().describe('The room whose board is being read'),
FilterType: z.int().describe('Client-side filter selector; its members arent known yet'), FilterType: z.int().describe('Who the board counts: 0 Global, 1 Friends'),
SortAscending: z.boolean().describe('false ranks highest-first, the usual leaderboard'), SortAscending: z.boolean().describe('false ranks highest-first, the usual leaderboard'),
}) })
/**
* The body the client posts to `GetPlayerRank`, e.g.
* `{"PlayerId":205,"StatChannel":2,"RoomId":14,"FilterType":0,"SortAscending":false}`.
*
* 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.
*/
export const GetPlayerRankBody = z.object({
PlayerId: z.int().describe('The player whose rank is being asked for'),
StatChannel: z.int().describe('Which of the rooms tracked stats to rank on'),
RoomId: z.int().describe('The room whose board is being read'),
FilterType: z.int().describe('Who the board counts: 0 Global, 1 Friends'),
SortAscending: z.boolean().describe('false ranks highest-first, the usual leaderboard'),
})
/**
* The body the client posts to `CheckAndSetStat`, e.g.
* `{"StatChannel":2,"RoomId":14,"StatValue":1,"CurrentStatValue":null}`.
*
* A compare-and-set: `CurrentStatValue` is what the client believes is already stored (null
* when it believes nothing is), and `StatValue` is what it wants stored. There is no
* `PlayerId` — the stat belongs to whoever is calling.
*/
export const CheckAndSetStatBody = z.object({
StatChannel: z.int().describe('Which of the rooms tracked stats is being written'),
RoomId: z.int().describe('The room the stat belongs to'),
StatValue: z.number().describe('The value to store'),
CurrentStatValue: z
.number()
.nullable()
.describe('What the client believes is stored now; null when it believes nothing is'),
})
/** /**
* The body posted to `GetNearbyScores`. Its shape has NOT been recovered from the client — * The body posted to `GetNearbyScores`. Its shape has NOT been recovered from the client —
* the handler logs the raw text precisely so it can be — so this documents an open object * the handler logs the raw text precisely so it can be — so this documents an open object
@@ -33,13 +33,53 @@ it('answers GetRanks with an empty row list', async () => {
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 () => {
const res = await SELF.fetch('https://example.com/leaderboard/GetPlayerRank', {
method: 'POST',
body: JSON.stringify({
PlayerId: 205,
StatChannel: 2,
RoomId: 14,
FilterType: 0,
SortAscending: false,
}),
})
expect(res.status).toBe(200)
// 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
// render the unranked caller as first place.
expect(await res.json()).toEqual({ PlayerId: 205, Score: 0, Rank: 99999 })
})
it('answers GetPlayerRank even when the body is unreadable', async () => {
const res = await SELF.fetch('https://example.com/leaderboard/GetPlayerRank', {
method: 'POST',
body: 'not json',
})
// A board that fails to draw is worse than one that draws the player as unranked.
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ PlayerId: 0, Score: 0, Rank: 99999 })
})
it('answers CheckAndSetStat with a bare 0', async () => {
const res = await SELF.fetch('https://example.com/leaderboard/CheckAndSetStat', {
method: 'POST',
body: JSON.stringify({ StatChannel: 2, RoomId: 14, StatValue: 1, CurrentStatValue: null }),
})
expect(res.status).toBe(200)
// The whole body is the number — not an envelope, not `{ value: 0 }`.
expect(await res.text()).toBe('0')
})
it('serves an openapi spec with no dangling refs', async () => { it('serves an openapi spec with no dangling refs', async () => {
const res = await SELF.fetch('https://example.com/openapi.json') const res = await SELF.fetch('https://example.com/openapi.json')
expect(res.status).toBe(200) expect(res.status).toBe(200)
const spec = (await res.json()) as Record<string, unknown> const spec = (await res.json()) as Record<string, unknown>
expect(Object.keys(spec.paths as object).sort()).toEqual([ expect(Object.keys(spec.paths as object).sort()).toEqual([
'/', '/',
'/leaderboard/CheckAndSetStat',
'/leaderboard/GetNearbyScores', '/leaderboard/GetNearbyScores',
'/leaderboard/GetPlayerRank',
'/leaderboard/GetRanks', '/leaderboard/GetRanks',
]) ])
expect(JSON.stringify(spec).match(/\$ref/g)).toBeNull() expect(JSON.stringify(spec).match(/\$ref/g)).toBeNull()