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