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 { 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 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
|
// 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 client’s parser.',
|
'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',
|
'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' }],
|
||||||
|
|||||||
@@ -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 board’s rows. Always empty — nothing is scored or stored yet.'),
|
.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 -------------------------------------------------------
|
// ---- 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 room’s tracked stats to rank on'),
|
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'),
|
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'),
|
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 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 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 () => {
|
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()
|
||||||
|
|||||||
Reference in New Issue
Block a user