diff --git a/CLAUDE.md b/CLAUDE.md index cc2faa0..ac28ab5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -73,6 +73,40 @@ inconsistency here without checking the client first. - Endpoints the client re-renders from must return the updated entity, not `{ 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`, `/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; only the room-level `POST /rooms/:id/clone` returns the thing it created. +- The room save (`rooms`: `POST /subrooms/:sid/data`) is the ONE exception to that shape: + `value` is `{ room, subRoomDataSave }`, and `error` is NULL rather than `""`. The + `subRoomDataSave` is camelCase with a different field set from the PascalCase + `CurrentSave` embedded in the room (no persistence/OM/UGC versions, no moderation state, + no asset arrays; but `unityAsset`/`unityAssetHash`). Don't unify the two projections. +- 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 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=`. 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. `…/saves` is + auth-gated and CREATOR-only (not co-owners) — it lists unpublished staged saves. There + is no `GET …/subrooms/:sid/data`; only the POST (the room save) exists on that path. +- 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 + (Private, Public, Unlisted, Dev_only, Dev_Unlisted); parse via `parseAccessibility`, + which accepts either form. diff --git a/apps/auth/src/auth.app.ts b/apps/auth/src/auth.app.ts index 42d54a3..d6e620f 100644 --- a/apps/auth/src/auth.app.ts +++ b/apps/auth/src/auth.app.ts @@ -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: '', diff --git a/apps/match/src/match.app.ts b/apps/match/src/match.app.ts index 23fc778..63b6e77 100644 --- a/apps/match/src/match.app.ts +++ b/apps/match/src/match.app.ts @@ -27,6 +27,7 @@ import { RoomInstanceType, setPresence, setRoomInstanceInProgress, + subRoomDataBlob, } from '@repo/domain' import { logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers' import { generatePhotonAuthToken, validateAndGetAccountId } from '@repo/jwt' @@ -34,7 +35,6 @@ import { generatePhotonAuthToken, 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, ConnectionInfoResponse, @@ -413,7 +413,11 @@ function instanceFieldsFromRoom(room: Room, subRoomId?: number) { roomId: num(room.RoomId, 1), subRoomId: num(sub?.SubRoomId, 1), location: str(sub?.UnitySceneId), - dataBlob: str(sub?.DataBlob), + // 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), roomInstanceType: room.IsDorm === true ? RoomInstanceType.Dormroom : RoomInstanceType.Public, @@ -743,7 +747,10 @@ const app = new Hono() '`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) => { diff --git a/apps/match/src/test/integration/api.test.ts b/apps/match/src/test/integration/api.test.ts index b9828aa..6d48917 100644 --- a/apps/match/src/test/integration/api.test.ts +++ b/apps/match/src/test/integration/api.test.ts @@ -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) + // 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}`, { diff --git a/apps/rooms/migrations/0008_subroom_saves.sql b/apps/rooms/migrations/0008_subroom_saves.sql new file mode 100644 index 0000000..0ea8edd --- /dev/null +++ b/apps/rooms/migrations/0008_subroom_saves.sql @@ -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'); diff --git a/apps/rooms/src/openapi.ts b/apps/rooms/src/openapi.ts index b9c0ca2..3d82d4b 100644 --- a/apps/rooms/src/openapi.ts +++ b/apps/rooms/src/openapi.ts @@ -138,14 +138,68 @@ 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 request’s `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 + * 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(), @@ -156,10 +210,15 @@ export const SubRoomDto = z.object({ LastModeratedSaveModerationState: z.int(), IsSandbox: z.boolean(), MaxPlayers: z.int(), - Accessibility: z.int().describe('0 = Private, 1 = Public, 2 = Unlisted'), + Accessibility: z + .int() + .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(), @@ -318,17 +377,17 @@ export const RoomEnvelope = z.object({ }) /** - * 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. + * 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 SubRoomSaveResult = z.union([SubRoomDto, RoomResultEnvelope]) - -/** The same envelope carrying a subroom (`POST …/subrooms/{subRoomId}/clone`). */ -export const SubRoomEnvelope = z.object({ +export const RoomSaveEnvelope = z.object({ success: z.boolean(), - error: z.string().describe('Empty on success'), - value: SubRoomDto.nullable(), + 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 isn’t HTTP 200. */ @@ -406,6 +465,28 @@ export const AccessibilityRequest = z.object({ accessibility: z.string().describe('0 = Private, 1 = Public, 2 = Unlisted'), }) +/** + * `PUT /rooms/{roomId}/subrooms/{subRoomId}/accessibility`. Unlike the room-level route + * above, the client sends the enum NAME here (`accessibility=Private`), so both the name + * and the number are accepted. + */ +export const SubRoomAccessibilityRequest = z.object({ + accessibility: z + .string() + .describe( + 'A `RoomAccessibility` name — `Private`, `Public`, `Unlisted`, `Dev_only`, ' + + '`Dev_Unlisted` (case-insensitive) — or its ordinal 0–4' + ), +}) + +/** + * `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'), @@ -414,7 +495,10 @@ export const CreateSubRoomRequest = z.object({ /** `PUT /rooms/{roomId}/subrooms/{subRoomId}/modify`. */ export const ModifySubRoomRequest = z.object({ name: z.string().describe('Required — an empty name is rejected'), - accessibility: z.string().optional().describe('0 = Private, 1 = Public, 2 = Unlisted'), + accessibility: z + .string() + .optional() + .describe('A `RoomAccessibility` name (case-insensitive) or its ordinal 0–4'), maxPlayers: z.string().optional().describe('Ignored when not a positive integer'), }) @@ -428,14 +512,19 @@ export const SaveSubRoomDataRequest = z.object({ SubRoomData: z .object({ Filename: z.string() }) .optional() - .describe('The uploaded scene-data blob — becomes the subroom’s `DataBlob`'), + .describe('The uploaded scene-data blob — becomes the subroom’s `CurrentSave.DataBlob`'), RoomData: z .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'), }) /** @@ -443,8 +532,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 --------------------------------------------------------------- diff --git a/apps/rooms/src/rooms.app.ts b/apps/rooms/src/rooms.app.ts index c880bbe..4d51b74 100644 --- a/apps/rooms/src/rooms.app.ts +++ b/apps/rooms/src/rooms.app.ts @@ -3,6 +3,7 @@ import { describeRoute, openAPIRouteHandler } from 'hono-openapi' import { useWorkersLogger } from 'workers-tagged-logger' import { + Accessibility, canManageRoom, cloneRoom, cloneSubRoom, @@ -24,8 +25,10 @@ import { getRoomsByCreator, getRoomsByIds, getSimilarRooms, + getSubRoomSaves, getVisitedRooms, modifySubRoom, + publishSubRoomSave, removeCheer, removeFavorite, saveSubRoomData, @@ -64,6 +67,7 @@ import { pageParams, PhotonAccessTokenDto, PlayerDataDto, + PublishSaveRequest, RestrictionsRequest, RoleRequest, RoomDto, @@ -71,13 +75,12 @@ import { roomIdParam, RoomLookup, RoomResultEnvelope, + RoomSaveEnvelope, SaveSubRoomDataRequest, ServiceStatus, stringQuery, - SubRoomDto, - SubRoomEnvelope, + SubRoomAccessibilityRequest, subRoomIdParam, - SubRoomSaveResult, SubRoomSavesPage, TagRequest, UNAUTHORIZED_EMPTY, @@ -195,6 +198,22 @@ function unauthorized(c: Context) { return c.json({ error: 'Unauthorized' }, 401) } +/** + * Parse an `accessibility` form field into a `RoomAccessibility` value. The client + * sends the enum NAME on the subroom route (`accessibility=Private`), not the number + * the room-level route takes, so both forms are accepted. Returns undefined when the + * field is missing or names nothing in the enum. + */ +function parseAccessibility(value: unknown): number | undefined { + if (typeof value !== 'string') return undefined + const raw = value.trim() + if (/^-?\d+$/.test(raw)) return Number.parseInt(raw, 10) + const named = Object.entries(Accessibility).find( + ([name, ordinal]) => typeof ordinal === 'number' && name.toLowerCase() === raw.toLowerCase() + ) + return named ? (named[1] as number) : undefined +} + /** The notifications hub is a single global DO instance (see the `notify` worker). */ const HUB_INSTANCE = 'global' @@ -253,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) { + 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, value: unknown, error = '') { return c.json({ success: error === '', error, value }) @@ -1403,62 +1449,64 @@ const app = new Hono() } ) - // A subroom's data descriptor (the SubRoom object from the room's SubRooms - // array). Public — the client fetches it while loading the room. 404 when the - // room or subroom is unknown. - .get( - '/rooms/:roomId{[0-9]+}/subrooms/:subRoomId{[0-9]+}/data', - describeRoute({ - tags: ['Subrooms'], - summary: 'A subroom’s data descriptor', - description: [ - 'The `SubRoom` object from the room’s `SubRooms` array — the descriptor the client', - 'fetches while loading the room, carrying the scene id and the saved-data blob keys.', - 'Public; an unknown room or subroom is a 404.', - ].join(' '), - parameters: [roomIdParam, subRoomIdParam], - responses: { - 200: json(SubRoomDto, 'The subroom'), - 404: { description: 'No such room or subroom' }, - }, - }), - async (c) => { - 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) - const sub = room ? findSubRoom(room, subRoomId) : undefined - 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. + // 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. Auth-gated (401) and creator-only (403): + // the list exposes unpublished saves, which only the owner is entitled to see. .get( '/rooms/:roomId{[0-9]+}/subrooms/:subRoomId{[0-9]+}/saves', describeRoute({ tags: ['Subrooms'], summary: 'A subroom’s saved-data versions', description: [ - 'The room-history / “restore a save” list, paged as', - '`PagedResultsDTO`. We keep no save history — a save (`POST', - '…/data`) overwrites the subroom’s 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 subroom’s full history; it is empty', + 'only when the subroom has never been saved.', + '`unityAssetTarget`/`unityAssetVersion` are accepted and ignored.', + '', + 'Owner-only (403 otherwise) — the list includes STAGED saves that were never', + 'published, so it is not public. It is what the client reads to offer the owner', + '“load the latest or the published version?” when they enter a private instance.', + '', + '`TotalResults` and `TotalCount` carry the same number: the client’s paged DTO and', + 'the reference disagree on the name, so both are emitted.', ].join(' '), + security: AUTHED, 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 subroom’s saves, newest first'), + 401: UNAUTHORIZED_RESPONSE, + 403: FORBIDDEN_RESPONSE, + }, }), - (c) => c.json({ Results: [], TotalResults: 0 }) + async (c) => { + const accountId = await authedAccountId(c) + if (accountId === null) return unauthorized(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 }) + } + if (room.CreatorAccountId !== accountId) return c.body(null, 403) + 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 @@ -1472,14 +1520,22 @@ const app = new Hono() 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.', + '', + '`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', 'saver’s id here, because the client NREs on a null one.', ].join('\n'), @@ -1487,10 +1543,7 @@ const app = new Hono() 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(RoomSaveEnvelope, 'The updated room + the new save, or a rejection'), 401: UNAUTHORIZED_EMPTY, 403: FORBIDDEN_RESPONSE, }, @@ -1504,44 +1557,49 @@ const app = new Hono() const room = await getRoomById(c.env.DB, roomId) if (!room) { - return roomResult(c, { - Success: false, - ErrorId: 'Rooms.DoesntExist', - Error: 'This room does not exist!', - }) + 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) + // 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. `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 InventionUsage?: string + 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, 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 (!result) { + return c.json({ success: false, error: 'This subroom does not exist!', value: null }) } - // 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. - await pushRoomUpdate(c, accountId, updated) - return c.json(findSubRoom(updated, subRoomId) ?? {}) + // `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) }, + }) } ) @@ -1607,16 +1665,14 @@ const app = new Hono() Error: 'You must enter a name for your room!', }) } - const accessibility = - typeof body.accessibility === 'string' - ? Number.parseInt(body.accessibility, 10) - : Number.NaN const maxPlayers = typeof body.maxPlayers === 'string' ? Number.parseInt(body.maxPlayers, 10) : Number.NaN const updated = await modifySubRoom(c.env.DB, roomId, subRoomId, { name, - accessibility: Number.isNaN(accessibility) ? undefined : accessibility, + // Accepts the enum name as well as the ordinal — the dedicated + // `/accessibility` route below is sent names, so this may be too. + accessibility: parseAccessibility(body.accessibility), maxPlayers: Number.isNaN(maxPlayers) || maxPlayers <= 0 ? undefined : maxPlayers, }) if (!updated) { @@ -1632,11 +1688,144 @@ const app = new Hono() } ) + // 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 + 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 + // (`accessibility=Private`), not the number the room-level `/accessibility` takes. + // Auth-gated (401) and owner-only, like the other subroom mutations. Answers the + // updated ROOM in the `{ success, error, value }` envelope — the client re-renders + // the room's subroom list from `value`, the same as subroom create/delete. + .put( + '/rooms/:roomId{[0-9]+}/subrooms/:subRoomId{[0-9]+}/accessibility', + describeRoute({ + tags: ['Subrooms'], + summary: 'Set a subroom’s accessibility', + description: [ + 'A subroom’s own visibility, independent of the room’s top-level `Accessibility`.', + 'The client sends the `RoomAccessibility` NAME here (`accessibility=Private`) rather', + 'than the ordinal the room-level route takes, so both forms are accepted; an', + 'unrecognised value is rejected. Owner-only — only the room’s creator may change', + 'its subrooms, not co-owners.', + '', + 'Answers the updated ROOM, not the bare subroom, so the client can re-render the', + 'room’s subroom list from `value`.', + ].join(' '), + security: AUTHED, + parameters: [roomIdParam, subRoomIdParam], + requestBody: form(SubRoomAccessibilityRequest, 'The new accessibility'), + 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!') + } + if (!findSubRoom(room, subRoomId)) { + return roomEnvelope(c, null, 'This subroom does not exist!') + } + + const body = (await c.req.parseBody().catch(() => ({}))) as Record + const accessibility = parseAccessibility(body.accessibility) + if (accessibility === undefined) { + return roomEnvelope(c, null, 'You must provide a valid accessibility!') + } + + const updated = await modifySubRoom(c.env.DB, roomId, subRoomId, { accessibility }) + if (!updated) return roomEnvelope(c, null, 'This subroom does not exist!') + + await pushRoomUpdate(c, accountId, updated) + return roomEnvelope(c, updated) + } + ) + // Clone a subroom into a new subroom of the same room (fresh SubRoomId, same // scene/settings/data). Auth-gated (401) and owner-only. Notifies the owner and - // returns the `{ success, error, value }` envelope with the new subroom as `value`, - // mirroring the room-level `/clone`. Response shape is a best guess (the real - // client's expected body is unknown). + // returns the updated ROOM in the `{ success, error, value }` envelope — NOT the new + // subroom, even though the new subroom is what the call produces. The client + // re-renders the room's subroom list from `value`, the same as subroom + // create/delete/accessibility. .post( '/rooms/:roomId{[0-9]+}/subrooms/:subRoomId{[0-9]+}/clone', describeRoute({ @@ -1647,13 +1836,14 @@ const app = new Hono() 'data blobs, so it loads identical content — with a fresh globally-unique `SubRoomId`.', 'Owner-only.', '', - 'The response shape is a best guess: it mirrors the room-level `/clone` envelope, but', - 'the real client’s expected body for this call is unknown.', + 'Answers the updated ROOM, not the new subroom — the client re-renders the room’s', + 'subroom list from `value`. Unlike the room-level `/clone`, whose `value` IS the new', + 'room, the thing this call creates is not what comes back.', ].join('\n'), security: AUTHED, parameters: [roomIdParam, subRoomIdParam], responses: { - 200: json(SubRoomEnvelope, 'The new subroom, or a rejection with `success: false`'), + 200: json(RoomEnvelope, 'The updated room, or a rejection with `success: false`'), 401: UNAUTHORIZED_ENVELOPE, }, }), @@ -1676,7 +1866,7 @@ const app = new Hono() if (!result) return roomEnvelope(c, null, 'This subroom does not exist!') await pushRoomUpdate(c, accountId, result.room) - return roomEnvelope(c, result.subRoom) + return roomEnvelope(c, result.room) } ) diff --git a/apps/rooms/src/test/integration/api.test.ts b/apps/rooms/src/test/integration/api.test.ts index ba33b2d..7da49c6 100644 --- a/apps/rooms/src/test/integration/api.test.ts +++ b/apps/rooms/src/test/integration/api.test.ts @@ -583,6 +583,18 @@ describe('rooms endpoints', () => { type RoomEnv = { success: boolean; error: string; value: Record | null } const envOf = async (res: Response) => (await res.json()) as RoomEnv + // A subroom as the client sees it. There is no GET for a single subroom — the client + // reads them off the room — so tests do the same. + const subRoomOf = async ( + roomId: number, + subRoomId: number + ): Promise | undefined> => { + const res = await SELF.fetch(`${ORIGIN}/rooms/${roomId}`) + if (res.status !== 200) return undefined + const room = (await res.json()) as { SubRooms?: Array> } + return (room.SubRooms ?? []).find((s) => s.SubRoomId === subRoomId) + } + it('PUT /rooms/:id/description is auth-gated, owner-only, and persists', async () => { // No token → 401 (auth gate). expect((await putForm('/rooms/2/description', { description: 'x' })).status).toBe(401) @@ -929,16 +941,11 @@ describe('rooms endpoints', () => { expect(pub.value?.Accessibility).toBe(1) }) - it('GET /rooms/:id/subrooms/:sid/data returns the subroom descriptor (404 when unknown)', async () => { - // Room 2 has SubRoomId 2 in the seed. - const res = await SELF.fetch(`${ORIGIN}/rooms/2/subrooms/2/data`) - expect(res.status).toBe(200) - expect((await res.json()) as { SubRoomId: number }).toMatchObject({ SubRoomId: 2 }) - - // Unknown subroom → 404. - expect((await SELF.fetch(`${ORIGIN}/rooms/2/subrooms/9999/data`)).status).toBe(404) - // Unknown room → 404. - expect((await SELF.fetch(`${ORIGIN}/rooms/99999/subrooms/2/data`)).status).toBe(404) + it('there is no GET for a single subroom — only the room carries them', async () => { + // The real API has no `GET …/subrooms/{id}/data`; the client reads subrooms off the + // room. Only the POST (the room save) exists on that path, and it is auth-gated. + expect((await SELF.fetch(`${ORIGIN}/rooms/2/subrooms/2/data`)).status).toBe(404) + expect(await subRoomOf(2, 2)).toMatchObject({ SubRoomId: 2 }) }) it('POST /rooms/:id/subrooms/:sid/data is auth-gated, owner-only, and saves the blobs', async () => { @@ -975,36 +982,84 @@ 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. `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) - expect(await bodyOf(ok)).toMatchObject({ - SubRoomId: 2, - DataBlob: 'a84167b16796452ab70ee8a6a5b1dc5f', + const saved = (await ok.json()) as { + success: boolean + error: string | null + value: { + room: Record + subRoomDataSave: Record + } + } + expect(saved.success).toBe(true) + 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.room.SubRooms as Array>).find( + (s) => s.SubRoomId === 2 + )! + expect(savedSub).toMatchObject({ RoomDataBlob: '5c618c920f6247efb8327e327d0b4417', CreatorAccountId: 1, PersistenceVersion: 41, }) - - // It also persists — the GET returns the subroom with the new blob + creator. - const sub = (await (await SELF.fetch(`${ORIGIN}/rooms/2/subrooms/2/data`)).json()) as { - SubRoomId: number - DataBlob: string - CreatorAccountId: number - } - expect(sub).toMatchObject({ - SubRoomId: 2, + expect(savedSub.CurrentSave).toMatchObject({ DataBlob: 'a84167b16796452ab70ee8a6a5b1dc5f', - CreatorAccountId: 1, }) + + // It also persists — reading the room back shows the save live. + const sub = (await subRoomOf(2, 2)) as unknown as { + SubRoomId: number + CreatorAccountId: number + CurrentSave: { + DataBlob: string + SubRoomDataSaveId: number + SavedByAccountId: number + PersistenceVersion: number + UnitySubAssets: unknown[] + Tags: unknown[] + } + StagedSubRoomDataSaveId: number | null + } + 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 @@ -1013,10 +1068,250 @@ 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 coOwner.json()) as { + success: boolean + value: { room: { SubRooms: Array> }; subRoomDataSave: unknown } + } + expect(coOwnerEnv.success).toBe(true) + 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 () => { + // 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> + } + 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 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 | null + StagedSubRoomDataSaveId: number | null + } + const subOf = async () => (await subRoomOf(5, 5)) as unknown 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 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) + + // 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, + Description: 'TEST', + OMVersion: 0, + UgcSubVersion: 0, + ModerationState: 0, + }) + // 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. + const room = (await (await SELF.fetch(`${ORIGIN}/rooms/5`)).json()) as { + SubRooms: Array<{ SubRoomId: number; CurrentSave: { SubRoomDataSaveId: number } | null }> + } + expect(room.SubRooms.find((s) => s.SubRoomId === 5)!.CurrentSave!.SubRoomDataSaveId).toBe( + firstId + ) + + // 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`, { headers: await bearer('1') }) + ).json()) as { + Results: Array<{ DataBlob: string; Description: 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(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 subRoomOf(2, 2)) as unknown 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 () => { + // 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 subRoomOf(820, 830)) as unknown as { + CurrentSave: Record + } + 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 subRoomOf(820, 830)) as unknown as { + CurrentSave: { SubRoomDataSaveId: number } + } + 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 subRoomOf(1, 1)) as unknown 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. + 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` } }), + }) + // Non-dorm saves stage, so the fresh id lands on StagedSubRoomDataSaveId. + const idOf = async (roomId: number, subRoomId: number) => + ((await subRoomOf(roomId, subRoomId)) as unknown as { StagedSubRoomDataSaveId: number }) + .StagedSubRoomDataSaveId + + // 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 () => { @@ -1326,7 +1621,7 @@ describe('rooms endpoints', () => { const ok = await putForm('/rooms/2/subrooms/2/modify', fields, '1') expect(ok.status).toBe(200) expect(await bodyOf(ok)).toMatchObject({ Success: true }) - const sub = (await (await SELF.fetch(`${ORIGIN}/rooms/2/subrooms/2/data`)).json()) as { + const sub = (await subRoomOf(2, 2)) as unknown as { Name: string Accessibility: number MaxPlayers: number @@ -1334,17 +1629,65 @@ describe('rooms endpoints', () => { expect(sub).toMatchObject({ Name: 'My Cool Subroom', Accessibility: 1, MaxPlayers: 20 }) }) + it('PUT /rooms/:id/subrooms/:sid/accessibility takes the enum name the client sends', async () => { + const path = '/rooms/2/subrooms/2/accessibility' + const accessibilityOf = async () => + ((await subRoomOf(2, 2)) as unknown as { Accessibility: number }).Accessibility + + // No token → 401. + expect((await putForm(path, { accessibility: 'Private' })).status).toBe(401) + // Not the owner (room 2 is owned by account 1) → failure envelope. + expect(await envOf(await putForm(path, { accessibility: 'Private' }, '999'))).toMatchObject({ + success: false, + error: 'You are not the owner of this room!', + }) + // Unknown room / unknown subroom → failure envelope. + expect( + await envOf( + await putForm('/rooms/99999/subrooms/2/accessibility', { accessibility: '0' }, '1') + ) + ).toMatchObject({ success: false }) + expect( + await envOf( + await putForm('/rooms/2/subrooms/9999/accessibility', { accessibility: '0' }, '1') + ) + ).toMatchObject({ success: false }) + // A value that names nothing in the enum → rejected, not silently stored. + expect(await envOf(await putForm(path, { accessibility: 'Nonsense' }, '1'))).toMatchObject({ + success: false, + error: 'You must provide a valid accessibility!', + }) + + // The name form is what the live client sends. + const priv = await envOf(await putForm(path, { accessibility: 'Private' }, '1')) + expect(priv.success).toBe(true) + // The envelope carries the updated ROOM, so the client can re-render the subroom list. + expect(priv.value).toMatchObject({ RoomId: 2 }) + expect(await accessibilityOf()).toBe(0) + + // Case-insensitive, and the later enum members resolve too. + expect((await envOf(await putForm(path, { accessibility: 'dev_unlisted' }, '1'))).success).toBe( + true + ) + expect(await accessibilityOf()).toBe(4) + + // The ordinal still works. + expect((await envOf(await putForm(path, { accessibility: '1' }, '1'))).success).toBe(true) + expect(await accessibilityOf()).toBe(1) + }) + it('POST /rooms/:id/subrooms/:sid/clone is auth-gated, owner-only, and copies the subroom', async () => { const clone = async (roomId: number, subRoomId: number, sub?: string) => SELF.fetch(`${ORIGIN}/rooms/${roomId}/subrooms/${subRoomId}/clone`, { method: 'POST', headers: sub ? await bearer(sub) : {}, }) + type SubRoom = { SubRoomId: number; CreatorAccountId: number } const envelope = async (res: Response) => (await res.json()) as { success: boolean error: string - value: { SubRoomId: number; CreatorAccountId: number } | null + value: { RoomId: number; SubRooms: SubRoom[] } | null } // No token → 401. @@ -1354,17 +1697,27 @@ describe('rooms endpoints', () => { // Unknown subroom → success:false envelope. expect((await envelope(await clone(2, 9999, '1'))).success).toBe(false) - // Owner clones → success, a fresh SubRoomId owned by the caller, fetchable on the room. + const before = new Set( + ( + (await (await SELF.fetch(`${ORIGIN}/rooms/2`)).json()) as { SubRooms: SubRoom[] } + ).SubRooms.map((s) => s.SubRoomId) + ) + + // Owner clones → success. `value` is the updated ROOM, not the new subroom, so the + // clone shows up as one extra entry in its re-attached SubRooms list. const res = await clone(2, 2, '1') expect(res.status).toBe(200) const body = await envelope(res) expect(body.success).toBe(true) - expect(body.value?.SubRoomId).not.toBe(2) - expect(body.value?.CreatorAccountId).toBe(1) - const fetched = (await ( - await SELF.fetch(`${ORIGIN}/rooms/2/subrooms/${body.value?.SubRoomId}/data`) - ).json()) as { SubRoomId: number } - expect(fetched.SubRoomId).toBe(body.value?.SubRoomId) + expect(body.value?.RoomId).toBe(2) + const added = body.value!.SubRooms.filter((s) => !before.has(s.SubRoomId)) + expect(added).toHaveLength(1) + expect(added[0]!.CreatorAccountId).toBe(1) + // A fresh id, and fetchable as a subroom of the room. + expect(added[0]!.SubRoomId).not.toBe(2) + expect(await subRoomOf(2, added[0]!.SubRoomId)).toMatchObject({ + SubRoomId: added[0]!.SubRoomId, + }) }) it('subroom clone mints a globally-unique SubRoomId (no cross-room clash)', async () => { @@ -1379,14 +1732,18 @@ describe('rooms endpoints', () => { method: 'POST', headers: await bearer('1'), }) - const body = (await res.json()) as { value: { SubRoomId: number; RoomId: number } } + // `value` is the updated room; the clone is its highest-numbered subroom. + const body = (await res.json()) as { + value: { RoomId: number; SubRooms: Array<{ SubRoomId: number }> } + } + const cloned = Math.max(...body.value.SubRooms.map((s) => s.SubRoomId)) // Above every prior subroom id — a fresh global id, not a per-room collision. - expect(body.value.SubRoomId).toBeGreaterThan(maxBefore) + expect(cloned).toBeGreaterThan(maxBefore) expect(body.value.RoomId).toBe(2) // The id is unique across the whole table (exactly one row owns it). const dupes = (await env.DB.prepare('SELECT COUNT(*) AS n FROM subroom WHERE sub_room_id = ?1') - .bind(body.value.SubRoomId) + .bind(cloned) .first<{ n: number }>())!.n expect(dupes).toBe(1) }) @@ -1431,9 +1788,11 @@ describe('rooms endpoints', () => { 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 } + const fetched = (await subRoomOf(2, created!.SubRoomId)) as unknown 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. @@ -1476,7 +1835,7 @@ describe('rooms endpoints', () => { 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) + expect(await subRoomOf(2, newId)).toBeUndefined() // 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. @@ -1488,15 +1847,57 @@ describe('rooms endpoints', () => { }) 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) + expect(await subRoomOf(700, 900)).toBeDefined() }) - 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}`, { + headers: await bearer('1'), + }) + ).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`, { + headers: await bearer('1'), + }) + expect(await empty.json()).toEqual({ Results: [], TotalResults: 0, TotalCount: 0 }) + + // The list exposes unpublished saves, so it is owner-only: no token → 401, and a + // valid token that isn't the room's creator → 403. + expect((await SELF.fetch(`${ORIGIN}/rooms/2/subrooms/2/saves`)).status).toBe(401) + expect( + (await SELF.fetch(`${ORIGIN}/rooms/2/subrooms/2/saves`, { headers: await bearer('999') })) + .status + ).toBe(403) + // Even a co-owner (account 2 holds Role 30 on the seeded rooms) is refused. + expect( + (await SELF.fetch(`${ORIGIN}/rooms/2/subrooms/2/saves`, { headers: await bearer('2') })) + .status + ).toBe(403) }) it('GET /openapi.json documents every route', async () => { @@ -1542,7 +1943,6 @@ describe('rooms endpoints', () => { 'GET /rooms/{roomId}/interactionby/me', 'GET /rooms/{roomId}/playerdata/me', 'GET /rooms/{roomId}/similar', - 'GET /rooms/{roomId}/subrooms/{subRoomId}/data', 'GET /rooms/{roomId}/subrooms/{subRoomId}/saves', 'GET /roomserver/photon_access_token', 'GET /roomserver/rooms/createdby/me', @@ -1550,6 +1950,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', @@ -1560,6 +1961,7 @@ describe('rooms endpoints', () => { 'PUT /rooms/{roomId}/name', 'PUT /rooms/{roomId}/restrictions', 'PUT /rooms/{roomId}/roles/{accountId}', + 'PUT /rooms/{roomId}/subrooms/{subRoomId}/accessibility', 'PUT /rooms/{roomId}/subrooms/{subRoomId}/modify', 'PUT /rooms/{roomId}/tags', 'PUT /rooms/{roomId}/warning', diff --git a/apps/www/index.html b/apps/www/index.html index faf638b..ed7d3e3 100644 --- a/apps/www/index.html +++ b/apps/www/index.html @@ -3,10 +3,10 @@ - RecFlare — an open source implementation of the 2023 RecNet servers + RecFlare — play like it's 2023 @@ -14,14 +14,11 @@ rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%2314100c'/%3E%3Cpath d='M16 5c1.8 4.2 4.3 6 6.4 8.1a8.9 8.9 0 1 1-12.8 0C11.7 11 14.2 9.2 16 5z' fill='%23fe7101'/%3E%3C/svg%3E" /> - + diff --git a/apps/www/src/client/App.tsx b/apps/www/src/client/App.tsx index 99e0672..4a1cf14 100644 --- a/apps/www/src/client/App.tsx +++ b/apps/www/src/client/App.tsx @@ -1,19 +1,9 @@ import { useCallback, useEffect, useState } from 'react' +import { DISCORD_INVITE, DOWNLOAD_URL, LICENSE_URL, SOURCE_REPO } from '../links' + import type { ReactNode } from 'react' -/** The community Discord — the join instructions and the build both live there. */ -const DISCORD_INVITE = 'https://discord.gg/HhmMAKhrz' - -/** Where the stage's "Download for PC" button goes: the client's release listing. */ -const DOWNLOAD_URL = 'https://github.com/djdevin/recflare-client/releases' - -/** The public source repo, linked from the homepage and footer. */ -const SOURCE_REPO = 'https://github.com/djdevin/recflare' - -/** The repo's licence, behind the footer's "MIT licensed". */ -const LICENSE_URL = `${SOURCE_REPO}/blob/main/LICENSE` - /** The self-account shape returned by the www BFF (`/api/me`, `/api/login`, …). */ interface SelfAccount { accountId: number @@ -132,9 +122,12 @@ function SiteFooter() { MIT licensed {' '} - · a fan project, not affiliated with Rec Room Inc. + — made by fans, not affiliated with Rec Room Inc.