mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 23:18:51 -07:00
[rooms] add calculation mode
This commit is contained in:
@@ -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'),
|
||||
|
||||
@@ -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<string, string> = {
|
||||
* 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<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(
|
||||
c: Context<App>,
|
||||
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).
|
||||
// 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<App>()
|
||||
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)' },
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -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}',
|
||||
|
||||
Reference in New Issue
Block a user