fix room save shape

This commit is contained in:
Devin Zuczek
2026-07-28 23:49:32 -04:00
parent 6a910b27bd
commit 4c6b9679e4
5 changed files with 155 additions and 38 deletions
+39
View File
@@ -138,6 +138,31 @@ export const LoadScreenDto = z.object({
Subtitle: z.string(),
})
/**
* The save as the room-save RESPONSE renders it — camelCase, and a different field set
* from the PascalCase `CurrentSave` embedded in a room (no persistence/OM/UGC versions,
* no moderation state, no asset arrays; but `unityAsset`/`unityAssetHash`/`dataBlobHash`
* that `CurrentSave` doesn't show). The two are deliberately not unified.
*/
export const SubRoomDataSaveResponseDto = z.object({
subRoomDataSaveId: z.int(),
subRoomId: z.int(),
unityAssetId: z.string().nullable().describe('Null unless the save carried one'),
unityAsset: z.string().nullable().describe('Always null — we resolve no baked assets'),
unityAssetHash: z.string().nullable().describe('Always null — we resolve no baked assets'),
dataBlob: z.string(),
dataBlobHash: z.string().nullable().describe('Echoed from the requests `SubRoomData.Hash`'),
savedByAccountId: z.int().nullable(),
savedOnPlatform: z
.int()
.describe(
'Steam=0 Oculus=1 PlayStation=2 Xbox=3 RecNet=4 IOS=5 GooglePlay=6 Standalone=7 Pico=8'
),
savedOnDeviceClass: z.int().describe('Unknown=0 VR=1 Screen=2 Mobile=3 VRLow=4 Quest2=5'),
description: z.string().nullable(),
createdAt: z.string(),
})
/**
* A subroom's most recent room save — the `SubRoomDataSave` the client reads to find the
* scene-data blob to download. This is the ONLY place the loader looks for it, so a
@@ -351,6 +376,20 @@ export const RoomEnvelope = z.object({
value: RoomDto.nullable(),
})
/**
* What `POST /rooms/{roomId}/subrooms/{subRoomId}/data` answers: `value` carries BOTH the
* updated room and the save that was just created. Note `error` is NULL here, not the
* empty string the other room envelopes use.
*/
export const RoomSaveEnvelope = z.object({
success: z.boolean(),
error: z.string().nullable().describe('Null on success'),
value: z
.object({ room: RoomDto, subRoomDataSave: SubRoomDataSaveResponseDto })
.nullable()
.describe('Null on a rejection'),
})
/** The 401 the envelope-returning routes answer with — the only one that isnt HTTP 200. */
export const UNAUTHORIZED_ENVELOPE = json(
z.object({ success: z.literal(false), error: z.literal('Unauthorized'), value: z.null() }),
+51 -14
View File
@@ -75,6 +75,7 @@ import {
roomIdParam,
RoomLookup,
RoomResultEnvelope,
RoomSaveEnvelope,
SaveSubRoomDataRequest,
ServiceStatus,
stringQuery,
@@ -271,6 +272,33 @@ function roomResult(
})
}
/**
* The room save's `value.subRoomDataSave` — a camelCase projection with a DIFFERENT
* field set from the PascalCase `CurrentSave` embedded in a room (no persistence/OM/UGC
* versions, no moderation state, no asset arrays; but `unityAsset`/`unityAssetHash`
* that `CurrentSave` never shows). Don't unify the two without checking the client.
*
* `unityAsset`/`unityAssetHash` are always null: we resolve no baked Unity assets.
*/
function toSaveResponse(save: Record<string, unknown>) {
const str = (v: unknown) => (typeof v === 'string' ? v : null)
const num = (v: unknown) => (typeof v === 'number' ? v : null)
return {
subRoomDataSaveId: num(save.SubRoomDataSaveId),
subRoomId: num(save.SubRoomId),
unityAssetId: str(save.UnityAssetId),
unityAsset: null,
unityAssetHash: null,
dataBlob: str(save.DataBlob) ?? '',
dataBlobHash: str(save.DataBlobHash),
savedByAccountId: num(save.SavedByAccountId),
savedOnPlatform: num(save.SavedOnPlatform) ?? 0,
savedOnDeviceClass: num(save.SavedOnDeviceClass) ?? 0,
description: str(save.Description),
createdAt: str(save.CreatedAt) ?? '',
}
}
/** Client envelope for room mutations: `{ success, error, value }` (lowercase). */
function roomEnvelope(c: Context<App>, value: unknown, error = '') {
return c.json({ success: error === '', error, value })
@@ -1504,9 +1532,10 @@ const app = new Hono<App>()
'`POST …/subrooms/{subRoomId}/publish_save`. DORMS always publish — they have no',
'publish step in the client, so staging one would hide the players own edits.',
'',
'Answers the whole updated ROOM in the `{ success, error, value }` envelope, like',
'every other subroom mutation — the client re-renders from `value`, and a bare',
'subroom leaves the old scene on screen even though the save landed.',
'`value` carries BOTH the updated `room` and the `subRoomDataSave` just created,',
'and `error` is NULL here rather than the empty string the other room envelopes',
'use. The save is projected in camelCase with a different field set from the',
'PascalCase `CurrentSave` embedded in the room — the two are not the same shape.',
'A subroom with no `CreatorAccountId` yet (the seeded rooms start null) gets the',
'savers id here, because the client NREs on a null one.',
].join('\n'),
@@ -1514,7 +1543,7 @@ const app = new Hono<App>()
parameters: [roomIdParam, subRoomIdParam],
requestBody: jsonBody(SaveSubRoomDataRequest, 'The uploaded blob keys and save fields'),
responses: {
200: json(RoomEnvelope, 'The updated room, or a rejection with `success: false`'),
200: json(RoomSaveEnvelope, 'The updated room + the new save, or a rejection'),
401: UNAUTHORIZED_EMPTY,
403: FORBIDDEN_RESPONSE,
},
@@ -1527,7 +1556,9 @@ const app = new Hono<App>()
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) {
return c.json({ success: false, error: 'This room does not exist!', value: null })
}
// A valid token but not the room's owner/co-owner → 403 (the auth gate above
// already returned 401 for a missing/invalid token).
if (!canManageRoom(room, accountId)) return c.body(null, 403)
@@ -1537,7 +1568,7 @@ const app = new Hono<App>()
// metadata blob. `OwnershipProof` is accepted and ignored.
const body = (await c.req.json().catch(() => ({}))) as {
RoomData?: { Filename?: string }
SubRoomData?: { Filename?: string }
SubRoomData?: { Filename?: string; Hash?: string | null }
UnityAssetId?: string | null
Description?: string
PersistenceVersion?: number
@@ -1545,8 +1576,10 @@ const app = new Hono<App>()
AutoPublish?: boolean
}
const updated = await saveSubRoomData(c.env.DB, roomId, subRoomId, accountId, {
const result = await saveSubRoomData(c.env.DB, roomId, subRoomId, accountId, {
subRoomDataFilename: body.SubRoomData?.Filename,
subRoomDataHash:
typeof body.SubRoomData?.Hash === 'string' ? body.SubRoomData.Hash : undefined,
roomDataFilename: body.RoomData?.Filename,
unityAssetId: typeof body.UnityAssetId === 'string' ? body.UnityAssetId : undefined,
autoPublish: body.AutoPublish === true,
@@ -1555,14 +1588,18 @@ const app = new Hono<App>()
typeof body.PersistenceVersion === 'number' ? body.PersistenceVersion : undefined,
inventionUsage: typeof body.InventionUsage === 'string' ? body.InventionUsage : undefined,
})
if (!updated) return roomEnvelope(c, null, 'This subroom does not exist!')
if (!result) {
return c.json({ success: false, error: 'This subroom does not exist!', value: null })
}
// The whole updated ROOM, like every other subroom mutation — the client
// re-renders the room from `value` and does NOT pick up a bare subroom, so
// answering with just the saved subroom leaves the old scene on screen even
// though the save landed.
await pushRoomUpdate(c, accountId, updated)
return roomEnvelope(c, updated)
// `value` carries BOTH the updated room and the save just created — and `error`
// is null here, not the empty string the other room envelopes use.
await pushRoomUpdate(c, accountId, result.room)
return c.json({
success: true,
error: null,
value: { room: result.room, subRoomDataSave: toSaveResponse(result.save) },
})
}
)
+42 -13
View File
@@ -989,16 +989,40 @@ describe('rooms endpoints', () => {
})
expect(await envOf(await authed(2, 9999, '1'))).toMatchObject({ success: false })
// Owner saves → 200 with the whole updated ROOM in the envelope. A bare subroom
// here leaves the client showing the old scene even though the save landed. This
// fixture sends `AutoPublish: true`, so the save goes live immediately.
// Owner saves → 200. `value` carries BOTH the updated room and the new save, and
// `error` is null (not ''). This fixture sends `AutoPublish: true`, so it goes live.
const ok = await authed(2, 2, '1')
expect(ok.status).toBe(200)
const saved = await envOf(ok)
const saved = (await ok.json()) as {
success: boolean
error: string | null
value: {
room: Record<string, unknown>
subRoomDataSave: Record<string, unknown>
}
}
expect(saved.success).toBe(true)
expect(saved.value).toMatchObject({ RoomId: 2, Description: 'mydescription here' })
expect(saved.error).toBeNull()
expect(saved.value.room).toMatchObject({ RoomId: 2, Description: 'mydescription here' })
// The save is a camelCase projection, NOT the PascalCase CurrentSave shape.
expect(saved.value.subRoomDataSave).toEqual({
subRoomDataSaveId: expect.any(Number),
subRoomId: 2,
unityAssetId: null,
unityAsset: null,
unityAssetHash: null,
dataBlob: 'a84167b16796452ab70ee8a6a5b1dc5f',
dataBlobHash: null,
savedByAccountId: 1,
savedOnPlatform: 0,
savedOnDeviceClass: 0,
description: 'mydescription here',
createdAt: expect.any(String),
})
// The saved subroom rides along inside the room's SubRooms, carrying the new save.
const savedSub = (saved.value!.SubRooms as Array<Record<string, unknown>>).find(
const savedSub = (saved.value.room.SubRooms as Array<Record<string, unknown>>).find(
(s) => s.SubRoomId === 2
)!
expect(savedSub).toMatchObject({
@@ -1047,11 +1071,16 @@ describe('rooms endpoints', () => {
// with the room envelope. The creator stays account 1 (not clobbered).
const coOwner = await authed(2, 2, '2')
expect(coOwner.status).toBe(200)
const coOwnerEnv = await envOf(coOwner)
const coOwnerEnv = (await coOwner.json()) as {
success: boolean
value: { room: { SubRooms: Array<Record<string, unknown>> }; subRoomDataSave: unknown }
}
expect(coOwnerEnv.success).toBe(true)
expect(
(coOwnerEnv.value!.SubRooms as Array<Record<string, unknown>>).find((s) => s.SubRoomId === 2)
).toMatchObject({ CreatorAccountId: 1 })
expect(coOwnerEnv.value.room.SubRooms.find((s) => s.SubRoomId === 2)).toMatchObject({
CreatorAccountId: 1,
})
// The save records who actually saved it, not the room's creator.
expect(coOwnerEnv.value.subRoomDataSave).toMatchObject({ savedByAccountId: 2 })
})
it('GET /rooms/:id gives every subroom a CurrentSave key (null before the first save)', async () => {
@@ -1125,9 +1154,9 @@ describe('rooms endpoints', () => {
UgcSubVersion: 0,
ModerationState: 0,
})
// No DataBlobHash — it is commented out of the reference DTO — and no UnityAssetId
// key at all, since the client sent null.
expect('DataBlobHash' in live.CurrentSave!).toBe(false)
// DataBlobHash rides along (null — the client sent `Hash: null`); UnityAssetId is
// omitted entirely rather than nulled, since the save carried none.
expect(live.CurrentSave!.DataBlobHash).toBeNull()
expect('UnityAssetId' in live.CurrentSave!).toBe(false)
// It's on the room read too — that's what the loader actually fetches.