diff --git a/apps/leaderboard/src/leaderboard.app.ts b/apps/leaderboard/src/leaderboard.app.ts index 3fa4c93..8cb55a0 100644 --- a/apps/leaderboard/src/leaderboard.app.ts +++ b/apps/leaderboard/src/leaderboard.app.ts @@ -4,14 +4,40 @@ import { useWorkersLogger } from 'workers-tagged-logger' 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' /** * 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() .use( '*', @@ -121,6 +147,86 @@ const app = new Hono() } ) + // 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 player’s rank', + description: [ + 'What the client asks when it needs a single player’s 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 player’s 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 player’s stat', + description: [ + 'A compare-and-set on one of a room’s 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 client’s 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(() => '') + logger.info('CheckAndSetStat', { body }) + + 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( @@ -136,14 +242,17 @@ app.get( 'Leaderboards for recflare, a private-server reimplementation of the Rec Room', 'backend — the boards a room keeps for the stats it tracks.', '', - 'NOTHING IS SCORED HERE YET. Both reads answer `{ "Rows": [] }`, which is a complete', - 'answer rather than an error: an empty list means "this leaderboard has no scores"', - 'and the client renders a blank board. The `Rows` key is always present — a bare', - '`{}` trips the client’s parser.', + 'NOTHING IS SCORED HERE YET, and every route answers accordingly rather than', + 'failing: the two board reads answer `{ "Rows": [] }`, an empty list being a', + 'complete answer meaning "this leaderboard has no scores" (the `Rows` key is always', + 'present — a bare `{}` trips the client’s 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', - 'the shapes below get recovered from a live client; `GetNearbyScores`’ body is', - 'still unknown for exactly that reason. No route needs a token today.', + 'Only `GetPlayerRank` reads anything out of its request body, and only the', + '`PlayerId` it echoes back. Every route logs the body verbatim, which is how these', + 'shapes get recovered from a live client; `GetNearbyScores`’ body is still unknown', + 'for exactly that reason. No route needs a token today.', ].join('\n'), }, servers: [{ url: 'https://leaderboard.recflare.net', description: 'Production' }], diff --git a/apps/leaderboard/src/openapi.ts b/apps/leaderboard/src/openapi.ts index 367c2fc..820478c 100644 --- a/apps/leaderboard/src/openapi.ts +++ b/apps/leaderboard/src/openapi.ts @@ -9,8 +9,9 @@ import type { OpenAPIV3_1 } from 'openapi-types' * 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 * 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 - * bodies at all yet, so a body that contradicts the schema below is still answered. + * runtime validation. Here it matters more than usual — three of the four handlers do not + * 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 * 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 } -/** 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 { return { description, content: { 'application/json': { schema: toOpenApiSchema(schema) } } } } @@ -58,6 +59,32 @@ export const LeaderboardRows = z.object({ .describe('The board’s 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 ------------------------------------------------------- /** @@ -74,10 +101,43 @@ export const GetRanksBody = z.object({ PlayerId: z.int().describe('The player reading the board'), StatChannel: z.int().describe('Which of the room’s tracked stats to rank on'), RoomId: z.int().describe('The room whose board is being read'), - FilterType: z.int().describe('Client-side filter selector; its members aren’t 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'), }) +/** + * 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 room’s 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 room’s 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 handler logs the raw text precisely so it can be — so this documents an open object diff --git a/apps/leaderboard/src/test/integration/api.test.ts b/apps/leaderboard/src/test/integration/api.test.ts index 2d206d2..98eb60f 100644 --- a/apps/leaderboard/src/test/integration/api.test.ts +++ b/apps/leaderboard/src/test/integration/api.test.ts @@ -33,13 +33,53 @@ it('answers GetRanks with an empty row list', async () => { expect(await res.json()).toEqual({ Rows: [] }) }) +it('answers GetPlayerRank with the unranked sentinel and the caller’s 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 () => { const res = await SELF.fetch('https://example.com/openapi.json') expect(res.status).toBe(200) const spec = (await res.json()) as Record expect(Object.keys(spec.paths as object).sort()).toEqual([ '/', + '/leaderboard/CheckAndSetStat', '/leaderboard/GetNearbyScores', + '/leaderboard/GetPlayerRank', '/leaderboard/GetRanks', ]) expect(JSON.stringify(spec).match(/\$ref/g)).toBeNull()