diff --git a/demos/lane-j.html b/demos/lane-j.html
index 27b11cb..0365baf 100644
--- a/demos/lane-j.html
+++ b/demos/lane-j.html
@@ -31,9 +31,13 @@
.slot-state{font-size:11px;padding:2px 7px;border-radius:20px;background:#eef3f8;color:var(--ink-soft)}
.slot[data-state="custom"] .slot-state{background:#e6f8ea;color:#1c7a35}
.slot[data-state="derived"] .slot-state{background:#f3ecff;color:#6b3fa0}
+ .slot[data-state="broken"] .slot-state{background:#fff1ef;color:#a3271d}
+ .readonly-banner{margin:18px 0 0;padding:11px 12px;border-radius:10px;
+ border:1px solid #f0d9a8;background:#fff8ea;color:#7a5200;font-size:12.5px}
.big-actions{display:flex;flex-direction:column;gap:8px;margin:22px 0 12px}
.action{padding:12px;border:1px solid var(--line);border-radius:11px;background:#fff;cursor:pointer;font-weight:600}
+ .action:disabled{opacity:.45;cursor:not-allowed}
.action.primary{background:var(--pick);border-color:var(--pick);color:#fff}
.action.danger{color:var(--bad);border-color:#f6d2cf}
.status{margin:0;padding:10px 12px;border-radius:10px;background:#eef3f8;font-size:13px;color:var(--ink-soft)}
diff --git a/editor.html b/editor.html
index d348cc2..aea1968 100644
--- a/editor.html
+++ b/editor.html
@@ -38,11 +38,20 @@
color:var(--ink-soft);white-space:nowrap}
.slot[data-state="custom"] .slot-state{background:#e6f8ea;color:#1c7a35}
.slot[data-state="derived"] .slot-state{background:#f3ecff;color:#6b3fa0}
+ .slot[data-state="broken"] .slot-state{background:#fff1ef;color:#a3271d}
+ .slot[data-state="broken"]{border-color:#f6d2cf}
+ .slot[data-state="absent"]{opacity:.55}
+ .slot[data-state="absent"] .slot-state{background:#eef3f8;color:#7b8ca0}
+
+ .readonly-banner{margin:18px 0 0;padding:11px 12px;border-radius:10px;
+ border:1px solid #f0d9a8;background:#fff8ea;color:#7a5200;font-size:12.5px}
.big-actions{display:flex;flex-direction:column;gap:8px;margin:22px 0 12px}
.action{padding:12px;border:1px solid var(--line);border-radius:11px;background:#fff;
cursor:pointer;font-weight:600}
.action:hover{border-color:var(--pick)}
+ .action:disabled{opacity:.45;cursor:not-allowed}
+ .action:disabled:hover{border-color:var(--line)}
.action.primary{background:var(--pick);border-color:var(--pick);color:#fff}
.action.danger{color:var(--bad);border-color:#f6d2cf}
.status{margin:0;padding:10px 12px;border-radius:10px;background:#eef3f8;
diff --git a/src/demo/lane-j.ts b/src/demo/lane-j.ts
index 9f33e71..1eb3a96 100644
--- a/src/demo/lane-j.ts
+++ b/src/demo/lane-j.ts
@@ -7,6 +7,18 @@
* fit controls move it, the export round-trips, reset restores the procedural
* build, and Clear leaves nothing behind.
*
+ * THE DEMO IS A GUEST, NOT AN OWNER. It used to run against the same IndexedDB
+ * database as the real editor, which meant opening this page deleted whatever
+ * the user had saved (`pruneOrphans` treats every blob key the demo's manifest
+ * does not mention as garbage), and closing the tab mid-run left a fake
+ * `machine.boot` override that the LIVE game would then load. So:
+ *
+ * - everything runs against a throwaway database, DEMO_DB;
+ * - the real database is fingerprinted before and after and the run FAILS if
+ * one byte of it moved;
+ * - the throwaway database is deleted in a `finally`, so a crash or a reload
+ * mid-run leaves nothing behind either.
+ *
* Asset note: `assets/meshes/*.glb` is NOT in the vite build graph today
* (nothing copies `assets/` into dist/), so the real boot GLB is fetched
* best-effort and a generated stand-in GLB is used when it 404s. Which one ran
@@ -18,9 +30,14 @@ import {
} from '../editor/manifest-io'
import { buildTinyGlb } from '../editor/tiny-glb'
import { getGlb, loadOverrideManifest } from '../editor/store'
+import {
+ BLOB_STORE, DEFAULT_DB_NAME, deleteDb, idbKeys, setDbName,
+} from '../editor/idb'
const SLOT = 'machine.boot'
const BOOT_URL = import.meta.env.BASE_URL + 'assets/meshes/prop-spring-boot.glb'
+/** Never 'blobbo-workshop'. That one belongs to the user. */
+const DEMO_DB = 'blobbo-workshop-demo'
const out = document.getElementById('results')!
let passed = 0
@@ -60,10 +77,49 @@ async function fetchBootGlb(): Promise<{ name: string; bytes: ArrayBuffer; real:
return { name: 'prop-spring-boot.glb', bytes: buildTinyGlb({ half: 1.1 }), real: false }
}
+/**
+ * Everything about the user's real store that this demo could possibly disturb:
+ * the saved manifest and the list of stored files. Read-only. (Reading does open
+ * the database, which creates it empty if absent — an empty store and a missing
+ * store are the same thing to both the editor and the game.)
+ */
+async function fingerprintRealDb(): Promise {
+ setDbName(DEFAULT_DB_NAME)
+ try {
+ const manifest = await loadOverrideManifest()
+ const keys = (await idbKeys(BLOB_STORE)).map(String).sort()
+ return JSON.stringify({ manifest, keys })
+ } catch (err) {
+ return `unreadable:${String(err)}`
+ }
+}
+
async function run(): Promise {
+ const before = await fingerprintRealDb()
+ note(`Real store fingerprinted before the run (${before.length} chars). ` +
+ `Everything below happens in "${DEMO_DB}".`)
+ setDbName(DEMO_DB)
+
+ try {
+ await runChecks()
+ } finally {
+ // Runs on the crash path too. Without it, an assertion failure between the
+ // save and the clear left a persisted override behind.
+ setDbName(DEMO_DB)
+ await deleteDb(DEMO_DB)
+ const after = await fingerprintRealDb()
+ check(after === before,
+ "the user's own saved swaps were not touched by this demo",
+ after === before ? 'byte-identical before and after' : 'THE REAL STORE CHANGED')
+ }
+}
+
+async function runChecks(): Promise {
const editor = await bootEditor(document.getElementById('workshop')!)
const { stage, workshop } = editor
+ check(editor.canSave(), 'this browser can store models (otherwise the demo is read-only)')
+
// ---- a clean session must be all-procedural ------------------------------
check(stage.slots.has(SLOT), 'the spring boot slot exists on the stage')
check(!workshop.hasCustom(SLOT), 'nothing is swapped before the drop')
@@ -86,6 +142,9 @@ async function run(): Promise {
'the dropped model is actually inside the wrapper')
check(entryRef.procedural.every((p) => !p.visible),
'the procedural boot is hidden (swapped, not stacked)')
+ check(entryRef.fitNodes.length === entryRef.procedural.length,
+ 'every original this slot holds got its own replacement',
+ `${entryRef.procedural.length} original(s) → ${entryRef.fitNodes.length} replacement(s)`)
check(entryRef.procedural[0]!.children.length === proceduralChildCount,
'the procedural boot was hidden, not destroyed (reset can restore it)')
check(isIdbUrl(result.entry.url), 'the manifest entry points at browser storage',
@@ -96,6 +155,13 @@ async function run(): Promise {
check(stage.currentObject(SLOT) === entryRef.fitNode,
'the fit panel now acts on the dropped model')
+ // ---- a file with no model in it is refused, on a slot that isn't paintable
+ const empty = await editor.dropFile(SLOT, 'empty.glb', buildTinyGlb({ withMesh: false }))
+ check(empty.rejected, 'a .glb with no model inside is refused', empty.message)
+ check(/no model inside/i.test(empty.message), 'the refusal is explained in plain words')
+ check(entryRef.fitNode !== null && workshop.entry(SLOT)?.url === result.entry.url,
+ 'the refused file left the previous good swap exactly as it was')
+
// Where the swapped boot sits before any nudging.
const basePos = entryRef.fitNode!.position.clone()
@@ -108,6 +174,29 @@ async function run(): Promise {
check(Math.abs(moved.rotation.y - Math.PI / 2) < 1e-6, 'turning it applies the rotation')
check(Math.abs(moved.scale.x - 2) < 1e-6, 'resizing it applies the scale')
+ // ---- multi-instance slots keep every object ------------------------------
+ // fx.puddle is nine separate patches. Replacing one and hiding all nine is
+ // what used to happen; the count is asserted rather than assumed.
+ const puddles = stage.slots.get('fx.puddle')
+ if (puddles && puddles.procedural.length > 1) {
+ const n = puddles.procedural.length
+ const puddleDrop = await editor.dropFile('fx.puddle', 'puddle.glb', buildTinyGlb())
+ check(!puddleDrop.rejected, `dropping onto the ${n} paint puddles was accepted`,
+ puddleDrop.message)
+ check(puddles.fitNodes.length === n,
+ `all ${n} puddles were replaced, not just one`,
+ `${puddles.fitNodes.length} replacements for ${n} puddles`)
+ const distinct = new Set(puddles.fitNodes.map((f) =>
+ `${f.position.x.toFixed(3)},${f.position.z.toFixed(3)}`))
+ check(distinct.size === n, 'each replacement sits where its own puddle was',
+ `${distinct.size} distinct positions`)
+ workshop.reset('fx.puddle')
+ check(puddles.procedural.every((p) => p.visible) && puddles.fitNodes.length === 0,
+ 'resetting the puddles brings all of them back')
+ } else {
+ note('No multi-instance puddle slot on this stage — skipped the multi-instance check.')
+ }
+
// ---- export round-trips --------------------------------------------------
const text = editor.exportManifest()
const reparsed = parseManifest(text)
@@ -140,17 +229,21 @@ async function run(): Promise {
await editor.clearAll()
const afterClear = await loadOverrideManifest()
check(Object.keys(afterClear).length === 0, 'Clear removes the saved override manifest')
+ ;(window as unknown as { LANE_J: unknown }).LANE_J = { editor, passed, failed }
+}
+function summarise(): void {
const summary = document.createElement('div')
summary.className = failed === 0 ? 'summary pass' : 'summary fail'
summary.textContent = failed === 0
? `All ${passed} checks passed ✓`
: `${failed} of ${passed + failed} checks FAILED`
out.append(summary)
- ;(window as unknown as { LANE_J: unknown }).LANE_J = { editor, passed, failed }
}
-void run().catch((err) => {
- console.error(err)
- check(false, 'the demo crashed', String(err))
-})
+void run()
+ .catch((err: unknown) => {
+ console.error(err)
+ check(false, 'the demo crashed', String(err))
+ })
+ .finally(summarise)
diff --git a/src/editor/demo-isolation.test.ts b/src/editor/demo-isolation.test.ts
new file mode 100644
index 0000000..2080230
--- /dev/null
+++ b/src/editor/demo-isolation.test.ts
@@ -0,0 +1,126 @@
+/**
+ * Lane J — the demo cannot touch the user's work.
+ *
+ * demos/lane-j.html used to run against the SAME database as the real editor.
+ * Its scripted `save()` handed `pruneOrphans` a manifest containing only the
+ * demo's own slot, and pruneOrphans deletes every stored file the manifest given
+ * to it does not mention — so opening the demo deleted every custom model the
+ * user had saved, and a run interrupted between save and clear left a fake
+ * override that the LIVE game would then load.
+ *
+ * This reproduces both, against the real store/idb code, with the database name
+ * being the only thing that changed.
+ *
+ * node --experimental-strip-types --import ./src/editor/node-ts-resolve.mjs \
+ * src/editor/demo-isolation.test.ts
+ */
+export {} // everything is loaded with await import(), so say "module" explicitly
+
+type Store = Map
+const dbs = new Map>()
+
+const fakeIndexedDb = {
+ open(name: string) {
+ const req: Record = {
+ result: null, onsuccess: null, onerror: null, onupgradeneeded: null, onblocked: null,
+ }
+ const stores = dbs.get(name) ?? new Map()
+ dbs.set(name, stores)
+ const db = {
+ objectStoreNames: { contains: (s: string) => stores.has(s) },
+ createObjectStore: (s: string) => void stores.set(s, new Map()),
+ transaction: (s: string) => ({
+ objectStore: () => {
+ const store = stores.get(s)!
+ const wrap = (result: T): Record => {
+ const r: Record = { result, onsuccess: null, onerror: null }
+ queueMicrotask(() => void (r.onsuccess as (() => void) | null)?.())
+ return r
+ }
+ return {
+ get: (k: string) => wrap(store.get(k)),
+ put: (v: unknown, k: string) => { store.set(k, v); return wrap(undefined) },
+ delete: (k: string) => { store.delete(k); return wrap(undefined) },
+ getAllKeys: () => wrap([...store.keys()]),
+ clear: () => { store.clear(); return wrap(undefined) },
+ }
+ },
+ }),
+ }
+ req.result = db
+ queueMicrotask(() => {
+ ;(req.onupgradeneeded as (() => void) | null)?.()
+ ;(req.onsuccess as (() => void) | null)?.()
+ })
+ return req
+ },
+ deleteDatabase(name: string) {
+ const req: Record = { onsuccess: null, onerror: null, onblocked: null }
+ dbs.delete(name)
+ queueMicrotask(() => void (req.onsuccess as (() => void) | null)?.())
+ return req
+ },
+}
+;(globalThis as unknown as { indexedDB: unknown }).indexedDB = fakeIndexedDb
+
+const {
+ BLOB_STORE, DEFAULT_DB_NAME, deleteDb, idbKeys, setDbName,
+} = await import('./idb')
+const {
+ loadOverrideManifest, saveOverrideManifest, putGlb, pruneOrphans,
+} = await import('./store')
+
+const DEMO_DB = 'blobbo-workshop-demo'
+
+let passed = 0
+function ok(cond: boolean, msg: string): void {
+ if (!cond) throw new Error('FAIL: ' + msg)
+ passed++
+}
+
+const fingerprint = async (): Promise => JSON.stringify({
+ manifest: await loadOverrideManifest(),
+ keys: (await idbKeys(BLOB_STORE)).map(String).sort(),
+})
+
+// ---- the user's real, hard-won session --------------------------------------
+setDbName(DEFAULT_DB_NAME)
+await putGlb('blob.body-my-blob.glb', 'my-blob.glb', new ArrayBuffer(2048))
+await putGlb('machine.bucket-my-bucket.glb', 'my-bucket.glb', new ArrayBuffer(4096))
+await saveOverrideManifest({
+ 'blob.body': { url: 'idb:blob.body-my-blob.glb', scale: 1.2 },
+ 'machine.bucket': { url: 'idb:machine.bucket-my-bucket.glb' },
+})
+const before = await fingerprint()
+ok(Object.keys(JSON.parse(before).manifest).length === 2, 'the user has two saved swaps')
+
+// ---- the demo runs, in its own database -------------------------------------
+setDbName(DEMO_DB)
+await putGlb('machine.boot-prop-spring-boot.glb', 'prop-spring-boot.glb', new ArrayBuffer(512))
+const demoManifest = { 'machine.boot': { url: 'idb:machine.boot-prop-spring-boot.glb' } }
+await saveOverrideManifest(demoManifest)
+// The destructive call: with a shared database this deleted BOTH of the user's
+// files, because neither appears in the demo's manifest.
+const pruned = await pruneOrphans(demoManifest)
+ok(pruned === 0, 'pruning inside the demo database finds nothing of the user\'s to delete')
+ok(Object.keys(await loadOverrideManifest()).length === 1, 'the demo has its own manifest')
+
+// ---- the demo is interrupted, then cleans up in its finally ------------------
+setDbName(DEMO_DB)
+await deleteDb(DEMO_DB)
+setDbName(DEMO_DB)
+ok(Object.keys(await loadOverrideManifest()).length === 0,
+ 'after cleanup the demo leaves no override behind — not even for its own database')
+
+// ---- the user's session is untouched ----------------------------------------
+setDbName(DEFAULT_DB_NAME)
+const after = await fingerprint()
+ok(after === before, 'the real store is byte-identical before and after the demo')
+const m = JSON.parse(after).manifest as Record
+ok(m['blob.body']?.url === 'idb:blob.body-my-blob.glb', 'the custom blob body survived')
+ok(m['machine.bucket']?.url === 'idb:machine.bucket-my-bucket.glb', 'the custom bucket survived')
+ok((JSON.parse(after).keys as string[]).length === 2, 'both stored .glb files survived')
+ok(!(JSON.parse(after).manifest as Record)['machine.boot'],
+ "the demo's fake spring-boot override never reached the game's database")
+
+console.log(`editor demo-isolation.test: ${passed} assertions passed ✓`)
diff --git a/src/editor/hardening.test.ts b/src/editor/hardening.test.ts
new file mode 100644
index 0000000..ca86f8e
--- /dev/null
+++ b/src/editor/hardening.test.ts
@@ -0,0 +1,220 @@
+/**
+ * Lane J — the failure paths, headlessly.
+ *
+ * workshop.test.ts covers the happy path. This one covers what the editor does
+ * when things go wrong, because every case here was once a silent data loss or a
+ * frozen UI:
+ *
+ * [1] a .glb with no model in it, dropped on a slot that isn't the paintable
+ * body — used to hide the original and show nothing;
+ * [2] a slot the game builds nine of — used to hide all nine and add one;
+ * [3] a full/blocked IndexedDB — used to reject into an unhandled promise;
+ * [4] a saved swap whose stored bytes no longer load — used to be dropped from
+ * the manifest, after which the next save deleted the bytes for good;
+ * [5] the slot list agreeing with the runtime's, by construction.
+ *
+ * node --experimental-strip-types --import ./src/editor/node-ts-resolve.mjs \
+ * src/editor/hardening.test.ts
+ */
+import * as THREE from 'three'
+
+// ---- in-memory IndexedDB with a fault switch -------------------------------
+type Store = Map
+const dbs = new Map>()
+/** Flip to make every write fail the way a full disk does. */
+let failWrites = false
+
+const fakeIndexedDb = {
+ open(name: string) {
+ const req: Record = {
+ result: null, onsuccess: null, onerror: null, onupgradeneeded: null, onblocked: null,
+ }
+ const stores = dbs.get(name) ?? new Map()
+ dbs.set(name, stores)
+ const db = {
+ objectStoreNames: { contains: (s: string) => stores.has(s) },
+ createObjectStore: (s: string) => void stores.set(s, new Map()),
+ close: () => {}, // the runtime's reader closes its handle; the editor's pools it
+ transaction: (s: string) => ({
+ objectStore: (_n: string) => {
+ const store = stores.get(s)!
+ const wrap = (result: T, fail = false): Record => {
+ const r: Record = {
+ result, onsuccess: null, onerror: null,
+ error: fail ? new Error('QuotaExceededError') : null,
+ }
+ queueMicrotask(() => {
+ if (fail) (r.onerror as (() => void) | null)?.()
+ else (r.onsuccess as (() => void) | null)?.()
+ })
+ return r
+ }
+ return {
+ get: (k: string) => wrap(store.get(k)),
+ put: (v: unknown, k: string) => {
+ if (failWrites) return wrap(undefined, true)
+ store.set(k, v)
+ return wrap(undefined)
+ },
+ delete: (k: string) => { store.delete(k); return wrap(undefined) },
+ getAllKeys: () => wrap([...store.keys()]),
+ clear: () => { store.clear(); return wrap(undefined) },
+ }
+ },
+ }),
+ }
+ req.result = db
+ queueMicrotask(() => {
+ ;(req.onupgradeneeded as (() => void) | null)?.()
+ ;(req.onsuccess as (() => void) | null)?.()
+ })
+ return req
+ },
+ deleteDatabase(name: string) {
+ const req: Record = { onsuccess: null, onerror: null, onblocked: null }
+ dbs.delete(name)
+ queueMicrotask(() => void (req.onsuccess as (() => void) | null)?.())
+ return req
+ },
+}
+;(globalThis as unknown as { indexedDB: unknown }).indexedDB = fakeIndexedDb
+
+const { Workshop } = await import('./workshop')
+const { SlotSwap } = await import('./slot-swap')
+const { buildTinyGlb } = await import('./tiny-glb')
+const { BLOB_STORE, idbKeys, idbPut } = await import('./idb')
+const { saveOverrideManifest } = await import('./store')
+const { SLOTS, SLOT_IDS } = await import('./slots')
+const runtimeSlots = await import('../assets/slots')
+type EditorStage = import('./stage').EditorStage
+type Entry = import('./manifest-io').SlotEntry
+
+let passed = 0
+function ok(cond: boolean, msg: string): void {
+ if (!cond) throw new Error('FAIL: ' + msg)
+ passed++
+}
+
+const scene = new THREE.Scene()
+const swap = new SlotSwap()
+const makeMesh = (name: string, pos: [number, number, number]): THREE.Mesh => {
+ const mesh = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), new THREE.MeshStandardMaterial())
+ mesh.name = name
+ mesh.position.set(...pos)
+ scene.add(mesh)
+ return mesh
+}
+const stage = {
+ slots: swap.slots,
+ setCustom: (s: string, a: THREE.Object3D, e: Entry) => swap.setCustom(s, a, e),
+ clearCustom: (s: string) => swap.clearCustom(s),
+ applyFit: (s: string, e: Entry) => swap.applyFit(s, e),
+ currentObject: (s: string) => swap.currentObject(s),
+ highlight: () => {},
+ focus: () => {},
+ dispose: () => {},
+} as unknown as EditorStage
+
+// ---- [1] a file with no model in it -----------------------------------------
+const BOOT = 'machine.boot'
+swap.register(BOOT, [makeMesh('boot', [-6, 0.2, -49.5])], scene)
+const workshop = new Workshop(stage)
+const bootSlot = swap.slots.get(BOOT)!
+
+const empty = await workshop.dropFile(BOOT, 'empty.glb', buildTinyGlb({ withMesh: false }))
+ok(empty.rejected, '[1] a .glb with no mesh is refused on a non-paintable slot')
+ok(/no model inside this file/i.test(empty.message), '[1] the refusal says so in plain words')
+ok(bootSlot.procedural.every((p) => p.visible), '[1] the original is still on screen')
+ok(bootSlot.fitNodes.length === 0, '[1] nothing was put in its place')
+ok(!workshop.hasCustom(BOOT), '[1] nothing was written to the manifest')
+ok((await idbKeys(BLOB_STORE)).length === 0, '[1] nothing reached storage either')
+
+// ---- [2] a slot the game builds several of ----------------------------------
+const PUD = 'fx.puddle'
+const puddleMeshes = [
+ makeMesh('p1', [0, 0.05, 10]),
+ makeMesh('p2', [4, 0.05, -12]),
+ makeMesh('p3', [-3, 0.05, -40]),
+]
+swap.register(PUD, puddleMeshes, scene, { extraScale: [[2, 1, 3], [1, 1, 1], [4, 1, 2]] })
+
+const puddleDrop = await workshop.dropFile(PUD, 'puddle.glb', buildTinyGlb())
+const puddles = swap.slots.get(PUD)!
+ok(!puddleDrop.rejected, '[2] the puddle drop was accepted')
+ok(puddles.fitNodes.length === 3, `[2] all three puddles were replaced (got ${puddles.fitNodes.length})`)
+ok(puddles.procedural.every((p) => !p.visible), '[2] all three originals are hidden')
+ok(puddles.fitNodes.every((f, i) =>
+ Math.abs(f.position.z - puddleMeshes[i]!.position.z) < 1e-9),
+'[2] each replacement sits where its own puddle stood')
+ok(puddles.fitNodes.every((f) => f.children.length === 1),
+ '[2] each replacement holds its own copy of the model')
+ok(new Set(puddles.fitNodes.map((f) => f.children[0])).size === 3,
+ '[2] the three copies are three distinct objects, not one object moved about')
+ok(Math.abs(puddles.fitNodes[2]!.scale.x - 4) < 1e-9,
+ "[2] each replacement is stretched to its own puddle's footprint")
+ok(/all 3/.test(puddleDrop.message), `[2] the message says how many changed: "${puddleDrop.message}"`)
+workshop.reset(PUD)
+ok(puddles.procedural.every((p) => p.visible) && puddles.fitNodes.length === 0,
+ '[2] reset brings every puddle back')
+
+// ---- [3] storage that refuses to keep anything ------------------------------
+failWrites = true
+const full = await workshop.dropFile(BOOT, 'huge.glb', buildTinyGlb())
+ok(full.rejected, '[3] a drop the browser cannot store is refused, not thrown')
+ok(/wasn't room in this browser/i.test(full.message),
+ `[3] the refusal is plain English: "${full.message}"`)
+ok(bootSlot.fitNodes.length === 0 && bootSlot.procedural.every((p) => p.visible),
+ '[3] the slot stayed procedural')
+ok(!workshop.hasCustom(BOOT), '[3] the manifest was not touched')
+
+const failedSave = await workshop.saveLocally()
+ok(failedSave.ok === false, '[3] a save the browser refuses comes back as a result, not a throw')
+ok(/wasn't room in this browser/i.test(failedSave.message), '[3] and it says so in plain words')
+failWrites = false
+
+// ---- [4] a saved swap whose file will not load ------------------------------
+// The exact shape of the loss: bytes ARE there but truncated, so the load fails.
+await idbPut(BLOB_STORE, 'machine.boot-lost.glb', new TextEncoder().encode('trunc').buffer)
+await saveOverrideManifest({ [BOOT]: { url: 'idb:machine.boot-lost.glb' } })
+
+const reopened = new Workshop(stage)
+const result = await reopened.restore()
+ok(result.restored.length === 0, '[4] the broken swap did not come back on screen')
+ok(result.unloadable.includes(BOOT), '[4] it is reported as unloadable rather than forgotten')
+ok(reopened.hasCustom(BOOT), '[4] the entry is STILL in the working manifest')
+ok(reopened.isUnloadable(BOOT), '[4] and is flagged so the UI can say the file is missing')
+
+const saved = await reopened.saveLocally()
+ok(saved.ok && saved.saved === 1, '[4] saving after that keeps the entry')
+const keysAfter = (await idbKeys(BLOB_STORE)).map(String)
+ok(keysAfter.includes('machine.boot-lost.glb'),
+ `[4] and the stored file was NOT deleted (keys: ${JSON.stringify(keysAfter)})`)
+
+// ---- [5] the slot list is the runtime's, not a copy --------------------------
+ok(JSON.stringify(SLOT_IDS) === JSON.stringify([...runtimeSlots.SLOT_IDS]),
+ '[5] the editor offers exactly the runtime slots, in the runtime order')
+ok(SLOTS.every((s) => runtimeSlots.isSlotId(s.id)),
+ '[5] every slot the editor describes is one the runtime accepts')
+ok(SLOTS.every((s) => s.label.length > 0 && s.hint.length > 0),
+ '[5] every slot has a name and a sentence, including ones with no bespoke text')
+ok(SLOTS.every((s) => !/[a-z]+\.[a-z]/.test(s.label)),
+ '[5] no slot id leaks into a label shown on screen')
+
+// ---- [6] the game can read what the editor writes ---------------------------
+// Cross-lane: src/assets/idb.ts `getBlob` feeds its result straight into
+// `new Blob([buf])`. The editor used to store a `{name, bytes, savedAt}` wrapper
+// there, which produces a Blob of the string "[object Object]" and a GLB the
+// game silently fails to parse. Both sides must speak raw ArrayBuffer.
+{
+ const { putGlb } = await import('./store')
+ const runtimeIdb = await import('../assets/idb')
+ const bytes = buildTinyGlb({ half: 0.7 })
+ const url = await putGlb('machine.arch-cross-lane.glb', 'cross-lane.glb', bytes)
+ const readBack = await runtimeIdb.getBlob(runtimeIdb.idbKeyFromUrl(url))
+ ok(readBack instanceof ArrayBuffer,
+ `[6] the game's reader gets an ArrayBuffer back (got ${Object.prototype.toString.call(readBack)})`)
+ ok(readBack!.byteLength === bytes.byteLength,
+ '[6] byte-for-byte the same file the editor was handed')
+}
+
+console.log(`editor hardening.test: ${passed} assertions passed ✓`)
diff --git a/src/editor/idb-degrade.test.ts b/src/editor/idb-degrade.test.ts
new file mode 100644
index 0000000..6b696ae
--- /dev/null
+++ b/src/editor/idb-degrade.test.ts
@@ -0,0 +1,88 @@
+/**
+ * Lane J — what storage does when the browser says no.
+ *
+ * In a Firefox/Safari private window `indexedDB` exists and `open()` then fires
+ * onerror. The old availability check was `typeof indexedDB !== 'undefined'`,
+ * which answered "yes, go ahead" and led to the whole editor being replaced by
+ * an error string. Three properties are asserted here:
+ *
+ * [1] availability is a real probe, so it says NO when open fails;
+ * [2] a failure is not memoised as a rejected promise — storage coming back
+ * (a different database, a granted permission) works without a reload;
+ * [3] an open that neither succeeds nor errors (a blocked upgrade) still
+ * settles, so nothing waits forever.
+ *
+ * node --experimental-strip-types --import ./src/editor/node-ts-resolve.mjs \
+ * src/editor/idb-degrade.test.ts
+ */
+
+export {} // everything is loaded with await import(), so say "module" explicitly
+
+type Mode = 'error' | 'ok' | 'silent'
+let mode: Mode = 'error'
+
+const stores = new Map>()
+
+const fakeIndexedDb = {
+ open(_name: string) {
+ const req: Record = {
+ result: null, error: null, onsuccess: null, onerror: null,
+ onupgradeneeded: null, onblocked: null,
+ }
+ if (mode === 'silent') return req // fires nothing, ever
+ if (mode === 'error') {
+ req.error = new Error('InvalidStateError: storage is not available here')
+ queueMicrotask(() => void (req.onerror as (() => void) | null)?.())
+ return req
+ }
+ const db = {
+ objectStoreNames: { contains: (s: string) => stores.has(s) },
+ createObjectStore: (s: string) => void stores.set(s, new Map()),
+ transaction: () => ({ objectStore: () => ({}) }),
+ }
+ req.result = db
+ queueMicrotask(() => {
+ ;(req.onupgradeneeded as (() => void) | null)?.()
+ ;(req.onsuccess as (() => void) | null)?.()
+ })
+ return req
+ },
+}
+;(globalThis as unknown as { indexedDB: unknown }).indexedDB = fakeIndexedDb
+
+const { idbAvailable, resetIdb, setDbName } = await import('./idb')
+
+let passed = 0
+function ok(cond: boolean, msg: string): void {
+ if (!cond) throw new Error('FAIL: ' + msg)
+ passed++
+}
+
+// ---- [1] a real probe, not a typeof check ----------------------------------
+ok(typeof indexedDB !== 'undefined',
+ '[1] the browser object exists (the old check would have said yes here)')
+ok((await idbAvailable()) === false,
+ '[1] but actually opening it fails, so availability is false')
+ok((await idbAvailable()) === false, '[1] the answer is stable and cached')
+
+// ---- [2] the failure is not permanent --------------------------------------
+mode = 'ok'
+resetIdb()
+ok((await idbAvailable()) === true,
+ '[2] once storage works, the same session gets a working database (no memoised rejection)')
+
+// A different database name is a fresh question, never the old cached rejection.
+mode = 'error'
+setDbName('blobbo-workshop-demo')
+ok((await idbAvailable()) === false, '[2] switching database re-asks the question')
+
+// ---- [3] an open that never answers still settles --------------------------
+mode = 'silent'
+setDbName('blobbo-workshop-blocked')
+const started = Date.now()
+const answer = await idbAvailable()
+ok(answer === false, '[3] a database that never answers is reported unavailable')
+ok(Date.now() - started < 10_000,
+ `[3] and it gives up rather than hanging (took ${Date.now() - started}ms)`)
+
+console.log(`editor idb-degrade.test: ${passed} assertions passed ✓`)
diff --git a/src/editor/idb.ts b/src/editor/idb.ts
index 31d8d72..6147c44 100644
--- a/src/editor/idb.ts
+++ b/src/editor/idb.ts
@@ -9,30 +9,86 @@
*
* Raw IDB rather than a wrapper because no new deps are allowed, and the surface
* needed here is four calls wide.
+ *
+ * Two rules this module exists to enforce:
+ * - The database NAME is a variable. The demo points itself at a throwaway
+ * database so running it can never touch a real saved swap.
+ * - A failed open is never memoised. A private window, a blocked upgrade or a
+ * momentarily unavailable disk would otherwise dead-end the whole session.
*/
-export const DB_NAME = 'blobbo-workshop'
+export const DEFAULT_DB_NAME = 'blobbo-workshop'
+/** The database the live game reads. Kept exported: existing callers import it. */
+export const DB_NAME = DEFAULT_DB_NAME
export const DB_VERSION = 1
export const MANIFEST_STORE = 'manifest'
export const BLOB_STORE = 'blobs'
/** The single record in the `manifest` store that holds the working manifest. */
export const MANIFEST_KEY = 'current'
+/** How long a blocked upgrade may hang before we call storage unusable. */
+const OPEN_TIMEOUT_MS = 4000
+
+let dbName = DEFAULT_DB_NAME
let dbPromise: Promise | null = null
+let availability: Promise | null = null
+
+/** Which database this process talks to. Switching drops every cached handle. */
+export function setDbName(name: string): void {
+ if (name === dbName) return
+ dbName = name
+ dbPromise = null
+ availability = null
+}
+
+export const getDbName = (): string => dbName
+
+/** Throw away the cached connection and the cached availability answer. */
+export function resetIdb(): void {
+ dbPromise = null
+ availability = null
+}
function openDb(): Promise {
if (dbPromise) return dbPromise
- dbPromise = new Promise((resolve, reject) => {
- const req = indexedDB.open(DB_NAME, DB_VERSION)
+ const attempt = new Promise((resolve, reject) => {
+ if (typeof indexedDB === 'undefined' || indexedDB === null) {
+ reject(new Error('This browser has no local storage for models.'))
+ return
+ }
+ let settled = false
+ const done = (fn: () => void): void => {
+ if (settled) return
+ settled = true
+ fn()
+ }
+ // A blocked upgrade fires neither success nor error — without this the
+ // caller waits forever and the UI never leaves its "reading…" state.
+ const timer = setTimeout(
+ () => done(() => reject(new Error('Local storage did not respond.'))),
+ OPEN_TIMEOUT_MS,
+ )
+ const finish = (fn: () => void): void => done(() => { clearTimeout(timer); fn() })
+ let req: IDBOpenDBRequest
+ try {
+ req = indexedDB.open(dbName, DB_VERSION)
+ } catch (err) {
+ finish(() => reject(err instanceof Error ? err : new Error(String(err))))
+ return
+ }
req.onupgradeneeded = () => {
const db = req.result
if (!db.objectStoreNames.contains(MANIFEST_STORE)) db.createObjectStore(MANIFEST_STORE)
if (!db.objectStoreNames.contains(BLOB_STORE)) db.createObjectStore(BLOB_STORE)
}
- req.onsuccess = () => resolve(req.result)
- req.onerror = () => reject(req.error ?? new Error('IndexedDB open failed'))
+ req.onsuccess = () => finish(() => resolve(req.result))
+ req.onerror = () => finish(() => reject(req.error ?? new Error('IndexedDB open failed')))
+ req.onblocked = () => finish(() => reject(new Error('Another tab is using this storage.')))
})
- return dbPromise
+ // Memoise the SUCCESS only: a cached rejection makes one bad moment permanent.
+ dbPromise = attempt
+ attempt.catch(() => { if (dbPromise === attempt) dbPromise = null })
+ return attempt
}
function run(
@@ -43,8 +99,14 @@ function run(
return openDb().then(
(db) =>
new Promise((resolve, reject) => {
- const tx = db.transaction(store, mode)
- const req = fn(tx.objectStore(store))
+ let req: IDBRequest
+ try {
+ const tx = db.transaction(store, mode)
+ req = fn(tx.objectStore(store))
+ } catch (err) {
+ reject(err instanceof Error ? err : new Error(String(err)))
+ return
+ }
req.onsuccess = () => resolve(req.result)
req.onerror = () => reject(req.error ?? new Error('IndexedDB request failed'))
}),
@@ -66,6 +128,30 @@ export const idbKeys = (store: string): Promise =>
export const idbClear = (store: string): Promise =>
run(store, 'readwrite', (s) => s.clear() as IDBRequest)
-/** True when this browser can hold overrides at all (private modes sometimes can't). */
-export const idbAvailable = (): boolean =>
- typeof indexedDB !== 'undefined' && indexedDB !== null
+/**
+ * True when this browser can actually hold overrides. `typeof indexedDB` is not
+ * an answer: Safari/Firefox private windows expose the object and then fail the
+ * open, which is exactly the case the editor has to survive. So open it once for
+ * real and cache the BOOLEAN.
+ */
+export function idbAvailable(): Promise {
+ if (!availability) availability = openDb().then(() => true, () => false)
+ return availability
+}
+
+/** Delete a whole database — the demo's cleanup, never used on the real one. */
+export function deleteDb(name: string): Promise {
+ return new Promise((resolve) => {
+ if (typeof indexedDB === 'undefined' || indexedDB === null) return resolve()
+ if (name === dbName) resetIdb()
+ let req: IDBOpenDBRequest
+ try {
+ req = indexedDB.deleteDatabase(name)
+ } catch {
+ return resolve()
+ }
+ req.onsuccess = () => resolve()
+ req.onerror = () => resolve()
+ req.onblocked = () => resolve()
+ })
+}
diff --git a/src/editor/main.ts b/src/editor/main.ts
index 4767427..dd91545 100644
--- a/src/editor/main.ts
+++ b/src/editor/main.ts
@@ -42,6 +42,12 @@ interface FitRow {
step: number
}
+/** Shown when the browser will not keep anything (private windows, blocked storage). */
+const READ_ONLY_MESSAGE =
+ "This window won't let me keep anything, so nothing you do here will be remembered. " +
+ 'You can still try models out and download a manifest.json. Open the workshop in a ' +
+ 'normal window to save.'
+
const AXES = ['left / right', 'up / down', 'front / back'] as const
const FIT_ROWS: FitRow[] = [
@@ -82,6 +88,8 @@ export async function bootEditor(root: HTMLElement): Promise<{
exportManifest: () => string
clearAll: () => Promise
status: () => string
+ /** False when this browser refuses to store anything (private windows). */
+ canSave: () => boolean
}> {
root.classList.add('workshop')
@@ -195,9 +203,17 @@ export async function bootEditor(root: HTMLElement): Promise<{
for (const [id, button] of slotButtons) {
const spec = slotById(id)!
const state = button.querySelector('.slot-state') as HTMLElement
- if (spec.derivedFrom) {
+ if (!stage.slots.has(id)) {
+ // The runtime has a slot for this, but this course never builds one.
+ // Saying so beats a row that looks live and does nothing when clicked.
+ state.textContent = 'not in this course'
+ button.dataset.state = 'absent'
+ } else if (spec.derivedFrom) {
state.textContent = 'copies the blob'
button.dataset.state = 'derived'
+ } else if (workshop.isUnloadable(id)) {
+ state.textContent = 'file missing'
+ button.dataset.state = 'broken'
} else if (workshop.hasCustom(id)) {
state.textContent = 'yours'
button.dataset.state = 'custom'
@@ -282,11 +298,15 @@ export async function bootEditor(root: HTMLElement): Promise<{
selected = slot
const spec = slotById(slot)
if (!spec) return
+ const absent = !stage.slots.has(slot)
fitTitle.textContent = spec.label
- fitHint.textContent = spec.derivedFrom
- ? `${spec.hint} There is nothing to drop here.`
- : spec.hint
- dropZone.style.display = spec.derivedFrom ? 'none' : 'flex'
+ fitHint.textContent = absent
+ ? `${spec.hint} There isn't one of these anywhere in the course at the moment, ` +
+ 'so there is nothing here to replace.'
+ : spec.derivedFrom
+ ? `${spec.hint} There is nothing to drop here.`
+ : spec.hint
+ dropZone.style.display = spec.derivedFrom || absent ? 'none' : 'flex'
stage.highlight(slot)
stage.focus(slot)
renderPaintCard(spec)
@@ -301,7 +321,16 @@ export async function bootEditor(root: HTMLElement): Promise<{
return
}
setStatus(`Reading ${file.name}…`)
- const bytes = await file.arrayBuffer()
+ let bytes: ArrayBuffer
+ try {
+ // The file can be moved or renamed between picking it and reading it, and
+ // then this throws — leaving the status stuck on "Reading…" forever.
+ bytes = await file.arrayBuffer()
+ } catch (err) {
+ console.warn('[workshop] could not read the dropped file', err)
+ setStatus(`I couldn't read ${file.name}. Is it still where it was?`, 'warn')
+ return
+ }
const result = await workshop.dropFile(selected, file.name, bytes)
setStatus(result.message, result.rejected ? 'warn' : 'ok')
const spec = slotById(selected)
@@ -310,6 +339,14 @@ export async function bootEditor(root: HTMLElement): Promise<{
refreshSlotStates()
}
+ /** Every DOM handler funnels through here: nothing may end as a dead promise. */
+ const guard = (label: string, work: () => Promise): void => {
+ void work().catch((err: unknown) => {
+ console.error(`[workshop] ${label} failed`, err)
+ setStatus('Something went wrong there and nothing was changed. Try again.', 'warn')
+ })
+ }
+
const stop = (e: Event): void => { e.preventDefault(); e.stopPropagation() }
for (const type of ['dragenter', 'dragover'] as const) {
dropZone.addEventListener(type, (e) => { stop(e); dropZone.dataset.hot = 'true' })
@@ -319,12 +356,12 @@ export async function bootEditor(root: HTMLElement): Promise<{
}
dropZone.addEventListener('drop', (e) => {
const file = (e as DragEvent).dataTransfer?.files?.[0]
- if (file) void handleFile(file)
+ if (file) guard('the drop', () => handleFile(file))
})
dropZone.addEventListener('click', () => filePicker.click())
filePicker.addEventListener('change', () => {
const file = filePicker.files?.[0]
- if (file) void handleFile(file)
+ if (file) guard('the drop', () => handleFile(file))
filePicker.value = ''
})
// Dropping anywhere else must not make the browser navigate to the file.
@@ -343,23 +380,29 @@ export async function bootEditor(root: HTMLElement): Promise<{
})
const save = async (): Promise => {
- if (!idbAvailable()) {
- setStatus("This browser won't let me save anything locally.", 'warn')
+ if (!canSave) {
+ setStatus(READ_ONLY_MESSAGE, 'warn')
return
}
- const count = await workshop.saveLocally()
- setStatus(count === 0
- ? 'Saved — no swaps, so the game looks like it always did.'
- : `Saved ${count} swap${count === 1 ? '' : 's'}. Press "Test drive" to play with them.`)
+ const result = await workshop.saveLocally()
+ setStatus(result.message, result.ok ? 'ok' : 'warn')
}
- saveBtn.addEventListener('click', () => void save())
+ saveBtn.addEventListener('click', () => guard('saving', save))
- testBtn.addEventListener('click', () => {
- void save().then(() => {
- setStatus('Opened the game in a new tab — your swaps are already in it.')
- open(import.meta.env.BASE_URL, '_blank')
- })
- })
+ testBtn.addEventListener('click', () => guard('the test drive', async () => {
+ if (!canSave) {
+ setStatus(READ_ONLY_MESSAGE, 'warn')
+ return
+ }
+ const result = await workshop.saveLocally()
+ if (!result.ok) {
+ // Opening the game now would show none of what is on screen here.
+ setStatus(result.message, 'warn')
+ return
+ }
+ setStatus('Opened the game in a new tab — your swaps are already in it.')
+ open(import.meta.env.BASE_URL, '_blank')
+ }))
const exportManifest = (): string => workshop.exportText()
exportBtn.addEventListener('click', () => {
@@ -374,26 +417,54 @@ export async function bootEditor(root: HTMLElement): Promise<{
})
const clearAll = async (): Promise => {
- await workshop.clearEverything()
+ const ok = await workshop.clearEverything()
refreshSlotStates()
renderFitControls()
if (selected) {
const spec = slotById(selected)
if (spec) renderPaintCard(spec)
}
- setStatus('All swaps cleared. The game is back to how it ships.')
+ setStatus(ok
+ ? 'All swaps cleared. The game is back to how it ships.'
+ : "Everything is off the screen, but this browser wouldn't let me clear what it had stored.",
+ ok ? 'ok' : 'warn')
}
clearBtn.addEventListener('click', () => {
- if (confirm('Throw away every model you dropped in? This cannot be undone.')) void clearAll()
+ if (confirm('Throw away every model you dropped in? This cannot be undone.')) {
+ guard('clearing', clearAll)
+ }
})
+ // ---- can this browser keep anything? -------------------------------------
+ // A real open(), not a `typeof indexedDB` guess: private windows expose the
+ // object and then fail. Failing that probe means a look-but-don't-save
+ // editor, NOT an error page over a stage that built perfectly well.
+ const canSave = await idbAvailable()
+ if (!canSave) {
+ saveBtn.disabled = true
+ clearBtn.disabled = true
+ testBtn.disabled = true
+ saveBtn.textContent = "Can't save in this window"
+ const banner = el('p', 'readonly-banner', READ_ONLY_MESSAGE)
+ left.insertBefore(banner, bigActions)
+ setStatus(READ_ONLY_MESSAGE, 'warn')
+ }
+
// ---- restore a previous session -----------------------------------------
refreshSlotStates()
renderFitControls()
- if (idbAvailable()) {
- const restored = await workshop.restore()
- if (restored.length) {
- refreshSlotStates()
+ if (canSave) {
+ const { restored, unloadable } = await workshop.restore()
+ if (restored.length || unloadable.length) refreshSlotStates()
+ const names = unloadable.map((id) => slotById(id)?.label ?? id).join(', ')
+ if (unloadable.length) {
+ // Kept, not dropped: the entry stays in the manifest so saving again can
+ // never delete the stored file it points at.
+ setStatus(
+ `Brought back ${restored.length} swap${restored.length === 1 ? '' : 's'}. ` +
+ `I couldn't find the file for: ${names}. Those are still listed — drop the ` +
+ 'model in again, or press "Put the original back" on each one.', 'warn')
+ } else if (restored.length) {
setStatus(`Brought back ${restored.length} swap${restored.length === 1 ? '' : 's'} from last time.`)
}
}
@@ -407,6 +478,7 @@ export async function bootEditor(root: HTMLElement): Promise<{
exportManifest,
clearAll,
status: () => statusLine.textContent ?? '',
+ canSave: () => canSave,
}
}
@@ -414,8 +486,12 @@ export async function bootEditor(root: HTMLElement): Promise<{
// `data-manual` and calls bootEditor itself, so it never gets two editors.
const host = document.getElementById('workshop')
if (host && !host.hasAttribute('data-manual')) {
- void bootEditor(host).catch((err) => {
+ void bootEditor(host).catch((err: unknown) => {
+ // Storage problems no longer reach here — bootEditor degrades instead. What
+ // is left is a genuinely dead page (no WebGL), so a plain sentence is right.
console.error(err)
- host.textContent = 'The workshop could not start: ' + String(err)
+ host.textContent =
+ "The workshop couldn't start in this browser. It needs 3D graphics turned on — " +
+ 'try a different browser, or check your browser settings for hardware acceleration.'
})
}
diff --git a/src/editor/manifest-io.test.ts b/src/editor/manifest-io.test.ts
index f39860f..b1e30a4 100644
--- a/src/editor/manifest-io.test.ts
+++ b/src/editor/manifest-io.test.ts
@@ -29,19 +29,41 @@ function eq(a: unknown, b: unknown, msg: string): void {
ok(JSON.stringify(a) === JSON.stringify(b), `${msg} (got ${JSON.stringify(a)}, want ${JSON.stringify(b)})`)
}
-// ---- every catalogued slot is actually wired to something on the stage -----
+// ---- the slot list, the stage and the runtime ------------------------------
// Static source check: the stage builds its objects inside WebGL-only code that
-// can't run here, but the registration calls are greppable, and a slot in the UI
-// with nothing behind it would silently do nothing when clicked.
+// can't run here, but the registration calls are greppable.
+//
+// The three lists are deliberately NOT required to be equal, because two honest
+// gaps exist and hiding either one is how the editor started lying:
+//
+// NOT_IN_THIS_COURSE — the runtime supports the slot but this course builds no
+// such object (nothing calls createFan or createSeeSaw). The editor still
+// LISTS these, marked unavailable, rather than pretending the game has no
+// fan slot at all. `stage.slots.has(id)` is what the UI tests at run time.
+// NOT_YET_IN_THE_RUNTIME — the plan's table has these and the stage already
+// builds them, but src/assets/slots.ts has not adopted the ids yet, so the
+// runtime would discard the manifest entry. The editor must NOT offer them
+// until it does; the registrations cost nothing and light up on the day.
{
const stageSource = readFileSync(new URL('./stage.ts', import.meta.url), 'utf8')
const registered = new Set(
[...stageSource.matchAll(/register\('([^']+)'/g)].map((m) => m[1]!))
+ const knownIds = new Set(SLOT_IDS)
+ const NOT_IN_THIS_COURSE = new Set(['machine.fan', 'machine.seesaw'])
+ const NOT_YET_IN_THE_RUNTIME = new Set([
+ 'cannon.base', 'course.tramp', 'course.tunnel', 'course.finish',
+ ])
for (const id of SLOT_IDS) {
- ok(registered.has(id), `stage.ts registers a real object for the "${id}" slot`)
+ ok(registered.has(id) || NOT_IN_THIS_COURSE.has(id),
+ `the stage builds something for the "${id}" slot, or it is a known course gap`)
}
for (const id of registered) {
- ok(SLOT_IDS.includes(id), `stage.ts registers "${id}", which the slot list also knows about`)
+ ok(knownIds.has(id) || NOT_YET_IN_THE_RUNTIME.has(id),
+ `stage.ts registers "${id}", which the runtime knows about (or is queued for it)`)
+ }
+ for (const id of NOT_YET_IN_THE_RUNTIME) {
+ ok(!knownIds.has(id),
+ `"${id}" is still absent from the runtime — move it out of this list once added`)
}
}
diff --git a/src/editor/slot-swap.ts b/src/editor/slot-swap.ts
index 2a3d0a8..49ed1d3 100644
--- a/src/editor/slot-swap.ts
+++ b/src/editor/slot-swap.ts
@@ -8,6 +8,14 @@
* keeps reset instant, keeps every reference other systems captured alive, and
* keeps the manifest's offset/rotation/scale on a node nobody else writes to
* (feel.ts, telegraph.ts and the machine parts all own transforms of their own).
+ *
+ * ONE FIT NODE PER PROCEDURAL OBJECT. Several slots are backed by more than one
+ * object — nine paint puddles under `fx.puddle`, one barrel per cannon under
+ * `cannon.barrel`. Hiding all of them and adding a single replacement made the
+ * rest of the course silently disappear. The runtime calls `attachSlot` once per
+ * instance (see paint/puddles.ts and paint/cannon.ts), so the editor clones the
+ * asset per instance at that instance's own resting pose, and what the editor
+ * shows is what the game builds.
*/
import * as THREE from 'three'
import { composeFit } from './manifest-io'
@@ -15,15 +23,30 @@ import type { BaseTransform, SlotEntry } from './manifest-io'
export const DEG = Math.PI / 180
+/** How a slot clones its asset. Injected so this module never imports GLTF. */
+export type Cloner = (object: THREE.Object3D) => THREE.Object3D
+
+const plainClone: Cloner = (object) => object.clone(true)
+
export interface SlotStageEntry {
id: string
/** What the game builds today. Hidden — never removed — while a swap is in. */
procedural: THREE.Object3D[]
- /** Parent the fit node hangs off: the procedural object's own parent. */
+ /** Parent the fit nodes hang off: each procedural object's own parent. */
parent: THREE.Object3D
- /** Resting pose of the procedural object, in the units the manifest speaks. */
+ /** Resting pose of the FIRST procedural object, in manifest units. */
base: BaseTransform
- /** Wrapper holding the current custom asset, or null when procedural. */
+ /** Resting pose of every procedural object, index-aligned with `procedural`. */
+ bases: BaseTransform[]
+ /**
+ * Per-instance extra scale, index-aligned. The runtime stretches a puddle
+ * decal to its own strip footprint (`fit:` in paint/puddles.ts); without the
+ * same multiplier the editor preview would be a different size to the game.
+ */
+ extraScale: [number, number, number][]
+ /** One wrapper per procedural object, or empty when the slot is procedural. */
+ fitNodes: THREE.Group[]
+ /** The first wrapper, or null. Kept so single-instance callers read naturally. */
fitNode: THREE.Group | null
}
@@ -51,18 +74,39 @@ export function dressAsset(object: THREE.Object3D): void {
})
}
+export interface RegisterOptions {
+ /** Per-object extra scale (puddle footprints). Index-aligned with `objects`. */
+ extraScale?: [number, number, number][]
+}
+
export class SlotSwap {
readonly slots = new Map()
+ private readonly clone: Cloner
+
+ // Plain assignment rather than a parameter property: node's type-stripping
+ // mode (how the .test.ts files run) rejects parameter properties outright.
+ constructor(clone: Cloner = plainClone) {
+ this.clone = clone
+ }
/** Called once per slot as the stage builds. First object defines the pose. */
- register(id: string, objects: THREE.Object3D[], fallbackParent: THREE.Object3D): void {
+ register(
+ id: string,
+ objects: THREE.Object3D[],
+ fallbackParent: THREE.Object3D,
+ opts: RegisterOptions = {},
+ ): void {
const first = objects[0]
if (!first) return
+ const bases = objects.map(readBase)
this.slots.set(id, {
id,
procedural: objects,
parent: first.parent ?? fallbackParent,
- base: readBase(first),
+ base: bases[0]!,
+ bases,
+ extraScale: objects.map((_, i) => opts.extraScale?.[i] ?? [1, 1, 1]),
+ fitNodes: [],
fitNode: null,
})
}
@@ -71,49 +115,65 @@ export class SlotSwap {
return this.slots.has(id)
}
+ /** How many of this thing the course actually holds. */
+ instanceCount(id: string): number {
+ return this.slots.get(id)?.procedural.length ?? 0
+ }
+
setCustom(id: string, asset: THREE.Object3D, entry: SlotEntry): void {
const s = this.slots.get(id)
if (!s) return
this.clearCustom(id)
- const fitNode = new THREE.Group()
- fitNode.name = `fit:${id}`
- dressAsset(asset)
- fitNode.add(asset)
- s.parent.add(fitNode)
- s.fitNode = fitNode
- for (const p of s.procedural) p.visible = false
+ s.procedural.forEach((original, i) => {
+ const fitNode = new THREE.Group()
+ fitNode.name = s.procedural.length > 1 ? `fit:${id}#${i}` : `fit:${id}`
+ // Always a copy, never `asset` itself: the caller caches parsed scenes and
+ // re-uses them across slots, and a scene graph node can only have one
+ // parent — adopting the original would tear it out of wherever it was.
+ const instance = this.clone(asset)
+ dressAsset(instance)
+ fitNode.add(instance)
+ ;(original.parent ?? s.parent).add(fitNode)
+ s.fitNodes.push(fitNode)
+ original.visible = false
+ })
+ s.fitNode = s.fitNodes[0] ?? null
this.applyFit(id, entry)
}
clearCustom(id: string): void {
const s = this.slots.get(id)
if (!s) return
- if (s.fitNode) {
- s.parent.remove(s.fitNode)
- s.fitNode = null
- }
+ for (const node of s.fitNodes) node.parent?.remove(node)
+ s.fitNodes = []
+ s.fitNode = null
for (const p of s.procedural) p.visible = true
}
applyFit(id: string, entry: SlotEntry): void {
const s = this.slots.get(id)
- if (!s?.fitNode) return
- const fit = composeFit(s.base, entry)
- s.fitNode.position.set(fit.position[0], fit.position[1], fit.position[2])
- s.fitNode.rotation.set(
- fit.rotationDeg[0] * DEG, fit.rotationDeg[1] * DEG, fit.rotationDeg[2] * DEG)
- s.fitNode.scale.set(fit.scale[0], fit.scale[1], fit.scale[2])
+ if (!s) return
+ s.fitNodes.forEach((node, i) => {
+ const base = s.bases[i] ?? s.base
+ const extra = s.extraScale[i] ?? [1, 1, 1]
+ const fit = composeFit(base, entry)
+ node.position.set(fit.position[0], fit.position[1], fit.position[2])
+ node.rotation.set(
+ fit.rotationDeg[0] * DEG, fit.rotationDeg[1] * DEG, fit.rotationDeg[2] * DEG)
+ node.scale.set(
+ fit.scale[0] * extra[0], fit.scale[1] * extra[1], fit.scale[2] * extra[2])
+ })
}
/** What the fit panel is acting on: the custom asset if any, else the original. */
currentObject(id: string): THREE.Object3D | null {
const s = this.slots.get(id)
if (!s) return null
- return s.fitNode ?? s.procedural[0] ?? null
+ return s.fitNodes[0] ?? s.procedural[0] ?? null
}
/** True when this slot is showing a custom asset. */
isCustom(id: string): boolean {
- return this.slots.get(id)?.fitNode != null
+ return (this.slots.get(id)?.fitNodes.length ?? 0) > 0
}
}
diff --git a/src/editor/slots.ts b/src/editor/slots.ts
index bdc1c37..41d3ede 100644
--- a/src/editor/slots.ts
+++ b/src/editor/slots.ts
@@ -1,19 +1,30 @@
/**
* Lane J — the slot catalogue the editor's left panel renders.
*
- * Ids are docs/WORKSHOP-PLAN.md's table verbatim (minus the UI rows, which are
- * out of scope for v1); Lane I's registry answers to the same strings, so this
- * list is the contract between the two lanes. `label`/`hint` are written for
- * John, not for a programmer — no slot ids appear on screen.
+ * The VOCABULARY is not defined here. `SLOT_IDS` and `SLOT_LABELS` come from
+ * src/assets/slots.ts, which is the same list the runtime registry answers to —
+ * an editor that offered a slot the game discards (or hid one the game supports)
+ * would be lying to the user, and the two lists drifted apart exactly that way
+ * before this import existed.
+ *
+ * What lives here is PRESENTATION only: help text, camera framing, grouping,
+ * ordering. It is keyed by `SlotId`, so describing a slot the runtime does not
+ * have is a compile error rather than a dead row in the list, and a slot the
+ * runtime adds shows up automatically with its runtime label.
*
* THREE-free on purpose: the stage maps ids to objects, this file only names
* them, so the catalogue is testable and cheap to import anywhere.
*/
+import { SLOT_IDS as RUNTIME_SLOT_IDS, SLOT_LABELS, SLOT_NOTES } from '../assets/slots'
+import type { SlotId } from '../assets/slots'
+
+export type { SlotId }
+export { isSlotId } from '../assets/slots'
export type SlotGroupName = 'The blob' | 'Machines' | 'The course' | 'Effects'
export interface SlotSpec {
- id: string
+ id: SlotId
group: SlotGroupName
/** Plain-language name shown in the list. */
label: string
@@ -27,127 +38,140 @@ export interface SlotSpec {
* Derived slots have no drop zone of their own: they copy another slot.
* (The ghost is a see-through copy of the blob, per the plan.)
*/
- derivedFrom?: string
+ derivedFrom?: SlotId
+ /**
+ * True when the game builds SEVERAL of this thing (nine puddles, one barrel
+ * per cannon). A drop replaces every one of them, each at its own place —
+ * which is what the runtime does too.
+ */
+ multi?: boolean
}
-export const SLOTS: SlotSpec[] = [
- {
- id: 'blob.body',
- group: 'The blob',
+interface Presentation {
+ label?: string
+ hint: string
+ focus: { target: [number, number, number]; distance: number }
+ paintable?: boolean
+ derivedFrom?: SlotId
+ multi?: boolean
+}
+
+/**
+ * Keyed by SlotId: a typo, or a slot the runtime dropped, fails `tsc`.
+ * Partial on purpose — a slot with no entry still appears, using its runtime
+ * label and note, so the editor can never silently hide part of the game.
+ */
+const PRESENTATION: Partial> = {
+ 'blob.body': {
label: 'Blob body',
hint: 'The thing you play as. Paint sticks to this one, so it needs tidy UVs.',
focus: { target: [0, 3.5, 30], distance: 6 },
paintable: true,
},
- {
- id: 'blob.face',
- group: 'The blob',
+ 'blob.face': {
label: 'Blob face',
hint: 'Just the eyes. Kept separate so paint never covers them.',
focus: { target: [0, 3.7, 30], distance: 4 },
},
- {
- id: 'ghost.body',
- group: 'The blob',
+ 'ghost.body': {
label: 'Ghost racer',
hint: 'Your best-run ghost. It copies the blob body automatically.',
focus: { target: [3, 3.5, 30], distance: 6 },
derivedFrom: 'blob.body',
},
- {
- id: 'cannon.base',
- group: 'Machines',
- label: 'Paint cannon stand',
- hint: 'The chunky foot the cannon sits on.',
- focus: { target: [-9, 2.5, 14], distance: 7 },
- },
- {
- id: 'cannon.barrel',
- group: 'Machines',
+ 'cannon.barrel': {
label: 'Paint cannon barrel',
- hint: 'The swivelling tube. It gets tinted the colour it shoots.',
+ hint: 'The swivelling tube. Every cannon on the course gets your model, ' +
+ 'each one tinted the colour it shoots.',
focus: { target: [-9, 3, 14], distance: 6 },
+ multi: true,
},
- {
- id: 'machine.plate',
- group: 'Machines',
+ 'machine.plate': {
label: 'Pressure plate',
- hint: 'The pad a heavy blob stands on to set the boot off.',
+ hint: 'The pad a heavy blob stands on to set the boot off. Frame only — the ' +
+ 'pressed pad stays as it is so it can light up.',
focus: { target: [-6, 0.5, -47], distance: 9 },
},
- {
- id: 'machine.boot',
- group: 'Machines',
+ 'machine.boot': {
label: 'Spring boot',
- hint: 'Kicks you down the course. A ready-made boot model is in assets/meshes.',
+ hint: 'Kicks you down the course. Model it standing on the floor, origin at the base.',
focus: { target: [-6, 1, -49.5], distance: 9 },
},
- {
- id: 'machine.bucket',
- group: 'Machines',
+ 'machine.bucket': {
label: 'Paint bucket',
hint: 'Tips a colour over you. Its model must pivot at the lip and pour toward +X.',
focus: { target: [-1.4, 6, -50], distance: 10 },
},
- {
- id: 'machine.arch',
- group: 'Machines',
+ 'machine.arch': {
label: 'Bubble wash',
hint: 'Walk through it and paint comes off.',
focus: { target: [-4.5, 2, -56], distance: 9 },
},
- {
- id: 'machine.belt',
- group: 'Machines',
+ 'machine.belt': {
label: 'Conveyor belt',
- hint: 'Slow-carries you along. The moving stripes stay animated.',
+ hint: 'Slow-carries you along. Model the slab only — the moving stripes stay animated.',
focus: { target: [0, 0.5, -50], distance: 12 },
},
- {
- id: 'course.scenery.cereal',
- group: 'The course',
+ 'machine.fan': {
+ label: 'Fan',
+ hint: 'Blows you sideways. Face it toward +Z; the blades stay as they are so they keep spinning.',
+ focus: { target: [0, 2, -30], distance: 10 },
+ },
+ 'machine.seesaw': {
+ label: 'See-saw plank',
+ hint: 'Tips under your weight. Plank only, long axis left-to-right, origin in the middle.',
+ focus: { target: [0, 1, -20], distance: 12 },
+ },
+ 'course.scenery.cereal': {
label: 'Giant cereal box',
hint: 'Big silly prop next to the start. Nothing depends on it — go wild.',
focus: { target: [-20, 7, 26], distance: 22 },
},
- {
- id: 'course.scenery.block',
- group: 'The course',
+ 'course.scenery.block': {
label: 'Purple block',
hint: 'The other roadside prop. Also purely decorative.',
focus: { target: [20, 5, 12], distance: 20 },
},
- {
- id: 'course.tramp',
- group: 'The course',
- label: 'Gap launcher',
- hint: 'The orange pad in the jump gap that throws you back up.',
- focus: { target: [0, 0.25, 0], distance: 14 },
- },
- {
- id: 'course.tunnel',
- group: 'The course',
- label: 'Low tunnel',
- hint: 'The pink shortcut only a shrunken blob fits under. Keep the underside flat.',
- focus: { target: [6.5, 1.3, -51], distance: 16 },
- },
- {
- id: 'course.finish',
- group: 'The course',
- label: 'Finish podium',
- hint: 'The pink pad at the end of the run.',
- focus: { target: [0, 0.6, -64], distance: 14 },
- },
- {
- id: 'fx.puddle',
- group: 'Effects',
+ 'fx.puddle': {
label: 'Paint puddle',
- hint: 'The glossy colour patches on the ground. Swaps all of them at once.',
+ hint: 'The glossy colour patches on the ground. Every puddle gets your model, ' +
+ 'stretched to fit its own patch.',
focus: { target: [0, 1.1, 12], distance: 10 },
+ multi: true,
},
+}
+
+const GROUP_OF: [prefix: string, group: SlotGroupName][] = [
+ ['blob.', 'The blob'],
+ ['ghost.', 'The blob'],
+ ['cannon.', 'Machines'],
+ ['machine.', 'Machines'],
+ ['course.', 'The course'],
+ ['fx.', 'Effects'],
]
-export const SLOT_IDS: string[] = SLOTS.map((s) => s.id)
+const groupFor = (id: SlotId): SlotGroupName =>
+ GROUP_OF.find(([prefix]) => id.startsWith(prefix))?.[1] ?? 'The course'
+
+/** A slot with no bespoke help text still gets a usable line, never a blank. */
+const fallbackHint = (id: SlotId): string =>
+ SLOT_NOTES[id] ?? `Drop a model here to replace the ${SLOT_LABELS[id].toLowerCase()}.`
+
+export const SLOTS: SlotSpec[] = RUNTIME_SLOT_IDS.map((id): SlotSpec => {
+ const p = PRESENTATION[id]
+ return {
+ id,
+ group: groupFor(id),
+ label: p?.label ?? SLOT_LABELS[id],
+ hint: p?.hint ?? fallbackHint(id),
+ focus: p?.focus ?? { target: [0, 2, 0], distance: 16 },
+ ...(p?.paintable ? { paintable: true } : {}),
+ ...(p?.derivedFrom ? { derivedFrom: p.derivedFrom } : {}),
+ ...(p?.multi ? { multi: true } : {}),
+ }
+})
+
+export const SLOT_IDS: SlotId[] = SLOTS.map((s) => s.id)
export const slotById = (id: string): SlotSpec | undefined =>
SLOTS.find((s) => s.id === id)
diff --git a/src/editor/stage.ts b/src/editor/stage.ts
index 4a8c222..46df269 100644
--- a/src/editor/stage.ts
+++ b/src/editor/stage.ts
@@ -29,13 +29,22 @@ import { SlotSwap } from './slot-swap'
import type { SlotStageEntry } from './slot-swap'
import { SLOTS } from './slots'
+// Some ids below (`cannon.base`, `course.tramp`, `course.tunnel`,
+// `course.finish`) are registered whether or not the runtime knows them yet.
+// Registering costs nothing, the left panel only lists ids the runtime does
+// support (see slots.ts), and they light up by themselves the day it does.
+
export type { SlotStageEntry } from './slot-swap'
export interface EditorStage {
world: World
controls: OrbitControls
slots: Map
- /** Swap a slot's visual for a loaded GLB scene (takes ownership of the object). */
+ /**
+ * Swap a slot's visual for a loaded GLB scene. The scene is CLONED, once per
+ * procedural instance the slot holds, so the caller keeps its own copy intact
+ * and a nine-puddle slot ends up with nine puddles.
+ */
setCustom(slot: string, asset: THREE.Object3D, entry: SlotEntry): void
/** Drop the custom asset and show today's procedural build again. */
clearCustom(slot: string): void
@@ -54,10 +63,11 @@ export async function createEditorStage(container: HTMLElement): Promise
- swap.register(id, objects, world.scene)
+ const register = (
+ id: string, objects: THREE.Object3D[], extraScale?: [number, number, number][],
+ ): void => swap.register(id, objects, world.scene, extraScale ? { extraScale } : {})
/** Everything a builder adds straight to the scene, recovered by diffing. */
const capture = (fn: () => T): { result: T; added: THREE.Object3D[] } => {
@@ -127,7 +137,18 @@ export async function createEditorStage(container: HTMLElement): Promise o instanceof THREE.Group)
if (zoneGroups[0]) register('machine.plate', [zoneGroups[0]])
if (zoneGroups[1]) register('machine.boot', [zoneGroups[1]])
- if (zones.puddles.length) register('fx.puddle', zones.puddles.map((p) => p.mesh))
+ // Each puddle is a BoxGeometry(w, 0.05, l) at scale 1 and the runtime stretches
+ // a custom decal to that footprint (paint/puddles.ts passes `fit: (w,1,l)`).
+ // Reading the same numbers back off the geometry keeps the preview honest.
+ if (zones.puddles.length) {
+ const puddleMeshList = zones.puddles.map((p) => p.mesh)
+ const footprints = puddleMeshList.map((m): [number, number, number] => {
+ const params = (m.geometry as THREE.BoxGeometry).parameters as
+ { width?: number; depth?: number } | undefined
+ return [params?.width ?? 1, 1, params?.depth ?? 1]
+ })
+ register('fx.puddle', puddleMeshList, footprints)
+ }
// The tunnel is whatever loose Meshes are left once the puddles are accounted
// for — installPuddles runs FIRST and also adds bare Meshes to the scene, so
// filtering on "not a Group" alone would swallow all ten puddles.
diff --git a/src/editor/store.ts b/src/editor/store.ts
index ea40fe9..e0e6892 100644
--- a/src/editor/store.ts
+++ b/src/editor/store.ts
@@ -12,13 +12,29 @@ import {
import { cleanManifest, idbKey, isIdbUrl, parseManifest } from './manifest-io'
import type { Manifest } from './manifest-io'
-/** What the `blobs` store holds per key (name kept so exports can suggest a path). */
+/**
+ * What a `blobs` record reads back as.
+ *
+ * On disk the record is the RAW ArrayBuffer, because that is what the shipped
+ * game's reader expects (src/assets/idb.ts `getBlob` feeds its result straight
+ * into `new Blob([buf])`). An earlier version wrote a `{name, bytes, savedAt}`
+ * wrapper here, which the game could not read at all — so reads stay tolerant of
+ * that shape and writes never produce it again. The file name is recoverable
+ * from the key, which is `-`.
+ */
export interface StoredGlb {
name: string
bytes: ArrayBuffer
savedAt: number
}
+/** Legacy wrapper shape, still on disk for anyone who saved before the fix. */
+interface LegacyStoredGlb {
+ name?: unknown
+ bytes?: unknown
+ savedAt?: unknown
+}
+
export async function loadOverrideManifest(): Promise {
const raw = await idbGet(MANIFEST_STORE, MANIFEST_KEY)
if (typeof raw === 'string') return parseManifest(raw)
@@ -34,16 +50,31 @@ export const saveOverrideManifest = (manifest: Manifest): Promise =>
export const blobKeyFor = (slot: string, fileName: string): string =>
`${slot}-${fileName}`.replace(/[^a-zA-Z0-9._-]/g, '_')
-export async function putGlb(key: string, name: string, bytes: ArrayBuffer): Promise {
- const record: StoredGlb = { name, bytes, savedAt: Date.now() }
- await idbPut(BLOB_STORE, key, record)
+export async function putGlb(key: string, _name: string, bytes: ArrayBuffer): Promise {
+ await idbPut(BLOB_STORE, key, bytes)
return `idb:${key}`
}
export async function getGlb(key: string): Promise {
- const raw = await idbGet(BLOB_STORE, key)
- if (!raw || !(raw.bytes instanceof ArrayBuffer)) return undefined
- return raw
+ const raw = await idbGet(BLOB_STORE, key)
+ if (raw instanceof ArrayBuffer) {
+ return { name: nameFromKey(key), bytes: raw, savedAt: 0 }
+ }
+ const legacy = raw as LegacyStoredGlb | undefined
+ if (legacy && legacy.bytes instanceof ArrayBuffer) {
+ return {
+ name: typeof legacy.name === 'string' ? legacy.name : nameFromKey(key),
+ bytes: legacy.bytes,
+ savedAt: typeof legacy.savedAt === 'number' ? legacy.savedAt : 0,
+ }
+ }
+ return undefined
+}
+
+/** `-` back to `` — best effort, display only. */
+const nameFromKey = (key: string): string => {
+ const dash = key.lastIndexOf('-')
+ return dash >= 0 ? key.slice(dash + 1) : key
}
/** Every override gone: manifest record and all stored GLB bytes. */
@@ -52,7 +83,14 @@ export async function clearOverrides(): Promise {
await idbClear(BLOB_STORE)
}
-/** Blob keys with nothing pointing at them any more (drop-then-reset leftovers). */
+/**
+ * Blob keys with nothing pointing at them any more (drop-then-reset leftovers).
+ *
+ * CONSTRAINT: whatever manifest is handed in decides what survives, so a caller
+ * must never hand in a manifest it has already pruned entries from. Workshop
+ * keeps entries whose file failed to load precisely so their bytes are not
+ * counted as orphans here — dropping them would destroy an unrecoverable file.
+ */
export async function orphanBlobKeys(manifest: Manifest): Promise {
const live = new Set(
Object.values(manifest).filter((e) => isIdbUrl(e.url)).map((e) => idbKey(e.url)),
diff --git a/src/editor/tiny-glb.ts b/src/editor/tiny-glb.ts
index e4149a1..1d9ac9b 100644
--- a/src/editor/tiny-glb.ts
+++ b/src/editor/tiny-glb.ts
@@ -23,12 +23,20 @@ export interface TinyGlbOptions {
color?: [number, number, number, number]
/** Omit UVs to produce a deliberately unpaintable asset (for testing warnings). */
withUv?: boolean
+ /**
+ * Omit the mesh entirely: a valid GLB whose scene holds one empty node. This
+ * is what a Blender export with the mesh on a hidden collection looks like,
+ * and the drop path has to refuse it rather than hide the original and put
+ * nothing in its place.
+ */
+ withMesh?: boolean
}
export function buildTinyGlb(opts: TinyGlbOptions = {}): ArrayBuffer {
const h = opts.half ?? 0.5
const color = opts.color ?? [1, 0.58, 0, 1]
const withUv = opts.withUv !== false
+ const withMesh = opts.withMesh !== false
const positions = new Float32Array([
-h, 0, -h,
@@ -82,11 +90,16 @@ export function buildTinyGlb(opts: TinyGlbOptions = {}): ArrayBuffer {
asset: { version: '2.0', generator: 'BLOBBO workshop tiny-glb' },
scene: 0,
scenes: [{ nodes: [0] }],
- nodes: [{ mesh: 0, name: 'TinyQuad' }],
- meshes: [{
- name: 'TinyQuad',
- primitives: [{ attributes, indices: indexAccessor, material: 0, mode: 4 }],
- }],
+ nodes: withMesh ? [{ mesh: 0, name: 'TinyQuad' }] : [{ name: 'EmptyNode' }],
+ // glTF forbids an empty `meshes` array, so the key is dropped entirely.
+ ...(withMesh
+ ? {
+ meshes: [{
+ name: 'TinyQuad',
+ primitives: [{ attributes, indices: indexAccessor, material: 0, mode: 4 }],
+ }],
+ }
+ : {}),
materials: [{
name: 'TinyMat',
pbrMetallicRoughness: { baseColorFactor: color, metallicFactor: 0, roughnessFactor: 0.6 },
diff --git a/src/editor/workshop.test.ts b/src/editor/workshop.test.ts
index ff61345..b5aadfc 100644
--- a/src/editor/workshop.test.ts
+++ b/src/editor/workshop.test.ts
@@ -167,8 +167,9 @@ ok(ghost.rejected, 'the ghost slot refuses its own drop')
ok(/blob body/i.test(ghost.message), 'it points you at the blob body slot instead')
// ---- save + restore --------------------------------------------------------
-const savedCount = await workshop.saveLocally()
-ok(savedCount === 1, `saving wrote the one live swap (got ${savedCount})`)
+const saveResult = await workshop.saveLocally()
+ok(saveResult.ok, 'saving succeeded')
+ok(saveResult.saved === 1, `saving wrote the one live swap (got ${saveResult.saved})`)
const persisted = await loadOverrideManifest()
ok(manifestsEqual(workshop.getManifest(), persisted),
'what was saved is exactly what the live game reads back')
@@ -176,7 +177,8 @@ ok(manifestsEqual(workshop.getManifest(), persisted),
const reopened = new Workshop(stage)
swap.clearCustom(BOOT)
const restored = await reopened.restore()
-ok(restored.includes(BOOT), 'reopening the editor brings the swap back')
+ok(restored.restored.includes(BOOT), 'reopening the editor brings the swap back')
+ok(restored.unloadable.length === 0, 'nothing was reported as missing')
ok(bootEntry.fitNode !== null, 'the restored swap is on the stage again')
ok(Math.abs(bootEntry.fitNode!.position.y - 1.7) < 1e-9, 'the restored swap keeps its fit')
diff --git a/src/editor/workshop.ts b/src/editor/workshop.ts
index b9ed438..31daa05 100644
--- a/src/editor/workshop.ts
+++ b/src/editor/workshop.ts
@@ -4,11 +4,24 @@
* Holds the working manifest, owns the drop → store → swap pipeline, and is the
* single code path both the real drag-drop and the demo's scripted drop go
* through (so a green demo means the real button works).
+ *
+ * TWO INVARIANTS, both learned the hard way:
+ *
+ * 1. NOTHING LEAVES THIS CLASS AS A REJECTED PROMISE. Every caller is a DOM
+ * handler that can only `void` the result, so a rejection used to vanish
+ * into an unhandled-rejection with the status line frozen mid-sentence.
+ * Storage failures come back as a `rejected` result carrying a sentence a
+ * person can read, and the slot stays procedural.
+ *
+ * 2. A SAVED SWAP IS NEVER DESTROYED BY THE EDITOR. An entry whose file will
+ * not re-load stays in the manifest, flagged unloadable, so the next save
+ * cannot prune its bytes away. Losing a file the user cannot regenerate is
+ * worse than showing them a broken row.
*/
import * as THREE from 'three'
import type { EditorStage } from './stage'
-import { cloneAsset, loadGlb } from './glb'
-import { inspectPaintability } from './paintability'
+import { loadGlb } from './glb'
+import { firstMesh, inspectPaintability } from './paintability'
import type { PaintableInfo } from './paintability'
import {
cleanManifest, idbKey, isIdbUrl, serializeManifest,
@@ -31,11 +44,36 @@ export interface DropResult {
message: string
}
+export interface SaveResult {
+ /** How many swaps are now saved. */
+ saved: number
+ /** False when the browser refused to keep them. */
+ ok: boolean
+ /** Plain-language outcome for the status line. */
+ message: string
+}
+
+export interface RestoreResult {
+ /** Slots whose model came back and is on the stage. */
+ restored: string[]
+ /** Slots still in the manifest whose file could not be read back. */
+ unloadable: string[]
+}
+
+/** What the user is told when the browser will not keep a file. */
+const NO_ROOM =
+ "There wasn't room in this browser to keep that file, so the original is still there. " +
+ 'Try a smaller model, or clear some swaps you no longer want.'
+
+const NO_MESH = "There's no model inside this file — the original is still there."
+
export class Workshop {
private manifest: Manifest = {}
/** Parsed GLB scenes by url, so a slot switch never re-parses. */
private readonly cache = new Map()
private readonly paintReports = new Map()
+ /** Slots whose stored file would not load: kept, shown, never pruned. */
+ private readonly unloadable = new Set()
private readonly stage: EditorStage
// Plain assignment rather than a parameter property: node's type-stripping
@@ -60,19 +98,49 @@ export class Workshop {
return this.manifest[slot] !== undefined
}
- /** Re-hydrate everything the browser remembered from a previous session. */
- async restore(): Promise {
- const stored = await loadOverrideManifest()
+ /** True when this slot's saved file is missing or unreadable. */
+ isUnloadable(slot: string): boolean {
+ return this.unloadable.has(slot)
+ }
+
+ unloadableSlots(): string[] {
+ return [...this.unloadable]
+ }
+
+ /**
+ * Re-hydrate everything the browser remembered from a previous session.
+ * Never throws: no storage is a normal state, not a reason to lose the editor.
+ */
+ async restore(): Promise {
+ let stored: Manifest = {}
+ try {
+ stored = await loadOverrideManifest()
+ } catch (err) {
+ console.warn('[workshop] could not read what was saved last time', err)
+ return { restored: [], unloadable: [] }
+ }
const restored: string[] = []
+ const unloadable: string[] = []
for (const [slot, entry] of Object.entries(stored)) {
if (!this.stage.slots.has(slot)) continue
- const ok = await this.applyEntry(slot, entry)
+ let ok = false
+ try {
+ ok = await this.applyEntry(slot, entry)
+ } catch (err) {
+ console.warn(`[workshop] stored asset for ${slot} could not be restored`, err)
+ }
+ // The entry is kept either way. Dropping it here is what used to make the
+ // next save delete the only copy of the file.
+ this.manifest[slot] = entry
if (ok) {
- this.manifest[slot] = entry
+ this.unloadable.delete(slot)
restored.push(slot)
+ } else {
+ this.unloadable.add(slot)
+ unloadable.push(slot)
}
}
- return restored
+ return { restored, unloadable }
}
/**
@@ -82,8 +150,18 @@ export class Workshop {
*/
async dropFile(slot: string, fileName: string, bytes: ArrayBuffer): Promise {
const spec = slotById(slot)
- if (!spec || !this.stage.slots.has(slot)) {
- return { slot, entry: { url: '' }, rejected: true, message: `No such slot: ${slot}` }
+ if (!spec) {
+ return {
+ slot, entry: { url: '' }, rejected: true,
+ message: "That isn't something this game can swap out.",
+ }
+ }
+ if (!this.stage.slots.has(slot)) {
+ return {
+ slot, entry: { url: '' }, rejected: true,
+ message: `There isn't a ${spec.label.toLowerCase()} anywhere in the course at the ` +
+ 'moment, so there is nothing here to replace.',
+ }
}
if (spec.derivedFrom) {
return {
@@ -103,6 +181,13 @@ export class Workshop {
}
}
+ // EVERY slot, not just the paintable one: a file with no mesh in it used to
+ // be accepted, hide the original and put nothing in its place — a cheerful
+ // success message for an object that had just vanished from the course.
+ if (!firstMesh(scene)) {
+ return { slot, entry: { url: '' }, rejected: true, message: NO_MESH }
+ }
+
let paint: PaintableInfo | undefined
if (spec.paintable) {
paint = inspectPaintability(scene)
@@ -116,8 +201,17 @@ export class Workshop {
}
}
+ // Storage first: a swap the browser cannot keep must not appear on the
+ // stage, or Save would silently drop it and the preview would be a lie.
const key = blobKeyFor(slot, fileName)
- const url = await putGlb(key, fileName, bytes)
+ let url: string
+ try {
+ url = await putGlb(key, fileName, bytes)
+ } catch (err) {
+ console.warn(`[workshop] could not store ${fileName}`, err)
+ this.paintReports.delete(slot)
+ return { slot, entry: { url: '' }, rejected: true, paint, message: NO_ROOM }
+ }
this.cache.set(url, scene)
const previous = this.manifest[slot]
@@ -128,10 +222,14 @@ export class Workshop {
scale: previous?.scale ?? 1,
}
this.manifest[slot] = entry
- this.stage.setCustom(slot, cloneAsset(scene), entry)
+ this.unloadable.delete(slot)
+ this.stage.setCustom(slot, scene, entry)
+ const count = this.stage.slots.get(slot)?.procedural.length ?? 1
return {
slot, entry, paint, rejected: false,
- message: `${fileName} is in the ${spec.label.toLowerCase()} slot.`,
+ message: count > 1
+ ? `${fileName} is now all ${count} of the ${spec.label.toLowerCase()}s.`
+ : `${fileName} is in the ${spec.label.toLowerCase()} slot.`,
}
}
@@ -149,24 +247,52 @@ export class Workshop {
reset(slot: string): void {
delete this.manifest[slot]
this.paintReports.delete(slot)
+ this.unloadable.delete(slot)
this.stage.clearCustom(slot)
}
- /** Write the working manifest where the live game will find it. */
- async saveLocally(): Promise {
+ /**
+ * Write the working manifest where the live game will find it.
+ * Pruning happens only after the write succeeds, and only against a manifest
+ * that still holds every entry — including the ones that would not load.
+ */
+ async saveLocally(): Promise {
const clean = cleanManifest(this.manifest)
- await saveOverrideManifest(clean)
- await pruneOrphans(clean)
- return Object.keys(clean).length
+ const saved = Object.keys(clean).length
+ try {
+ await saveOverrideManifest(clean)
+ } catch (err) {
+ console.warn('[workshop] could not save the manifest', err)
+ return { saved: 0, ok: false, message: NO_ROOM }
+ }
+ try {
+ await pruneOrphans(clean)
+ } catch (err) {
+ // Leftover bytes waste space but break nothing — never fail a save for it.
+ console.warn('[workshop] could not tidy up unused files', err)
+ }
+ return {
+ saved, ok: true,
+ message: saved === 0
+ ? 'Saved — no swaps, so the game looks like it always did.'
+ : `Saved ${saved} swap${saved === 1 ? '' : 's'}. Press "Test drive" to play with them.`,
+ }
}
/** Wipe every override — the game goes back to its shipped look. */
- async clearEverything(): Promise {
+ async clearEverything(): Promise {
for (const slot of Object.keys(this.manifest)) this.stage.clearCustom(slot)
this.manifest = {}
this.paintReports.clear()
+ this.unloadable.clear()
this.cache.clear()
- await clearOverrides()
+ try {
+ await clearOverrides()
+ return true
+ } catch (err) {
+ console.warn('[workshop] could not clear stored swaps', err)
+ return false
+ }
}
/** The exact text the download button hands over. */
@@ -186,22 +312,34 @@ export class Workshop {
console.warn(`[workshop] stored asset for ${slot} could not be read`, err)
return false
}
+ if (!firstMesh(scene)) {
+ console.warn(`[workshop] stored asset for ${slot} has no model in it`)
+ return false
+ }
this.cache.set(entry.url, scene)
const spec = slotById(slot)
if (spec?.paintable) this.paintReports.set(slot, inspectPaintability(scene))
}
- this.stage.setCustom(slot, cloneAsset(scene), entry)
+ this.stage.setCustom(slot, scene, entry)
return true
}
private async readBytes(url: string): Promise {
if (isIdbUrl(url)) {
- const record = await getGlb(idbKey(url))
- return record?.bytes ?? null
+ try {
+ const record = await getGlb(idbKey(url))
+ return record?.bytes ?? null
+ } catch (err) {
+ console.warn(`[workshop] could not read the stored file for ${url}`, err)
+ return null
+ }
}
try {
const res = await fetch(url)
- if (!res.ok) return null
+ if (!res.ok) {
+ console.warn(`[workshop] ${url} answered ${res.status} — keeping the original.`)
+ return null
+ }
return await res.arrayBuffer()
} catch (err) {
console.warn(`[workshop] could not fetch ${url}`, err)