fix(editor): stop the workshop destroying the user's work
Seven hardening fixes on the lane-J editor, several sharing one root cause.
- The demo ran against the LIVE database. Opening demos/lane-j.html deleted
every saved model (pruneOrphans treats anything the demo's manifest omits as
garbage) and an interrupted run left a fake machine.boot override the real
game would load. The database name is now a variable, the demo uses a
throwaway one, deletes it in a finally, and fingerprints the real store
before/after so the run FAILS if it moved.
- A .glb with no mesh was accepted on every non-paintable slot: original hidden,
nothing in its place, cheerful success message, savable. The mesh check now
runs for every slot, before anything is written.
- Multi-object slots (nine puddles, every cannon barrel) hid all instances and
added one replacement. One fit node per instance now, each at its own pose and
its own footprint scale — the same contract the runtime's per-instance
attachSlot already implements.
- Storage failures rejected into void-discarded promises, freezing the UI on
"Reading…". Nothing leaves Workshop as a rejection any more: failures come
back as results carrying a sentence a person can read, and every DOM handler
goes through one guard.
- idbAvailable() was a typeof check, so private windows passed it and then blew
up, replacing the whole built UI with a raw error string. It now probes open()
once and caches the boolean; failure means a labelled read-only editor.
- An override whose GLB failed to reload was dropped from the manifest, after
which the next save deleted its bytes for good. Failed entries are kept,
flagged unloadable, surfaced in the slot list, and never pruned.
- The editor kept its own slot list, which disagreed with the runtime's in both
directions. It now imports SLOT_IDS/SLOT_LABELS from src/assets/slots and
keeps only presentation data, keyed by SlotId so describing a slot the runtime
lacks is a compile error.
Also fixes a cross-lane mismatch found on the way: the editor stored GLBs as a
{name,bytes,savedAt} wrapper while the game's reader feeds the record straight
into new Blob([buf]). Writes are raw ArrayBuffer now (reads stay tolerant of the
old shape), so a saved swap can actually load in the game.
Build passes; 312 assertions across 9 headless test files, 49 of them new.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
f827f6f861
commit
624caf08da
@ -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)}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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) => {
|
||||
void run()
|
||||
.catch((err: unknown) => {
|
||||
console.error(err)
|
||||
check(false, 'the demo crashed', String(err))
|
||||
})
|
||||
.finally(summarise)
|
||||
|
||||
126
src/editor/demo-isolation.test.ts
Normal file
126
src/editor/demo-isolation.test.ts
Normal 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 ✓`)
|
||||
220
src/editor/hardening.test.ts
Normal file
220
src/editor/hardening.test.ts
Normal 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 ✓`)
|
||||
88
src/editor/idb-degrade.test.ts
Normal file
88
src/editor/idb-degrade.test.ts
Normal 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 ✓`)
|
||||
@ -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) => {
|
||||
let req: IDBRequest<T>
|
||||
try {
|
||||
const tx = db.transaction(store, mode)
|
||||
const req = fn(tx.objectStore(store))
|
||||
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()
|
||||
})
|
||||
}
|
||||
|
||||
@ -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
|
||||
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 ? 'none' : 'flex'
|
||||
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(() => {
|
||||
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.'
|
||||
})
|
||||
}
|
||||
|
||||
@ -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`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
s.procedural.forEach((original, i) => {
|
||||
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
|
||||
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)
|
||||
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(
|
||||
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)
|
||||
s.fitNode.scale.set(fit.scale[0], fit.scale[1], fit.scale[2])
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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)),
|
||||
|
||||
@ -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' }],
|
||||
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 },
|
||||
|
||||
@ -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')
|
||||
|
||||
|
||||
@ -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)
|
||||
if (ok) {
|
||||
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.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)
|
||||
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)
|
||||
return Object.keys(clean).length
|
||||
} 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()
|
||||
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)) {
|
||||
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)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user