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

152 lines
6.8 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 { resolver } from 'hono-openapi'
import { z } from 'zod'
import type { OpenAPIV3_1 } from 'openapi-types'
/**
* OpenAPI schemas for the leaderboard worker.
*
* 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 — 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
* `components.schemas`, leaving a dangling reference. Leaving meta off makes every schema
* inline, which renders correctly in any tool.
*/
/** Emit a zod schema as an `application/json` response body. */
export function json(schema: z.ZodType, description: string) {
return { description, content: { 'application/json': { schema: resolver(schema) } } }
}
/**
* 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
* `additionalProperties: false` are dropped — the handlers ignore fields they don't read,
* so a closed object would misreport them as stricter than they are.
*/
function toOpenApiSchema(schema: z.ZodType): OpenAPIV3_1.SchemaObject {
const { $schema: _$schema, additionalProperties: _extra, ...jsonSchema } = z.toJSONSchema(schema)
return jsonSchema as OpenAPIV3_1.SchemaObject
}
/** 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) } } }
}
// ---- Response schemas ------------------------------------------------------
/**
* Both leaderboard reads answer this and nothing else: `{ Rows: [...] }`.
*
* 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 must be present; a bare `{}`
* trips its parser. Each row is the same `{ PlayerId, Score, Rank }` {@link PlayerRank}
* answers — the shape both reference servers serve.
*/
export const LeaderboardRows = z.object({
Rows: z
.array(z.lazy(() => PlayerRank))
.describe('The board’s rows, in rank order. Empty when the board has no scores.'),
})
/**
* `POST /leaderboard/GetPlayerRank` — one player's standing on one board, e.g.
* `{"PlayerId":205,"Score":4200,"Rank":16}` for the 17th place the client draws.
*
* 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. A player with no row gets
* the 99999 sentinel with a zero score — see the route for why that rather than a rank of
* 0, which IS first place: the client adds one before it draws.
*/
export const PlayerRank = z.object({
PlayerId: z.int().describe('Echoed from the request — whose rank this is'),
Score: z.int().describe('The player’s value on the board; 0 when they have no row there'),
Rank: z
.int()
.describe(
'0-based position on the board — the client adds one to draw it, so 0 is “#1”; 99999 when the player isn’t on it'
),
})
/**
* `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 -------------------------------------------------------
/**
* The body the client posts to `GetRanks`, e.g.
* `{"RankStart":0,"RankEnd":9,"PlayerId":2,"StatChannel":1,"RoomId":6,"FilterType":0,"SortAscending":false}`.
*
* Recovered from a live client, not from a spec. `FilterType` 1 restricts the board to
* `PlayerId` and their friends.
*/
export const GetRanksBody = z.object({
RankStart: z.int().describe('First rank of the slice, 0-based and inclusive'),
RankEnd: z.int().describe('Last rank of the slice, inclusive — 0–9 is the first ten'),
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('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.
*/
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`: {@link GetPlayerRankBody} plus `WindowSize`. This
* is the reference servers' shape (the client's `GetNearbyScoresRequestDTO` extends its
* rank request with `WindowSize`); it has not yet been watched from a live client here,
* which is why the handler still logs it.
*/
export const GetNearbyScoresBody = GetPlayerRankBody.extend({
WindowSize: z
.int()
.describe('How many rows either side of the player to return; default and maximum 10'),
})