[rooms] support to move subrooms

This commit is contained in:
Devin Zuczek
2026-09-23 17:40:03 -04:00
parent 9bd3e1630f
commit 4597f9cb03
4 changed files with 207 additions and 0 deletions
+19
View File
@@ -822,6 +822,25 @@ export const PublishSaveRequest = z.object({
subRoomDataSaveId: z.string().describe('The `SubRoomDataSaveId` to make live'),
})
/** `POST /rooms/{roomId}/subrooms/{subRoomId}/move` — form body naming the destination. */
export const MoveSubRoomRequest = z.object({
newRoomId: z.string().describe('The `RoomId` to move the subroom into'),
})
/**
* 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
* 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.
*/
export const MoveSubRoomEnvelope = z.object({
Value: RoomDto.nullable().describe('The source 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'),
})
/** `POST /rooms/{roomId}/subrooms`. */
export const CreateSubRoomRequest = z.object({
name: z.string().describe('The new subroom’s name'),
+74
View File
@@ -10,6 +10,7 @@ import {
autocompleteRoomSearch,
banPlayerFromRoom,
canManageRoom,
canManageRoomById,
cloneRoom,
cloneSubRoom,
countRoomsByCreator,
@@ -50,6 +51,7 @@ import {
MessageType,
modifySubRoom,
movePlayerToDorm,
moveSubRoom,
publishSubRoomSave,
removeCheer,
removeFavorite,
@@ -115,6 +117,8 @@ import {
LoadScreenRequest,
MissingLookupParam,
ModifySubRoomRequest,
MoveSubRoomEnvelope,
MoveSubRoomRequest,
NameRequest,
NOT_FRIENDS_RESPONSE,
PagedRooms,
@@ -3881,6 +3885,76 @@ const app = new Hono<App>()
}
)
// Move a subroom into another room (form body `newRoomId`). Auth-gated (401), and the
// caller must be able to MANAGE both rooms — owner or co-owner, unlike the creator-only
// gate on the other subroom mutations. Answers the SOURCE room in the PascalCase
// `{ Value, Success, Error, error_id }` envelope, not the lowercase one its siblings use.
.post(
'/rooms/:roomId{[0-9]+}/subrooms/:subRoomId{[0-9]+}/move',
describeRoute({
tags: ['Subrooms'],
summary: 'Move a subroom to another room',
description: [
'Re-parents a subroom onto another room. The subroom keeps its `SubRoomId`, its',
'save history and its permission overrides — all keyed by that globally-unique id',
'— and only its `RoomId` changes. It refuses to move a room’s only subroom, for the',
'reason delete refuses it: the room would be left with no scene to load. Moving a',
'subroom onto the room it is already in is refused as well.',
'',
'The caller must be able to manage BOTH rooms — owner or co-owner (`CreatorAccountId`',
'or a Creator/CoOwner role) — not just the one the subroom leaves.',
'',
'Answers the SOURCE room, which no longer lists the subroom, under `Value` in the',
'PascalCase `{ Value, Success, Error, error_id }` envelope: NOT the lowercase',
'`{ success, error, value }` the other subroom mutations 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, subRoomIdParam],
requestBody: form(MoveSubRoomRequest, 'The destination room'),
responses: {
200: json(MoveSubRoomEnvelope, 'The source 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 subRoomId = Number.parseInt(c.req.param('subRoomId'), 10)
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
const newRoomId = Number.parseInt(String(body.newRoomId ?? ''), 10)
if (!Number.isInteger(newRoomId)) return refuse('You must provide a valid newRoomId!')
if (newRoomId === roomId) return refuse('This subroom is already in that room!')
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 canManageTarget = await canManageRoomById(c.env.DB, newRoomId, accountId)
if (canManageTarget === null) return refuse('The destination room does not exist!')
if (!canManageTarget) return refuse('You are not the owner of the destination room!')
const result = await moveSubRoom(c.env.DB, roomId, subRoomId, newRoomId)
if (!result.ok) {
return refuse(
result.reason === 'last_subroom'
? "You can't move a room's only subroom!"
: 'This subroom does not exist!'
)
}
// Both rooms changed shape for the caller's other sessions.
await pushRoomUpdate(c, accountId, result.room)
const target = await getRoomById(c.env.DB, newRoomId)
if (target) await pushRoomUpdate(c, accountId, target)
return c.json({ Value: result.room, Success: true, Error: null, error_id: null })
}
)
// Create a new (empty) subroom in a room (form body `name`). Auth-gated (401) and
// owner-only. Mints a fresh globally-unique SubRoomId, bases the scene/capacity on the
// room's first subroom, notifies the owner (RoomUpdate), and returns the updated ROOM
@@ -4819,6 +4819,84 @@ describe('rooms endpoints', () => {
})
})
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.
await seedRoomWithSubRooms(env.DB, {
RoomId: 710,
Name: 'MoveSource',
CreatorAccountId: 1,
SubRooms: [
{ SubRoomId: 910, UnitySceneId: 'x', MaxPlayers: 4 },
{ SubRoomId: 911, UnitySceneId: 'x', MaxPlayers: 4 },
],
})
await seedRoomWithSubRooms(env.DB, {
RoomId: 711,
Name: 'MoveTarget',
CreatorAccountId: 5,
Roles: [{ AccountId: 1, Role: 30, LastChangedByAccountId: null, InvitedRole: 0 }],
SubRooms: [{ SubRoomId: 912, UnitySceneId: 'x', MaxPlayers: 4 }],
})
await seedRoomWithSubRooms(env.DB, {
RoomId: 712,
Name: 'NotMine',
CreatorAccountId: 999,
SubRooms: [{ SubRoomId: 913, UnitySceneId: 'x', MaxPlayers: 4 }],
})
const move = async (roomId: number, subRoomId: number, newRoomId: string, sub?: string) =>
SELF.fetch(`${ORIGIN}/rooms/${roomId}/subrooms/${subRoomId}/move`, {
method: 'POST',
headers: {
...(sub ? await bearer(sub) : {}),
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({ newRoomId }).toString(),
})
type Envelope = {
Value: { RoomId: number; SubRooms: Array<{ SubRoomId: number; RoomId: 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 move(710, 910, '711')).status).toBe(401)
const refused = async (res: Response) => {
expect(res.status).toBe(200)
const body = await envelope(res)
expect(body).toMatchObject({ Value: null, Success: false, error_id: null })
expect(typeof body.Error).toBe('string')
}
await refused(await move(710, 910, '711', '999')) // can't manage the source
await refused(await move(710, 910, '712', '1')) // can't manage the target
await refused(await move(710, 910, '424242', '1')) // no such target
await refused(await move(710, 910, '710', '1')) // already there
await refused(await move(710, 910, '', '1')) // no newRoomId
await refused(await move(710, 99999, '711', '1')) // no such subroom
await refused(await move(712, 913, '711', '999')) // 999 owns 712 but not 711
// Nothing moved on any of those.
expect(await subRoomOf(710, 910)).toBeDefined()
expect(await subRoomOf(711, 910)).toBeUndefined()
// Owner of the source and co-owner of the target → moved. Value is the SOURCE room,
// which no longer lists the subroom, in the PascalCase envelope.
const res = await move(710, 910, '711', '1')
expect(res.status).toBe(200)
const body = await envelope(res)
expect(body).toMatchObject({ Success: true, Error: null, error_id: null })
expect(body.Value?.RoomId).toBe(710)
expect(body.Value?.SubRooms.map((s) => s.SubRoomId)).toEqual([911])
// It now reads as the target's, with its RoomId re-injected from the column.
expect(await subRoomOf(710, 910)).toBeUndefined()
expect(await subRoomOf(711, 910)).toMatchObject({ SubRoomId: 910, RoomId: 711 })
// The source's last subroom can't leave — the room would have no scene to load.
await refused(await move(710, 911, '711', '1'))
expect(await subRoomOf(710, 911)).toBeDefined()
})
it('subroom clone mints a globally-unique SubRoomId (no cross-room clash)', async () => {
// The old per-room `max(SubRoomId)+1` allocator would mint id 3 for room 2's clone —
// colliding with another room that already owns subroom 3. The subroom table's
@@ -5225,6 +5303,7 @@ describe('rooms endpoints', () => {
'POST /rooms/{roomId}/subrooms',
'POST /rooms/{roomId}/subrooms/{subRoomId}/clone',
'POST /rooms/{roomId}/subrooms/{subRoomId}/data',
'POST /rooms/{roomId}/subrooms/{subRoomId}/move',
'POST /rooms/{roomId}/subrooms/{subRoomId}/publish_save',
'PUT /rooms/{roomId}/accessibility',
'PUT /rooms/{roomId}/cloning',
+35
View File
@@ -1632,6 +1632,41 @@ export async function deleteSubRoom(
return { ok: true, room }
}
/**
* Move a subroom from one room to another (`POST …/subrooms/{subRoomId}/move`). A subroom
* is its own row keyed by a globally-unique id, and its saves and permission overrides hang
* off that id rather than the room's, so the move is one column: `room_id`. The saves,
* the current/staged pointers and the permissions all travel with it untouched. The
* subroom's `RoomId` is re-injected from the column on read, so the blob needs nothing.
*
* Refuses to move a room's only subroom, for the reason {@link deleteSubRoom} refuses to
* delete it: the room would be left with no scene to load. The caller checks the target
* room exists and that it may manage both rooms — this only checks the subroom's side.
*
* Returns the SOURCE room, hydrated, which no longer lists the subroom — that is what the
* client re-renders from — or a reason: `not_found` (no such subroom in that room) /
* `last_subroom`.
*/
export async function moveSubRoom(
db: D1Database,
roomId: number,
subRoomId: number,
newRoomId: number
): Promise<{ ok: true; room: Room } | { ok: false; reason: 'not_found' | 'last_subroom' }> {
const subRooms = await getSubRooms(db, roomId)
if (!subRooms.some((s) => s.SubRoomId === subRoomId)) return { ok: false, reason: 'not_found' }
if (subRooms.length <= 1) return { ok: false, reason: 'last_subroom' }
await db
.prepare('UPDATE subroom SET room_id = ?3 WHERE room_id = ?1 AND sub_room_id = ?2')
.bind(roomId, subRoomId, newRoomId)
.run()
const room = await getRoomById(db, roomId)
if (!room) return { ok: false, reason: 'not_found' }
return { ok: true, room }
}
interface RoomRow {
data: string
visits: number