fixed room and subroom saving, add room saves

This commit is contained in:
Devin Zuczek
2026-07-28 20:29:20 -04:00
parent a28b9b4561
commit d5ccad51d3
8 changed files with 728 additions and 58 deletions
+2 -1
View File
@@ -18,6 +18,7 @@ import {
setLoginContext,
setPasswordHash,
setPresence,
subRoomDataBlob,
verifyPassword,
} from '@repo/domain'
import { intVar, logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
@@ -119,7 +120,7 @@ async function placeNewPlayerInOrientation(
subRoomId: num(sub?.SubRoomId, 1),
roomInstanceType: RoomInstanceType.Public,
location: str(sub?.UnitySceneId),
dataBlob: str(sub?.DataBlob),
dataBlob: subRoomDataBlob(sub),
eventId: 0,
clubId: 0,
roomCode: '',
+6 -3
View File
@@ -27,6 +27,7 @@ import {
RoomInstanceType,
setPresence,
setRoomInstanceInProgress,
subRoomDataBlob,
} from '@repo/domain'
import { logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
import { validateAndGetAccountId } from '@repo/jwt'
@@ -34,7 +35,6 @@ import { validateAndGetAccountId } from '@repo/jwt'
// Value import of the notify worker's NotificationType enum (its bundle has no runtime
// deps), so /invite sends a typed MessageReceived frame instead of a magic number.
import { NotificationType } from '../../notify/src/notification-types'
import {
AUTHED,
EMPTY_OK,
@@ -363,7 +363,7 @@ function instanceFieldsFromRoom(room: Room, subRoomId?: number) {
roomId: num(room.RoomId, 1),
subRoomId: num(sub?.SubRoomId, 1),
location: str(sub?.UnitySceneId),
dataBlob: str(sub?.DataBlob),
dataBlob: subRoomDataBlob(sub),
name,
maxCapacity: num(sub?.MaxPlayers, 4),
roomInstanceType: room.IsDorm === true ? RoomInstanceType.Dormroom : RoomInstanceType.Public,
@@ -693,7 +693,10 @@ const app = new Hono<App>()
'`PlayerId`/`RoomInstanceId`). Currently just logged and acked — presence is cleared',
'by logout and otherwise expires on its TTL — but the hook is here for a future check.',
].join(' '),
requestBody: form(NotifyDisconnectRequest, 'The disconnecting player and the instance they left'),
requestBody: form(
NotifyDisconnectRequest,
'The disconnecting player and the instance they left'
),
responses: { 200: EMPTY_OK },
}),
async (c) => {
@@ -0,0 +1,66 @@
-- Room saves as first-class entities. A subroom POINTS at its saves by bare id — the
-- live/published one the loader downloads (`current_save_id`) and the creator's
-- unpublished one (`staged_save_id`, unused for now) — and `StagedSubRoomDataSaveId`
-- carries no subroom context, so a save id has to be globally unique to be resolvable.
-- Numbering saves per subroom (what the embedded `CurrentSave` did) makes every
-- subroom's first save id 1 and those pointers ambiguous. Same reasoning as 0007 for
-- SubRoomId. It also gives `GET …/subrooms/{id}/saves` real history to page over.
--
-- Generated from packages/domain/src/rooms-db.ts (SUBROOM_SCHEMA_DDL) — keep in sync.
CREATE TABLE IF NOT EXISTS subroom_save (
sub_room_data_save_id INTEGER PRIMARY KEY AUTOINCREMENT,
sub_room_id INTEGER NOT NULL,
data TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_subroom_save_sub ON subroom_save (sub_room_id);
ALTER TABLE subroom ADD COLUMN current_save_id INTEGER;
ALTER TABLE subroom ADD COLUMN staged_save_id INTEGER;
-- Backfill 1: subrooms that already carry an embedded `CurrentSave` object. The two id
-- fields are dropped from the stored blob — the columns are authoritative and are
-- re-injected on read.
INSERT INTO subroom_save (sub_room_id, data)
SELECT
sub_room_id,
json_remove(json_extract(data, '$.CurrentSave'), '$.SubRoomDataSaveId', '$.SubRoomId')
FROM subroom
WHERE json_extract(data, '$.CurrentSave') IS NOT NULL;
-- Backfill 2: subrooms saved before `CurrentSave` existed, whose blob key sits in the
-- flat `DataBlob`/`DataSavedAt`/`PersistenceVersion` fields. They hold real saved content
-- the client can't see (it reads only `CurrentSave`), so they become saves too rather
-- than reading as never-saved. Shape matches the reference's MapSave projection.
INSERT INTO subroom_save (sub_room_id, data)
SELECT
sub_room_id,
json_object(
'UnitySubAssets', json('[]'),
'ReferencedUnityAssets', json('[]'),
'DataBlob', json_extract(data, '$.DataBlob'),
'ReferencedUnityAssetIds', json('[]'),
'PersistenceVersion', COALESCE(json_extract(data, '$.PersistenceVersion'), 0),
'OMVersion', 0,
'UgcSubVersion', 0,
'SavedByAccountId', json_extract(data, '$.CreatorAccountId'),
'SavedOnPlatform', 0,
'SavedOnDeviceClass', 0,
'Description', '',
'Tags', json('[]'),
'ModerationState', 0,
'CreatedAt', COALESCE(json_extract(data, '$.DataSavedAt'), '1970-01-01T00:00:00.000Z')
)
FROM subroom
WHERE json_extract(data, '$.CurrentSave') IS NULL
AND COALESCE(json_extract(data, '$.DataBlob'), '') <> '';
-- Point each subroom at the save just minted for it. At this moment a subroom has at
-- most one save row, so the correlated subquery is unambiguous.
UPDATE subroom SET current_save_id = (
SELECT s.sub_room_data_save_id FROM subroom_save s WHERE s.sub_room_id = subroom.sub_room_id
);
-- Single source of truth: the save now lives in `subroom_save`, and
-- `StagedSubRoomDataSaveId` is served from the `staged_save_id` column.
UPDATE subroom SET data = json_remove(data, '$.CurrentSave', '$.StagedSubRoomDataSaveId');
+40 -5
View File
@@ -138,14 +138,43 @@ export const LoadScreenDto = z.object({
Subtitle: 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
* subroom whose `CurrentSave` is missing loads no saved content at all.
*
* The array fields are always empty here: we neither resolve nor record referenced Unity
* assets. They are still emitted because the client's parser expects them present.
*/
export const SubRoomDataSaveDto = z.object({
UnitySubAssets: z.array(z.unknown()).describe('Always empty'),
ReferencedUnityAssets: z.array(z.unknown()).describe('Always empty'),
SubRoomDataSaveId: z.int().describe('Numbered from 1, incremented on every save'),
SubRoomId: z.int().describe('The owning subroom — re-pointed when a subroom is cloned'),
DataBlob: z.string().describe('The scene-data key the client downloads from the CDN'),
ReferencedUnityAssetIds: z.array(z.string()).describe('Always empty'),
PersistenceVersion: z.int(),
OMVersion: z.int(),
UgcSubVersion: z.int(),
SavedByAccountId: z.int().nullable(),
SavedOnPlatform: z.int().describe('0 — the save request carries no platform'),
SavedOnDeviceClass: z.int().describe('0 — the save request carries no device class'),
Description: z.string().describe('The save comment; empty string when none'),
Tags: z.array(z.unknown()).describe('Always empty'),
ModerationState: z.int(),
CreatedAt: z.string(),
UnityAssetId: z.string().optional().describe('Emitted only when the save carried one'),
})
/**
* A subroom — a room's individual scene. Subrooms are their own table with a globally
* unique, autoincrementing `SubRoomId` (the original game mints them from a single
* sequence, not per-room); a room's `SubRooms` array is reconstructed on read.
*
* `CreatorAccountId` starts null on the seeded rooms and is filled in on the first save —
* the client NREs on a null one. The `DataBlob`/`RoomDataBlob`/`DataSavedAt` fields only
* appear once the subroom has been saved at least once.
* the client NREs on a null one. `CurrentSave` is null until the first save; the flat
* `DataBlob`/`RoomDataBlob`/`DataSavedAt` fields are legacy and are NOT what the client
* loads from.
*/
export const SubRoomDto = z.object({
SubRoomId: z.int(),
@@ -161,7 +190,10 @@ export const SubRoomDto = z.object({
.describe('0 Private, 1 Public, 2 Unlisted, 3 Dev_only, 4 Dev_Unlisted — set independently'),
ShouldAutoStageSaves: z.boolean(),
StagedSubRoomDataSaveId: z.int().nullable(),
DataBlob: z.string().optional().describe('Uploaded scene-data key; absent until first save'),
CurrentSave: SubRoomDataSaveDto.nullable().describe(
'The latest room save — where the client finds the scene blob. Null until first save'
),
DataBlob: z.string().optional().describe('Legacy flat key; the client reads `CurrentSave`'),
RoomDataBlob: z.string().optional().describe('Uploaded room-data key; absent until first save'),
DataSavedAt: z.string().optional().describe('ISO timestamp of the last save'),
PersistenceVersion: z.int().optional(),
@@ -440,7 +472,7 @@ export const SaveSubRoomDataRequest = z.object({
SubRoomData: z
.object({ Filename: z.string() })
.optional()
.describe('The uploaded scene-data blob — becomes the subrooms `DataBlob`'),
.describe('The uploaded scene-data blob — becomes the subrooms `CurrentSave.DataBlob`'),
RoomData: z
.object({ Filename: z.string() })
.optional()
@@ -455,8 +487,11 @@ export const SaveSubRoomDataRequest = z.object({
* save history (a save overwrites the subroom's blob inline), so it's always empty.
*/
export const SubRoomSavesPage = z.object({
Results: z.array(z.unknown()).describe('Always empty — no save history is kept'),
Results: z
.array(SubRoomDataSaveDto)
.describe('At most one — the current save; we keep no history'),
TotalResults: z.int(),
TotalCount: z.int().describe('Same value as `TotalResults` — the two references disagree'),
})
// ---- Session ---------------------------------------------------------------
+37 -14
View File
@@ -25,6 +25,7 @@ import {
getRoomsByCreator,
getRoomsByIds,
getSimilarRooms,
getSubRoomSaves,
getVisitedRooms,
modifySubRoom,
removeCheer,
@@ -1448,34 +1449,50 @@ const app = new Hono<App>()
}
)
// 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.
// A subroom's saved-data versions — the room-history / "restore a save" list. Every
// save is its own `subroom_save` row (nothing is overwritten), so this is real
// history, newest first, paged by skip/take.
.get(
'/rooms/:roomId{[0-9]+}/subrooms/:subRoomId{[0-9]+}/saves',
describeRoute({
tags: ['Subrooms'],
summary: 'A subrooms saved-data versions',
description: [
'The room-history / “restore a save” list, paged as',
'`PagedResultsDTO<SubRoomDataSaveDTO>`. We keep no save history — a save (`POST',
'…/data`) overwrites the subrooms current blob inline, so there are no distinct',
'versions to list — and this is always an empty page. The',
'`unityAssetTarget`/`unityAssetVersion`/`skip`/`take` params are accepted and ignored.',
'The room-history / “restore a save” list, newest first. Every room save appends a',
'row rather than overwriting, so this is the subrooms full history; it is empty',
'only when the subroom has never been saved.',
'`unityAssetTarget`/`unityAssetVersion` are accepted and ignored.',
'',
'`TotalResults` and `TotalCount` carry the same number: the clients paged DTO and',
'the reference disagree on the name, so both are emitted.',
].join(' '),
parameters: [
roomIdParam,
subRoomIdParam,
stringQuery('unityAssetTarget', 'Accepted and ignored'),
stringQuery('unityAssetVersion', 'Accepted and ignored'),
stringQuery('skip', 'Accepted and ignored — the page is always empty'),
stringQuery('take', 'Accepted and ignored — the page is always empty'),
stringQuery('skip', 'How many saves to skip (default 0)'),
stringQuery('take', 'How many saves to return (default all)'),
],
responses: { 200: json(SubRoomSavesPage, 'Always an empty page') },
responses: { 200: json(SubRoomSavesPage, 'The subrooms saves, newest first') },
}),
(c) => c.json({ Results: [], TotalResults: 0 })
async (c) => {
const roomId = Number.parseInt(c.req.param('roomId'), 10)
const subRoomId = Number.parseInt(c.req.param('subRoomId'), 10)
// Scoped through the room so a subroom id from another room can't read its saves.
const room = await getRoomById(c.env.DB, roomId)
if (!room || !findSubRoom(room, subRoomId)) {
return c.json({ Results: [], TotalResults: 0, TotalCount: 0 })
}
const saves = await getSubRoomSaves(c.env.DB, subRoomId)
const skip = Number.parseInt(c.req.query('skip') ?? '', 10)
const take = Number.parseInt(c.req.query('take') ?? '', 10)
const from = Number.isNaN(skip) || skip < 0 ? 0 : skip
const page = saves.slice(from, Number.isNaN(take) || take < 0 ? undefined : from + take)
return c.json({ Results: page, TotalResults: saves.length, TotalCount: saves.length })
}
)
// Save a subroom's data (room save). Auth-gated (401 with empty body). Editable
@@ -1531,9 +1548,14 @@ const app = new Hono<App>()
// 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.
const body = (await c.req.json().catch(() => ({}))) as {
RoomData?: { Filename?: string }
SubRoomData?: { Filename?: string }
UnityAssetId?: string | null
Description?: string
PersistenceVersion?: number
InventionUsage?: string
@@ -1542,6 +1564,7 @@ const app = new Hono<App>()
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,
description: typeof body.Description === 'string' ? body.Description : undefined,
persistenceVersion:
typeof body.PersistenceVersion === 'number' ? body.PersistenceVersion : undefined,
+218 -10
View File
@@ -988,23 +988,34 @@ describe('rooms endpoints', () => {
expect(ok.status).toBe(200)
expect(await bodyOf(ok)).toMatchObject({
SubRoomId: 2,
DataBlob: 'a84167b16796452ab70ee8a6a5b1dc5f',
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: [],
},
})
// It also persists — the GET returns the subroom with the new blob + creator.
// It also persists — the GET returns the subroom with the new save + creator.
const sub = (await (await SELF.fetch(`${ORIGIN}/rooms/2/subrooms/2/data`)).json()) as {
SubRoomId: number
DataBlob: string
CreatorAccountId: number
CurrentSave: { DataBlob: string; SubRoomDataSaveId: number }
}
expect(sub).toMatchObject({
SubRoomId: 2,
DataBlob: 'a84167b16796452ab70ee8a6a5b1dc5f',
CreatorAccountId: 1,
CurrentSave: { DataBlob: 'a84167b16796452ab70ee8a6a5b1dc5f' },
})
expect(sub.CurrentSave.SubRoomDataSaveId).toBeGreaterThan(0)
const room = (await (await SELF.fetch(`${ORIGIN}/rooms/2`)).json()) as {
Description: string
PersistenceVersion: number
@@ -1019,6 +1030,180 @@ describe('rooms endpoints', () => {
expect(await bodyOf(coOwner)).toMatchObject({ SubRoomId: 2, CreatorAccountId: 1 })
})
it('GET /rooms/:id gives every subroom a CurrentSave key (null before the first save)', async () => {
// The client loads a subroom's scene data from CurrentSave and nothing else, so
// the key must be PRESENT — the seeded rooms predate it and have no such field in
// their stored blob. `in` rather than a value check: absent and null differ here.
// Room 3 is seeded and never saved by another test (room 2 is the save fixture).
const room = (await (await SELF.fetch(`${ORIGIN}/rooms/3`)).json()) as {
SubRooms: Array<Record<string, unknown>>
}
expect(room.SubRooms.length).toBeGreaterThan(0)
for (const sub of room.SubRooms) {
expect('CurrentSave' in sub).toBe(true)
expect(sub.CurrentSave).toBeNull()
}
})
it('a real client room-save body populates CurrentSave and comes back on GET /rooms/:id', async () => {
// 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,
}),
})
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({
SubRoomId: 5,
DataBlob: '2026-07-28/f176fc3b-scene',
PersistenceVersion: 51,
SavedByAccountId: 1,
Description: 'TEST',
OMVersion: 0,
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 sub.CurrentSave!).toBe(false)
expect('UnityAssetId' in sub.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
}
expect(saves.TotalResults).toBe(1)
expect(saves.Results[0]!.DataBlob).toBe('2026-07-28/f176fc3b-scene')
// 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')
// 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 }>
TotalResults: number
}
expect(history.TotalResults).toBe(2)
expect(history.Results.map((s) => s.DataBlob)).toEqual([
'second.room',
'2026-07-28/f176fc3b-scene',
])
// A save with no Description records an empty string, not null.
expect(after.CurrentSave.Description).toBe('')
})
it('migrates a pre-CurrentSave subroom into a real save row (0008 backfill 2)', async () => {
// A subroom saved by the older code has its blob in the flat DataBlob field and no
// CurrentSave at all. seedRoomWithSubRooms mirrors the migration, so this covers
// the backfill: the flat fields become a save row the subroom points at, rather
// than reading as never-saved and hiding real content from the loader.
await seedRoomWithSubRooms(env.DB, {
RoomId: 820,
Name: 'LegacyShaped',
CreatorAccountId: 1,
SubRooms: [
{
SubRoomId: 830,
Name: 'Legacy',
CreatorAccountId: 7,
UnitySceneId: '76d98498-60a1-430c-ab76-b54a29b7a163',
MaxPlayers: 4,
Accessibility: 2,
DataBlob: 'legacy-blob.room',
DataSavedAt: '2024-03-04T05:06:07.000Z',
PersistenceVersion: 12,
},
],
})
const sub = (await (await SELF.fetch(`${ORIGIN}/rooms/820/subrooms/830/data`)).json()) as {
CurrentSave: Record<string, unknown>
}
expect(sub.CurrentSave).toMatchObject({
SubRoomId: 830,
DataBlob: 'legacy-blob.room',
PersistenceVersion: 12,
SavedByAccountId: 7,
CreatedAt: '2024-03-04T05:06:07.000Z',
UnitySubAssets: [],
Tags: [],
})
// Stable across reads — it's a stored row now, not something rebuilt per request.
const again = (await (await SELF.fetch(`${ORIGIN}/rooms/820/subrooms/830/data`)).json()) as {
CurrentSave: { SubRoomDataSaveId: number }
}
expect(again.CurrentSave.SubRoomDataSaveId).toBe(sub.CurrentSave.SubRoomDataSaveId)
})
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.
const save = async (roomId: number, subRoomId: number) =>
SELF.fetch(`${ORIGIN}/rooms/${roomId}/subrooms/${subRoomId}/data`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...(await bearer('1')) },
body: JSON.stringify({ SubRoomData: { Filename: `blob-${subRoomId}.room` } }),
})
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
}
// Two different subrooms, each getting their FIRST save.
await save(6, 6)
await save(7, 7)
expect(await idOf(6, 6)).not.toBe(await idOf(7, 7))
})
it('a cloned subroom re-points CurrentSave at the copy, not the source', async () => {
// Save room 2's subroom so there is a CurrentSave to copy.
await SELF.fetch(`${ORIGIN}/rooms/2/subrooms/2/data`, {
method: 'POST',
headers: { ...(await bearer('1')), 'Content-Type': 'application/json' },
body: JSON.stringify({ SubRoomData: { Filename: 'cloned-source.room' } }),
})
const res = await SELF.fetch(`${ORIGIN}/rooms/2/subrooms/2/clone`, {
method: 'POST',
headers: await bearer('1'),
})
const body = (await res.json()) as {
value: { SubRooms: Array<{ SubRoomId: number; CurrentSave: { SubRoomId: number } | null }> }
}
const clone = body.value.SubRooms.find((s) => s.SubRoomId !== 2 && s.CurrentSave !== null)!
expect(clone).toBeDefined()
// The copy's save must claim the COPY, or the client resolves it against the source.
expect(clone.CurrentSave!.SubRoomId).toBe(clone.SubRoomId)
})
it('PUT /rooms/:id/tags is auth-gated, owner-only, and toggles (add/remove)', async () => {
// The lowercase `{ success, error, value }` envelope this endpoint returns.
type TagResult = {
@@ -1558,12 +1743,35 @@ describe('rooms endpoints', () => {
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 })
it('GET /rooms/:id/subrooms/:sid/saves pages the save history, newest first', async () => {
type Page = {
Results: Array<{ SubRoomId: number; SubRoomDataSaveId: number }>
TotalResults: number
TotalCount: number
}
const page = async (query: string) =>
(await (await SELF.fetch(`${ORIGIN}/rooms/2/subrooms/2/saves${query}`)).json()) as Page
// Room 2's subroom is saved several times by the tests above — each save appended.
const all = await page('?unityAssetTarget=0&unityAssetVersion=1')
expect(all.Results.length).toBeGreaterThan(1)
expect(all.Results.every((s) => s.SubRoomId === 2)).toBe(true)
// Newest first: ids descend.
const ids = all.Results.map((s) => s.SubRoomDataSaveId)
expect([...ids].sort((a, b) => b - a)).toEqual(ids)
// Both spellings of the count, and they agree with the list.
expect(all.TotalResults).toBe(all.Results.length)
expect(all.TotalCount).toBe(all.TotalResults)
// skip/take actually page rather than being ignored.
const paged = await page('?skip=1&take=1')
expect(paged.Results).toHaveLength(1)
expect(paged.Results[0]!.SubRoomDataSaveId).toBe(ids[1])
expect(paged.TotalResults).toBe(all.TotalResults)
// A never-saved subroom pages empty rather than 404ing.
const empty = await SELF.fetch(`${ORIGIN}/rooms/3/subrooms/3/saves`)
expect(await empty.json()).toEqual({ Results: [], TotalResults: 0, TotalCount: 0 })
})
it('GET /openapi.json documents every route', async () => {