[rooms] add calculation mode

This commit is contained in:
Devin Zuczek
2026-09-23 17:56:08 -04:00
parent 4597f9cb03
commit 5013c5d4e6
3 changed files with 166 additions and 9 deletions
+22 -7
View File
@@ -324,7 +324,12 @@ export const RoomDto = z.object({
RankingContext: z.unknown().nullable(), RankingContext: z.unknown().nullable(),
IsDorm: z.boolean().describe('Auto-provisioned personal room; excluded from every feed'), IsDorm: z.boolean().describe('Auto-provisioned personal room; excluded from every feed'),
IsPlacePlay: z.boolean(), 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(), MaxPlayers: z.int(),
CloningAllowed: z.boolean().describe('False blocks `POST /rooms/{roomId}/clone`'), CloningAllowed: z.boolean().describe('False blocks `POST /rooms/{roomId}/clone`'),
DisableMicAutoMute: z.boolean(), 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 * A room in the PascalCase `{ Value, Success, Error, error_id }` envelope — the same mixed
* `Value`, in the PascalCase `{ Value, Success, Error, error_id }` envelope — the same mixed
* casing the unprefixed isBanned check has ({@link IsBannedPascalEnvelope}), NOT the * casing the unprefixed isBanned check has ({@link IsBannedPascalEnvelope}), NOT the
* lowercase `{ success, error, value }` every other subroom mutation answers. Kept apart * lowercase `{ success, error, value }` most room mutations answer. The newer settings
* deliberately: the client decodes this one with a different reader. * 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({ export const RoomPascalEnvelope = z.object({
Value: RoomDto.nullable().describe('The source room as it now stands; null on a rejection'), Value: RoomDto.nullable().describe('The room as it now stands; null on a rejection'),
Success: z.boolean(), Success: z.boolean(),
Error: z.string().nullable().describe('The message shown on a rejection; null on success'), 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'), 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`. */ /** `POST /rooms/{roomId}/subrooms`. */
export const CreateSubRoomRequest = z.object({ export const CreateSubRoomRequest = z.object({
name: z.string().describe('The new subroom’s name'), name: z.string().describe('The new subroom’s name'),
+81 -2
View File
@@ -115,9 +115,9 @@ import {
LeaderboardRequest, LeaderboardRequest,
LeaderboardResultEnvelope, LeaderboardResultEnvelope,
LoadScreenRequest, LoadScreenRequest,
MaxPlayerCalculationModeRequest,
MissingLookupParam, MissingLookupParam,
ModifySubRoomRequest, ModifySubRoomRequest,
MoveSubRoomEnvelope,
MoveSubRoomRequest, MoveSubRoomRequest,
NameRequest, NameRequest,
NOT_FRIENDS_RESPONSE, NOT_FRIENDS_RESPONSE,
@@ -140,6 +140,7 @@ import {
RoomExperiencePlayer, RoomExperiencePlayer,
roomIdParam, roomIdParam,
RoomLookup, RoomLookup,
RoomPascalEnvelope,
RoomResultEnvelope, RoomResultEnvelope,
RoomRoleDto, RoomRoleDto,
RoomSaveEnvelope, RoomSaveEnvelope,
@@ -509,6 +510,32 @@ const RESTRICTION_FIELDS: Record<string, string> = {
* Hub failures are logged and swallowed — the room write has already committed, * Hub failures are logged and swallowed — the room write has already committed,
* so a hub hiccup must not fail the request. * 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<Record<string, number>> = {
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( async function pushRoomUpdate(
c: Context<App>, c: Context<App>,
playerId: number, playerId: number,
@@ -3124,6 +3151,58 @@ const app = new Hono<App>()
} }
) )
// 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<string, unknown>
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). // 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 // 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 // `supports*=True|False` fields (see RESTRICTION_FIELDS); only the fields present
@@ -3914,7 +3993,7 @@ const app = new Hono<App>()
parameters: [roomIdParam, subRoomIdParam], parameters: [roomIdParam, subRoomIdParam],
requestBody: form(MoveSubRoomRequest, 'The destination room'), requestBody: form(MoveSubRoomRequest, 'The destination room'),
responses: { 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)' }, 401: { description: 'No bearer token (empty body)' },
}, },
}), }),
@@ -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 () => { 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 // 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. // 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/cheer',
'PUT /rooms/{roomId}/interactionby/me/favorite', 'PUT /rooms/{roomId}/interactionby/me/favorite',
'PUT /rooms/{roomId}/loadscreen', 'PUT /rooms/{roomId}/loadscreen',
'PUT /rooms/{roomId}/max_player_calculation_mode',
'PUT /rooms/{roomId}/name', 'PUT /rooms/{roomId}/name',
'PUT /rooms/{roomId}/restrictions', 'PUT /rooms/{roomId}/restrictions',
'PUT /rooms/{roomId}/roles/{accountId}', 'PUT /rooms/{roomId}/roles/{accountId}',