[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
* 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
* 2026 client sends `PrimaryKey` (the room) + `SecondaryKey` (the channel) and adds
* `LeaderboardType`, which is 0 for a room board. Same room, same board, either way. */
* Two shapes name a board, told apart by which keys are PRESENT (see {@link board}). Up to
* 2025 the client sends `RoomId` + `StatChannel`. 20260323 renamed them `PrimaryKey` +
* `SecondaryKey`, made both nullable, and added `LeaderboardType` (0 Room, 1 Event), which
* says what `PrimaryKey` is the id of. */
interface BoardBody {
PlayerId?: number
RoomId?: number
StatChannel?: number
PrimaryKey?: number
SecondaryKey?: number
PrimaryKey?: number | null
SecondaryKey?: number | null
LeaderboardType?: number
FilterType?: number
SortAscending?: boolean
@@ -69,26 +70,46 @@ const enum FilterType {
Friends = 1,
}
/** The 2026 client's `LeaderboardType`: what kind of thing `PrimaryKey` names. Only the room
* board (0) has been observed. */
const ROOM_LEADERBOARD = 0
/** The 2026 client's `LeaderboardType`: what `PrimaryKey` is the id of. */
const enum LeaderboardType {
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`),
* each 0 when absent, seen through `PlayerId`'s friends when `FilterType` is Friends. A
* friends board with no `PlayerId` has nobody to be friends of, so it falls back to the
* global one rather than to nothing.
* The board a body names, or null when it names none, which every read answers as an empty
* board (or an unranked player). Seen through `PlayerId`'s friends when `FilterType` is
* Friends. A friends board with no `PlayerId` has nobody to be friends of, so it falls back
* to the global one rather than to nothing.
*
* A `LeaderboardType` other than a room board is one this server has never seen, and its
* `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.
* 2025 body: `RoomId` + `StatChannel`, each 0 when absent, exactly as before.
*
* 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 type = int(body.LeaderboardType, ROOM_LEADERBOARD)
return {
roomId: type === ROOM_LEADERBOARD ? int(body.RoomId, int(body.PrimaryKey, 0)) : 0,
statChannel: int(body.StatChannel, int(body.SecondaryKey, 0)),
roomId,
statChannel,
...(int(body.FilterType, 0) === FilterType.Friends &&
playerId !== 0 && { friendsOf: playerId }),
}
@@ -161,9 +182,11 @@ const app = new Hono<App>()
const body = await readBody<BoardBody>(c)
logger.info('GetNearbyScores', { body })
const target = board(body)
if (target === null) return c.json({ Rows: [] })
const rows = await getNearbyScores(
c.env.DB,
board(body),
target,
int(body.PlayerId, 0),
int(body.WindowSize, MAX_WINDOW),
body.SortAscending === true
@@ -207,9 +230,11 @@ const app = new Hono<App>()
const body = await readBody<BoardBody>(c)
logger.info('GetRanks', { body })
const target = board(body)
if (target === null) return c.json({ Rows: [] })
const rows = await getRanks(
c.env.DB,
board(body),
target,
int(body.RankStart, 0),
int(body.RankEnd, 9),
body.SortAscending === true
@@ -259,11 +284,13 @@ const app = new Hono<App>()
const body = await readBody<BoardBody>(c)
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)
if (playerId === 0) return c.json({ PlayerId: 0, Score: NO_SCORE, Rank: UNRANKED })
return c.json(
await getPlayerRank(c.env.DB, board(body), playerId, body.SortAscending === true)
)
const target = board(body)
if (playerId === 0 || target === null)
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())
if (accountId === null) return c.body(null, 401)
const body = await readBody<
BoardBody & { StatValue?: number; CurrentStatValue?: number | null }
>(c)
// Deliberately its own body, NOT a BoardBody: 20260323 still posts `RoomId` +
// `StatChannel` here (byte-identical to 2025) even though the reads moved to
// `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 })
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') {
const expected = typeof body.CurrentStatValue === 'number' ? body.CurrentStatValue : null
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',
'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.',
'Unreadable bodies are answered (empty board / unranked), never rejected.',
].join('\n'),
+40 -40
View File
@@ -89,37 +89,50 @@ export const CheckAndSetStatResponse = z
// ---- 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}`.
* The board selectors every read carries, in either client shape. A body is the 2026 shape
* 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
* `PlayerId` and their friends.
* - 2025: `{"PlayerId":205,"StatChannel":1,"RoomId":6,"FilterType":0,"SortAscending":false}`
* - 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({
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'),
const BoardSelectors = z.object({
PlayerId: z.int().describe('The player reading the board, or whose rank is asked for'),
StatChannel: z.int().optional().describe('≤2025: which of the room’s tracked stats to rank on'),
RoomId: z.int().optional().describe('≤2025: the room whose board is being read'),
PrimaryKey: z
.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'),
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.
* The body the client posts to `GetRanks`: {@link BoardSelectors} plus the slice, e.g.
* `{"RankStart":0,"RankEnd":9,"PlayerId":2,"StatChannel":1,"RoomId":6,"FilterType":0,"SortAscending":false}`.
*/
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'),
export const GetRanksBody = BoardSelectors.extend({
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'),
})
/** The body the client posts to `GetPlayerRank`: just {@link BoardSelectors}. */
export const GetPlayerRankBody = BoardSelectors
/**
* The body the client posts to `CheckAndSetStat`, e.g.
* `{"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
* when it believes nothing is), and `StatValue` is what it wants stored. There is no
* `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({
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.
* (2025) `{"WindowSize":10,"PlayerId":205,"StatChannel":1,"RoomId":6,"FilterType":0,"SortAscending":false}`.
*
* 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.
* The body posted to `GetNearbyScores`: {@link BoardSelectors} plus `WindowSize`, e.g.
* `{"WindowSize":10,"PlayerId":205,"PrimaryKey":6,"SecondaryKey":1,"FilterType":0,"SortAscending":false,"LeaderboardType":0}`.
*/
export const GetNearbyScoresBody = GetPlayerRankBody.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'),
export const GetNearbyScoresBody = BoardSelectors.extend({
WindowSize: z
.int()
.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', () => {
// 2026 renamed the selectors: `PrimaryKey` is the room, `SecondaryKey` the channel, and
// `LeaderboardType` 0 says it's a room board. Same room, same board as the 2025 body.
// 20260323 renamed the read selectors: `PrimaryKey` is the room, `SecondaryKey` the
// 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 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 () => {
await setStat(51, ROOM, 7, null, 1)
await setStat(52, ROOM, 9, null, 1)
@@ -458,47 +474,71 @@ describe('the 2026 body shape', () => {
FilterType: 0,
SortAscending: false,
})
const modern = await post('GetNearbyScores', {
WindowSize: 10,
PlayerId: 51,
PrimaryKey: ROOM,
SecondaryKey: 1,
FilterType: 0,
SortAscending: false,
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)
expect(await legacy.json()).toEqual(ROWS)
const modern = await post('GetNearbyScores', body2026({ WindowSize: 10 }))
expect(await modern.json()).toEqual(ROWS)
const ranks = await post('GetRanks', body2026({ RankStart: 0, RankEnd: 9 }))
expect(await ranks.json()).toEqual(ROWS)
const rank = await post('GetPlayerRank', body2026({}))
expect(await rank.json()).toEqual({ PlayerId: 51, Score: 7, Rank: 1 })
})
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(
'CheckAndSetStat',
{ PrimaryKey: ROOM, SecondaryKey: 1, StatValue: 12, CurrentStatValue: 7, LeaderboardType: 0 },
{ StatChannel: 1, RoomId: ROOM, StatValue: 12, CurrentStatValue: 7 },
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 stored(51, ROOM, 1)).toEqual({ stat_value: 12 })
})
it('serves an unknown LeaderboardType an empty board', async () => {
const res = await post('GetRanks', {
RankStart: 0,
RankEnd: 9,
PlayerId: 51,
PrimaryKey: ROOM,
SecondaryKey: 1,
FilterType: 0,
SortAscending: false,
LeaderboardType: 1,
})
expect(await res.json()).toEqual({ Rows: [] })
it('does not write CheckAndSetStat by the read-side PrimaryKey/SecondaryKey', async () => {
const res = await post(
'CheckAndSetStat',
{ PrimaryKey: ROOM + 1, SecondaryKey: 1, StatValue: 5, CurrentStatValue: null },
await bearer(51)
)
expect(await res.text()).toBe('0')
expect(await stored(51, ROOM + 1, 1)).toBeNull()
})
})
+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 })
}
/**
* 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`). */
const experienceEnvelope = leaderboardEnvelope
@@ -3374,14 +3395,22 @@ const app = new Hono<App>()
'Answers a bare `{ Success, Error, error_id }` — PascalCase with a lowercase',
'`error_id`, like the unprefixed isBanned route, carrying no entity. NOT the room',
'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'),
security: AUTHED,
parameters: [roomIdParam, leaderboardIdParam],
requestBody: form(LeaderboardRequest, 'The leaderboard configuration'),
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,
403: FORBIDDEN_RESPONSE,
404: json(LeaderboardResultEnvelope, 'Unknown room (build 20260323 and later)'),
},
}),
async (c) => {
@@ -3390,7 +3419,7 @@ const app = new Hono<App>()
const roomId = Number.parseInt(c.req.param('roomId'), 10)
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
// already returned 401 for a missing/invalid token).
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',
'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'),
security: AUTHED,
parameters: [roomIdParam, leaderboardIdParam],
@@ -3439,6 +3471,7 @@ const app = new Hono<App>()
200: json(LeaderboardResultEnvelope, 'Removed, or a rejection with `Success: false`'),
401: UNAUTHORIZED_RESPONSE,
403: FORBIDDEN_RESPONSE,
404: json(LeaderboardResultEnvelope, 'Unknown room (build 20260323 and later)'),
},
}),
async (c) => {
@@ -3447,11 +3480,15 @@ const app = new Hono<App>()
const roomId = Number.parseInt(c.req.param('roomId'), 10)
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)
const leaderboardId = Number.parseInt(c.req.param('leaderboardId'), 10)
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!')
return leaderboardEnvelope(c)
}
@@ -4949,9 +4986,13 @@ const app = new Hono<App>()
parameters: [roomIdParam],
requestBody: form(ExperienceRequest, 'The room’s XP settings'),
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,
403: FORBIDDEN_RESPONSE,
404: json(LeaderboardResultEnvelope, 'Unknown room (build 20260323 and later)'),
},
}),
async (c) => {
@@ -2852,6 +2852,49 @@ describe('rooms endpoints', () => {
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 () => {
// RecCenter (room 2) is owned by account 1, with account 2 as co-owner.
const getXp = async (roomId: number, sub?: string) =>