diff --git a/apps/leaderboard/src/leaderboard.app.ts b/apps/leaderboard/src/leaderboard.app.ts index 9f10ec0..5d9e65c 100644 --- a/apps/leaderboard/src/leaderboard.app.ts +++ b/apps/leaderboard/src/leaderboard.app.ts @@ -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() const body = await readBody(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() const body = await readBody(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() const body = await readBody(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() 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'), diff --git a/apps/leaderboard/src/openapi.ts b/apps/leaderboard/src/openapi.ts index d5fbf19..b4e92cb 100644 --- a/apps/leaderboard/src/openapi.ts +++ b/apps/leaderboard/src/openapi.ts @@ -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'), diff --git a/apps/leaderboard/src/test/integration/api.test.ts b/apps/leaderboard/src/test/integration/api.test.ts index b40ac2c..dd3d6b0 100644 --- a/apps/leaderboard/src/test/integration/api.test.ts +++ b/apps/leaderboard/src/test/integration/api.test.ts @@ -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() }) }) diff --git a/apps/rooms/src/rooms.app.ts b/apps/rooms/src/rooms.app.ts index fa6e496..d4491c6 100644 --- a/apps/rooms/src/rooms.app.ts +++ b/apps/rooms/src/rooms.app.ts @@ -1128,6 +1128,27 @@ function leaderboardEnvelope(c: Context, 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, 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() '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() 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() '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() 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() 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() 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) => { diff --git a/apps/rooms/src/test/integration/api.test.ts b/apps/rooms/src/test/integration/api.test.ts index 8deaf6f..cfc174d 100644 --- a/apps/rooms/src/test/integration/api.test.ts +++ b/apps/rooms/src/test/integration/api.test.ts @@ -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) => + 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) =>