mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
beta subrooms (saving not working)
This commit is contained in:
@@ -6,7 +6,9 @@ import {
|
||||
cloneRoom,
|
||||
cloneSubRoom,
|
||||
countRoomsByCreator,
|
||||
createSubRoom,
|
||||
deleteRoom,
|
||||
deleteSubRoom,
|
||||
findSubRoom,
|
||||
getBaseRooms,
|
||||
getFavoritedRooms,
|
||||
@@ -823,6 +825,15 @@ const app = new Hono<App>()
|
||||
return sub ? c.json(sub) : c.notFound()
|
||||
})
|
||||
|
||||
// A subroom's saved-data versions — the room-history / "restore a save" list, paged as
|
||||
// PagedResultsDTO<SubRoomDataSaveDTO> (`{ 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<App>()
|
||||
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<string, unknown>
|
||||
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.
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<string> {
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user