[www] subroom data

This commit is contained in:
Devin Zuczek
2026-08-10 14:10:20 -04:00
parent 30cf83a47d
commit e564d3c839
5 changed files with 179 additions and 9 deletions
+137 -9
View File
@@ -33,6 +33,7 @@ interface Hosts {
img: string
notify: string
rooms: string
cdn: string
}
/**
@@ -67,12 +68,12 @@ interface SelfAccount {
interface SubRoom {
SubRoomId: number
Name: string
/** The Unity scene the client loads under the saved objects. */
UnitySceneId: string
/** Set INDEPENDENTLY of the room's — a public room can hold a private subroom. */
Accessibility: number
IsSandbox: boolean
MaxPlayers: number
/** The subroom's scene-data key, served back by `cdn` under `room/`. */
DataBlob?: string
/**
* A save posted without `AutoPublish` waits here. Cleared when that save is
* published, so a non-null value means "edited since players last saw a change".
@@ -83,7 +84,17 @@ interface SubRoom {
* one silently loads nothing, which is worth surfacing to an owner who can't tell
* that apart from a broken room.
*/
CurrentSave: { SubRoomDataSaveId: number; CreatedAt: string; Description: string } | null
CurrentSave: {
SubRoomDataSaveId: number
CreatedAt: string
Description: string
/**
* The scene-data key for the published save — what the client downloads to load
* the place, and the file worth keeping a copy of. `subRoomDataBlob()` resolves
* this one first, ahead of the subroom's own.
*/
DataBlob: string
} | null
}
/**
@@ -1010,7 +1021,7 @@ function RoomPage({
// they have no business asking.
<p className="muted">That isn&apos;t one of your rooms.</p>
) : (
<RoomDetail room={room} imgHost={where().img} />
<RoomDetail room={room} imgHost={where().img} cdnHost={where().cdn} />
)}
</main>
)
@@ -1029,7 +1040,15 @@ function platformList(room: OwnedRoom): string[] {
}
/** A room's settings and its subrooms. Read-only: rooms are edited in game. */
function RoomDetail({ room, imgHost }: { room: OwnedRoom; imgHost: string }) {
function RoomDetail({
room,
imgHost,
cdnHost,
}: {
room: OwnedRoom
imgHost: string
cdnHost: string
}) {
const created = new Date(room.CreatedAt)
const platforms = platformList(room)
const subRooms = room.SubRooms ?? []
@@ -1095,7 +1114,7 @@ function RoomDetail({ room, imgHost }: { room: OwnedRoom; imgHost: string }) {
) : (
<ul className="subrooms">
{subRooms.map((sub) => (
<SubRoomRow key={sub.SubRoomId} sub={sub} />
<SubRoomRow key={sub.SubRoomId} sub={sub} roomName={room.Name} cdnHost={cdnHost} />
))}
</ul>
)}
@@ -1105,23 +1124,31 @@ function RoomDetail({ room, imgHost }: { room: OwnedRoom; imgHost: string }) {
}
/** One subroom: what it is, and — the part an owner can't see anywhere else — its save. */
function SubRoomRow({ sub }: { sub: SubRoom }) {
function SubRoomRow({
sub,
roomName,
cdnHost,
}: {
sub: SubRoom
roomName: string
cdnHost: string
}) {
const save = sub.CurrentSave ?? null
const saved = save ? new Date(save.CreatedAt) : null
// Cleared when that save is published (see publishSubRoomSave), so a value here always
// means work the owner saved but players still can't see.
const staged = sub.StagedSubRoomDataSaveId !== null && sub.StagedSubRoomDataSaveId !== undefined
const name = sub.Name || `Subroom ${sub.SubRoomId}`
return (
<li className="subroom">
<div className="room-head">
<span className="subroom-name">{sub.Name || `Subroom ${sub.SubRoomId}`}</span>
<span className="subroom-name">{name}</span>
<VisibilityBadge accessibility={sub.Accessibility} />
{sub.IsSandbox && <span className="badge">Sandbox</span>}
</div>
<p className="subroom-meta">
#{sub.SubRoomId} · up to {sub.MaxPlayers} players
{sub.UnitySceneId && ` · scene ${sub.UnitySceneId}`}
</p>
<p className="subroom-save">
{save === null ? (
@@ -1139,10 +1166,111 @@ function SubRoomRow({ sub }: { sub: SubRoom }) {
<span className="warn"> · a newer save is staged, waiting to be published.</span>
)}
</p>
{/* The published save's blob first: that's the copy of the room worth keeping,
and the one the client resolves ahead of the subroom's own key. */}
{save?.DataBlob && (
<BlobDownload
label="Save DataBlob"
blobKey={save.DataBlob}
filename={safeFilename(roomName, name, `save-${save.SubRoomDataSaveId}`)}
cdnHost={cdnHost}
/>
)}
{sub.DataBlob && (
<BlobDownload
label="Subroom DataBlob"
blobKey={sub.DataBlob}
filename={safeFilename(roomName, name, 'datablob')}
cdnHost={cdnHost}
/>
)}
</li>
)
}
/**
* A download filename built from player-supplied names, with everything that isn't a
* word character, dot or dash flattened to a dash — a subroom can be called anything,
* and that string is about to become a path on someone's disk.
*/
const safeFilename = (...parts: string[]): string =>
`${parts.join('-').replace(/[^\w.-]+/g, '-')}.bin`
/**
* One scene-data blob: the key, and a link that downloads it from `cdn`.
*
* `href` is the real CDN URL, so open-in-new-tab and right-click → Save As work like any
* other link. The click is intercepted only to give the file a NAME: blobs are stored
* under a date-foldered UUID, so three downloads otherwise land as three
* indistinguishable extensionless files. The `download` attribute can't do that on its
* own — browsers ignore it cross-origin, and `cdn` is always a different origin from the
* website — hence fetching the bytes and saving them through an object URL.
*/
function BlobDownload({
label,
blobKey,
filename,
cdnHost,
}: {
label: string
blobKey: string
filename: string
cdnHost: string
}) {
// Room build data is served under `room/` — the same prefix the storage worker
// uploads it to, and the one the game downloads it from.
const url = `${cdnHost}/room/${blobKey}`
const [pending, setPending] = useState(false)
const [error, setError] = useState('')
const download = async () => {
setPending(true)
setError('')
try {
const res = await fetch(url)
// The blob key is stored on the subroom, so a miss here means the object is gone
// from the bucket — worth saying, rather than saving a file of the 404 body.
if (!res.ok) throw new Error(`the CDN answered ${res.status}`)
const href = URL.createObjectURL(await res.blob())
const link = document.createElement('a')
link.href = href
link.download = filename
link.click()
// The click is dispatched synchronously but the save reads the URL after this
// frame, so the revoke waits a tick rather than pulling it out from under.
setTimeout(() => URL.revokeObjectURL(href), 0)
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally {
setPending(false)
}
}
return (
<div className="blob">
<span className="blob-label">{label}</span>
<a
className="blob-key"
href={url}
download={filename}
onClick={(e) => {
e.preventDefault()
void download()
}}
>
{blobKey}
</a>
{/* Only rendered when it has something to say — an empty span would still take a
gap from the flex row, leaving the key trailed by a stray space. */}
{pending ? (
<span className="blob-note">Downloading</span>
) : error ? (
<span className="blob-note error">Couldnt download {error}.</span>
) : null}
</div>
)
}
/** How a room or subroom's `Accessibility` reads on screen. */
const accessibilityLabel = (accessibility: number): string =>
// Unknown ordinals shouldn't happen, but this label is the only thing telling an owner
+38
View File
@@ -835,6 +835,44 @@ h2 {
color: var(--accent);
}
/*
* A scene-data blob and the link that downloads it. The KEY is the link, rather than a
* "Download" button beside it: the key is the thing an owner came to find (it's what
* `CurrentSave.DataBlob` holds, and what a bug report quotes), so making it the target
* keeps it readable and clickable without a second control competing for the row.
*/
.blob {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 4px 10px;
margin-top: 8px;
font-size: 0.8rem;
}
.blob-label {
flex: none;
color: var(--muted);
}
/* Blob keys are `<date>/<uuid>` — long, and with only two places a line could break.
`anywhere` lets one wrap mid-key instead of pushing the card sideways. */
.blob-key {
color: var(--accent);
text-decoration: none;
overflow-wrap: anywhere;
min-width: 0;
}
.blob-key:hover {
text-decoration: underline;
}
.blob-note {
color: var(--muted);
overflow-wrap: anywhere;
}
/* ---- Forms -------------------------------------------------------------- */
label {
@@ -49,6 +49,7 @@ it('advertises signup and where the other workers live', async () => {
img: 'https://img.rec.example.com',
notify: 'https://notify.rec.example.com',
rooms: 'https://rooms.rec.example.com',
cdn: 'https://cdn.rec.example.com',
},
})
})
+1
View File
@@ -16,6 +16,7 @@ export const notifyBase = (env: Env): string => `https://notify.${env.DOMAIN}`
export const apiBase = (env: Env): string => `https://api.${env.DOMAIN}`
export const imgBase = (env: Env): string => `https://img.${env.DOMAIN}`
export const roomsBase = (env: Env): string => `https://rooms.${env.DOMAIN}`
export const cdnBase = (env: Env): string => `https://cdn.${env.DOMAIN}`
/**
* POST a form body to the `auth` worker, carrying the browser's real IP across.
+2
View File
@@ -12,6 +12,7 @@ import {
accountsBase,
apiBase,
authBase,
cdnBase,
imgBase,
notifyBase,
postAuthForm,
@@ -69,6 +70,7 @@ const app = new Hono<App>()
img: imgBase(c.env),
notify: notifyBase(c.env),
rooms: roomsBase(c.env),
cdn: cdnBase(c.env),
},
})
})