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)