mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 23:18:51 -07:00
[studio] beta studio (#114)
* rrs stuff * studio service yay * rrs whitelist * moree studiooooooo fellowwww * rec room studio room loading yay * fixes yay * fix 2 because idk yayuh * Update cdn.app.ts * ccontent length fix? * Preserve content-length and upgrade metadata Return a real zero-filled byte array for CDN HEAD responses so the runtime preserves Content-Length while still sending no payload. In notify, re-create the Request with its original headers, set OWNER_HEADER from the validated token, and forward that Request to hub.fetch to avoid losing WebSocket upgrade metadata (which could trigger 500s in Durable Objects). These changes ensure the editor's size checks and WebSocket upgrades remain reliable. * fix x524234 * shirt thing? * [auth] remove grant table as we already have audit_log for now * rename table to generic device_grant * cleanup/lint --------- Co-authored-by: Devin Zuczek <devin.zuczek@gmail.com>
This commit is contained in:
@@ -36,6 +36,10 @@ Reads are the exception: they carry no envelope at all. A paged read (the save h
|
||||
`…/subrooms/{subRoomId}/saves` and its lighter `…/saves/no_unity_assets` twin) is a bare
|
||||
`{ Results, TotalResults, TotalCount }` wrapper — `TotalResults` and `TotalCount` are the
|
||||
same number, because the client's paged DTO and the reference disagree on the name.
|
||||
`GET /rooms/{roomId}` puts a Studio build's main bundles on `CurrentSave.UnitySubAssets`
|
||||
(target 0 Windows, 2 Android/Quest). `unityAssetTarget` keeps only that target. Maker-pen
|
||||
saves keep the arrays empty. `GET …/unityasset?unityAssetId=` is the bare metadata for
|
||||
those files; the bytes are on the CDN.
|
||||
|
||||
## Cron
|
||||
|
||||
|
||||
@@ -184,8 +184,14 @@ 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'),
|
||||
unityAsset: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe('Windows baked-bundle filename when this save has a Studio build; otherwise null'),
|
||||
unityAssetHash: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe('Base64 SHA-256 of `unityAsset` when that file is set; otherwise null'),
|
||||
dataBlob: z.string(),
|
||||
dataBlobHash: z.string().nullable().describe('Echoed from the request’s `SubRoomData.Hash`'),
|
||||
savedByAccountId: z.int().nullable(),
|
||||
@@ -204,11 +210,22 @@ export const SubRoomDataSaveResponseDto = z.object({
|
||||
* 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.
|
||||
* `UnitySubAssets` is empty for a maker-pen save. A Rec Room Studio build fills it
|
||||
* with one main bundle per platform. Referenced assets stay empty. The arrays are
|
||||
* always emitted because the client's parser expects them present.
|
||||
*/
|
||||
export const UnitySubAssetDto = z.object({
|
||||
UnityAssetId: z.string(),
|
||||
Target: z.int().describe('0 Windows, 2 Android/Quest — the game’s `unityAssetTarget`'),
|
||||
Version: z.int(),
|
||||
Filename: z.string().describe('Downloaded from the CDN at `/unityasset/{Filename}`'),
|
||||
Hash: z.string().describe('Base64 SHA-256 of the bundle'),
|
||||
})
|
||||
|
||||
export const SubRoomDataSaveDto = z.object({
|
||||
UnitySubAssets: z.array(z.unknown()).describe('Always empty'),
|
||||
UnitySubAssets: z
|
||||
.array(UnitySubAssetDto)
|
||||
.describe('Main Studio bundles for this save; empty for a maker-pen save'),
|
||||
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'),
|
||||
@@ -952,6 +969,26 @@ export const SubRoomSavesNoUnityAssetsPage = z.object({
|
||||
TotalCount: z.int().describe('Same value as `TotalResults` — the two references disagree'),
|
||||
})
|
||||
|
||||
/**
|
||||
* `GET /rooms/{roomId}/subrooms/{subRoomId}/unityasset` — the editor's unity-asset
|
||||
* document, camelCase. `bakedUnityAssets` are the main bundles; `filename`/`hash`
|
||||
* name the Windows one. Bytes live on the CDN.
|
||||
*/
|
||||
export const BakedUnityAssetDto = z.object({
|
||||
unityAssetId: z.string(),
|
||||
target: z.int().describe('0 Windows, 2 Android/Quest'),
|
||||
version: z.int(),
|
||||
filename: z.string(),
|
||||
})
|
||||
|
||||
export const UnityAssetWithSourceDto = z.object({
|
||||
unityAssetId: z.string(),
|
||||
createdByAccountId: z.int(),
|
||||
bakedUnityAssets: z.array(BakedUnityAssetDto),
|
||||
filename: z.string().describe('The Windows main bundle'),
|
||||
hash: z.string().describe('Base64 SHA-256 of `filename`'),
|
||||
})
|
||||
|
||||
// ---- Session ---------------------------------------------------------------
|
||||
|
||||
/** One entry of the permission table the client applies when it spawns into a room. */
|
||||
|
||||
+163
-13
@@ -6,6 +6,7 @@ import {
|
||||
Accessibility,
|
||||
answerRoomRoleInvite,
|
||||
applyRoomTagEdit,
|
||||
attachStudioUnityAssets,
|
||||
autocompleteRoomSearch,
|
||||
banPlayerFromRoom,
|
||||
canManageRoom,
|
||||
@@ -41,12 +42,15 @@ import {
|
||||
getRoomsByCreator,
|
||||
getRoomsByIds,
|
||||
getSimilarRooms,
|
||||
getStudioUnityAsset,
|
||||
getSubRoom,
|
||||
getSubRoomPermissions,
|
||||
getSubRoomSaveById,
|
||||
getSubRoomSaves,
|
||||
getTrendingRooms,
|
||||
getVisitedRooms,
|
||||
inviteRoomRole,
|
||||
isMissingStudioAssetTable,
|
||||
isPlayerBannedFromRoom,
|
||||
isRoomOwner,
|
||||
MessageType,
|
||||
@@ -69,6 +73,9 @@ import {
|
||||
setRoomName,
|
||||
setRoomRole,
|
||||
setSubRoomPermissions,
|
||||
sha256HexToBase64,
|
||||
STUDIO_ASSET_VERSION,
|
||||
studioAssetTarget,
|
||||
toggleCheer,
|
||||
toggleFavorite,
|
||||
transferRoomOwnership,
|
||||
@@ -165,6 +172,7 @@ import {
|
||||
UNAUTHORIZED_ENVELOPE,
|
||||
UNAUTHORIZED_RESPONSE,
|
||||
UnbanBulkRequest,
|
||||
UnityAssetWithSourceDto,
|
||||
WarningRequest,
|
||||
} from './openapi'
|
||||
|
||||
@@ -793,17 +801,20 @@ function roomResult(
|
||||
* 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.
|
||||
* `unityAsset`/`unityAssetHash` name one baked main bundle when this save has a Studio
|
||||
* build attached, and stay null for a maker-pen save. `target` null prefers Windows (0).
|
||||
* A caller that named a target gets that platform or null, never the other one.
|
||||
*/
|
||||
function toSaveResponse(save: Record<string, unknown>) {
|
||||
function toSaveResponse(save: Record<string, unknown>, target: number | null = null) {
|
||||
const str = (v: unknown) => (typeof v === 'string' ? v : null)
|
||||
const num = (v: unknown) => (typeof v === 'number' ? v : null)
|
||||
const baked = pickBakedBundle(save, target)
|
||||
return {
|
||||
subRoomDataSaveId: num(save.SubRoomDataSaveId),
|
||||
subRoomId: num(save.SubRoomId),
|
||||
unityAssetId: str(save.UnityAssetId),
|
||||
unityAsset: null,
|
||||
unityAssetHash: null,
|
||||
unityAsset: baked?.filename ?? null,
|
||||
unityAssetHash: baked?.hash ?? null,
|
||||
dataBlob: str(save.DataBlob) ?? '',
|
||||
dataBlobHash: str(save.DataBlobHash),
|
||||
savedByAccountId: num(save.SavedByAccountId),
|
||||
@@ -814,6 +825,54 @@ function toSaveResponse(save: Record<string, unknown>) {
|
||||
}
|
||||
}
|
||||
|
||||
/** The `unityAssetTarget` query, or null when the caller did not name an integer. */
|
||||
function unityAssetTargetQuery(c: Context<App>): number | null {
|
||||
const raw = c.req.query('unityAssetTarget')
|
||||
if (raw === undefined || raw === '') return null
|
||||
const target = Number(raw)
|
||||
return Number.isInteger(target) ? target : null
|
||||
}
|
||||
|
||||
/** One main bundle off a save: Windows when `target` is null, else that target only. */
|
||||
function pickBakedBundle(
|
||||
save: Record<string, unknown>,
|
||||
target: number | null
|
||||
): { filename: string; hash: string | null } | null {
|
||||
if (!Array.isArray(save.UnitySubAssets)) return null
|
||||
const rows = save.UnitySubAssets.filter(
|
||||
(item): item is { Target?: unknown; Filename?: unknown; Hash?: unknown } =>
|
||||
typeof item === 'object' && item !== null
|
||||
)
|
||||
const chosen =
|
||||
target === null
|
||||
? (rows.find((row) => row.Target === 0) ?? rows[0])
|
||||
: rows.find((row) => row.Target === target)
|
||||
if (!chosen || typeof chosen.Filename !== 'string' || chosen.Filename === '') return null
|
||||
const hash = typeof chosen.Hash === 'string' && chosen.Hash !== '' ? chosen.Hash : null
|
||||
return { filename: chosen.Filename, hash }
|
||||
}
|
||||
|
||||
/** Drop baked bundles whose `Target` is not the one the caller asked for. */
|
||||
function keepUnityAssetTarget(save: Record<string, unknown>, target: number) {
|
||||
if (!Array.isArray(save.UnitySubAssets)) return
|
||||
save.UnitySubAssets = save.UnitySubAssets.filter((item) => {
|
||||
if (typeof item !== 'object' || item === null) return false
|
||||
return (item as { Target?: unknown }).Target === target
|
||||
})
|
||||
}
|
||||
|
||||
/** Narrow every subroom's current save to `target`. No-op when the caller named none. */
|
||||
function filterRoomUnityAssets(room: Record<string, unknown>, target: number | null) {
|
||||
if (target === null || !Array.isArray(room.SubRooms)) return
|
||||
for (const sub of room.SubRooms) {
|
||||
if (typeof sub !== 'object' || sub === null) continue
|
||||
const save = (sub as { CurrentSave?: unknown }).CurrentSave
|
||||
if (typeof save === 'object' && save !== null) {
|
||||
keepUnityAssetTarget(save as Record<string, unknown>, target)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A save row with its Unity-asset payloads left out — what `…/saves/no_unity_assets`
|
||||
* lists. PascalCase like the rows `…/saves` serves, minus the two hydrated asset arrays
|
||||
@@ -3385,7 +3444,8 @@ const app = new Hono<App>()
|
||||
'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.',
|
||||
'An integer `unityAssetTarget` keeps only `UnitySubAssets` for that target',
|
||||
'(0 Windows, 2 Android/Quest). `unityAssetVersion` is accepted and ignored.',
|
||||
'',
|
||||
'The list includes STAGED saves that were never published, so it is not public:',
|
||||
'the room’s creator may read it, and so may anyone standing IN the room (their live',
|
||||
@@ -3400,7 +3460,10 @@ const app = new Hono<App>()
|
||||
parameters: [
|
||||
roomIdParam,
|
||||
subRoomIdParam,
|
||||
stringQuery('unityAssetTarget', 'Accepted and ignored'),
|
||||
stringQuery(
|
||||
'unityAssetTarget',
|
||||
'When an integer, `UnitySubAssets` on each row keeps only that target (0 Windows, 2 Android/Quest)'
|
||||
),
|
||||
stringQuery('unityAssetVersion', 'Accepted and ignored'),
|
||||
stringQuery('skip', 'How many saves to skip (default 0)'),
|
||||
stringQuery('take', 'How many saves to return (default all)'),
|
||||
@@ -3429,6 +3492,10 @@ const app = new Hono<App>()
|
||||
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)
|
||||
const target = unityAssetTargetQuery(c)
|
||||
if (target !== null) {
|
||||
for (const save of page) keepUnityAssetTarget(save, target)
|
||||
}
|
||||
|
||||
return c.json({ Results: page, TotalResults: saves.length, TotalCount: saves.length })
|
||||
}
|
||||
@@ -3546,7 +3613,79 @@ const app = new Hono<App>()
|
||||
if (!(await canReadSaves(c, room, roomId, accountId))) return c.body(null, 403)
|
||||
|
||||
const save = await getSubRoomSaveById(c.env.DB, subRoomId, saveId)
|
||||
return save ? c.json(toSaveResponse(save)) : c.notFound()
|
||||
return save ? c.json(toSaveResponse(save, unityAssetTargetQuery(c))) : c.notFound()
|
||||
}
|
||||
)
|
||||
|
||||
// Metadata for the unity asset on one of this subroom's saves. The editor calls
|
||||
// this after a build; the game reads the same bundles from `CurrentSave` and then
|
||||
// downloads `filename` from the CDN. Bare object, no envelope, no auth — the room
|
||||
// document already names the files.
|
||||
.get(
|
||||
'/rooms/:roomId{[0-9]+}/subrooms/:subRoomId{[0-9]+}/unityasset',
|
||||
describeRoute({
|
||||
tags: ['Subrooms'],
|
||||
summary: 'The unity asset attached to a subroom save',
|
||||
description: [
|
||||
'The baked Windows and Android bundles stored for `unityAssetId`, when that id is',
|
||||
'on a save of this subroom. A bare object: `unityAssetId`, `createdByAccountId`,',
|
||||
'`bakedUnityAssets` (one entry per main bundle), plus `filename` and `hash` for the',
|
||||
'Windows bundle. Bytes are `GET /unityasset/{filename}` on the CDN.',
|
||||
'',
|
||||
'`target` is 0 for Windows and 2 for Android/Quest. Stripped bundles are stored and',
|
||||
'not listed. An unknown room, subroom, or asset is a 404. No auth: `GET /rooms/{id}`',
|
||||
'already includes the same filenames on `CurrentSave`.',
|
||||
].join(' '),
|
||||
parameters: [
|
||||
roomIdParam,
|
||||
subRoomIdParam,
|
||||
stringQuery('unityAssetId', 'The save’s `UnityAssetId`'),
|
||||
],
|
||||
responses: {
|
||||
200: json(UnityAssetWithSourceDto, 'The unity asset and its baked bundles'),
|
||||
404: { description: 'No such room, subroom, or asset on that subroom' },
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const roomId = Number.parseInt(c.req.param('roomId'), 10)
|
||||
const subRoomId = Number.parseInt(c.req.param('subRoomId'), 10)
|
||||
const unityAssetId = c.req.query('unityAssetId') ?? ''
|
||||
if (unityAssetId === '') return c.notFound()
|
||||
|
||||
const sub = await getSubRoom(c.env.DB, roomId, subRoomId)
|
||||
if (!sub) return c.notFound()
|
||||
const saves = await getSubRoomSaves(c.env.DB, subRoomId)
|
||||
const save = saves.find((row) => row.UnityAssetId === unityAssetId)
|
||||
if (!save) return c.notFound()
|
||||
|
||||
let asset
|
||||
try {
|
||||
asset = await getStudioUnityAsset(c.env.DB, unityAssetId)
|
||||
} catch (err) {
|
||||
if (isMissingStudioAssetTable(err)) return c.notFound()
|
||||
throw err
|
||||
}
|
||||
if (!asset) return c.notFound()
|
||||
|
||||
const mains = asset.files
|
||||
.filter((file) => file.kind === 'main')
|
||||
.sort((a, b) => studioAssetTarget(a.platform) - studioAssetTarget(b.platform))
|
||||
const source = mains.find((file) => file.platform === 'windows') ?? mains[0]
|
||||
if (!source) return c.notFound()
|
||||
|
||||
const savedBy = typeof save.SavedByAccountId === 'number' ? save.SavedByAccountId : 0
|
||||
return c.json({
|
||||
unityAssetId: asset.unityAssetId,
|
||||
createdByAccountId: asset.createdByAccountId || savedBy,
|
||||
bakedUnityAssets: mains.map((file) => ({
|
||||
unityAssetId: file.unityAssetId,
|
||||
target: studioAssetTarget(file.platform),
|
||||
version: STUDIO_ASSET_VERSION,
|
||||
filename: file.filename,
|
||||
})),
|
||||
filename: source.filename,
|
||||
hash: sha256HexToBase64(source.sha256),
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
@@ -3639,6 +3778,7 @@ const app = new Hono<App>()
|
||||
// `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)
|
||||
await attachStudioUnityAssets(c.env.DB, [result.save])
|
||||
return c.json({
|
||||
success: true,
|
||||
error: null,
|
||||
@@ -4295,8 +4435,9 @@ const app = new Hono<App>()
|
||||
(c) => c.json([])
|
||||
)
|
||||
|
||||
// Single room by id. 404 when the room isn't in D1. Ignores the
|
||||
// include/unityAsset* query params.
|
||||
// Single room by id. 404 when the room isn't in D1. A Studio save's
|
||||
// `CurrentSave.UnitySubAssets` lists the baked bundles; `unityAssetTarget`
|
||||
// narrows that list to the platform the caller asked for.
|
||||
.get(
|
||||
'/rooms/:roomId{[0-9]+}',
|
||||
describeRoute({
|
||||
@@ -4304,20 +4445,29 @@ const app = new Hono<App>()
|
||||
summary: 'A room by id',
|
||||
description: [
|
||||
'The room as stored, with its `SubRooms` re-attached. Unlike `GET /rooms?id=`, an',
|
||||
'unknown room here is a 404, not `{}`. The `include`/`unityAsset*` query params the',
|
||||
'client sends are accepted and ignored.',
|
||||
'unknown room here is a 404, not `{}`. A save that has a Rec Room Studio build',
|
||||
'lists its main bundles on `CurrentSave.UnitySubAssets` (`Target` 0 Windows, 2',
|
||||
'Android/Quest, `Filename` downloaded from the CDN at `/unityasset/{filename}`).',
|
||||
'A maker-pen save keeps those arrays empty. An integer `unityAssetTarget` leaves',
|
||||
'only the matching target, so a PC client is not also handed the Android bundle.',
|
||||
'`include` and `unityAssetVersion` are accepted and ignored.',
|
||||
].join(' '),
|
||||
parameters: [
|
||||
roomIdParam,
|
||||
stringQuery('include', 'Accepted and ignored'),
|
||||
stringQuery('unityAssetTarget', 'Accepted and ignored'),
|
||||
stringQuery(
|
||||
'unityAssetTarget',
|
||||
'When an integer, each current save’s `UnitySubAssets` keeps only that target (0 Windows, 2 Android/Quest)'
|
||||
),
|
||||
stringQuery('unityAssetVersion', 'Accepted and ignored'),
|
||||
],
|
||||
responses: { 200: json(RoomDto, 'The room'), 404: { description: 'No such room' } },
|
||||
}),
|
||||
async (c) => {
|
||||
const room = await getRoomById(c.env.DB, Number.parseInt(c.req.param('roomId'), 10))
|
||||
return room ? c.json(room) : c.notFound()
|
||||
if (!room) return c.notFound()
|
||||
filterRoomUnityAssets(room, unityAssetTargetQuery(c))
|
||||
return c.json(room)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -17,10 +17,13 @@ import {
|
||||
MessageType,
|
||||
NOTIFICATION_SCHEMA_DDL,
|
||||
PRESENCE_SCHEMA_DDL,
|
||||
publicStudioBundleFilename,
|
||||
ROOM_INSTANCE_SCHEMA_DDL,
|
||||
ROOM_INVITE_SCHEMA_DDL,
|
||||
ROOM_SCHEMA_DDL,
|
||||
seedRoomWithSubRooms,
|
||||
sha256HexToBase64,
|
||||
STUDIO_UNITY_ASSET_SCHEMA_DDL,
|
||||
SUBROOM_SCHEMA_DDL,
|
||||
} from '@repo/domain'
|
||||
|
||||
@@ -5529,6 +5532,7 @@ describe('rooms endpoints', () => {
|
||||
'GET /rooms/{roomId}/subrooms/{subRoomId}/saves',
|
||||
'GET /rooms/{roomId}/subrooms/{subRoomId}/saves/no_unity_assets',
|
||||
'GET /rooms/{roomId}/subrooms/{subRoomId}/saves/{saveId}',
|
||||
'GET /rooms/{roomId}/subrooms/{subRoomId}/unityasset',
|
||||
'GET /roomserver/rooms/createdby/me',
|
||||
'GET /showcase/{playerId}',
|
||||
'POST /rooms/bulk',
|
||||
@@ -5569,6 +5573,222 @@ describe('rooms endpoints', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// A Rec Room Studio build is a unity asset on the save. The game loads the scene from
|
||||
// `CurrentSave.DataBlob` and the bundles from `UnitySubAssets`. Maker-pen saves stay
|
||||
// empty arrays, and a missing studio table must not fail the room read.
|
||||
describe('studio room bundles', () => {
|
||||
const ASSET = '11111111-2222-4333-8444-555555555555'
|
||||
const WIN_HEX = 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855'
|
||||
const ANDROID_HEX = 'ca978112ca1bbdcafac231b39a23dc4da786eff8147c4e72b9807785afee48bb'
|
||||
const winName = publicStudioBundleFilename(ASSET, 'windows', 'main')
|
||||
const androidName = publicStudioBundleFilename(ASSET, 'android', 'main')
|
||||
const winHash = sha256HexToBase64(WIN_HEX)
|
||||
const androidHash = sha256HexToBase64(ANDROID_HEX)
|
||||
|
||||
const save = (unityAssetId?: string) => ({
|
||||
UnitySubAssets: [],
|
||||
ReferencedUnityAssets: [],
|
||||
DataBlob: 'studio-scene-blob',
|
||||
DataBlobHash: null,
|
||||
ReferencedUnityAssetIds: [],
|
||||
PersistenceVersion: 1,
|
||||
OMVersion: 0,
|
||||
UgcSubVersion: 0,
|
||||
SavedByAccountId: 7,
|
||||
SavedOnPlatform: 0,
|
||||
SavedOnDeviceClass: 0,
|
||||
Description: '',
|
||||
Tags: [],
|
||||
ModerationState: 0,
|
||||
CreatedAt: '2026-10-03T00:00:00.000Z',
|
||||
...(unityAssetId ? { UnityAssetId: unityAssetId } : {}),
|
||||
})
|
||||
|
||||
async function reset() {
|
||||
await env.DB.prepare('DROP TABLE IF EXISTS studio_unity_asset_file').run()
|
||||
await env.DB.prepare('DROP TABLE IF EXISTS studio_cloud_build').run()
|
||||
await env.DB.prepare('DELETE FROM subroom_save WHERE sub_room_id IN (99011, 99021)').run()
|
||||
await env.DB.prepare('DELETE FROM subroom WHERE room_id IN (9901, 9902)').run()
|
||||
await env.DB.prepare('DELETE FROM room WHERE room_id IN (9901, 9902)').run()
|
||||
await seedRoomWithSubRooms(env.DB, {
|
||||
RoomId: 9901,
|
||||
Name: 'StudioLoadRoom',
|
||||
CreatorAccountId: 1,
|
||||
Accessibility: 1,
|
||||
IsDorm: false,
|
||||
SubRooms: [
|
||||
{
|
||||
SubRoomId: 99011,
|
||||
Name: 'Main',
|
||||
UnitySceneId: '76d98498-60a1-430c-ab76-b54a29b7a163',
|
||||
MaxPlayers: 20,
|
||||
Accessibility: 1,
|
||||
CurrentSave: save(ASSET),
|
||||
},
|
||||
],
|
||||
})
|
||||
await seedRoomWithSubRooms(env.DB, {
|
||||
RoomId: 9902,
|
||||
Name: 'MakerPenRoom',
|
||||
CreatorAccountId: 1,
|
||||
Accessibility: 1,
|
||||
IsDorm: false,
|
||||
SubRooms: [
|
||||
{
|
||||
SubRoomId: 99021,
|
||||
Name: 'Sandbox',
|
||||
UnitySceneId: '76d98498-60a1-430c-ab76-b54a29b7a163',
|
||||
MaxPlayers: 20,
|
||||
Accessibility: 1,
|
||||
CurrentSave: save(),
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
async function storeFiles() {
|
||||
for (const sql of STUDIO_UNITY_ASSET_SCHEMA_DDL) await env.DB.prepare(sql).run()
|
||||
await env.DB.prepare(
|
||||
`INSERT INTO studio_cloud_build
|
||||
(cloud_build_id, room_id, sub_room_id, sub_room_data_save_id, unity_asset_id,
|
||||
created_by_account_id, started_at, completed_at, error)
|
||||
VALUES ('build-1', 9901, 99011, 0, ?1, 42, '2026-10-03T00:00:00.000Z',
|
||||
'2026-10-03T00:00:00.000Z', NULL)`
|
||||
)
|
||||
.bind(ASSET)
|
||||
.run()
|
||||
const insert = async (platform: string, kind: string, filename: string, sha256: string) => {
|
||||
await env.DB.prepare(
|
||||
`INSERT INTO studio_unity_asset_file
|
||||
(unity_asset_id, platform, kind, filename, sha256, byte_length, r2_key)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, 4, ?6)`
|
||||
)
|
||||
.bind(ASSET, platform, kind, filename, sha256, `studio-room-bundles/${filename}`)
|
||||
.run()
|
||||
}
|
||||
await insert('windows', 'main', winName, WIN_HEX)
|
||||
await insert('android', 'main', androidName, ANDROID_HEX)
|
||||
await insert(
|
||||
'windows',
|
||||
'stripped',
|
||||
publicStudioBundleFilename(ASSET, 'windows', 'stripped'),
|
||||
WIN_HEX
|
||||
)
|
||||
}
|
||||
|
||||
it('loads baked bundles onto the room and serves the unity asset', async () => {
|
||||
await reset()
|
||||
|
||||
const before = (await (await SELF.fetch(`${ORIGIN}/rooms/9901`)).json()) as {
|
||||
SubRooms: Array<{ CurrentSave: { UnitySubAssets: unknown[] } }>
|
||||
}
|
||||
expect(before.SubRooms[0]!.CurrentSave.UnitySubAssets).toEqual([])
|
||||
|
||||
await storeFiles()
|
||||
|
||||
type Baked = { Target: number; Filename: string; Hash: string; Version: number }
|
||||
const loaded = (await (await SELF.fetch(`${ORIGIN}/rooms/9901`)).json()) as {
|
||||
SubRooms: Array<{
|
||||
CurrentSave: {
|
||||
UnitySubAssets: Baked[]
|
||||
ReferencedUnityAssets: unknown[]
|
||||
SubRoomDataSaveId: number
|
||||
}
|
||||
}>
|
||||
}
|
||||
const current = loaded.SubRooms[0]!.CurrentSave
|
||||
expect(current.ReferencedUnityAssets).toEqual([])
|
||||
expect(current.UnitySubAssets).toEqual([
|
||||
{
|
||||
UnityAssetId: ASSET,
|
||||
Target: 0,
|
||||
Version: 1,
|
||||
Filename: winName,
|
||||
Hash: winHash,
|
||||
},
|
||||
{
|
||||
UnityAssetId: ASSET,
|
||||
Target: 2,
|
||||
Version: 1,
|
||||
Filename: androidName,
|
||||
Hash: androidHash,
|
||||
},
|
||||
])
|
||||
|
||||
const quest = (await (
|
||||
await SELF.fetch(`${ORIGIN}/rooms/9901?unityAssetTarget=2`)
|
||||
).json()) as typeof loaded
|
||||
expect(quest.SubRooms[0]!.CurrentSave.UnitySubAssets.map((asset) => asset.Target)).toEqual([2])
|
||||
|
||||
const pc = (await (
|
||||
await SELF.fetch(`${ORIGIN}/rooms/9901?unityAssetTarget=0`)
|
||||
).json()) as typeof loaded
|
||||
expect(pc.SubRooms[0]!.CurrentSave.UnitySubAssets.map((asset) => asset.Filename)).toEqual([
|
||||
winName,
|
||||
])
|
||||
|
||||
const maker = (await (await SELF.fetch(`${ORIGIN}/rooms/9902`)).json()) as typeof loaded
|
||||
expect(maker.SubRooms[0]!.CurrentSave.UnitySubAssets).toEqual([])
|
||||
|
||||
const meta = await SELF.fetch(
|
||||
`${ORIGIN}/rooms/9901/subrooms/99011/unityasset?unityAssetId=${ASSET}`
|
||||
)
|
||||
expect(meta.status).toBe(200)
|
||||
expect(await meta.json()).toEqual({
|
||||
unityAssetId: ASSET,
|
||||
createdByAccountId: 42,
|
||||
bakedUnityAssets: [
|
||||
{ unityAssetId: ASSET, target: 0, version: 1, filename: winName },
|
||||
{ unityAssetId: ASSET, target: 2, version: 1, filename: androidName },
|
||||
],
|
||||
filename: winName,
|
||||
hash: winHash,
|
||||
})
|
||||
expect((await SELF.fetch(`${ORIGIN}/rooms/9901/subrooms/99011/unityasset`)).status).toBe(404)
|
||||
expect(
|
||||
(
|
||||
await SELF.fetch(
|
||||
`${ORIGIN}/rooms/9901/subrooms/99011/unityasset?unityAssetId=00000000-0000-4000-8000-000000000000`
|
||||
)
|
||||
).status
|
||||
).toBe(404)
|
||||
expect(
|
||||
(await SELF.fetch(`${ORIGIN}/rooms/9902/subrooms/99021/unityasset?unityAssetId=${ASSET}`))
|
||||
.status
|
||||
).toBe(404)
|
||||
|
||||
const detail = await SELF.fetch(
|
||||
`${ORIGIN}/rooms/9901/subrooms/99011/saves/${current.SubRoomDataSaveId}`,
|
||||
{ headers: await bearer('1') }
|
||||
)
|
||||
expect(detail.status).toBe(200)
|
||||
expect(await detail.json()).toMatchObject({ unityAsset: winName, unityAssetHash: winHash })
|
||||
|
||||
const questDetail = await SELF.fetch(
|
||||
`${ORIGIN}/rooms/9901/subrooms/99011/saves/${current.SubRoomDataSaveId}?unityAssetTarget=2`,
|
||||
{ headers: await bearer('1') }
|
||||
)
|
||||
expect(await questDetail.json()).toMatchObject({
|
||||
unityAsset: androidName,
|
||||
unityAssetHash: androidHash,
|
||||
})
|
||||
|
||||
const light = await SELF.fetch(`${ORIGIN}/rooms/9901/subrooms/99011/saves/no_unity_assets`, {
|
||||
headers: await bearer('1'),
|
||||
})
|
||||
const page = (await light.json()) as { Results: Array<Record<string, unknown>> }
|
||||
expect(page.Results[0]).not.toHaveProperty('UnitySubAssets')
|
||||
expect(page.Results[0]).not.toHaveProperty('ReferencedUnityAssets')
|
||||
|
||||
const makerDetailId = maker.SubRooms[0]!.CurrentSave.SubRoomDataSaveId
|
||||
const makerDetail = await SELF.fetch(
|
||||
`${ORIGIN}/rooms/9902/subrooms/99021/saves/${makerDetailId}`,
|
||||
{ headers: await bearer('1') }
|
||||
)
|
||||
expect(await makerDetail.json()).toMatchObject({ unityAsset: null, unityAssetHash: null })
|
||||
})
|
||||
})
|
||||
|
||||
// Room and subroom names take letters, digits and underscores, at most 32 (see
|
||||
// `roomNameRejection` in @repo/domain — usernames are held to the narrower rule, with no
|
||||
// underscore). All four routes that take a player-supplied name enforce it, and each
|
||||
|
||||
Reference in New Issue
Block a user