mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
fixup autopublish
This commit is contained in:
@@ -74,14 +74,30 @@ inconsistency here without checking the client first.
|
||||
`{ error, success, value: null }` — e.g. `clubs` `PUT /club/:id/clubhouse` left the old
|
||||
clubhouse on screen until it answered the full details envelope.
|
||||
- Every subroom mutation (`rooms`: create, delete, `/subrooms/:sid/clone`,
|
||||
`/subrooms/:sid/accessibility`) answers `{ success, error, value }` with the whole
|
||||
updated ROOM — the client re-renders the room's subroom list from `value`. Notably
|
||||
`value` is the room even for `clone`, whose product is a new SUBROOM; only the
|
||||
room-level `POST /rooms/:id/clone` returns the thing it created.
|
||||
`/subrooms/:sid/accessibility`, `/subrooms/:sid/data`, `/subrooms/:sid/publish_save`)
|
||||
answers `{ success, error, value }` with the whole updated ROOM — the client re-renders
|
||||
the room from `value`. Notably `value` is the room even for `clone`, whose product is a
|
||||
new SUBROOM, and even for `data`, whose product is a SAVE; only the room-level
|
||||
`POST /rooms/:id/clone` returns the thing it created. A bare subroom from `data` leaves
|
||||
the old scene on screen even though the save landed — the symptom is "saved fine in the
|
||||
DB, didn't update visually".
|
||||
- A subroom's saved scene loads from `CurrentSave.DataBlob` (`rooms`: `GET /rooms/:id`),
|
||||
NOT the flat `DataBlob` on the subroom — a subroom with no `CurrentSave` silently loads
|
||||
nothing. The key must be present (null before the first save); read it via
|
||||
nothing. The key must be present (null before the first publish); read it via
|
||||
`subRoomDataBlob()` so `match`/`auth` instance payloads resolve it the same way.
|
||||
- A room save (`rooms`: `POST …/subrooms/:sid/data`) publishes only when the body says
|
||||
`AutoPublish: true`; otherwise it STAGES onto `StagedSubRoomDataSaveId` and leaves
|
||||
`CurrentSave` alone, so players keep loading the last published version until the owner
|
||||
posts `…/subrooms/:sid/publish_save` with `subRoomDataSaveId=<id>`. DORMS always
|
||||
publish: no publish step exists in the client for them. Saves live in the
|
||||
`subroom_save` table with globally-unique ids (a bare id has to resolve —
|
||||
`StagedSubRoomDataSaveId` carries no subroom context), and nothing is overwritten, so
|
||||
`…/saves` is real history and `publish_save` doubles as restore-a-save.
|
||||
- Matchmaking (`match`: `/matchmake/room/:roomId/:subRoomId`) always serves the PUBLISHED
|
||||
`CurrentSave` blob, creator included. Joining a private instance, the client itself asks
|
||||
the owner whether to load the latest or the published version and resolves it from the
|
||||
`/subrooms/:sid/saves` list — the matchmake call is identical either way. Don't make
|
||||
this server-side: it would put two people in one instance on different versions.
|
||||
- Accessibility is sent as the `RoomAccessibility` enum NAME on
|
||||
`rooms` `PUT /rooms/:id/subrooms/:sid/accessibility` (`accessibility=Private`), not the
|
||||
ordinal the room-level `/rooms/:id/accessibility` takes. The enum has five members
|
||||
|
||||
@@ -363,6 +363,10 @@ function instanceFieldsFromRoom(room: Room, subRoomId?: number) {
|
||||
roomId: num(room.RoomId, 1),
|
||||
subRoomId: num(sub?.SubRoomId, 1),
|
||||
location: str(sub?.UnitySceneId),
|
||||
// Always the PUBLISHED save. A creator who wants their unpublished work is offered
|
||||
// the choice client-side from the `/subrooms/{id}/saves` list — matchmaking is not
|
||||
// involved, and serving a staged blob here would put two people in one instance on
|
||||
// different versions.
|
||||
dataBlob: subRoomDataBlob(sub),
|
||||
name,
|
||||
maxCapacity: num(sub?.MaxPlayers, 4),
|
||||
|
||||
@@ -351,6 +351,59 @@ describe('public endpoints', () => {
|
||||
expect(unknown).toMatchObject({ subRoomId: 34, location: RECCENTER_SCENE })
|
||||
})
|
||||
|
||||
test('matchmaking serves the PUBLISHED save to everyone, creator included', async () => {
|
||||
// The client offers the owner "latest or published" itself, from the
|
||||
// `/subrooms/{id}/saves` list — matchmaking never picks. Serving a staged blob to
|
||||
// the creator here would put them on a different version to everyone else in the
|
||||
// same instance.
|
||||
const room = {
|
||||
RoomId: 78,
|
||||
Name: 'StagedRoom',
|
||||
IsDorm: false,
|
||||
Accessibility: 1,
|
||||
CreatorAccountId: 400,
|
||||
Roles: [{ AccountId: 401, Role: 30, LastChangedByAccountId: null, InvitedRole: 0 }],
|
||||
SubRooms: [
|
||||
{
|
||||
SubRoomId: 36,
|
||||
UnitySceneId: RECCENTER_SCENE,
|
||||
MaxPlayers: 10,
|
||||
// Seeded as the published save (seedRoomWithSubRooms mirrors the backfill).
|
||||
CurrentSave: { DataBlob: 'published.room' },
|
||||
},
|
||||
],
|
||||
}
|
||||
await seedRoomWithSubRooms(env.DB, room as unknown as Record<string, unknown>)
|
||||
// Stage a newer save the creator hasn't published.
|
||||
const staged = await env.DB.prepare(
|
||||
'INSERT INTO subroom_save (sub_room_id, data) VALUES (?1, ?2) RETURNING sub_room_data_save_id'
|
||||
)
|
||||
.bind(36, JSON.stringify({ DataBlob: 'staged.room' }))
|
||||
.first<{ sub_room_data_save_id: number }>()
|
||||
await env.DB.prepare('UPDATE subroom SET staged_save_id = ?2 WHERE sub_room_id = ?1')
|
||||
.bind(36, staged!.sub_room_data_save_id)
|
||||
.run()
|
||||
|
||||
const matchmake = async (sub: string) => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/matchmake/room/78/36`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...(await bearer(sub)),
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: 'JoinMode=0',
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
return ((await res.json()) as { roomInstance: { dataBlob: string } }).roomInstance
|
||||
}
|
||||
|
||||
// Creator, co-owner and ordinary player all land on the same published version —
|
||||
// having a newer staged save changes nothing here.
|
||||
expect((await matchmake('400')).dataBlob).toBe('published.room')
|
||||
expect((await matchmake('401')).dataBlob).toBe('published.room')
|
||||
expect((await matchmake('402')).dataBlob).toBe('published.room')
|
||||
})
|
||||
|
||||
test('POST /matchmake/club/:clubId places members into the clubhouse', async () => {
|
||||
const matchmake = async (path: string, sub?: string) =>
|
||||
exports.default.fetch(`${ORIGIN}${path}`, {
|
||||
|
||||
@@ -351,13 +351,6 @@ export const RoomEnvelope = z.object({
|
||||
value: RoomDto.nullable(),
|
||||
})
|
||||
|
||||
/**
|
||||
* What a room save answers: the saved subroom on success (no envelope — the client
|
||||
* deserializes the body directly as the subroom), or the PascalCase result envelope when
|
||||
* the room or subroom doesn't exist. Both at HTTP 200.
|
||||
*/
|
||||
export const SubRoomSaveResult = z.union([SubRoomDto, RoomResultEnvelope])
|
||||
|
||||
/** The 401 the envelope-returning routes answer with — the only one that isn’t HTTP 200. */
|
||||
export const UNAUTHORIZED_ENVELOPE = json(
|
||||
z.object({ success: z.literal(false), error: z.literal('Unauthorized'), value: z.null() }),
|
||||
@@ -447,6 +440,14 @@ export const SubRoomAccessibilityRequest = z.object({
|
||||
),
|
||||
})
|
||||
|
||||
/**
|
||||
* `POST /rooms/{roomId}/subrooms/{subRoomId}/publish_save` — promotes one save to live.
|
||||
* Any id from the subroom's history works, so this is both publish and restore.
|
||||
*/
|
||||
export const PublishSaveRequest = z.object({
|
||||
subRoomDataSaveId: z.string().describe('The `SubRoomDataSaveId` to make live'),
|
||||
})
|
||||
|
||||
/** `POST /rooms/{roomId}/subrooms`. */
|
||||
export const CreateSubRoomRequest = z.object({
|
||||
name: z.string().describe('The new subroom’s name'),
|
||||
@@ -477,9 +478,14 @@ export const SaveSubRoomDataRequest = z.object({
|
||||
.object({ Filename: z.string() })
|
||||
.optional()
|
||||
.describe('The uploaded room-level data blob — becomes `RoomDataBlob`'),
|
||||
Description: z.string().optional().describe('Written to the ROOM, not the subroom'),
|
||||
Description: z.string().optional().describe('The save comment; also written to the ROOM'),
|
||||
PersistenceVersion: z.int().optional(),
|
||||
InventionUsage: z.string().optional().describe('Written to the room'),
|
||||
UnityAssetId: z.string().nullable().optional().describe('Recorded on the save when set'),
|
||||
AutoPublish: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe('True publishes the save immediately; otherwise it is staged'),
|
||||
})
|
||||
|
||||
/**
|
||||
|
||||
+94
-28
@@ -28,6 +28,7 @@ import {
|
||||
getSubRoomSaves,
|
||||
getVisitedRooms,
|
||||
modifySubRoom,
|
||||
publishSubRoomSave,
|
||||
removeCheer,
|
||||
removeFavorite,
|
||||
saveSubRoomData,
|
||||
@@ -66,6 +67,7 @@ import {
|
||||
pageParams,
|
||||
PhotonAccessTokenDto,
|
||||
PlayerDataDto,
|
||||
PublishSaveRequest,
|
||||
RestrictionsRequest,
|
||||
RoleRequest,
|
||||
RoomDto,
|
||||
@@ -79,7 +81,6 @@ import {
|
||||
SubRoomAccessibilityRequest,
|
||||
SubRoomDto,
|
||||
subRoomIdParam,
|
||||
SubRoomSaveResult,
|
||||
SubRoomSavesPage,
|
||||
TagRequest,
|
||||
UNAUTHORIZED_EMPTY,
|
||||
@@ -1506,14 +1507,21 @@ const app = new Hono<App>()
|
||||
tags: ['Subrooms'],
|
||||
summary: 'Save a subroom’s data (room save)',
|
||||
description: [
|
||||
'Points the subroom at the blobs the client has already uploaded through the `storage`',
|
||||
'worker and stamps the save; the room-level fields the save carries (`Description`,',
|
||||
'Records a save against the subroom from the blobs the client has already uploaded',
|
||||
'through the `storage` worker; the room-level fields it carries (`Description`,',
|
||||
'`PersistenceVersion`, `InventionUsage`) are written to the room. Editable by the',
|
||||
'room’s creator or a co-owner (403 otherwise); a missing token is an EMPTY-body 401,',
|
||||
'unlike the other room writes.',
|
||||
'',
|
||||
'The push notification carries the whole room, but the RESPONSE is the saved SUBROOM',
|
||||
'itself with no envelope — the client deserializes the body directly as the subroom.',
|
||||
'`AutoPublish: true` makes the save live immediately. Otherwise it is STAGED: it',
|
||||
'lands on `StagedSubRoomDataSaveId` with the live `CurrentSave` untouched, so',
|
||||
'players keep loading the last published version until the owner calls',
|
||||
'`POST …/subrooms/{subRoomId}/publish_save`. DORMS always publish — they have no',
|
||||
'publish step in the client, so staging one would hide the player’s 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.',
|
||||
'A subroom with no `CreatorAccountId` yet (the seeded rooms start null) gets the',
|
||||
'saver’s id here, because the client NREs on a null one.',
|
||||
].join('\n'),
|
||||
@@ -1521,10 +1529,7 @@ const app = new Hono<App>()
|
||||
parameters: [roomIdParam, subRoomIdParam],
|
||||
requestBody: jsonBody(SaveSubRoomDataRequest, 'The uploaded blob keys and save fields'),
|
||||
responses: {
|
||||
200: json(
|
||||
SubRoomSaveResult,
|
||||
'The saved subroom, or the result envelope when the room/subroom is unknown'
|
||||
),
|
||||
200: json(RoomEnvelope, 'The updated room, or a rejection with `success: false`'),
|
||||
401: UNAUTHORIZED_EMPTY,
|
||||
403: FORBIDDEN_RESPONSE,
|
||||
},
|
||||
@@ -1537,21 +1542,14 @@ 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 roomResult(c, {
|
||||
Success: false,
|
||||
ErrorId: 'Rooms.DoesntExist',
|
||||
Error: 'This room does not exist!',
|
||||
})
|
||||
}
|
||||
if (!room) return roomEnvelope(c, null, 'This room does not exist!')
|
||||
// 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)
|
||||
|
||||
// The client uploads BOTH blobs to `storage` first and sends their keys here:
|
||||
// `SubRoomData` is the scene blob (what the loader downloads), `RoomData` the
|
||||
// metadata blob. `UnityAssetId`/`AutoPublish`/`OwnershipProof` are accepted
|
||||
// and ignored.
|
||||
// metadata blob. `OwnershipProof` is accepted and ignored.
|
||||
const body = (await c.req.json().catch(() => ({}))) as {
|
||||
RoomData?: { Filename?: string }
|
||||
SubRoomData?: { Filename?: string }
|
||||
@@ -1559,29 +1557,27 @@ const app = new Hono<App>()
|
||||
Description?: string
|
||||
PersistenceVersion?: number
|
||||
InventionUsage?: string
|
||||
AutoPublish?: boolean
|
||||
}
|
||||
|
||||
const updated = await saveSubRoomData(c.env.DB, roomId, subRoomId, accountId, {
|
||||
subRoomDataFilename: body.SubRoomData?.Filename,
|
||||
roomDataFilename: body.RoomData?.Filename,
|
||||
unityAssetId: typeof body.UnityAssetId === 'string' ? body.UnityAssetId : undefined,
|
||||
autoPublish: body.AutoPublish === true,
|
||||
description: typeof body.Description === 'string' ? body.Description : undefined,
|
||||
persistenceVersion:
|
||||
typeof body.PersistenceVersion === 'number' ? body.PersistenceVersion : undefined,
|
||||
inventionUsage: typeof body.InventionUsage === 'string' ? body.InventionUsage : undefined,
|
||||
})
|
||||
if (!updated) {
|
||||
return roomResult(c, {
|
||||
Success: false,
|
||||
ErrorId: 'Rooms.DoesntExist',
|
||||
Error: 'This room does not exist!',
|
||||
})
|
||||
}
|
||||
if (!updated) return roomEnvelope(c, null, 'This subroom does not exist!')
|
||||
|
||||
// RoomUpdate carries the full room, but the HTTP response is the saved SUBROOM
|
||||
// itself — no envelope. The client deserializes the body directly as the subroom.
|
||||
// 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 c.json(findSubRoom(updated, subRoomId) ?? {})
|
||||
return roomEnvelope(c, updated)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1670,6 +1666,76 @@ const app = new Hono<App>()
|
||||
}
|
||||
)
|
||||
|
||||
// Publish a subroom's staged save — promote it to the live one players load. Every
|
||||
// non-dorm room save only STAGES (see the save route), so this is the manual step that
|
||||
// makes edits visible. Auth-gated (401) and creator-only: co-owners may save, but
|
||||
// only the room's owner decides what goes live. Answers the updated ROOM in the
|
||||
// `{ success, error, value }` envelope, like the other subroom mutations.
|
||||
.post(
|
||||
'/rooms/:roomId{[0-9]+}/subrooms/:subRoomId{[0-9]+}/publish_save',
|
||||
describeRoute({
|
||||
tags: ['Subrooms'],
|
||||
summary: 'Publish one of a subroom’s saves',
|
||||
description: [
|
||||
'Makes the save named by the `subRoomDataSaveId` form field the one players load —',
|
||||
'it becomes the subroom’s `CurrentSave`. A room save only STAGES (dorms excepted),',
|
||||
'so nothing a creator saves reaches players until this is called.',
|
||||
'',
|
||||
'The id may be any save in the subroom’s history, so this doubles as restore-a-save.',
|
||||
'`StagedSubRoomDataSaveId` is cleared only when the published save IS the staged',
|
||||
'one — restoring an older version keeps newer unpublished work staged.',
|
||||
'',
|
||||
'Owner-only: co-owners may save but not decide what goes live. A save id belonging',
|
||||
'to another subroom is rejected.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
parameters: [roomIdParam, subRoomIdParam],
|
||||
requestBody: form(PublishSaveRequest, 'The save to publish'),
|
||||
responses: {
|
||||
200: json(RoomEnvelope, 'The updated room, or a rejection with `success: false`'),
|
||||
401: UNAUTHORIZED_ENVELOPE,
|
||||
},
|
||||
}),
|
||||
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 body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
||||
const saveId =
|
||||
typeof body.subRoomDataSaveId === 'string'
|
||||
? Number.parseInt(body.subRoomDataSaveId, 10)
|
||||
: Number.NaN
|
||||
if (Number.isNaN(saveId)) {
|
||||
return roomEnvelope(c, null, 'You must provide a valid save!')
|
||||
}
|
||||
|
||||
const result = await publishSubRoomSave(c.env.DB, roomId, subRoomId, saveId)
|
||||
if (!result.ok) {
|
||||
return roomEnvelope(
|
||||
c,
|
||||
null,
|
||||
result.reason === 'unknown_save'
|
||||
? 'That save does not exist!'
|
||||
: 'This subroom does not exist!'
|
||||
)
|
||||
}
|
||||
|
||||
await pushRoomUpdate(c, accountId, result.room)
|
||||
return roomEnvelope(c, result.room)
|
||||
}
|
||||
)
|
||||
|
||||
// Set a single subroom's `Accessibility`. Same effect as the `accessibility` field of
|
||||
// the subroom `modify` call, but this is what the client actually calls when the
|
||||
// player flips one subroom's visibility, and the body carries the enum NAME
|
||||
|
||||
@@ -975,47 +975,60 @@ describe('rooms endpoints', () => {
|
||||
|
||||
// A valid token but no role on the room → 403.
|
||||
expect((await authed(2, 2, '999')).status).toBe(403)
|
||||
// The response uses the PascalCase `{ Success, Value, ErrorId, Error }` envelope.
|
||||
// Unknown room → DoesntExist.
|
||||
expect(await bodyOf(await authed(99999, 2, '1'))).toMatchObject({
|
||||
Success: false,
|
||||
ErrorId: 'Rooms.DoesntExist',
|
||||
// Rejections use the same lowercase envelope as the success case.
|
||||
expect(await envOf(await authed(99999, 2, '1'))).toMatchObject({
|
||||
success: false,
|
||||
error: 'This room does not exist!',
|
||||
})
|
||||
expect(await envOf(await authed(2, 9999, '1'))).toMatchObject({ success: false })
|
||||
|
||||
// Owner saves → 200 with the saved SUBROOM as the bare body (no envelope),
|
||||
// carrying the new blobs and populated creator.
|
||||
// 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.
|
||||
const ok = await authed(2, 2, '1')
|
||||
expect(ok.status).toBe(200)
|
||||
expect(await bodyOf(ok)).toMatchObject({
|
||||
SubRoomId: 2,
|
||||
const saved = await envOf(ok)
|
||||
expect(saved.success).toBe(true)
|
||||
expect(saved.value).toMatchObject({ RoomId: 2, Description: 'mydescription here' })
|
||||
// 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(
|
||||
(s) => s.SubRoomId === 2
|
||||
)!
|
||||
expect(savedSub).toMatchObject({
|
||||
RoomDataBlob: '5c618c920f6247efb8327e327d0b4417',
|
||||
CreatorAccountId: 1,
|
||||
PersistenceVersion: 41,
|
||||
// The blob the client actually loads from lives on CurrentSave.
|
||||
CurrentSave: {
|
||||
SubRoomId: 2,
|
||||
DataBlob: 'a84167b16796452ab70ee8a6a5b1dc5f',
|
||||
SavedByAccountId: 1,
|
||||
PersistenceVersion: 41,
|
||||
UnitySubAssets: [],
|
||||
ReferencedUnityAssets: [],
|
||||
ReferencedUnityAssetIds: [],
|
||||
Tags: [],
|
||||
},
|
||||
})
|
||||
expect(savedSub.CurrentSave).toMatchObject({
|
||||
DataBlob: 'a84167b16796452ab70ee8a6a5b1dc5f',
|
||||
})
|
||||
|
||||
// It also persists — the GET returns the subroom with the new save + creator.
|
||||
// It also persists — the GET returns the subroom with the save live.
|
||||
const sub = (await (await SELF.fetch(`${ORIGIN}/rooms/2/subrooms/2/data`)).json()) as {
|
||||
SubRoomId: number
|
||||
CreatorAccountId: number
|
||||
CurrentSave: { DataBlob: string; SubRoomDataSaveId: number }
|
||||
CurrentSave: {
|
||||
DataBlob: string
|
||||
SubRoomDataSaveId: number
|
||||
SavedByAccountId: number
|
||||
PersistenceVersion: number
|
||||
UnitySubAssets: unknown[]
|
||||
Tags: unknown[]
|
||||
}
|
||||
StagedSubRoomDataSaveId: number | null
|
||||
}
|
||||
expect(sub).toMatchObject({
|
||||
SubRoomId: 2,
|
||||
CreatorAccountId: 1,
|
||||
CurrentSave: { DataBlob: 'a84167b16796452ab70ee8a6a5b1dc5f' },
|
||||
expect(sub).toMatchObject({ SubRoomId: 2, CreatorAccountId: 1 })
|
||||
expect(sub.CurrentSave).toMatchObject({
|
||||
DataBlob: 'a84167b16796452ab70ee8a6a5b1dc5f',
|
||||
SavedByAccountId: 1,
|
||||
PersistenceVersion: 41,
|
||||
UnitySubAssets: [],
|
||||
Tags: [],
|
||||
})
|
||||
expect(sub.CurrentSave.SubRoomDataSaveId).toBeGreaterThan(0)
|
||||
expect(sub.StagedSubRoomDataSaveId).toBeNull()
|
||||
|
||||
// Room-level fields land on the room too.
|
||||
const room = (await (await SELF.fetch(`${ORIGIN}/rooms/2`)).json()) as {
|
||||
Description: string
|
||||
PersistenceVersion: number
|
||||
@@ -1024,10 +1037,14 @@ describe('rooms endpoints', () => {
|
||||
expect(room.PersistenceVersion).toBe(41)
|
||||
|
||||
// A CoOwner (account 2 holds Role 30 in the seeded rooms) may also save — 200
|
||||
// with the subroom body. The creator stays account 1 (not clobbered).
|
||||
// with the room envelope. The creator stays account 1 (not clobbered).
|
||||
const coOwner = await authed(2, 2, '2')
|
||||
expect(coOwner.status).toBe(200)
|
||||
expect(await bodyOf(coOwner)).toMatchObject({ SubRoomId: 2, CreatorAccountId: 1 })
|
||||
const coOwnerEnv = await envOf(coOwner)
|
||||
expect(coOwnerEnv.success).toBe(true)
|
||||
expect(
|
||||
(coOwnerEnv.value!.SubRooms as Array<Record<string, unknown>>).find((s) => s.SubRoomId === 2)
|
||||
).toMatchObject({ CreatorAccountId: 1 })
|
||||
})
|
||||
|
||||
it('GET /rooms/:id gives every subroom a CurrentSave key (null before the first save)', async () => {
|
||||
@@ -1045,31 +1062,55 @@ describe('rooms endpoints', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('a real client room-save body populates CurrentSave and comes back on GET /rooms/:id', async () => {
|
||||
it('a real client room-save body stages, and publish_save makes it live', async () => {
|
||||
const save = async (body: unknown) =>
|
||||
SELF.fetch(`${ORIGIN}/rooms/5/subrooms/5/data`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...(await bearer('1')) },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
type Sub = {
|
||||
CurrentSave: Record<string, unknown> | null
|
||||
StagedSubRoomDataSaveId: number | null
|
||||
}
|
||||
const subOf = async () =>
|
||||
(await (await SELF.fetch(`${ORIGIN}/rooms/5/subrooms/5/data`)).json()) as Sub
|
||||
const publish = async (saveId: number, sub = '1') =>
|
||||
SELF.fetch(`${ORIGIN}/rooms/5/subrooms/5/publish_save`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...(await bearer(sub)),
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: new URLSearchParams({ subRoomDataSaveId: String(saveId) }).toString(),
|
||||
})
|
||||
|
||||
// The exact body the live client posts after uploading both blobs to `storage`:
|
||||
// SubRoomData is the scene blob, RoomData the metadata blob.
|
||||
const res = await SELF.fetch(`${ORIGIN}/rooms/5/subrooms/5/data`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...(await bearer('1')) },
|
||||
body: JSON.stringify({
|
||||
UnityAssetId: null,
|
||||
RoomData: { Filename: '2026-07-28/b266ccd5-metadata', Hash: null, OwnershipProof: null },
|
||||
SubRoomData: { Filename: '2026-07-28/f176fc3b-scene', Hash: null, OwnershipProof: null },
|
||||
InventionUsage: 'CAE=',
|
||||
PersistenceVersion: 51,
|
||||
Description: 'TEST',
|
||||
AutoPublish: false,
|
||||
}),
|
||||
const res = await save({
|
||||
UnityAssetId: null,
|
||||
RoomData: { Filename: '2026-07-28/b266ccd5-metadata', Hash: null, OwnershipProof: null },
|
||||
SubRoomData: { Filename: '2026-07-28/f176fc3b-scene', Hash: null, OwnershipProof: null },
|
||||
InventionUsage: 'CAE=',
|
||||
PersistenceVersion: 51,
|
||||
Description: 'TEST',
|
||||
AutoPublish: false,
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
// It must be visible on the room read — that's what the loader fetches.
|
||||
const room = (await (await SELF.fetch(`${ORIGIN}/rooms/5`)).json()) as {
|
||||
SubRooms: Array<{ SubRoomId: number; CurrentSave: Record<string, unknown> | null }>
|
||||
}
|
||||
const sub = room.SubRooms.find((s) => s.SubRoomId === 5)!
|
||||
expect(sub.CurrentSave).toMatchObject({
|
||||
// Room 5 is not a dorm → staged, nothing live yet.
|
||||
const stagedSub = await subOf()
|
||||
expect(stagedSub.CurrentSave).toBeNull()
|
||||
const firstId = stagedSub.StagedSubRoomDataSaveId!
|
||||
expect(firstId).toBeGreaterThan(0)
|
||||
|
||||
// Publishing makes it what the loader fetches, and clears the staging slot.
|
||||
expect((await publish(firstId)).status).toBe(200)
|
||||
const live = await subOf()
|
||||
expect(live.StagedSubRoomDataSaveId).toBeNull()
|
||||
expect(live.CurrentSave).toMatchObject({
|
||||
SubRoomId: 5,
|
||||
SubRoomDataSaveId: firstId,
|
||||
DataBlob: '2026-07-28/f176fc3b-scene',
|
||||
PersistenceVersion: 51,
|
||||
SavedByAccountId: 1,
|
||||
@@ -1080,34 +1121,27 @@ describe('rooms endpoints', () => {
|
||||
})
|
||||
// No DataBlobHash — it is commented out of the reference DTO — and no UnityAssetId
|
||||
// key at all, since the client sent null.
|
||||
expect('DataBlobHash' in sub.CurrentSave!).toBe(false)
|
||||
expect('UnityAssetId' in sub.CurrentSave!).toBe(false)
|
||||
expect('DataBlobHash' in live.CurrentSave!).toBe(false)
|
||||
expect('UnityAssetId' in live.CurrentSave!).toBe(false)
|
||||
|
||||
// The save list serves that save rather than an empty page.
|
||||
const firstId = sub.CurrentSave!.SubRoomDataSaveId as number
|
||||
expect(firstId).toBeGreaterThan(0)
|
||||
const saves = (await (await SELF.fetch(`${ORIGIN}/rooms/5/subrooms/5/saves`)).json()) as {
|
||||
Results: Array<{ DataBlob: string }>
|
||||
TotalResults: number
|
||||
// It's on the room read too — that's what the loader actually fetches.
|
||||
const room = (await (await SELF.fetch(`${ORIGIN}/rooms/5`)).json()) as {
|
||||
SubRooms: Array<{ SubRoomId: number; CurrentSave: { SubRoomDataSaveId: number } | null }>
|
||||
}
|
||||
expect(saves.TotalResults).toBe(1)
|
||||
expect(saves.Results[0]!.DataBlob).toBe('2026-07-28/f176fc3b-scene')
|
||||
expect(room.SubRooms.find((s) => s.SubRoomId === 5)!.CurrentSave!.SubRoomDataSaveId).toBe(
|
||||
firstId
|
||||
)
|
||||
|
||||
// A second save appends rather than overwriting, and takes a fresh higher id.
|
||||
await SELF.fetch(`${ORIGIN}/rooms/5/subrooms/5/data`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...(await bearer('1')) },
|
||||
body: JSON.stringify({ SubRoomData: { Filename: 'second.room' } }),
|
||||
})
|
||||
const after = (await (await SELF.fetch(`${ORIGIN}/rooms/5/subrooms/5/data`)).json()) as {
|
||||
CurrentSave: { SubRoomDataSaveId: number; DataBlob: string; Description: string }
|
||||
}
|
||||
expect(after.CurrentSave.SubRoomDataSaveId).toBeGreaterThan(firstId)
|
||||
expect(after.CurrentSave.DataBlob).toBe('second.room')
|
||||
// A second save appends and stages — what players load does NOT change.
|
||||
await save({ SubRoomData: { Filename: 'second.room' } })
|
||||
const afterSecond = await subOf()
|
||||
const secondId = afterSecond.StagedSubRoomDataSaveId!
|
||||
expect(secondId).toBeGreaterThan(firstId)
|
||||
expect(afterSecond.CurrentSave).toMatchObject({ SubRoomDataSaveId: firstId })
|
||||
|
||||
// Both saves are in the history, newest first — the first one is not lost.
|
||||
const history = (await (await SELF.fetch(`${ORIGIN}/rooms/5/subrooms/5/saves`)).json()) as {
|
||||
Results: Array<{ DataBlob: string }>
|
||||
Results: Array<{ DataBlob: string; Description: string }>
|
||||
TotalResults: number
|
||||
}
|
||||
expect(history.TotalResults).toBe(2)
|
||||
@@ -1116,7 +1150,29 @@ describe('rooms endpoints', () => {
|
||||
'2026-07-28/f176fc3b-scene',
|
||||
])
|
||||
// A save with no Description records an empty string, not null.
|
||||
expect(after.CurrentSave.Description).toBe('')
|
||||
expect(history.Results[0]!.Description).toBe('')
|
||||
|
||||
// Publishing an OLDER save is a restore — and keeps the newer staged work.
|
||||
expect((await publish(secondId)).status).toBe(200)
|
||||
expect((await subOf()).StagedSubRoomDataSaveId).toBeNull()
|
||||
expect((await publish(firstId)).status).toBe(200)
|
||||
const restored = await subOf()
|
||||
expect(restored.CurrentSave).toMatchObject({ SubRoomDataSaveId: firstId })
|
||||
|
||||
// A save id from a different subroom is rejected, even though ids are global.
|
||||
const foreign = (await (
|
||||
await publish(
|
||||
((await (await SELF.fetch(`${ORIGIN}/rooms/2/subrooms/2/data`)).json()) as Sub).CurrentSave!
|
||||
.SubRoomDataSaveId as number
|
||||
)
|
||||
).json()) as { success: boolean; error: string }
|
||||
expect(foreign.success).toBe(false)
|
||||
expect(foreign.error).toBe('That save does not exist!')
|
||||
|
||||
// Co-owners may save but not publish.
|
||||
expect(((await (await publish(firstId, '2')).json()) as { success: boolean }).success).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('migrates a pre-CurrentSave subroom into a real save row (0008 backfill 2)', async () => {
|
||||
@@ -1162,6 +1218,25 @@ describe('rooms endpoints', () => {
|
||||
expect(again.CurrentSave.SubRoomDataSaveId).toBe(sub.CurrentSave.SubRoomDataSaveId)
|
||||
})
|
||||
|
||||
it('a dorm save publishes immediately instead of staging', async () => {
|
||||
// Room 1 is the seeded DormRoom (IsDorm). A dorm is the player's own space with no
|
||||
// publish step in the client, so staging one would make their edits permanently
|
||||
// invisible — dorm saves go straight live.
|
||||
const res = await SELF.fetch(`${ORIGIN}/rooms/1/subrooms/1/data`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...(await bearer('1')) },
|
||||
body: JSON.stringify({ SubRoomData: { Filename: 'dorm.room' } }),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
const sub = (await (await SELF.fetch(`${ORIGIN}/rooms/1/subrooms/1/data`)).json()) as {
|
||||
CurrentSave: { DataBlob: string } | null
|
||||
StagedSubRoomDataSaveId: number | null
|
||||
}
|
||||
expect(sub.CurrentSave).toMatchObject({ DataBlob: 'dorm.room' })
|
||||
expect(sub.StagedSubRoomDataSaveId).toBeNull()
|
||||
})
|
||||
|
||||
it('save ids are globally unique across subrooms, so a bare id resolves', async () => {
|
||||
// StagedSubRoomDataSaveId points at a save by bare id with no subroom context, so
|
||||
// per-subroom numbering (every subroom's first save being 1) would be ambiguous.
|
||||
@@ -1171,10 +1246,10 @@ describe('rooms endpoints', () => {
|
||||
headers: { 'Content-Type': 'application/json', ...(await bearer('1')) },
|
||||
body: JSON.stringify({ SubRoomData: { Filename: `blob-${subRoomId}.room` } }),
|
||||
})
|
||||
// Non-dorm saves stage, so the fresh id lands on StagedSubRoomDataSaveId.
|
||||
const idOf = async (roomId: number, subRoomId: number) => {
|
||||
const res = await SELF.fetch(`${ORIGIN}/rooms/${roomId}/subrooms/${subRoomId}/data`)
|
||||
return ((await res.json()) as { CurrentSave: { SubRoomDataSaveId: number } }).CurrentSave
|
||||
.SubRoomDataSaveId
|
||||
return ((await res.json()) as { StagedSubRoomDataSaveId: number }).StagedSubRoomDataSaveId
|
||||
}
|
||||
|
||||
// Two different subrooms, each getting their FIRST save.
|
||||
@@ -1825,6 +1900,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}/publish_save',
|
||||
'PUT /rooms/{roomId}/accessibility',
|
||||
'PUT /rooms/{roomId}/cloning',
|
||||
'PUT /rooms/{roomId}/description',
|
||||
|
||||
@@ -319,6 +319,12 @@ export interface SaveSubRoomDataInput {
|
||||
inventionUsage?: string
|
||||
/** Optional baked-asset id; emitted on the save only when present. */
|
||||
unityAssetId?: string
|
||||
/**
|
||||
* The client's `AutoPublish`. True publishes the save outright (the author wants it
|
||||
* live now); false/absent stages it for a manual `publish_save`. Dorms ignore this and
|
||||
* always publish.
|
||||
*/
|
||||
autoPublish?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -416,11 +422,15 @@ function legacySubRoomSave(sub: SubRoom): SubRoomDataSave | null {
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a room-save against a specific subroom: point the subroom at its newly
|
||||
* uploaded data blob (what the loader later downloads) and record the room-level
|
||||
* fields from the save. Returns the updated (hydrated) room, or null when the room or
|
||||
* subroom doesn't exist. The subroom row is updated in the `subroom` table; the
|
||||
* room-level fields are written to the room blob.
|
||||
* Persist a room-save against a specific subroom and record the room-level fields the
|
||||
* save carries. Returns the updated (hydrated) room, or null when the room or subroom
|
||||
* doesn't exist.
|
||||
*
|
||||
* Whether the save goes live is the client's call: `AutoPublish: true` publishes it
|
||||
* outright, otherwise it becomes the subroom's `staged_save_id` with the live
|
||||
* `current_save_id` untouched, so what players load doesn't change until the room's
|
||||
* creator publishes (see {@link publishSubRoomSave}). Dorms always publish — they have
|
||||
* no publish flow in the client.
|
||||
*/
|
||||
export async function saveSubRoomData(
|
||||
db: D1Database,
|
||||
@@ -431,20 +441,30 @@ export async function saveSubRoomData(
|
||||
): Promise<Room | null> {
|
||||
const room = await getRoomById(db, roomId)
|
||||
if (!room) return null
|
||||
const sub = await getSubRoom(db, roomId, subRoomId)
|
||||
// Read off the already-hydrated room rather than re-querying the subroom and its
|
||||
// save — getRoomById has both, and this path is write-heavy enough already.
|
||||
const sub = findSubRoom(room, subRoomId)
|
||||
if (!sub) return null
|
||||
|
||||
// Populate the subroom's creator on first save — it starts null, and the
|
||||
// client NREs on a null CreatorAccountId. Only the owner reaches this path.
|
||||
if (sub.CreatorAccountId == null) sub.CreatorAccountId = accountId
|
||||
|
||||
// Append a new save row and publish it. The blob the loader downloads lives on the
|
||||
// save — a subroom whose current_save_id resolves to nothing loads nothing — so this
|
||||
// never touches the flat DataBlob field. Previous saves stay in the table as history.
|
||||
// Append a new save row. The blob the loader downloads lives on the save — a subroom
|
||||
// whose current_save_id resolves to nothing loads nothing — so this never touches the
|
||||
// flat DataBlob field. Previous saves stay in the table as history.
|
||||
//
|
||||
// A staged save carries forward from the previous STAGED one when there is one, so a
|
||||
// creator's second edit builds on their first rather than on what's live.
|
||||
const staged =
|
||||
typeof sub.StagedSubRoomDataSaveId === 'number'
|
||||
? await getSubRoomSaveById(db, subRoomId, sub.StagedSubRoomDataSaveId)
|
||||
: null
|
||||
const previous =
|
||||
sub.CurrentSave && typeof sub.CurrentSave === 'object'
|
||||
staged ??
|
||||
(sub.CurrentSave && typeof sub.CurrentSave === 'object'
|
||||
? (sub.CurrentSave as SubRoomDataSave)
|
||||
: undefined
|
||||
: undefined)
|
||||
const priorVersion = previous?.PersistenceVersion
|
||||
const priorBlob = previous?.DataBlob
|
||||
const save = await insertSubRoomSave(
|
||||
@@ -465,28 +485,82 @@ export async function saveSubRoomData(
|
||||
unityAssetId: input.unityAssetId,
|
||||
})
|
||||
)
|
||||
// Publish it. Staging (AutoPublish:false → staged_save_id) is deliberately not wired
|
||||
// up yet: always publishing is what makes a save actually load.
|
||||
await setCurrentSave(db, subRoomId, Number(save.SubRoomDataSaveId))
|
||||
|
||||
const saveId = Number(save.SubRoomDataSaveId)
|
||||
if (input.roomDataFilename) sub.RoomDataBlob = input.roomDataFilename
|
||||
sub.DataSavedAt = new Date().toISOString()
|
||||
if (input.persistenceVersion !== undefined) sub.PersistenceVersion = input.persistenceVersion
|
||||
await updateSubRoom(db, sub)
|
||||
|
||||
// Room-level fields carried by the save.
|
||||
if (typeof input.description === 'string') room.Description = input.description
|
||||
if (input.persistenceVersion !== undefined) room.PersistenceVersion = input.persistenceVersion
|
||||
if (input.inventionUsage !== undefined) room.InventionUsage = input.inventionUsage
|
||||
await db
|
||||
.prepare('UPDATE room SET data = ?2 WHERE room_id = ?1')
|
||||
.bind(roomId, serializeRoom(room))
|
||||
.run()
|
||||
|
||||
// Publish outright when the client asked to (`AutoPublish`), or for a dorm — a dorm is
|
||||
// the player's own private space with no publish step in the client, so staging one
|
||||
// would leave their edits permanently invisible. Otherwise stage it and wait for
|
||||
// `publish_save`. One round trip for the rest of the save.
|
||||
const publishNow = input.autoPublish === true || room.IsDorm === true
|
||||
await db.batch([
|
||||
publishNow
|
||||
? db
|
||||
.prepare(
|
||||
'UPDATE subroom SET current_save_id = ?2, staged_save_id = NULL WHERE sub_room_id = ?1'
|
||||
)
|
||||
.bind(subRoomId, saveId)
|
||||
: db
|
||||
.prepare('UPDATE subroom SET staged_save_id = ?2 WHERE sub_room_id = ?1')
|
||||
.bind(subRoomId, saveId),
|
||||
db
|
||||
.prepare('UPDATE subroom SET data = ?2 WHERE sub_room_id = ?1')
|
||||
.bind(subRoomId, serializeSubRoom(sub, roomId)),
|
||||
db.prepare('UPDATE room SET data = ?2 WHERE room_id = ?1').bind(roomId, serializeRoom(room)),
|
||||
])
|
||||
|
||||
// Re-hydrate so the returned room reflects the just-saved subroom.
|
||||
return hydrateRoom(db, room)
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish one of a subroom's saves by id: make it the `current_save_id` players load.
|
||||
* This is the manual step every non-dorm room save waits on ({@link saveSubRoomData}
|
||||
* only stages). Because it takes an explicit id it doubles as restore-a-save — the id
|
||||
* can be any save in the subroom's history, not just the staged one.
|
||||
*
|
||||
* The staging slot is cleared only when the save being published IS the staged one, so
|
||||
* restoring an older version doesn't silently discard newer unpublished work.
|
||||
*
|
||||
* The id is looked up scoped to the subroom, so one subroom can't publish another's save
|
||||
* (ids are globally unique, so an unscoped lookup would happily resolve).
|
||||
*
|
||||
* Returns the updated (hydrated) room, or a reason: `not_found` (no such room/subroom) /
|
||||
* `unknown_save` (no such save on this subroom).
|
||||
*/
|
||||
export async function publishSubRoomSave(
|
||||
db: D1Database,
|
||||
roomId: number,
|
||||
subRoomId: number,
|
||||
saveId: number
|
||||
): Promise<{ ok: true; room: Room } | { ok: false; reason: 'not_found' | 'unknown_save' }> {
|
||||
const sub = await getSubRoom(db, roomId, subRoomId)
|
||||
if (!sub) return { ok: false, reason: 'not_found' }
|
||||
if (!(await getSubRoomSaveById(db, subRoomId, saveId))) {
|
||||
return { ok: false, reason: 'unknown_save' }
|
||||
}
|
||||
|
||||
await db
|
||||
.prepare(
|
||||
`UPDATE subroom SET current_save_id = ?2,
|
||||
staged_save_id = CASE WHEN staged_save_id = ?2 THEN NULL ELSE staged_save_id END
|
||||
WHERE sub_room_id = ?1`
|
||||
)
|
||||
.bind(subRoomId, saveId)
|
||||
.run()
|
||||
|
||||
const room = await getRoomById(db, roomId)
|
||||
if (!room) return { ok: false, reason: 'not_found' }
|
||||
return { ok: true, room }
|
||||
}
|
||||
|
||||
/** Fields from the client's subroom `modify` form (each applied only when supplied). */
|
||||
export interface ModifySubRoomInput {
|
||||
name?: string
|
||||
@@ -898,10 +972,12 @@ async function updateSubRoom(db: D1Database, sub: SubRoom): Promise<void> {
|
||||
.run()
|
||||
}
|
||||
|
||||
/** Point a subroom at its live/published save. */
|
||||
/** Point a subroom at its live/published save, clearing any staged one. */
|
||||
async function setCurrentSave(db: D1Database, subRoomId: number, saveId: number): Promise<void> {
|
||||
await db
|
||||
.prepare('UPDATE subroom SET current_save_id = ?2 WHERE sub_room_id = ?1')
|
||||
.prepare(
|
||||
'UPDATE subroom SET current_save_id = ?2, staged_save_id = NULL WHERE sub_room_id = ?1'
|
||||
)
|
||||
.bind(subRoomId, saveId)
|
||||
.run()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user