From 27c45792b80519fc92c800a660cf7f5173edeea0 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Fri, 24 Jul 2026 21:00:42 -0400 Subject: [PATCH] beta subrooms (saving not working) --- apps/rooms/src/rooms.app.ts | 74 ++++++++++++++ apps/rooms/src/test/integration/api.test.ts | 108 ++++++++++++++++++++ packages/domain/src/rooms-db.ts | 73 +++++++++++++ 3 files changed, 255 insertions(+) diff --git a/apps/rooms/src/rooms.app.ts b/apps/rooms/src/rooms.app.ts index 6d72f6e..0868d29 100644 --- a/apps/rooms/src/rooms.app.ts +++ b/apps/rooms/src/rooms.app.ts @@ -6,7 +6,9 @@ import { cloneRoom, cloneSubRoom, countRoomsByCreator, + createSubRoom, deleteRoom, + deleteSubRoom, findSubRoom, getBaseRooms, getFavoritedRooms, @@ -823,6 +825,15 @@ const app = new Hono() return sub ? c.json(sub) : c.notFound() }) + // A subroom's saved-data versions — the room-history / "restore a save" list, paged as + // PagedResultsDTO (`{ Results, TotalResults }`). We don't keep a save + // history yet: a save (POST …/data) overwrites the current blob inline on the subroom, so + // there are no distinct versions to list — this returns an empty page. The + // unityAssetTarget/unityAssetVersion/skip/take query params are accepted and ignored. + .get('/rooms/:roomId{[0-9]+}/subrooms/:subRoomId{[0-9]+}/saves', (c) => + c.json({ Results: [], TotalResults: 0 }) + ) + // Save a subroom's data (room save). Auth-gated (401 with empty body). Editable // by the room creator or a Creator/CoOwner role holder. Points the subroom at // the uploaded data blobs and records the room-level save fields, notifies the @@ -969,6 +980,69 @@ const app = new Hono() return roomEnvelope(c, result.subRoom) }) + // 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 + // in the `{ success, error, value }` envelope (the client re-renders the room's subroom + // list from `value`, so it's the whole room, not the bare subroom). + .post('/rooms/:roomId{[0-9]+}/subrooms', async (c) => { + const accountId = await authedAccountId(c) + if (accountId === null) { + return c.json({ success: false, error: 'Unauthorized', value: null }, 401) + } + + const roomId = Number.parseInt(c.req.param('roomId'), 10) + const room = await getRoomById(c.env.DB, roomId) + if (!room) return roomEnvelope(c, null, 'This room does not exist!') + if (room.CreatorAccountId !== accountId) { + return roomEnvelope(c, null, 'You are not the owner of this room!') + } + + const body = (await c.req.parseBody().catch(() => ({}))) as Record + const name = typeof body.name === 'string' ? body.name.trim() : '' + if (name === '') return roomEnvelope(c, null, 'You must enter a name for your subroom!') + + const result = await createSubRoom(c.env.DB, roomId, accountId, name) + if (!result) return roomEnvelope(c, null, 'This room does not exist!') + + await pushRoomUpdate(c, accountId, result.room) + return roomEnvelope(c, result.room) + }) + + // Delete a subroom from a room. Auth-gated (401) and owner-only. Refuses to remove a + // room's only subroom. Notifies the owner (RoomUpdate) and returns the updated ROOM in + // the `{ success, error, value }` envelope (same shape as create, so the client + // re-renders the subroom list from `value`). + .delete('/rooms/:roomId{[0-9]+}/subrooms/:subRoomId{[0-9]+}', async (c) => { + const accountId = await authedAccountId(c) + if (accountId === null) { + return c.json({ success: false, error: 'Unauthorized', value: null }, 401) + } + + const roomId = Number.parseInt(c.req.param('roomId'), 10) + const subRoomId = Number.parseInt(c.req.param('subRoomId'), 10) + + const room = await getRoomById(c.env.DB, roomId) + if (!room) return roomEnvelope(c, null, 'This room does not exist!') + if (room.CreatorAccountId !== accountId) { + return roomEnvelope(c, null, 'You are not the owner of this room!') + } + + const result = await deleteSubRoom(c.env.DB, roomId, subRoomId) + if (!result.ok) { + return roomEnvelope( + c, + null, + result.reason === 'last_subroom' + ? "You can't delete a room's only subroom!" + : 'This subroom does not exist!' + ) + } + + await pushRoomUpdate(c, accountId, result.room) + return roomEnvelope(c, result.room) + }) + // Rooms similar to the given room (sharing tags). Paginated via skip/take (take // defaults to 100). Returns `{ Results, TotalResults }`; empty when the room is // unknown/untagged. diff --git a/apps/rooms/src/test/integration/api.test.ts b/apps/rooms/src/test/integration/api.test.ts index 5264ec8..05eac7a 100644 --- a/apps/rooms/src/test/integration/api.test.ts +++ b/apps/rooms/src/test/integration/api.test.ts @@ -1385,4 +1385,112 @@ describe('rooms endpoints', () => { .first<{ n: number }>())!.n expect(dupes).toBe(1) }) + + it('POST /rooms/:id/subrooms creates a new subroom (auth-gated, owner-only, fresh id)', async () => { + const create = async (roomId: number, name: string, sub?: string) => + SELF.fetch(`${ORIGIN}/rooms/${roomId}/subrooms`, { + method: 'POST', + headers: { + ...(sub ? await bearer(sub) : {}), + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ name }).toString(), + }) + type SubRoom = { SubRoomId: number; RoomId: number; Name: string; CreatorAccountId: number } + const envelope = async (res: Response) => + (await res.json()) as { + success: boolean + // The whole room comes back on success (the client re-renders its subroom list). + value: { RoomId: number; SubRooms: SubRoom[] } | null + } + + // No token → 401. + expect((await create(2, 'ffff')).status).toBe(401) + // Valid token but not the owner → success:false envelope. + expect((await envelope(await create(2, 'ffff', '999'))).success).toBe(false) + // Blank name → success:false envelope. + expect((await envelope(await create(2, ' ', '1'))).success).toBe(false) + // Unknown room → success:false envelope. + expect((await envelope(await create(99999, 'ffff', '1'))).success).toBe(false) + + const maxBefore = (await env.DB.prepare('SELECT MAX(sub_room_id) AS m FROM subroom').first<{ + m: number + }>())!.m + + // Owner creates → success, and the returned room now embeds the new subroom: a fresh + // global SubRoomId owned by the caller, named, and fetchable. + const body = await envelope(await create(2, 'ffff', '1')) + expect(body.success).toBe(true) + expect(body.value?.RoomId).toBe(2) + const created = body.value?.SubRooms.find((s) => s.Name === 'ffff') + expect(created).toMatchObject({ RoomId: 2, Name: 'ffff', CreatorAccountId: 1 }) + expect(created!.SubRoomId).toBeGreaterThan(maxBefore) + + const fetched = (await ( + await SELF.fetch(`${ORIGIN}/rooms/2/subrooms/${created?.SubRoomId}/data`) + ).json()) as { SubRoomId: number; Name: string; UnitySceneId: string } + expect(fetched).toMatchObject({ SubRoomId: created?.SubRoomId, Name: 'ffff' }) + + // It inherits room 2's own existing (first) subroom scene. + const roomScene = ( + JSON.parse( + (await env.DB.prepare( + 'SELECT data FROM subroom WHERE room_id = 2 ORDER BY sub_room_id LIMIT 1' + ).first<{ data: string }>())!.data + ) as { UnitySceneId: string } + ).UnitySceneId + expect(fetched.UnitySceneId).toBe(roomScene) + }) + + it('DELETE /rooms/:id/subrooms/:sid removes a subroom (auth-gated, owner-only, not the last)', async () => { + const del = async (roomId: number, subRoomId: number, sub?: string) => + SELF.fetch(`${ORIGIN}/rooms/${roomId}/subrooms/${subRoomId}`, { + method: 'DELETE', + headers: sub ? await bearer(sub) : {}, + }) + type SubRoom = { SubRoomId: number; Name: string } + const envelope = async (res: Response) => + (await res.json()) as { success: boolean; value: { SubRooms: SubRoom[] } | null } + + // Add a subroom to room 2 (which already has others), and capture its id by name. + const created = (await ( + await SELF.fetch(`${ORIGIN}/rooms/2/subrooms`, { + method: 'POST', + headers: { ...(await bearer('1')), 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ name: 'to-delete' }).toString(), + }) + ).json()) as { value: { SubRooms: SubRoom[] } } + const newId = created.value.SubRooms.find((s) => s.Name === 'to-delete')!.SubRoomId + + // No token → 401. Not the owner → success:false. Unknown subroom → success:false. + expect((await del(2, newId)).status).toBe(401) + expect((await envelope(await del(2, newId, '999'))).success).toBe(false) + expect((await envelope(await del(2, 99999, '1'))).success).toBe(false) + + // Owner deletes → success, and the subroom is gone from the returned room + not fetchable. + const body = await envelope(await del(2, newId, '1')) + expect(body.success).toBe(true) + expect(body.value?.SubRooms.some((s) => s.SubRoomId === newId)).toBe(false) + expect((await SELF.fetch(`${ORIGIN}/rooms/2/subrooms/${newId}/data`)).status).toBe(404) + + // A room's only subroom can't be deleted (would leave it with no scene). Seed a + // dedicated single-subroom room owned by account 1 to exercise the guard. + await seedRoomWithSubRooms(env.DB, { + RoomId: 700, + Name: 'SoloSubRoom', + CreatorAccountId: 1, + SubRooms: [{ SubRoomId: 900, UnitySceneId: 'x', MaxPlayers: 4 }], + }) + expect((await envelope(await del(700, 900, '1'))).success).toBe(false) + // The lone subroom survives the refused delete. + expect((await SELF.fetch(`${ORIGIN}/rooms/700/subrooms/900/data`)).status).toBe(200) + }) + + it('GET /rooms/:id/subrooms/:sid/saves returns an empty paged result', async () => { + const res = await SELF.fetch( + `${ORIGIN}/rooms/2/subrooms/2/saves?unityAssetTarget=0&unityAssetVersion=1&skip=0&take=20` + ) + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ Results: [], TotalResults: 0 }) + }) }) diff --git a/packages/domain/src/rooms-db.ts b/packages/domain/src/rooms-db.ts index 488797c..7e536e2 100644 --- a/packages/domain/src/rooms-db.ts +++ b/packages/domain/src/rooms-db.ts @@ -393,6 +393,79 @@ export async function cloneSubRoom( return { room, subRoom } } +/** Fallback scene, used only when a room has no existing subroom to inherit from. */ +const DEFAULT_SUBROOM_SCENE = '76d98498-60a1-430c-ab76-b54a29b7a163' + +/** + * The scene a brand-new subroom inherits: the room's own first (existing) subroom — + * lowest SubRoomId — read from the subroom table. Falls back to the base sandbox scene + * only when the room has no subrooms yet. + */ +async function baseSubRoomScene(db: D1Database, roomId: number): Promise { + const row = await db + .prepare('SELECT data FROM subroom WHERE room_id = ?1 ORDER BY sub_room_id LIMIT 1') + .bind(roomId) + .first<{ data: string }>() + const scene = row ? (JSON.parse(row.data) as SubRoom).UnitySceneId : undefined + return typeof scene === 'string' ? scene : DEFAULT_SUBROOM_SCENE +} + +/** + * Create a new (empty) subroom in a room, owned by `accountId` and named `name`. It + * inherits the room's existing subroom scene (see {@link baseSubRoomScene}) with a clean + * save, and gets a fresh globally-unique SubRoomId. Returns the updated (hydrated) room + * and the new subroom, or null when the room doesn't exist. + */ +export async function createSubRoom( + db: D1Database, + roomId: number, + accountId: number, + name: string +): Promise<{ room: Room; subRoom: SubRoom } | null> { + const room = await getRoomById(db, roomId) + if (!room) return null + + const subRoom = await insertSubRoom(db, roomId, { + Name: name, + CreatorAccountId: accountId, + UnitySceneId: await baseSubRoomScene(db, roomId), + MaxPlayers: 4, + Accessibility: Accessibility.Unlisted, + IsSandbox: true, + LastModeratedSaveModerationState: 0, + ShouldAutoStageSaves: true, + StagedSubRoomDataSaveId: null, + }) + // Refresh the hydrated SubRooms so the returned room includes the one just inserted. + await attachSubRooms(db, [room]) + return { room, subRoom } +} + +/** + * Delete a subroom from a room. Refuses to remove a room's only subroom (that would + * leave it with no scene to load). Any saved-data blob the subroom pointed at is left in + * R2 (like {@link deleteRoom} leaves a room's images). Returns the updated (hydrated) + * room on success, or a reason: `not_found` (no such subroom) / `last_subroom`. + */ +export async function deleteSubRoom( + db: D1Database, + roomId: number, + subRoomId: 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('DELETE FROM subroom WHERE room_id = ?1 AND sub_room_id = ?2') + .bind(roomId, subRoomId) + .run() + + const room = await getRoomById(db, roomId) + if (!room) return { ok: false, reason: 'not_found' } + return { ok: true, room } +} + interface RoomRow { data: string }