mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
[leaderboard] stub 2 endpoints
This commit is contained in:
@@ -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<App>()
|
||||
.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 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(() => '<unreadable>')
|
||||
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' }],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string, unknown>
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user