diff --git a/apps/rooms/src/openapi.ts b/apps/rooms/src/openapi.ts index 7afbd57..1ebcecb 100644 --- a/apps/rooms/src/openapi.ts +++ b/apps/rooms/src/openapi.ts @@ -324,7 +324,12 @@ export const RoomDto = z.object({ RankingContext: z.unknown().nullable(), IsDorm: z.boolean().describe('Auto-provisioned personal room; excluded from every feed'), IsPlacePlay: z.boolean(), - MaxPlayerCalculationMode: z.int(), + MaxPlayerCalculationMode: z + .int() + .describe( + '0 AllSubrooms, 1 OnlyEntrySubrooms — set by `PUT …/max_player_calculation_mode`. A ' + + 'stored setting; `MaxPlayers` is not derived from it here' + ), MaxPlayers: z.int(), CloningAllowed: z.boolean().describe('False blocks `POST /rooms/{roomId}/clone`'), DisableMicAutoMute: z.boolean(), @@ -828,19 +833,29 @@ export const MoveSubRoomRequest = z.object({ }) /** - * What the subroom move answers: the SOURCE room (which no longer lists the subroom) under - * `Value`, in the PascalCase `{ Value, Success, Error, error_id }` envelope — the same mixed + * A room in the PascalCase `{ Value, Success, Error, error_id }` envelope — the same mixed * casing the unprefixed isBanned check has ({@link IsBannedPascalEnvelope}), NOT the - * lowercase `{ success, error, value }` every other subroom mutation answers. Kept apart - * deliberately: the client decodes this one with a different reader. + * lowercase `{ success, error, value }` most room mutations answer. The newer settings + * routes (the subroom move, the max-player calculation mode) answer this one; the client + * decodes them with a different reader, so the two are kept apart deliberately. */ -export const MoveSubRoomEnvelope = z.object({ - Value: RoomDto.nullable().describe('The source room as it now stands; null on a rejection'), +export const RoomPascalEnvelope = z.object({ + Value: RoomDto.nullable().describe('The room as it now stands; null on a rejection'), Success: z.boolean(), Error: z.string().nullable().describe('The message shown on a rejection; null on success'), error_id: z.string().nullable().describe('Null. Lowercase, unlike its siblings'), }) +/** + * `PUT /rooms/{roomId}/max_player_calculation_mode` — how the room's player cap is meant to + * be read. The client sends the enum NAME; the ordinal is accepted alongside it. + */ +export const MaxPlayerCalculationModeRequest = z.object({ + maxPlayerCalculationMode: z + .string() + .describe('`AllSubrooms` (0) or `OnlyEntrySubrooms` (1), case-insensitive, or the ordinal'), +}) + /** `POST /rooms/{roomId}/subrooms`. */ export const CreateSubRoomRequest = z.object({ name: z.string().describe('The new subroom’s name'), diff --git a/apps/rooms/src/rooms.app.ts b/apps/rooms/src/rooms.app.ts index bdf34a4..6e45472 100644 --- a/apps/rooms/src/rooms.app.ts +++ b/apps/rooms/src/rooms.app.ts @@ -115,9 +115,9 @@ import { LeaderboardRequest, LeaderboardResultEnvelope, LoadScreenRequest, + MaxPlayerCalculationModeRequest, MissingLookupParam, ModifySubRoomRequest, - MoveSubRoomEnvelope, MoveSubRoomRequest, NameRequest, NOT_FRIENDS_RESPONSE, @@ -140,6 +140,7 @@ import { RoomExperiencePlayer, roomIdParam, RoomLookup, + RoomPascalEnvelope, RoomResultEnvelope, RoomRoleDto, RoomSaveEnvelope, @@ -509,6 +510,32 @@ const RESTRICTION_FIELDS: Record = { * Hub failures are logged and swallowed — the room write has already committed, * so a hub hiccup must not fail the request. */ +/** + * The room's `MaxPlayerCalculationMode` enum: how its player cap is meant to be read. + * `AllSubrooms` (0) and `OnlyEntrySubrooms` (1). A stored setting — nothing here derives + * `MaxPlayers` from it. + */ +const MAX_PLAYER_CALCULATION_MODES: Readonly> = { + allsubrooms: 0, + onlyentrysubrooms: 1, +} + +/** + * Parse a `maxPlayerCalculationMode` field into its ordinal. The client sends the enum + * NAME (`OnlyEntrySubrooms`), matched case-insensitively; the ordinal is accepted too. + * Undefined when it names nothing in the enum, which the route refuses rather than + * defaulting — a silently-wrong mode would misreport a room's capacity. + */ +function parseMaxPlayerCalculationMode(value: unknown): number | undefined { + if (typeof value !== 'string') return undefined + const raw = value.trim() + const named = MAX_PLAYER_CALCULATION_MODES[raw.toLowerCase()] + if (named !== undefined) return named + if (!/^\d+$/.test(raw)) return undefined + const ordinal = Number.parseInt(raw, 10) + return Object.values(MAX_PLAYER_CALCULATION_MODES).includes(ordinal) ? ordinal : undefined +} + async function pushRoomUpdate( c: Context, playerId: number, @@ -3124,6 +3151,58 @@ const app = new Hono() } ) + // Set a room's MaxPlayerCalculationMode (form body `maxPlayerCalculationMode`, the enum + // NAME). Auth-gated (401) and owner/co-owner-only. A stored setting only: it does not + // touch `MaxPlayers`. Answers the room in the PascalCase `{ Value, Success, Error, + // error_id }` envelope, like the subroom move — not the lowercase one its neighbours use. + .put( + '/rooms/:roomId{[0-9]+}/max_player_calculation_mode', + describeRoute({ + tags: ['Room settings'], + summary: 'Set a room’s max-player calculation mode', + description: [ + 'Sets `MaxPlayerCalculationMode`: `AllSubrooms` (0) or `OnlyEntrySubrooms` (1). The', + 'client sends the enum name (`maxPlayerCalculationMode=OnlyEntrySubrooms`); the', + 'ordinal is accepted too, and anything else is rejected. It is a stored setting:', + '`MaxPlayers` is not recomputed from the subrooms here.', + '', + 'Owner or co-owner only. Answers the updated room under `Value` in the PascalCase', + '`{ Value, Success, Error, error_id }` envelope — NOT the lowercase', + '`{ success, error, value }` the other room-settings routes answer. A rejection is', + 'HTTP 200 with `Success: false`, `Value: null` and the message in `Error`; only a', + 'missing token is a 401.', + ].join('\n'), + security: AUTHED, + parameters: [roomIdParam], + requestBody: form(MaxPlayerCalculationModeRequest, 'The mode'), + responses: { + 200: json(RoomPascalEnvelope, 'The updated room, or a rejection with `Success: false`'), + 401: { description: 'No bearer token (empty body)' }, + }, + }), + async (c) => { + const accountId = await authedAccountId(c) + if (accountId === null) return c.body(null, 401) + const refuse = (error: string) => + c.json({ Value: null, Success: false, Error: error, error_id: null }) + + const roomId = Number.parseInt(c.req.param('roomId'), 10) + const room = await getRoomById(c.env.DB, roomId) + if (!room) return refuse('This room does not exist!') + if (!canManageRoom(room, accountId)) return refuse('You are not the owner of this room!') + + const body = (await c.req.parseBody().catch(() => ({}))) as Record + const mode = parseMaxPlayerCalculationMode(body.maxPlayerCalculationMode) + if (mode === undefined) return refuse('You must provide a valid maxPlayerCalculationMode!') + + const updated = await updateRoomFields(c.env.DB, roomId, room, { + MaxPlayerCalculationMode: mode, + }) + await pushRoomUpdate(c, accountId, updated) + return c.json({ Value: updated, Success: true, Error: null, error_id: null }) + } + ) + // Set a room's platform/movement support flags (its `Supports*` restrictions). // Auth-gated (401) and owner/co-owner-only (403). Body is a form of // `supports*=True|False` fields (see RESTRICTION_FIELDS); only the fields present @@ -3914,7 +3993,7 @@ const app = new Hono() parameters: [roomIdParam, subRoomIdParam], requestBody: form(MoveSubRoomRequest, 'The destination room'), responses: { - 200: json(MoveSubRoomEnvelope, 'The source room, or a rejection with `Success: false`'), + 200: json(RoomPascalEnvelope, 'The source room, or a rejection with `Success: false`'), 401: { description: 'No bearer token (empty body)' }, }, }), diff --git a/apps/rooms/src/test/integration/api.test.ts b/apps/rooms/src/test/integration/api.test.ts index b5d01ab..8cb26ad 100644 --- a/apps/rooms/src/test/integration/api.test.ts +++ b/apps/rooms/src/test/integration/api.test.ts @@ -4819,6 +4819,68 @@ describe('rooms endpoints', () => { }) }) + it('PUT /rooms/:id/max_player_calculation_mode stores the mode without touching MaxPlayers', async () => { + await seedRoomWithSubRooms(env.DB, { + RoomId: 720, + Name: 'CapMode', + CreatorAccountId: 5, + MaxPlayerCalculationMode: 0, + MaxPlayers: 12, + Roles: [{ AccountId: 1, Role: 30, LastChangedByAccountId: null, InvitedRole: 0 }], + SubRooms: [ + { SubRoomId: 920, UnitySceneId: 'x', MaxPlayers: 12 }, + { SubRoomId: 921, UnitySceneId: 'x', MaxPlayers: 4 }, + ], + }) + const put = async (roomId: number, mode: string, sub?: string) => + SELF.fetch(`${ORIGIN}/rooms/${roomId}/max_player_calculation_mode`, { + method: 'PUT', + headers: { + ...(sub ? await bearer(sub) : {}), + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ maxPlayerCalculationMode: mode }).toString(), + }) + type Envelope = { + Value: { RoomId: number; MaxPlayerCalculationMode: number; MaxPlayers: number } | null + Success: boolean + Error: string | null + error_id: null + } + const envelope = async (res: Response) => (await res.json()) as Envelope + + // No token → 401; every other refusal is a 200 with Success:false and Value:null. + expect((await put(720, 'OnlyEntrySubrooms')).status).toBe(401) + for (const res of [ + await put(720, 'OnlyEntrySubrooms', '999'), // not on the room + await put(720, 'Sideways', '1'), // not a mode + await put(720, '7', '1'), // not an ordinal in the enum + await put(424242, 'OnlyEntrySubrooms', '1'), // no such room + ]) { + expect(res.status).toBe(200) + expect(await envelope(res)).toMatchObject({ Value: null, Success: false, error_id: null }) + } + + // A co-owner sets it by NAME (what the client sends). Stored as the ordinal, and + // MaxPlayers is left exactly as it was — the mode is a setting, not a recompute. + const res = await put(720, 'OnlyEntrySubrooms', '1') + expect(res.status).toBe(200) + const body = await envelope(res) + expect(body).toMatchObject({ Success: true, Error: null, error_id: null }) + expect(body.Value).toMatchObject({ RoomId: 720, MaxPlayerCalculationMode: 1, MaxPlayers: 12 }) + const stored = (await (await SELF.fetch(`${ORIGIN}/rooms/720`)).json()) as { + MaxPlayerCalculationMode: number + MaxPlayers: number + } + expect(stored).toMatchObject({ MaxPlayerCalculationMode: 1, MaxPlayers: 12 }) + + // Case-insensitive names and the bare ordinal are accepted too. + expect( + (await envelope(await put(720, 'allsubrooms', '5'))).Value?.MaxPlayerCalculationMode + ).toBe(0) + expect((await envelope(await put(720, '1', '5'))).Value?.MaxPlayerCalculationMode).toBe(1) + }) + it('POST /rooms/:id/subrooms/:sid/move re-parents a subroom when the caller manages both rooms', async () => { // Source 710 is owned by 1 with two subrooms; target 711 is owned by 5 with 1 as a // CO-OWNER (the move gate is manage, not own); 712 is somebody else's entirely. @@ -5313,6 +5375,7 @@ describe('rooms endpoints', () => { 'PUT /rooms/{roomId}/interactionby/me/cheer', 'PUT /rooms/{roomId}/interactionby/me/favorite', 'PUT /rooms/{roomId}/loadscreen', + 'PUT /rooms/{roomId}/max_player_calculation_mode', 'PUT /rooms/{roomId}/name', 'PUT /rooms/{roomId}/restrictions', 'PUT /rooms/{roomId}/roles/{accountId}',