Merge fix-editor: demo DB isolation, mesh-less rejection, per-instance clones, storage-failure handling, private-browsing degrade, canonical slot import
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
commit
d318b4f031
@ -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) => {
|
||||
console.error(err)
|
||||
check(false, 'the demo crashed', String(err))
|
||||
})
|
||||
void run()
|
||||
.catch((err: unknown) => {
|
||||
console.error(err)
|
||||
check(false, 'the demo crashed', String(err))
|
||||
})
|
||||
.finally(summarise)
|
||||
|
||||
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) => {
|
||||
const tx = db.transaction(store, mode)
|
||||
const req = fn(tx.objectStore(store))
|
||||
let req: IDBRequest<T>
|
||||
try {
|
||||
const tx = db.transaction(store, mode)
|
||||
req = fn(tx.objectStore(store))
|
||||
} catch (err) {
|
||||
reject(err instanceof Error ? err : new Error(String(err)))
|
||||
return
|
||||
}
|
||||
req.onsuccess = () => resolve(req.result)
|
||||
req.onerror = () => reject(req.error ?? new Error('IndexedDB request failed'))
|
||||
}),
|
||||
@ -66,6 +128,30 @@ export const idbKeys = (store: string): Promise<IDBValidKey[]> =>
|
||||
export const idbClear = (store: string): Promise<unknown> =>
|
||||
run(store, 'readwrite', (s) => s.clear() as IDBRequest<unknown>)
|
||||
|
||||
/** True when this browser can hold overrides at all (private modes sometimes can't). */
|
||||
export const idbAvailable = (): boolean =>
|
||||
typeof indexedDB !== 'undefined' && indexedDB !== null
|
||||
/**
|
||||
* True when this browser can actually hold overrides. `typeof indexedDB` is not
|
||||
* an answer: Safari/Firefox private windows expose the object and then fail the
|
||||
* open, which is exactly the case the editor has to survive. So open it once for
|
||||
* real and cache the BOOLEAN.
|
||||
*/
|
||||
export function idbAvailable(): Promise<boolean> {
|
||||
if (!availability) availability = openDb().then(() => true, () => false)
|
||||
return availability
|
||||
}
|
||||
|
||||
/** Delete a whole database — the demo's cleanup, never used on the real one. */
|
||||
export function deleteDb(name: string): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
if (typeof indexedDB === 'undefined' || indexedDB === null) return resolve()
|
||||
if (name === dbName) resetIdb()
|
||||
let req: IDBOpenDBRequest
|
||||
try {
|
||||
req = indexedDB.deleteDatabase(name)
|
||||
} catch {
|
||||
return resolve()
|
||||
}
|
||||
req.onsuccess = () => resolve()
|
||||
req.onerror = () => resolve()
|
||||
req.onblocked = () => resolve()
|
||||
})
|
||||
}
|
||||
|
||||
@ -42,6 +42,12 @@ interface FitRow {
|
||||
step: number
|
||||
}
|
||||
|
||||
/** Shown when the browser will not keep anything (private windows, blocked storage). */
|
||||
const READ_ONLY_MESSAGE =
|
||||
"This window won't let me keep anything, so nothing you do here will be remembered. " +
|
||||
'You can still try models out and download a manifest.json. Open the workshop in a ' +
|
||||
'normal window to save.'
|
||||
|
||||
const AXES = ['left / right', 'up / down', 'front / back'] as const
|
||||
|
||||
const FIT_ROWS: FitRow[] = [
|
||||
@ -82,6 +88,8 @@ export async function bootEditor(root: HTMLElement): Promise<{
|
||||
exportManifest: () => string
|
||||
clearAll: () => Promise<void>
|
||||
status: () => string
|
||||
/** False when this browser refuses to store anything (private windows). */
|
||||
canSave: () => boolean
|
||||
}> {
|
||||
root.classList.add('workshop')
|
||||
|
||||
@ -195,9 +203,17 @@ export async function bootEditor(root: HTMLElement): Promise<{
|
||||
for (const [id, button] of slotButtons) {
|
||||
const spec = slotById(id)!
|
||||
const state = button.querySelector('.slot-state') as HTMLElement
|
||||
if (spec.derivedFrom) {
|
||||
if (!stage.slots.has(id)) {
|
||||
// The runtime has a slot for this, but this course never builds one.
|
||||
// Saying so beats a row that looks live and does nothing when clicked.
|
||||
state.textContent = 'not in this course'
|
||||
button.dataset.state = 'absent'
|
||||
} else if (spec.derivedFrom) {
|
||||
state.textContent = 'copies the blob'
|
||||
button.dataset.state = 'derived'
|
||||
} else if (workshop.isUnloadable(id)) {
|
||||
state.textContent = 'file missing'
|
||||
button.dataset.state = 'broken'
|
||||
} else if (workshop.hasCustom(id)) {
|
||||
state.textContent = 'yours'
|
||||
button.dataset.state = 'custom'
|
||||
@ -282,11 +298,15 @@ export async function bootEditor(root: HTMLElement): Promise<{
|
||||
selected = slot
|
||||
const spec = slotById(slot)
|
||||
if (!spec) return
|
||||
const absent = !stage.slots.has(slot)
|
||||
fitTitle.textContent = spec.label
|
||||
fitHint.textContent = spec.derivedFrom
|
||||
? `${spec.hint} There is nothing to drop here.`
|
||||
: spec.hint
|
||||
dropZone.style.display = spec.derivedFrom ? 'none' : 'flex'
|
||||
fitHint.textContent = absent
|
||||
? `${spec.hint} There isn't one of these anywhere in the course at the moment, ` +
|
||||
'so there is nothing here to replace.'
|
||||
: spec.derivedFrom
|
||||
? `${spec.hint} There is nothing to drop here.`
|
||||
: spec.hint
|
||||
dropZone.style.display = spec.derivedFrom || absent ? 'none' : 'flex'
|
||||
stage.highlight(slot)
|
||||
stage.focus(slot)
|
||||
renderPaintCard(spec)
|
||||
@ -301,7 +321,16 @@ export async function bootEditor(root: HTMLElement): Promise<{
|
||||
return
|
||||
}
|
||||
setStatus(`Reading ${file.name}…`)
|
||||
const bytes = await file.arrayBuffer()
|
||||
let bytes: ArrayBuffer
|
||||
try {
|
||||
// The file can be moved or renamed between picking it and reading it, and
|
||||
// then this throws — leaving the status stuck on "Reading…" forever.
|
||||
bytes = await file.arrayBuffer()
|
||||
} catch (err) {
|
||||
console.warn('[workshop] could not read the dropped file', err)
|
||||
setStatus(`I couldn't read ${file.name}. Is it still where it was?`, 'warn')
|
||||
return
|
||||
}
|
||||
const result = await workshop.dropFile(selected, file.name, bytes)
|
||||
setStatus(result.message, result.rejected ? 'warn' : 'ok')
|
||||
const spec = slotById(selected)
|
||||
@ -310,6 +339,14 @@ export async function bootEditor(root: HTMLElement): Promise<{
|
||||
refreshSlotStates()
|
||||
}
|
||||
|
||||
/** Every DOM handler funnels through here: nothing may end as a dead promise. */
|
||||
const guard = (label: string, work: () => Promise<void>): void => {
|
||||
void work().catch((err: unknown) => {
|
||||
console.error(`[workshop] ${label} failed`, err)
|
||||
setStatus('Something went wrong there and nothing was changed. Try again.', 'warn')
|
||||
})
|
||||
}
|
||||
|
||||
const stop = (e: Event): void => { e.preventDefault(); e.stopPropagation() }
|
||||
for (const type of ['dragenter', 'dragover'] as const) {
|
||||
dropZone.addEventListener(type, (e) => { stop(e); dropZone.dataset.hot = 'true' })
|
||||
@ -319,12 +356,12 @@ export async function bootEditor(root: HTMLElement): Promise<{
|
||||
}
|
||||
dropZone.addEventListener('drop', (e) => {
|
||||
const file = (e as DragEvent).dataTransfer?.files?.[0]
|
||||
if (file) void handleFile(file)
|
||||
if (file) guard('the drop', () => handleFile(file))
|
||||
})
|
||||
dropZone.addEventListener('click', () => filePicker.click())
|
||||
filePicker.addEventListener('change', () => {
|
||||
const file = filePicker.files?.[0]
|
||||
if (file) void handleFile(file)
|
||||
if (file) guard('the drop', () => handleFile(file))
|
||||
filePicker.value = ''
|
||||
})
|
||||
// Dropping anywhere else must not make the browser navigate to the file.
|
||||
@ -343,23 +380,29 @@ export async function bootEditor(root: HTMLElement): Promise<{
|
||||
})
|
||||
|
||||
const save = async (): Promise<void> => {
|
||||
if (!idbAvailable()) {
|
||||
setStatus("This browser won't let me save anything locally.", 'warn')
|
||||
if (!canSave) {
|
||||
setStatus(READ_ONLY_MESSAGE, 'warn')
|
||||
return
|
||||
}
|
||||
const count = await workshop.saveLocally()
|
||||
setStatus(count === 0
|
||||
? 'Saved — no swaps, so the game looks like it always did.'
|
||||
: `Saved ${count} swap${count === 1 ? '' : 's'}. Press "Test drive" to play with them.`)
|
||||
const result = await workshop.saveLocally()
|
||||
setStatus(result.message, result.ok ? 'ok' : 'warn')
|
||||
}
|
||||
saveBtn.addEventListener('click', () => void save())
|
||||
saveBtn.addEventListener('click', () => guard('saving', save))
|
||||
|
||||
testBtn.addEventListener('click', () => {
|
||||
void save().then(() => {
|
||||
setStatus('Opened the game in a new tab — your swaps are already in it.')
|
||||
open(import.meta.env.BASE_URL, '_blank')
|
||||
})
|
||||
})
|
||||
testBtn.addEventListener('click', () => guard('the test drive', async () => {
|
||||
if (!canSave) {
|
||||
setStatus(READ_ONLY_MESSAGE, 'warn')
|
||||
return
|
||||
}
|
||||
const result = await workshop.saveLocally()
|
||||
if (!result.ok) {
|
||||
// Opening the game now would show none of what is on screen here.
|
||||
setStatus(result.message, 'warn')
|
||||
return
|
||||
}
|
||||
setStatus('Opened the game in a new tab — your swaps are already in it.')
|
||||
open(import.meta.env.BASE_URL, '_blank')
|
||||
}))
|
||||
|
||||
const exportManifest = (): string => workshop.exportText()
|
||||
exportBtn.addEventListener('click', () => {
|
||||
@ -374,26 +417,54 @@ export async function bootEditor(root: HTMLElement): Promise<{
|
||||
})
|
||||
|
||||
const clearAll = async (): Promise<void> => {
|
||||
await workshop.clearEverything()
|
||||
const ok = await workshop.clearEverything()
|
||||
refreshSlotStates()
|
||||
renderFitControls()
|
||||
if (selected) {
|
||||
const spec = slotById(selected)
|
||||
if (spec) renderPaintCard(spec)
|
||||
}
|
||||
setStatus('All swaps cleared. The game is back to how it ships.')
|
||||
setStatus(ok
|
||||
? 'All swaps cleared. The game is back to how it ships.'
|
||||
: "Everything is off the screen, but this browser wouldn't let me clear what it had stored.",
|
||||
ok ? 'ok' : 'warn')
|
||||
}
|
||||
clearBtn.addEventListener('click', () => {
|
||||
if (confirm('Throw away every model you dropped in? This cannot be undone.')) void clearAll()
|
||||
if (confirm('Throw away every model you dropped in? This cannot be undone.')) {
|
||||
guard('clearing', clearAll)
|
||||
}
|
||||
})
|
||||
|
||||
// ---- can this browser keep anything? -------------------------------------
|
||||
// A real open(), not a `typeof indexedDB` guess: private windows expose the
|
||||
// object and then fail. Failing that probe means a look-but-don't-save
|
||||
// editor, NOT an error page over a stage that built perfectly well.
|
||||
const canSave = await idbAvailable()
|
||||
if (!canSave) {
|
||||
saveBtn.disabled = true
|
||||
clearBtn.disabled = true
|
||||
testBtn.disabled = true
|
||||
saveBtn.textContent = "Can't save in this window"
|
||||
const banner = el('p', 'readonly-banner', READ_ONLY_MESSAGE)
|
||||
left.insertBefore(banner, bigActions)
|
||||
setStatus(READ_ONLY_MESSAGE, 'warn')
|
||||
}
|
||||
|
||||
// ---- restore a previous session -----------------------------------------
|
||||
refreshSlotStates()
|
||||
renderFitControls()
|
||||
if (idbAvailable()) {
|
||||
const restored = await workshop.restore()
|
||||
if (restored.length) {
|
||||
refreshSlotStates()
|
||||
if (canSave) {
|
||||
const { restored, unloadable } = await workshop.restore()
|
||||
if (restored.length || unloadable.length) refreshSlotStates()
|
||||
const names = unloadable.map((id) => slotById(id)?.label ?? id).join(', ')
|
||||
if (unloadable.length) {
|
||||
// Kept, not dropped: the entry stays in the manifest so saving again can
|
||||
// never delete the stored file it points at.
|
||||
setStatus(
|
||||
`Brought back ${restored.length} swap${restored.length === 1 ? '' : 's'}. ` +
|
||||
`I couldn't find the file for: ${names}. Those are still listed — drop the ` +
|
||||
'model in again, or press "Put the original back" on each one.', 'warn')
|
||||
} else if (restored.length) {
|
||||
setStatus(`Brought back ${restored.length} swap${restored.length === 1 ? '' : 's'} from last time.`)
|
||||
}
|
||||
}
|
||||
@ -407,6 +478,7 @@ export async function bootEditor(root: HTMLElement): Promise<{
|
||||
exportManifest,
|
||||
clearAll,
|
||||
status: () => statusLine.textContent ?? '',
|
||||
canSave: () => canSave,
|
||||
}
|
||||
}
|
||||
|
||||
@ -414,8 +486,12 @@ export async function bootEditor(root: HTMLElement): Promise<{
|
||||
// `data-manual` and calls bootEditor itself, so it never gets two editors.
|
||||
const host = document.getElementById('workshop')
|
||||
if (host && !host.hasAttribute('data-manual')) {
|
||||
void bootEditor(host).catch((err) => {
|
||||
void bootEditor(host).catch((err: unknown) => {
|
||||
// Storage problems no longer reach here — bootEditor degrades instead. What
|
||||
// is left is a genuinely dead page (no WebGL), so a plain sentence is right.
|
||||
console.error(err)
|
||||
host.textContent = 'The workshop could not start: ' + String(err)
|
||||
host.textContent =
|
||||
"The workshop couldn't start in this browser. It needs 3D graphics turned on — " +
|
||||
'try a different browser, or check your browser settings for hardware acceleration.'
|
||||
})
|
||||
}
|
||||
|
||||
@ -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)
|
||||
const fitNode = new THREE.Group()
|
||||
fitNode.name = `fit:${id}`
|
||||
dressAsset(asset)
|
||||
fitNode.add(asset)
|
||||
s.parent.add(fitNode)
|
||||
s.fitNode = fitNode
|
||||
for (const p of s.procedural) p.visible = false
|
||||
s.procedural.forEach((original, i) => {
|
||||
const fitNode = new THREE.Group()
|
||||
fitNode.name = s.procedural.length > 1 ? `fit:${id}#${i}` : `fit:${id}`
|
||||
// Always a copy, never `asset` itself: the caller caches parsed scenes and
|
||||
// re-uses them across slots, and a scene graph node can only have one
|
||||
// parent — adopting the original would tear it out of wherever it was.
|
||||
const instance = this.clone(asset)
|
||||
dressAsset(instance)
|
||||
fitNode.add(instance)
|
||||
;(original.parent ?? s.parent).add(fitNode)
|
||||
s.fitNodes.push(fitNode)
|
||||
original.visible = false
|
||||
})
|
||||
s.fitNode = s.fitNodes[0] ?? null
|
||||
this.applyFit(id, entry)
|
||||
}
|
||||
|
||||
clearCustom(id: string): void {
|
||||
const s = this.slots.get(id)
|
||||
if (!s) return
|
||||
if (s.fitNode) {
|
||||
s.parent.remove(s.fitNode)
|
||||
s.fitNode = null
|
||||
}
|
||||
for (const node of s.fitNodes) node.parent?.remove(node)
|
||||
s.fitNodes = []
|
||||
s.fitNode = null
|
||||
for (const p of s.procedural) p.visible = true
|
||||
}
|
||||
|
||||
applyFit(id: string, entry: SlotEntry): void {
|
||||
const s = this.slots.get(id)
|
||||
if (!s?.fitNode) return
|
||||
const fit = composeFit(s.base, entry)
|
||||
s.fitNode.position.set(fit.position[0], fit.position[1], fit.position[2])
|
||||
s.fitNode.rotation.set(
|
||||
fit.rotationDeg[0] * DEG, fit.rotationDeg[1] * DEG, fit.rotationDeg[2] * DEG)
|
||||
s.fitNode.scale.set(fit.scale[0], fit.scale[1], fit.scale[2])
|
||||
if (!s) return
|
||||
s.fitNodes.forEach((node, i) => {
|
||||
const base = s.bases[i] ?? s.base
|
||||
const extra = s.extraScale[i] ?? [1, 1, 1]
|
||||
const fit = composeFit(base, entry)
|
||||
node.position.set(fit.position[0], fit.position[1], fit.position[2])
|
||||
node.rotation.set(
|
||||
fit.rotationDeg[0] * DEG, fit.rotationDeg[1] * DEG, fit.rotationDeg[2] * DEG)
|
||||
node.scale.set(
|
||||
fit.scale[0] * extra[0], fit.scale[1] * extra[1], fit.scale[2] * extra[2])
|
||||
})
|
||||
}
|
||||
|
||||
/** What the fit panel is acting on: the custom asset if any, else the original. */
|
||||
currentObject(id: string): THREE.Object3D | null {
|
||||
const s = this.slots.get(id)
|
||||
if (!s) return null
|
||||
return s.fitNode ?? s.procedural[0] ?? null
|
||||
return s.fitNodes[0] ?? s.procedural[0] ?? null
|
||||
}
|
||||
|
||||
/** True when this slot is showing a custom asset. */
|
||||
isCustom(id: string): boolean {
|
||||
return this.slots.get(id)?.fitNode != null
|
||||
return (this.slots.get(id)?.fitNodes.length ?? 0) > 0
|
||||
}
|
||||
}
|
||||
|
||||
@ -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' }],
|
||||
meshes: [{
|
||||
name: 'TinyQuad',
|
||||
primitives: [{ attributes, indices: indexAccessor, material: 0, mode: 4 }],
|
||||
}],
|
||||
nodes: withMesh ? [{ mesh: 0, name: 'TinyQuad' }] : [{ name: 'EmptyNode' }],
|
||||
// glTF forbids an empty `meshes` array, so the key is dropped entirely.
|
||||
...(withMesh
|
||||
? {
|
||||
meshes: [{
|
||||
name: 'TinyQuad',
|
||||
primitives: [{ attributes, indices: indexAccessor, material: 0, mode: 4 }],
|
||||
}],
|
||||
}
|
||||
: {}),
|
||||
materials: [{
|
||||
name: 'TinyMat',
|
||||
pbrMetallicRoughness: { baseColorFactor: color, metallicFactor: 0, roughnessFactor: 0.6 },
|
||||
|
||||
@ -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)
|
||||
let ok = false
|
||||
try {
|
||||
ok = await this.applyEntry(slot, entry)
|
||||
} catch (err) {
|
||||
console.warn(`[workshop] stored asset for ${slot} could not be restored`, err)
|
||||
}
|
||||
// The entry is kept either way. Dropping it here is what used to make the
|
||||
// next save delete the only copy of the file.
|
||||
this.manifest[slot] = entry
|
||||
if (ok) {
|
||||
this.manifest[slot] = entry
|
||||
this.unloadable.delete(slot)
|
||||
restored.push(slot)
|
||||
} else {
|
||||
this.unloadable.add(slot)
|
||||
unloadable.push(slot)
|
||||
}
|
||||
}
|
||||
return restored
|
||||
return { restored, unloadable }
|
||||
}
|
||||
|
||||
/**
|
||||
@ -82,8 +150,18 @@ export class Workshop {
|
||||
*/
|
||||
async dropFile(slot: string, fileName: string, bytes: ArrayBuffer): Promise<DropResult> {
|
||||
const spec = slotById(slot)
|
||||
if (!spec || !this.stage.slots.has(slot)) {
|
||||
return { slot, entry: { url: '' }, rejected: true, message: `No such slot: ${slot}` }
|
||||
if (!spec) {
|
||||
return {
|
||||
slot, entry: { url: '' }, rejected: true,
|
||||
message: "That isn't something this game can swap out.",
|
||||
}
|
||||
}
|
||||
if (!this.stage.slots.has(slot)) {
|
||||
return {
|
||||
slot, entry: { url: '' }, rejected: true,
|
||||
message: `There isn't a ${spec.label.toLowerCase()} anywhere in the course at the ` +
|
||||
'moment, so there is nothing here to replace.',
|
||||
}
|
||||
}
|
||||
if (spec.derivedFrom) {
|
||||
return {
|
||||
@ -103,6 +181,13 @@ export class Workshop {
|
||||
}
|
||||
}
|
||||
|
||||
// EVERY slot, not just the paintable one: a file with no mesh in it used to
|
||||
// be accepted, hide the original and put nothing in its place — a cheerful
|
||||
// success message for an object that had just vanished from the course.
|
||||
if (!firstMesh(scene)) {
|
||||
return { slot, entry: { url: '' }, rejected: true, message: NO_MESH }
|
||||
}
|
||||
|
||||
let paint: PaintableInfo | undefined
|
||||
if (spec.paintable) {
|
||||
paint = inspectPaintability(scene)
|
||||
@ -116,8 +201,17 @@ export class Workshop {
|
||||
}
|
||||
}
|
||||
|
||||
// Storage first: a swap the browser cannot keep must not appear on the
|
||||
// stage, or Save would silently drop it and the preview would be a lie.
|
||||
const key = blobKeyFor(slot, fileName)
|
||||
const url = await putGlb(key, fileName, bytes)
|
||||
let url: string
|
||||
try {
|
||||
url = await putGlb(key, fileName, bytes)
|
||||
} catch (err) {
|
||||
console.warn(`[workshop] could not store ${fileName}`, err)
|
||||
this.paintReports.delete(slot)
|
||||
return { slot, entry: { url: '' }, rejected: true, paint, message: NO_ROOM }
|
||||
}
|
||||
this.cache.set(url, scene)
|
||||
|
||||
const previous = this.manifest[slot]
|
||||
@ -128,10 +222,14 @@ export class Workshop {
|
||||
scale: previous?.scale ?? 1,
|
||||
}
|
||||
this.manifest[slot] = entry
|
||||
this.stage.setCustom(slot, cloneAsset(scene), entry)
|
||||
this.unloadable.delete(slot)
|
||||
this.stage.setCustom(slot, scene, entry)
|
||||
const count = this.stage.slots.get(slot)?.procedural.length ?? 1
|
||||
return {
|
||||
slot, entry, paint, rejected: false,
|
||||
message: `${fileName} is in the ${spec.label.toLowerCase()} slot.`,
|
||||
message: count > 1
|
||||
? `${fileName} is now all ${count} of the ${spec.label.toLowerCase()}s.`
|
||||
: `${fileName} is in the ${spec.label.toLowerCase()} slot.`,
|
||||
}
|
||||
}
|
||||
|
||||
@ -149,24 +247,52 @@ export class Workshop {
|
||||
reset(slot: string): void {
|
||||
delete this.manifest[slot]
|
||||
this.paintReports.delete(slot)
|
||||
this.unloadable.delete(slot)
|
||||
this.stage.clearCustom(slot)
|
||||
}
|
||||
|
||||
/** Write the working manifest where the live game will find it. */
|
||||
async saveLocally(): Promise<number> {
|
||||
/**
|
||||
* Write the working manifest where the live game will find it.
|
||||
* Pruning happens only after the write succeeds, and only against a manifest
|
||||
* that still holds every entry — including the ones that would not load.
|
||||
*/
|
||||
async saveLocally(): Promise<SaveResult> {
|
||||
const clean = cleanManifest(this.manifest)
|
||||
await saveOverrideManifest(clean)
|
||||
await pruneOrphans(clean)
|
||||
return Object.keys(clean).length
|
||||
const saved = Object.keys(clean).length
|
||||
try {
|
||||
await saveOverrideManifest(clean)
|
||||
} catch (err) {
|
||||
console.warn('[workshop] could not save the manifest', err)
|
||||
return { saved: 0, ok: false, message: NO_ROOM }
|
||||
}
|
||||
try {
|
||||
await pruneOrphans(clean)
|
||||
} catch (err) {
|
||||
// Leftover bytes waste space but break nothing — never fail a save for it.
|
||||
console.warn('[workshop] could not tidy up unused files', err)
|
||||
}
|
||||
return {
|
||||
saved, ok: true,
|
||||
message: saved === 0
|
||||
? 'Saved — no swaps, so the game looks like it always did.'
|
||||
: `Saved ${saved} swap${saved === 1 ? '' : 's'}. Press "Test drive" to play with them.`,
|
||||
}
|
||||
}
|
||||
|
||||
/** Wipe every override — the game goes back to its shipped look. */
|
||||
async clearEverything(): Promise<void> {
|
||||
async clearEverything(): Promise<boolean> {
|
||||
for (const slot of Object.keys(this.manifest)) this.stage.clearCustom(slot)
|
||||
this.manifest = {}
|
||||
this.paintReports.clear()
|
||||
this.unloadable.clear()
|
||||
this.cache.clear()
|
||||
await clearOverrides()
|
||||
try {
|
||||
await clearOverrides()
|
||||
return true
|
||||
} catch (err) {
|
||||
console.warn('[workshop] could not clear stored swaps', err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** The exact text the download button hands over. */
|
||||
@ -186,22 +312,34 @@ export class Workshop {
|
||||
console.warn(`[workshop] stored asset for ${slot} could not be read`, err)
|
||||
return false
|
||||
}
|
||||
if (!firstMesh(scene)) {
|
||||
console.warn(`[workshop] stored asset for ${slot} has no model in it`)
|
||||
return false
|
||||
}
|
||||
this.cache.set(entry.url, scene)
|
||||
const spec = slotById(slot)
|
||||
if (spec?.paintable) this.paintReports.set(slot, inspectPaintability(scene))
|
||||
}
|
||||
this.stage.setCustom(slot, cloneAsset(scene), entry)
|
||||
this.stage.setCustom(slot, scene, entry)
|
||||
return true
|
||||
}
|
||||
|
||||
private async readBytes(url: string): Promise<ArrayBuffer | null> {
|
||||
if (isIdbUrl(url)) {
|
||||
const record = await getGlb(idbKey(url))
|
||||
return record?.bytes ?? null
|
||||
try {
|
||||
const record = await getGlb(idbKey(url))
|
||||
return record?.bytes ?? null
|
||||
} catch (err) {
|
||||
console.warn(`[workshop] could not read the stored file for ${url}`, err)
|
||||
return null
|
||||
}
|
||||
}
|
||||
try {
|
||||
const res = await fetch(url)
|
||||
if (!res.ok) return null
|
||||
if (!res.ok) {
|
||||
console.warn(`[workshop] ${url} answered ${res.status} — keeping the original.`)
|
||||
return null
|
||||
}
|
||||
return await res.arrayBuffer()
|
||||
} catch (err) {
|
||||
console.warn(`[workshop] could not fetch ${url}`, err)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user