Merge fix-editor: demo DB isolation, mesh-less rejection, per-instance clones, storage-failure handling, private-browsing degrade, canonical slot import

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-19 12:26:44 +10:00
commit d318b4f031
16 changed files with 1217 additions and 197 deletions

View File

@ -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)}

View File

@ -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;

View File

@ -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<string> {
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<void> {
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<void> {
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<void> {
'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<void> {
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<void> {
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<void> {
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)

View File

@ -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<string, unknown>
const dbs = new Map<string, Map<string, Store>>()
const fakeIndexedDb = {
open(name: string) {
const req: Record<string, unknown> = {
result: null, onsuccess: null, onerror: null, onupgradeneeded: null, onblocked: null,
}
const stores = dbs.get(name) ?? new Map<string, Store>()
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 = <T>(result: T): Record<string, unknown> => {
const r: Record<string, unknown> = { 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<string, unknown> = { 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<string> => 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<string, { url: string }>
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<string, unknown>)['machine.boot'],
"the demo's fake spring-boot override never reached the game's database")
console.log(`editor demo-isolation.test: ${passed} assertions passed ✓`)

View File

@ -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<string, unknown>
const dbs = new Map<string, Map<string, Store>>()
/** Flip to make every write fail the way a full disk does. */
let failWrites = false
const fakeIndexedDb = {
open(name: string) {
const req: Record<string, unknown> = {
result: null, onsuccess: null, onerror: null, onupgradeneeded: null, onblocked: null,
}
const stores = dbs.get(name) ?? new Map<string, Store>()
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 = <T>(result: T, fail = false): Record<string, unknown> => {
const r: Record<string, unknown> = {
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<string, unknown> = { 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 ✓`)

View File

@ -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<string, Map<string, unknown>>()
const fakeIndexedDb = {
open(_name: string) {
const req: Record<string, unknown> = {
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 ✓`)

View File

@ -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<IDBDatabase> | null = null
let availability: Promise<boolean> | 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<IDBDatabase> {
if (dbPromise) return dbPromise
dbPromise = new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, DB_VERSION)
const attempt = new Promise<IDBDatabase>((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<T>(
@ -43,8 +99,14 @@ function run<T>(
return openDb().then(
(db) =>
new Promise<T>((resolve, reject) => {
const tx = db.transaction(store, mode)
const req = fn(tx.objectStore(store))
let req: IDBRequest<T>
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<IDBValidKey[]> =>
export const idbClear = (store: string): Promise<unknown> =>
run(store, 'readwrite', (s) => s.clear() as IDBRequest<unknown>)
/** 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<boolean> {
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<void> {
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()
})
}

View File

@ -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<void>
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 => {
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<void> => {
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<void> => {
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.'
})
}

View File

@ -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<string>(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`)
}
}

View File

@ -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<string, SlotStageEntry>()
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
}
}

View File

@ -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<Record<SlotId, Presentation>> = {
'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)

View File

@ -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<string, SlotStageEntry>
/** 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<EditorS
const world = await createWorld(container)
world.scene.fog = null // an editor wants to see the far end of the course
const swap = new SlotSwap()
const swap = new SlotSwap(cloneAsset)
const slots = swap.slots
const register = (id: string, objects: THREE.Object3D[]): void =>
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 = <T>(fn: () => T): { result: T; added: THREE.Object3D[] } => {
@ -127,7 +137,18 @@ export async function createEditorStage(container: HTMLElement): Promise<EditorS
const zoneGroups = zoned.added.filter((o) => 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.

View File

@ -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 `<slot>-<filename>`.
*/
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<Manifest> {
const raw = await idbGet<unknown>(MANIFEST_STORE, MANIFEST_KEY)
if (typeof raw === 'string') return parseManifest(raw)
@ -34,16 +50,31 @@ export const saveOverrideManifest = (manifest: Manifest): Promise<unknown> =>
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<string> {
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<string> {
await idbPut(BLOB_STORE, key, bytes)
return `idb:${key}`
}
export async function getGlb(key: string): Promise<StoredGlb | undefined> {
const raw = await idbGet<StoredGlb>(BLOB_STORE, key)
if (!raw || !(raw.bytes instanceof ArrayBuffer)) return undefined
return raw
const raw = await idbGet<unknown>(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
}
/** `<slot>-<filename>` back to `<filename>` — 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<void> {
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<string[]> {
const live = new Set(
Object.values(manifest).filter((e) => isIdbUrl(e.url)).map((e) => idbKey(e.url)),

View File

@ -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 },

View File

@ -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')

View File

@ -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<string, THREE.Object3D>()
private readonly paintReports = new Map<string, PaintableInfo>()
/** Slots whose stored file would not load: kept, shown, never pruned. */
private readonly unloadable = new Set<string>()
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<string[]> {
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<RestoreResult> {
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<DropResult> {
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<number> {
/**
* 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<SaveResult> {
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<void> {
async clearEverything(): Promise<boolean> {
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<ArrayBuffer | null> {
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)