[leaderboard] update leaderboard for 2026 changes

This commit is contained in:
Devin Zuczek
2026-10-22 14:58:29 -04:00
parent 20a947dcad
commit 16e5f61160
5 changed files with 269 additions and 107 deletions
+67 -29
View File
@@ -38,15 +38,16 @@ import type { Board } from './leaderboard-db'
/** A board selector as the client posts it. Every field is optional on the wire — a body /** A board selector as the client posts it. Every field is optional on the wire — a body
* that names nothing still gets an answer, it's just an empty one. * that names nothing still gets an answer, it's just an empty one.
* *
* Two shapes name the same board. Up to 2025 the client sends `RoomId` + `StatChannel`; the * Two shapes name a board, told apart by which keys are PRESENT (see {@link board}). Up to
* 2026 client sends `PrimaryKey` (the room) + `SecondaryKey` (the channel) and adds * 2025 the client sends `RoomId` + `StatChannel`. 20260323 renamed them `PrimaryKey` +
* `LeaderboardType`, which is 0 for a room board. Same room, same board, either way. */ * `SecondaryKey`, made both nullable, and added `LeaderboardType` (0 Room, 1 Event), which
* says what `PrimaryKey` is the id of. */
interface BoardBody { interface BoardBody {
PlayerId?: number PlayerId?: number
RoomId?: number RoomId?: number
StatChannel?: number StatChannel?: number
PrimaryKey?: number PrimaryKey?: number | null
SecondaryKey?: number SecondaryKey?: number | null
LeaderboardType?: number LeaderboardType?: number
FilterType?: number FilterType?: number
SortAscending?: boolean SortAscending?: boolean
@@ -69,26 +70,46 @@ const enum FilterType {
Friends = 1, Friends = 1,
} }
/** The 2026 client's `LeaderboardType`: what kind of thing `PrimaryKey` names. Only the room /** The 2026 client's `LeaderboardType`: what `PrimaryKey` is the id of. */
* board (0) has been observed. */ const enum LeaderboardType {
const ROOM_LEADERBOARD = 0 Room = 0,
Event = 1,
}
/** Whether a body is the 2026 shape. Decided by key PRESENCE, not value, because the 2026
* keys can be null and a 2025 body never carries any of them. */
const is2026Body = (body: BoardBody) =>
'PrimaryKey' in body || 'SecondaryKey' in body || 'LeaderboardType' in body
/** /**
* The board a body names: `RoomId` + `StatChannel` (2026: `PrimaryKey` + `SecondaryKey`), * The board a body names, or null when it names none, which every read answers as an empty
* each 0 when absent, seen through `PlayerId`'s friends when `FilterType` is Friends. A * board (or an unranked player). Seen through `PlayerId`'s friends when `FilterType` is
* friends board with no `PlayerId` has nobody to be friends of, so it falls back to the * Friends. A friends board with no `PlayerId` has nobody to be friends of, so it falls back
* global one rather than to nothing. * to the global one rather than to nothing.
* *
* A `LeaderboardType` other than a room board is one this server has never seen, and its * 2025 body: `RoomId` + `StatChannel`, each 0 when absent, exactly as before.
* `PrimaryKey` may not be a room id at all, so it reads as room 0: an empty board, and a *
* write `CheckAndSetStat` skips. Better than filing it on some room's board. * 2026 body: `PrimaryKey` + `SecondaryKey`. A null key is UNSCOPED and names no board. It is
* not room 0 or channel 0. Only a Room board (`LeaderboardType` 0) reads rows: an Event
* board's `PrimaryKey` is an event id, and nothing stores event stats (`CheckAndSetStat`
* takes only a room), so it reads empty instead of landing on whichever room shares the id.
*/ */
function board(body: BoardBody): Board { function board(body: BoardBody): Board | null {
let roomId: number
let statChannel: number
if (is2026Body(body)) {
if (int(body.LeaderboardType, LeaderboardType.Room) !== LeaderboardType.Room) return null
if (!Number.isInteger(body.PrimaryKey) || !Number.isInteger(body.SecondaryKey)) return null
roomId = body.PrimaryKey as number
statChannel = body.SecondaryKey as number
} else {
roomId = int(body.RoomId, 0)
statChannel = int(body.StatChannel, 0)
}
const playerId = int(body.PlayerId, 0) const playerId = int(body.PlayerId, 0)
const type = int(body.LeaderboardType, ROOM_LEADERBOARD)
return { return {
roomId: type === ROOM_LEADERBOARD ? int(body.RoomId, int(body.PrimaryKey, 0)) : 0, roomId,
statChannel: int(body.StatChannel, int(body.SecondaryKey, 0)), statChannel,
...(int(body.FilterType, 0) === FilterType.Friends && ...(int(body.FilterType, 0) === FilterType.Friends &&
playerId !== 0 && { friendsOf: playerId }), playerId !== 0 && { friendsOf: playerId }),
} }
@@ -161,9 +182,11 @@ const app = new Hono<App>()
const body = await readBody<BoardBody>(c) const body = await readBody<BoardBody>(c)
logger.info('GetNearbyScores', { body }) logger.info('GetNearbyScores', { body })
const target = board(body)
if (target === null) return c.json({ Rows: [] })
const rows = await getNearbyScores( const rows = await getNearbyScores(
c.env.DB, c.env.DB,
board(body), target,
int(body.PlayerId, 0), int(body.PlayerId, 0),
int(body.WindowSize, MAX_WINDOW), int(body.WindowSize, MAX_WINDOW),
body.SortAscending === true body.SortAscending === true
@@ -207,9 +230,11 @@ const app = new Hono<App>()
const body = await readBody<BoardBody>(c) const body = await readBody<BoardBody>(c)
logger.info('GetRanks', { body }) logger.info('GetRanks', { body })
const target = board(body)
if (target === null) return c.json({ Rows: [] })
const rows = await getRanks( const rows = await getRanks(
c.env.DB, c.env.DB,
board(body), target,
int(body.RankStart, 0), int(body.RankStart, 0),
int(body.RankEnd, 9), int(body.RankEnd, 9),
body.SortAscending === true body.SortAscending === true
@@ -259,11 +284,13 @@ const app = new Hono<App>()
const body = await readBody<BoardBody>(c) const body = await readBody<BoardBody>(c)
logger.info('GetPlayerRank', { body }) logger.info('GetPlayerRank', { body })
// Every path answers the full three-key object. 20260323 throws "Response was
// empty" on a null or empty body, so there is no 204 here and no null.
const playerId = int(body.PlayerId, 0) const playerId = int(body.PlayerId, 0)
if (playerId === 0) return c.json({ PlayerId: 0, Score: NO_SCORE, Rank: UNRANKED }) const target = board(body)
return c.json( if (playerId === 0 || target === null)
await getPlayerRank(c.env.DB, board(body), playerId, body.SortAscending === true) return c.json({ PlayerId: playerId, Score: NO_SCORE, Rank: UNRANKED })
) return c.json(await getPlayerRank(c.env.DB, target, playerId, body.SortAscending === true))
} }
) )
@@ -307,12 +334,18 @@ const app = new Hono<App>()
const accountId = await validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get()) const accountId = await validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get())
if (accountId === null) return c.body(null, 401) if (accountId === null) return c.body(null, 401)
const body = await readBody< // Deliberately its own body, NOT a BoardBody: 20260323 still posts `RoomId` +
BoardBody & { StatValue?: number; CurrentStatValue?: number | null } // `StatChannel` here (byte-identical to 2025) even though the reads moved to
>(c) // `PrimaryKey`/`SecondaryKey`. Keep the two request models apart.
const body = await readBody<{
RoomId?: number
StatChannel?: number
StatValue?: number
CurrentStatValue?: number | null
}>(c)
logger.info('CheckAndSetStat', { accountId, body }) logger.info('CheckAndSetStat', { accountId, body })
const target = board(body) const target: Board = { roomId: int(body.RoomId, 0), statChannel: int(body.StatChannel, 0) }
if (target.roomId !== 0 && typeof body.StatValue === 'number') { if (target.roomId !== 0 && typeof body.StatValue === 'number') {
const expected = typeof body.CurrentStatValue === 'number' ? body.CurrentStatValue : null const expected = typeof body.CurrentStatValue === 'number' ? body.CurrentStatValue : null
const written = await checkAndSetStat( const written = await checkAndSetStat(
@@ -357,6 +390,11 @@ app.get(
'answers a player with no row a rank of 99999, the sentinel for unranked, with a', 'answers a player with no row a rank of 99999, the sentinel for unranked, with a',
'score of 0; and `CheckAndSetStat` answers the bare number `0`.', 'score of 0; and `CheckAndSetStat` answers the bare number `0`.',
'', '',
'A board is named two ways: up to 2025 by `RoomId` + `StatChannel`; from 20260323',
'by `PrimaryKey` + `SecondaryKey` + `LeaderboardType` (0 Room, 1 Event), told apart',
'by which keys are present. A null 2026 key names no board and reads empty.',
'`CheckAndSetStat` takes `RoomId` + `StatChannel` from every build.',
'',
'Only `CheckAndSetStat` needs a token — the stat belongs to whoever is calling.', 'Only `CheckAndSetStat` needs a token — the stat belongs to whoever is calling.',
'Unreadable bodies are answered (empty board / unranked), never rejected.', 'Unreadable bodies are answered (empty board / unranked), never rejected.',
].join('\n'), ].join('\n'),
+40 -40
View File
@@ -89,37 +89,50 @@ export const CheckAndSetStatResponse = z
// ---- Request schemas ------------------------------------------------------- // ---- Request schemas -------------------------------------------------------
/** /**
* The body the client posts to `GetRanks`, e.g. * The board selectors every read carries, in either client shape. A body is the 2026 shape
* `{"RankStart":0,"RankEnd":9,"PlayerId":2,"StatChannel":1,"RoomId":6,"FilterType":0,"SortAscending":false}`. * when ANY of `PrimaryKey`/`SecondaryKey`/`LeaderboardType` is present, and its `RoomId` and
* `StatChannel` are then ignored. Otherwise it is the 2025 shape, read exactly as before.
* *
* Recovered from a live client, not from a spec. `FilterType` 1 restricts the board to * - 2025: `{"PlayerId":205,"StatChannel":1,"RoomId":6,"FilterType":0,"SortAscending":false}`
* `PlayerId` and their friends. * - 2026: `{"PlayerId":205,"PrimaryKey":6,"SecondaryKey":1,"FilterType":0,"SortAscending":false,"LeaderboardType":0}`
*
* Same room, same board. In 2026 a null key names no board (an empty answer, not room 0),
* and an Event board (`LeaderboardType` 1) is always empty because nothing stores event stats.
*/ */
export const GetRanksBody = z.object({ const BoardSelectors = z.object({
RankStart: z.int().describe('First rank of the slice, 0-based and inclusive'), PlayerId: z.int().describe('The player reading the board, or whose rank is asked for'),
RankEnd: z.int().describe('Last rank of the slice, inclusive — 0–9 is the first ten'), StatChannel: z.int().optional().describe('≤2025: which of the room’s tracked stats to rank on'),
PlayerId: z.int().describe('The player reading the board'), RoomId: z.int().optional().describe('≤2025: the room whose board is being read'),
StatChannel: z.int().describe('Which of the room’s tracked stats to rank on'), PrimaryKey: z
RoomId: z.int().describe('The room whose board is being read'), .int()
.nullable()
.optional()
.describe('2026: the room id (`LeaderboardType` 0) or event id (1); null names no board'),
SecondaryKey: z
.int()
.nullable()
.optional()
.describe('2026: which of the room’s tracked stats to rank on; null names no board'),
LeaderboardType: z
.int()
.optional()
.describe('2026: 0 Room, 1 Event (always empty: no event stats are stored)'),
FilterType: z.int().describe('Who the board counts: 0 Global, 1 Friends'), 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. * The body the client posts to `GetRanks`: {@link BoardSelectors} plus the slice, e.g.
* `{"PlayerId":205,"StatChannel":2,"RoomId":14,"FilterType":0,"SortAscending":false}`. * `{"RankStart":0,"RankEnd":9,"PlayerId":2,"StatChannel":1,"RoomId":6,"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({ export const GetRanksBody = BoardSelectors.extend({
PlayerId: z.int().describe('The player whose rank is being asked for'), RankStart: z.int().describe('First rank of the slice, 0-based and inclusive'),
StatChannel: z.int().describe('Which of the room’s tracked stats to rank on'), RankEnd: z.int().describe('Last rank of the slice, inclusive — 0–9 is the first ten'),
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`: just {@link BoardSelectors}. */
export const GetPlayerRankBody = BoardSelectors
/** /**
* The body the client posts to `CheckAndSetStat`, e.g. * The body the client posts to `CheckAndSetStat`, e.g.
* `{"StatChannel":2,"RoomId":14,"StatValue":1,"CurrentStatValue":null}`. * `{"StatChannel":2,"RoomId":14,"StatValue":1,"CurrentStatValue":null}`.
@@ -127,6 +140,9 @@ export const GetPlayerRankBody = z.object({
* A compare-and-set: `CurrentStatValue` is what the client believes is already stored (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 * when it believes nothing is), and `StatValue` is what it wants stored. There is no
* `PlayerId` — the stat belongs to whoever is calling. * `PlayerId` — the stat belongs to whoever is calling.
*
* 20260323 still posts `RoomId` + `StatChannel` here, NOT the reads' `PrimaryKey` /
* `SecondaryKey`. Keep this model separate from {@link BoardSelectors}.
*/ */
export const CheckAndSetStatBody = z.object({ export const CheckAndSetStatBody = z.object({
StatChannel: z.int().describe('Which of the room’s tracked stats is being written'), StatChannel: z.int().describe('Which of the room’s tracked stats is being written'),
@@ -139,26 +155,10 @@ export const CheckAndSetStatBody = z.object({
}) })
/** /**
* The body posted to `GetNearbyScores`: {@link GetPlayerRankBody} plus `WindowSize`, e.g. * The body posted to `GetNearbyScores`: {@link BoardSelectors} plus `WindowSize`, e.g.
* (2025) `{"WindowSize":10,"PlayerId":205,"StatChannel":1,"RoomId":6,"FilterType":0,"SortAscending":false}`. * `{"WindowSize":10,"PlayerId":205,"PrimaryKey":6,"SecondaryKey":1,"FilterType":0,"SortAscending":false,"LeaderboardType":0}`.
*
* The 2026 client names the same board differently:
* `{"WindowSize":10,"PlayerId":205,"PrimaryKey":6,"SecondaryKey":1,"FilterType":0,"SortAscending":false,"LeaderboardType":0}`
* — `PrimaryKey` is the room, `SecondaryKey` the channel. Every endpoint accepts either.
*/ */
export const GetNearbyScoresBody = GetPlayerRankBody.extend({ export const GetNearbyScoresBody = BoardSelectors.extend({
PrimaryKey: z
.int()
.optional()
.describe('2026 client: the room, in place of `RoomId` (when `LeaderboardType` is 0)'),
SecondaryKey: z
.int()
.optional()
.describe('2026 client: the stat channel, in place of `StatChannel`'),
LeaderboardType: z
.int()
.optional()
.describe('2026 client: 0 is a room board, the only kind observed; others read as empty'),
WindowSize: z WindowSize: z
.int() .int()
.describe('How many rows either side of the player to return; default and maximum 10'), .describe('How many rows either side of the player to return; default and maximum 10'),
@@ -441,9 +441,25 @@ describe('the nearby window', () => {
}) })
describe('the 2026 body shape', () => { describe('the 2026 body shape', () => {
// 2026 renamed the selectors: `PrimaryKey` is the room, `SecondaryKey` the channel, and // 20260323 renamed the read selectors: `PrimaryKey` is the room, `SecondaryKey` the
// `LeaderboardType` 0 says it's a room board. Same room, same board as the 2025 body. // channel, and `LeaderboardType` 0 says it's a room board. Same room, same board as the
// 2025 body. `CheckAndSetStat` did NOT change.
const ROOM = 400 const ROOM = 400
const body2026 = (extra: object) => ({
PlayerId: 51,
PrimaryKey: ROOM,
SecondaryKey: 1,
FilterType: 0,
SortAscending: false,
LeaderboardType: 0,
...extra,
})
const ROWS = {
Rows: [
{ PlayerId: 52, Score: 9, Rank: 0 },
{ PlayerId: 51, Score: 7, Rank: 1 },
],
}
beforeAll(async () => { beforeAll(async () => {
await setStat(51, ROOM, 7, null, 1) await setStat(51, ROOM, 7, null, 1)
await setStat(52, ROOM, 9, null, 1) await setStat(52, ROOM, 9, null, 1)
@@ -458,47 +474,71 @@ describe('the 2026 body shape', () => {
FilterType: 0, FilterType: 0,
SortAscending: false, SortAscending: false,
}) })
const modern = await post('GetNearbyScores', { expect(await legacy.json()).toEqual(ROWS)
WindowSize: 10, const modern = await post('GetNearbyScores', body2026({ WindowSize: 10 }))
PlayerId: 51, expect(await modern.json()).toEqual(ROWS)
PrimaryKey: ROOM, const ranks = await post('GetRanks', body2026({ RankStart: 0, RankEnd: 9 }))
SecondaryKey: 1, expect(await ranks.json()).toEqual(ROWS)
FilterType: 0, const rank = await post('GetPlayerRank', body2026({}))
SortAscending: false, expect(await rank.json()).toEqual({ PlayerId: 51, Score: 7, Rank: 1 })
LeaderboardType: 0,
})
const rows = {
Rows: [
{ PlayerId: 52, Score: 9, Rank: 0 },
{ PlayerId: 51, Score: 7, Rank: 1 },
],
}
expect(await legacy.json()).toEqual(rows)
expect(await modern.json()).toEqual(rows)
}) })
it('writes a stat through PrimaryKey + SecondaryKey', async () => { it('ignores RoomId/StatChannel once a 2026 key is present', async () => {
// The 2026 keys name no board here, so the stale 2025 ones must not be used instead.
const res = await post(
'GetRanks',
body2026({ PrimaryKey: null, RoomId: ROOM, StatChannel: 1, RankStart: 0, RankEnd: 9 })
)
expect(await res.json()).toEqual({ Rows: [] })
})
it('answers a null-keyed Event board on GetPlayerRank with the full object', async () => {
const res = await post(
'GetPlayerRank',
body2026({ PrimaryKey: null, SecondaryKey: null, LeaderboardType: 1 })
)
// 20260323 throws "Response was empty" on null/empty/204: always the three keys.
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ PlayerId: 51, Score: 0, Rank: 99999 })
})
it('answers an unranked player with the full object', async () => {
const res = await post('GetPlayerRank', body2026({ PlayerId: 53 }))
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ PlayerId: 53, Score: 0, Rank: 99999 })
})
it('answers null keys and Event boards with an empty Rows, never a bare array', async () => {
for (const extra of [{ PrimaryKey: null }, { SecondaryKey: null }, { LeaderboardType: 1 }]) {
const ranks = await post('GetRanks', body2026({ RankStart: 0, RankEnd: 9, ...extra }))
expect(ranks.status).toBe(200)
expect(await ranks.text()).toBe('{"Rows":[]}')
const nearby = await post('GetNearbyScores', body2026({ WindowSize: 10, ...extra }))
expect(nearby.status).toBe(200)
expect(await nearby.text()).toBe('{"Rows":[]}')
}
})
it('still writes CheckAndSetStat by RoomId + StatChannel, answering a bare 0', async () => {
const res = await post( const res = await post(
'CheckAndSetStat', 'CheckAndSetStat',
{ PrimaryKey: ROOM, SecondaryKey: 1, StatValue: 12, CurrentStatValue: 7, LeaderboardType: 0 }, { StatChannel: 1, RoomId: ROOM, StatValue: 12, CurrentStatValue: 7 },
await bearer(51) await bearer(51)
) )
// The whole body is the number: not `{"result":0}`, not an envelope.
expect(res.headers.get('content-type')).toContain('application/json')
expect(await res.text()).toBe('0') expect(await res.text()).toBe('0')
expect(await stored(51, ROOM, 1)).toEqual({ stat_value: 12 }) expect(await stored(51, ROOM, 1)).toEqual({ stat_value: 12 })
}) })
it('serves an unknown LeaderboardType an empty board', async () => { it('does not write CheckAndSetStat by the read-side PrimaryKey/SecondaryKey', async () => {
const res = await post('GetRanks', { const res = await post(
RankStart: 0, 'CheckAndSetStat',
RankEnd: 9, { PrimaryKey: ROOM + 1, SecondaryKey: 1, StatValue: 5, CurrentStatValue: null },
PlayerId: 51, await bearer(51)
PrimaryKey: ROOM, )
SecondaryKey: 1, expect(await res.text()).toBe('0')
FilterType: 0, expect(await stored(51, ROOM + 1, 1)).toBeNull()
SortAscending: false,
LeaderboardType: 1,
})
expect(await res.json()).toEqual({ Rows: [] })
}) })
}) })
+46 -5
View File
@@ -1128,6 +1128,27 @@ function leaderboardEnvelope(c: Context<App>, error: string | null = null) {
return c.json({ Success: error === null, Error: error, error_id: null }) return c.json({ Success: error === null, Error: error, error_id: null })
} }
/**
* From this build on, the leaderboard slot writes (`POST`/`DELETE
* /rooms/:id/leaderboards/:lid`) go through the client's non-generic send and never read the
* body. A `200 { Success: false }` therefore reads as SUCCESS, so a refusal has to be an
* HTTP status. Older builds parse the envelope and keep getting it on a 200.
*/
const STATUS_ONLY_LEADERBOARD_BUILD = '20260323'
/**
* A refusal from a leaderboard slot write: `status` for a build at or past
* {@link STATUS_ONLY_LEADERBOARD_BUILD}, a 200 failure envelope for the builds before it.
* The same envelope rides along as the body either way. Nothing reads it in 2026, but it
* costs nothing and shows up in a capture.
*/
async function leaderboardRefusal(c: Context<App>, status: 404, error: string) {
const build = await authedGameVersion(c)
if (build !== null && build >= STATUS_ONLY_LEADERBOARD_BUILD)
return c.json({ Success: false, Error: error, error_id: null }, status)
return leaderboardEnvelope(c, error)
}
/** The same bare envelope for the room progression write (`POST /rooms/:id/experience`). */ /** The same bare envelope for the room progression write (`POST /rooms/:id/experience`). */
const experienceEnvelope = leaderboardEnvelope const experienceEnvelope = leaderboardEnvelope
@@ -3374,14 +3395,22 @@ const app = new Hono<App>()
'Answers a bare `{ Success, Error, error_id }` — PascalCase with a lowercase', 'Answers a bare `{ Success, Error, error_id }` — PascalCase with a lowercase',
'`error_id`, like the unprefixed isBanned route, carrying no entity. NOT the room', '`error_id`, like the unprefixed isBanned route, carrying no entity. NOT the room',
'mutations’ lowercase `{ success, error, value }`.', 'mutations’ lowercase `{ success, error, value }`.',
'',
'From build 20260323 the client never reads the body, so a `200` with `Success: false`',
'would read as success: a token for that build or later gets an unknown room as a 404',
'instead. Earlier builds still get the 200 failure envelope.',
].join('\n'), ].join('\n'),
security: AUTHED, security: AUTHED,
parameters: [roomIdParam, leaderboardIdParam], parameters: [roomIdParam, leaderboardIdParam],
requestBody: form(LeaderboardRequest, 'The leaderboard configuration'), requestBody: form(LeaderboardRequest, 'The leaderboard configuration'),
responses: { responses: {
200: json(LeaderboardResultEnvelope, 'Stored, or a rejection with `Success: false`'), 200: json(
LeaderboardResultEnvelope,
'Stored, or (before 20260323) a rejection with `Success: false`'
),
401: UNAUTHORIZED_RESPONSE, 401: UNAUTHORIZED_RESPONSE,
403: FORBIDDEN_RESPONSE, 403: FORBIDDEN_RESPONSE,
404: json(LeaderboardResultEnvelope, 'Unknown room (build 20260323 and later)'),
}, },
}), }),
async (c) => { async (c) => {
@@ -3390,7 +3419,7 @@ const app = new Hono<App>()
const roomId = Number.parseInt(c.req.param('roomId'), 10) const roomId = Number.parseInt(c.req.param('roomId'), 10)
const room = await getRoomById(c.env.DB, roomId) const room = await getRoomById(c.env.DB, roomId)
if (!room) return leaderboardEnvelope(c, 'This room does not exist!') if (!room) return leaderboardRefusal(c, 404, 'This room does not exist!')
// A valid token but not the room's owner/co-owner → 403 (the auth gate above // A valid token but not the room's owner/co-owner → 403 (the auth gate above
// already returned 401 for a missing/invalid token). // already returned 401 for a missing/invalid token).
if (!canManageRoom(room, accountId)) return c.body(null, 403) if (!canManageRoom(room, accountId)) return c.body(null, 403)
@@ -3431,7 +3460,10 @@ const app = new Hono<App>()
'silent success — the caller asked to undo something that was not there. The client', 'silent success — the caller asked to undo something that was not there. The client',
'deletes slots blindly when tearing boards down and tolerates the refusal.', 'deletes slots blindly when tearing boards down and tolerates the refusal.',
'', '',
'Answers the same bare `{ Success, Error, error_id }` as the leaderboard write.', 'Answers the same bare `{ Success, Error, error_id }` as the leaderboard write. An',
'unknown room is a 404 for build 20260323 and later, which never read the body. An',
'unconfigured slot stays a 200 for them: the slot is already gone, which is what',
'they asked for.',
].join('\n'), ].join('\n'),
security: AUTHED, security: AUTHED,
parameters: [roomIdParam, leaderboardIdParam], parameters: [roomIdParam, leaderboardIdParam],
@@ -3439,6 +3471,7 @@ const app = new Hono<App>()
200: json(LeaderboardResultEnvelope, 'Removed, or a rejection with `Success: false`'), 200: json(LeaderboardResultEnvelope, 'Removed, or a rejection with `Success: false`'),
401: UNAUTHORIZED_RESPONSE, 401: UNAUTHORIZED_RESPONSE,
403: FORBIDDEN_RESPONSE, 403: FORBIDDEN_RESPONSE,
404: json(LeaderboardResultEnvelope, 'Unknown room (build 20260323 and later)'),
}, },
}), }),
async (c) => { async (c) => {
@@ -3447,11 +3480,15 @@ const app = new Hono<App>()
const roomId = Number.parseInt(c.req.param('roomId'), 10) const roomId = Number.parseInt(c.req.param('roomId'), 10)
const room = await getRoomById(c.env.DB, roomId) const room = await getRoomById(c.env.DB, roomId)
if (!room) return leaderboardEnvelope(c, 'This room does not exist!') if (!room) return leaderboardRefusal(c, 404, 'This room does not exist!')
if (!canManageRoom(room, accountId)) return c.body(null, 403) if (!canManageRoom(room, accountId)) return c.body(null, 403)
const leaderboardId = Number.parseInt(c.req.param('leaderboardId'), 10) const leaderboardId = Number.parseInt(c.req.param('leaderboardId'), 10)
const removed = await deleteRoomLeaderboard(c.env.DB, roomId, leaderboardId) const removed = await deleteRoomLeaderboard(c.env.DB, roomId, leaderboardId)
// Deliberately NOT a 404 for 2026: the client tears boards down by deleting every
// slot blindly, and an unconfigured slot is already in the state it asked for. The
// 2026 client reads this 200 as success, which is the truth. Older builds get the
// envelope's refusal, which they tolerate.
if (!removed) return leaderboardEnvelope(c, 'This room has no such leaderboard!') if (!removed) return leaderboardEnvelope(c, 'This room has no such leaderboard!')
return leaderboardEnvelope(c) return leaderboardEnvelope(c)
} }
@@ -4949,9 +4986,13 @@ const app = new Hono<App>()
parameters: [roomIdParam], parameters: [roomIdParam],
requestBody: form(ExperienceRequest, 'The room’s XP settings'), requestBody: form(ExperienceRequest, 'The room’s XP settings'),
responses: { responses: {
200: json(LeaderboardResultEnvelope, 'Stored, or a rejection with `Success: false`'), 200: json(
LeaderboardResultEnvelope,
'Stored, or (before 20260323) a rejection with `Success: false`'
),
401: UNAUTHORIZED_RESPONSE, 401: UNAUTHORIZED_RESPONSE,
403: FORBIDDEN_RESPONSE, 403: FORBIDDEN_RESPONSE,
404: json(LeaderboardResultEnvelope, 'Unknown room (build 20260323 and later)'),
}, },
}), }),
async (c) => { async (c) => {
@@ -2852,6 +2852,49 @@ describe('rooms endpoints', () => {
await env.DB.prepare('DELETE FROM room_leaderboard WHERE room_id IN (2, 3)').run() await env.DB.prepare('DELETE FROM room_leaderboard WHERE room_id IN (2, 3)').run()
}) })
it('POST/DELETE /rooms/:id/leaderboards/:lid refuse with an HTTP status for 2026', async () => {
// 20260323 never reads these bodies, so a 200 `Success: false` would read as success.
const as2026 = async (sub: string) => ({
...(await bearer(sub, undefined, '20260323.14')),
})
const body = { leaderboardTitle: 'full name', statFormat: '1', sortAscending: 'False' }
const form = new URLSearchParams(body).toString()
const send = async (method: string, path: string, headers: Record<string, string>) =>
SELF.fetch(`${ORIGIN}${path}`, {
method,
headers: { ...headers, 'Content-Type': 'application/x-www-form-urlencoded' },
...(method === 'POST' && { body: form }),
})
// Unknown room: 404 for 2026, both verbs, never 200 + Success:false.
const create = await send('POST', '/rooms/99999/leaderboards/1', await as2026('1'))
expect(create.status).toBe(404)
expect((await send('DELETE', '/rooms/99999/leaderboards/1', await as2026('1'))).status).toBe(
404
)
// 2025 keeps the 200 failure envelope it parses.
const legacy = await send(
'POST',
'/rooms/99999/leaderboards/1',
await bearer('1', undefined, '20250718.01')
)
expect(legacy.status).toBe(200)
expect(await legacy.json()).toEqual({
Success: false,
Error: 'This room does not exist!',
error_id: null,
})
// A real create still succeeds for 2026, and the denial paths stay 401/403.
expect((await send('POST', '/rooms/2/leaderboards/1', await as2026('1'))).status).toBe(200)
expect((await send('POST', '/rooms/2/leaderboards/1', await as2026('999'))).status).toBe(403)
// An unconfigured slot is already gone, which is what the delete asked for.
expect((await send('DELETE', '/rooms/2/leaderboards/7', await as2026('1'))).status).toBe(200)
await env.DB.prepare('DELETE FROM room_leaderboard WHERE room_id = 2').run()
})
it('POST /rooms/:id/experience sets a room’s progression, which the GETs read back', async () => { it('POST /rooms/:id/experience sets a room’s progression, which the GETs read back', async () => {
// RecCenter (room 2) is owned by account 1, with account 2 as co-owner. // RecCenter (room 2) is owned by account 1, with account 2 as co-owner.
const getXp = async (roomId: number, sub?: string) => const getXp = async (roomId: number, sub?: string) =>