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-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="custom"] .slot-state{background:#e6f8ea;color:#1c7a35}
|
||||||
.slot[data-state="derived"] .slot-state{background:#f3ecff;color:#6b3fa0}
|
.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}
|
.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{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.primary{background:var(--pick);border-color:var(--pick);color:#fff}
|
||||||
.action.danger{color:var(--bad);border-color:#f6d2cf}
|
.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)}
|
.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}
|
color:var(--ink-soft);white-space:nowrap}
|
||||||
.slot[data-state="custom"] .slot-state{background:#e6f8ea;color:#1c7a35}
|
.slot[data-state="custom"] .slot-state{background:#e6f8ea;color:#1c7a35}
|
||||||
.slot[data-state="derived"] .slot-state{background:#f3ecff;color:#6b3fa0}
|
.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}
|
.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;
|
.action{padding:12px;border:1px solid var(--line);border-radius:11px;background:#fff;
|
||||||
cursor:pointer;font-weight:600}
|
cursor:pointer;font-weight:600}
|
||||||
.action:hover{border-color:var(--pick)}
|
.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.primary{background:var(--pick);border-color:var(--pick);color:#fff}
|
||||||
.action.danger{color:var(--bad);border-color:#f6d2cf}
|
.action.danger{color:var(--bad);border-color:#f6d2cf}
|
||||||
.status{margin:0;padding:10px 12px;border-radius:10px;background:#eef3f8;
|
.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
|
* fit controls move it, the export round-trips, reset restores the procedural
|
||||||
* build, and Clear leaves nothing behind.
|
* 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
|
* 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
|
* (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
|
* 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'
|
} from '../editor/manifest-io'
|
||||||
import { buildTinyGlb } from '../editor/tiny-glb'
|
import { buildTinyGlb } from '../editor/tiny-glb'
|
||||||
import { getGlb, loadOverrideManifest } from '../editor/store'
|
import { getGlb, loadOverrideManifest } from '../editor/store'
|
||||||
|
import {
|
||||||
|
BLOB_STORE, DEFAULT_DB_NAME, deleteDb, idbKeys, setDbName,
|
||||||
|
} from '../editor/idb'
|
||||||
|
|
||||||
const SLOT = 'machine.boot'
|
const SLOT = 'machine.boot'
|
||||||
const BOOT_URL = import.meta.env.BASE_URL + 'assets/meshes/prop-spring-boot.glb'
|
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')!
|
const out = document.getElementById('results')!
|
||||||
let passed = 0
|
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 }
|
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> {
|
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 editor = await bootEditor(document.getElementById('workshop')!)
|
||||||
const { stage, workshop } = editor
|
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 ------------------------------
|
// ---- a clean session must be all-procedural ------------------------------
|
||||||
check(stage.slots.has(SLOT), 'the spring boot slot exists on the stage')
|
check(stage.slots.has(SLOT), 'the spring boot slot exists on the stage')
|
||||||
check(!workshop.hasCustom(SLOT), 'nothing is swapped before the drop')
|
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')
|
'the dropped model is actually inside the wrapper')
|
||||||
check(entryRef.procedural.every((p) => !p.visible),
|
check(entryRef.procedural.every((p) => !p.visible),
|
||||||
'the procedural boot is hidden (swapped, not stacked)')
|
'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,
|
check(entryRef.procedural[0]!.children.length === proceduralChildCount,
|
||||||
'the procedural boot was hidden, not destroyed (reset can restore it)')
|
'the procedural boot was hidden, not destroyed (reset can restore it)')
|
||||||
check(isIdbUrl(result.entry.url), 'the manifest entry points at browser storage',
|
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,
|
check(stage.currentObject(SLOT) === entryRef.fitNode,
|
||||||
'the fit panel now acts on the dropped model')
|
'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.
|
// Where the swapped boot sits before any nudging.
|
||||||
const basePos = entryRef.fitNode!.position.clone()
|
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.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')
|
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 --------------------------------------------------
|
// ---- export round-trips --------------------------------------------------
|
||||||
const text = editor.exportManifest()
|
const text = editor.exportManifest()
|
||||||
const reparsed = parseManifest(text)
|
const reparsed = parseManifest(text)
|
||||||
@ -140,17 +229,21 @@ async function run(): Promise<void> {
|
|||||||
await editor.clearAll()
|
await editor.clearAll()
|
||||||
const afterClear = await loadOverrideManifest()
|
const afterClear = await loadOverrideManifest()
|
||||||
check(Object.keys(afterClear).length === 0, 'Clear removes the saved override manifest')
|
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')
|
const summary = document.createElement('div')
|
||||||
summary.className = failed === 0 ? 'summary pass' : 'summary fail'
|
summary.className = failed === 0 ? 'summary pass' : 'summary fail'
|
||||||
summary.textContent = failed === 0
|
summary.textContent = failed === 0
|
||||||
? `All ${passed} checks passed ✓`
|
? `All ${passed} checks passed ✓`
|
||||||
: `${failed} of ${passed + failed} checks FAILED`
|
: `${failed} of ${passed + failed} checks FAILED`
|
||||||
out.append(summary)
|
out.append(summary)
|
||||||
;(window as unknown as { LANE_J: unknown }).LANE_J = { editor, passed, failed }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void run().catch((err) => {
|
void run()
|
||||||
console.error(err)
|
.catch((err: unknown) => {
|
||||||
check(false, 'the demo crashed', String(err))
|
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
|
* Raw IDB rather than a wrapper because no new deps are allowed, and the surface
|
||||||
* needed here is four calls wide.
|
* 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 DB_VERSION = 1
|
||||||
export const MANIFEST_STORE = 'manifest'
|
export const MANIFEST_STORE = 'manifest'
|
||||||
export const BLOB_STORE = 'blobs'
|
export const BLOB_STORE = 'blobs'
|
||||||
/** The single record in the `manifest` store that holds the working manifest. */
|
/** The single record in the `manifest` store that holds the working manifest. */
|
||||||
export const MANIFEST_KEY = 'current'
|
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 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> {
|
function openDb(): Promise<IDBDatabase> {
|
||||||
if (dbPromise) return dbPromise
|
if (dbPromise) return dbPromise
|
||||||
dbPromise = new Promise((resolve, reject) => {
|
const attempt = new Promise<IDBDatabase>((resolve, reject) => {
|
||||||
const req = indexedDB.open(DB_NAME, DB_VERSION)
|
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 = () => {
|
req.onupgradeneeded = () => {
|
||||||
const db = req.result
|
const db = req.result
|
||||||
if (!db.objectStoreNames.contains(MANIFEST_STORE)) db.createObjectStore(MANIFEST_STORE)
|
if (!db.objectStoreNames.contains(MANIFEST_STORE)) db.createObjectStore(MANIFEST_STORE)
|
||||||
if (!db.objectStoreNames.contains(BLOB_STORE)) db.createObjectStore(BLOB_STORE)
|
if (!db.objectStoreNames.contains(BLOB_STORE)) db.createObjectStore(BLOB_STORE)
|
||||||
}
|
}
|
||||||
req.onsuccess = () => resolve(req.result)
|
req.onsuccess = () => finish(() => resolve(req.result))
|
||||||
req.onerror = () => reject(req.error ?? new Error('IndexedDB open failed'))
|
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>(
|
function run<T>(
|
||||||
@ -43,8 +99,14 @@ function run<T>(
|
|||||||
return openDb().then(
|
return openDb().then(
|
||||||
(db) =>
|
(db) =>
|
||||||
new Promise<T>((resolve, reject) => {
|
new Promise<T>((resolve, reject) => {
|
||||||
const tx = db.transaction(store, mode)
|
let req: IDBRequest<T>
|
||||||
const req = fn(tx.objectStore(store))
|
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.onsuccess = () => resolve(req.result)
|
||||||
req.onerror = () => reject(req.error ?? new Error('IndexedDB request failed'))
|
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> =>
|
export const idbClear = (store: string): Promise<unknown> =>
|
||||||
run(store, 'readwrite', (s) => s.clear() as IDBRequest<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 =>
|
* True when this browser can actually hold overrides. `typeof indexedDB` is not
|
||||||
typeof indexedDB !== 'undefined' && indexedDB !== null
|
* 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
|
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 AXES = ['left / right', 'up / down', 'front / back'] as const
|
||||||
|
|
||||||
const FIT_ROWS: FitRow[] = [
|
const FIT_ROWS: FitRow[] = [
|
||||||
@ -82,6 +88,8 @@ export async function bootEditor(root: HTMLElement): Promise<{
|
|||||||
exportManifest: () => string
|
exportManifest: () => string
|
||||||
clearAll: () => Promise<void>
|
clearAll: () => Promise<void>
|
||||||
status: () => string
|
status: () => string
|
||||||
|
/** False when this browser refuses to store anything (private windows). */
|
||||||
|
canSave: () => boolean
|
||||||
}> {
|
}> {
|
||||||
root.classList.add('workshop')
|
root.classList.add('workshop')
|
||||||
|
|
||||||
@ -195,9 +203,17 @@ export async function bootEditor(root: HTMLElement): Promise<{
|
|||||||
for (const [id, button] of slotButtons) {
|
for (const [id, button] of slotButtons) {
|
||||||
const spec = slotById(id)!
|
const spec = slotById(id)!
|
||||||
const state = button.querySelector('.slot-state') as HTMLElement
|
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'
|
state.textContent = 'copies the blob'
|
||||||
button.dataset.state = 'derived'
|
button.dataset.state = 'derived'
|
||||||
|
} else if (workshop.isUnloadable(id)) {
|
||||||
|
state.textContent = 'file missing'
|
||||||
|
button.dataset.state = 'broken'
|
||||||
} else if (workshop.hasCustom(id)) {
|
} else if (workshop.hasCustom(id)) {
|
||||||
state.textContent = 'yours'
|
state.textContent = 'yours'
|
||||||
button.dataset.state = 'custom'
|
button.dataset.state = 'custom'
|
||||||
@ -282,11 +298,15 @@ export async function bootEditor(root: HTMLElement): Promise<{
|
|||||||
selected = slot
|
selected = slot
|
||||||
const spec = slotById(slot)
|
const spec = slotById(slot)
|
||||||
if (!spec) return
|
if (!spec) return
|
||||||
|
const absent = !stage.slots.has(slot)
|
||||||
fitTitle.textContent = spec.label
|
fitTitle.textContent = spec.label
|
||||||
fitHint.textContent = spec.derivedFrom
|
fitHint.textContent = absent
|
||||||
? `${spec.hint} There is nothing to drop here.`
|
? `${spec.hint} There isn't one of these anywhere in the course at the moment, ` +
|
||||||
: spec.hint
|
'so there is nothing here to replace.'
|
||||||
dropZone.style.display = spec.derivedFrom ? 'none' : 'flex'
|
: spec.derivedFrom
|
||||||
|
? `${spec.hint} There is nothing to drop here.`
|
||||||
|
: spec.hint
|
||||||
|
dropZone.style.display = spec.derivedFrom || absent ? 'none' : 'flex'
|
||||||
stage.highlight(slot)
|
stage.highlight(slot)
|
||||||
stage.focus(slot)
|
stage.focus(slot)
|
||||||
renderPaintCard(spec)
|
renderPaintCard(spec)
|
||||||
@ -301,7 +321,16 @@ export async function bootEditor(root: HTMLElement): Promise<{
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
setStatus(`Reading ${file.name}…`)
|
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)
|
const result = await workshop.dropFile(selected, file.name, bytes)
|
||||||
setStatus(result.message, result.rejected ? 'warn' : 'ok')
|
setStatus(result.message, result.rejected ? 'warn' : 'ok')
|
||||||
const spec = slotById(selected)
|
const spec = slotById(selected)
|
||||||
@ -310,6 +339,14 @@ export async function bootEditor(root: HTMLElement): Promise<{
|
|||||||
refreshSlotStates()
|
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() }
|
const stop = (e: Event): void => { e.preventDefault(); e.stopPropagation() }
|
||||||
for (const type of ['dragenter', 'dragover'] as const) {
|
for (const type of ['dragenter', 'dragover'] as const) {
|
||||||
dropZone.addEventListener(type, (e) => { stop(e); dropZone.dataset.hot = 'true' })
|
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) => {
|
dropZone.addEventListener('drop', (e) => {
|
||||||
const file = (e as DragEvent).dataTransfer?.files?.[0]
|
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())
|
dropZone.addEventListener('click', () => filePicker.click())
|
||||||
filePicker.addEventListener('change', () => {
|
filePicker.addEventListener('change', () => {
|
||||||
const file = filePicker.files?.[0]
|
const file = filePicker.files?.[0]
|
||||||
if (file) void handleFile(file)
|
if (file) guard('the drop', () => handleFile(file))
|
||||||
filePicker.value = ''
|
filePicker.value = ''
|
||||||
})
|
})
|
||||||
// Dropping anywhere else must not make the browser navigate to the file.
|
// 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> => {
|
const save = async (): Promise<void> => {
|
||||||
if (!idbAvailable()) {
|
if (!canSave) {
|
||||||
setStatus("This browser won't let me save anything locally.", 'warn')
|
setStatus(READ_ONLY_MESSAGE, 'warn')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const count = await workshop.saveLocally()
|
const result = await workshop.saveLocally()
|
||||||
setStatus(count === 0
|
setStatus(result.message, result.ok ? 'ok' : 'warn')
|
||||||
? 'Saved — no swaps, so the game looks like it always did.'
|
|
||||||
: `Saved ${count} swap${count === 1 ? '' : 's'}. Press "Test drive" to play with them.`)
|
|
||||||
}
|
}
|
||||||
saveBtn.addEventListener('click', () => void save())
|
saveBtn.addEventListener('click', () => guard('saving', save))
|
||||||
|
|
||||||
testBtn.addEventListener('click', () => {
|
testBtn.addEventListener('click', () => guard('the test drive', async () => {
|
||||||
void save().then(() => {
|
if (!canSave) {
|
||||||
setStatus('Opened the game in a new tab — your swaps are already in it.')
|
setStatus(READ_ONLY_MESSAGE, 'warn')
|
||||||
open(import.meta.env.BASE_URL, '_blank')
|
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()
|
const exportManifest = (): string => workshop.exportText()
|
||||||
exportBtn.addEventListener('click', () => {
|
exportBtn.addEventListener('click', () => {
|
||||||
@ -374,26 +417,54 @@ export async function bootEditor(root: HTMLElement): Promise<{
|
|||||||
})
|
})
|
||||||
|
|
||||||
const clearAll = async (): Promise<void> => {
|
const clearAll = async (): Promise<void> => {
|
||||||
await workshop.clearEverything()
|
const ok = await workshop.clearEverything()
|
||||||
refreshSlotStates()
|
refreshSlotStates()
|
||||||
renderFitControls()
|
renderFitControls()
|
||||||
if (selected) {
|
if (selected) {
|
||||||
const spec = slotById(selected)
|
const spec = slotById(selected)
|
||||||
if (spec) renderPaintCard(spec)
|
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', () => {
|
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 -----------------------------------------
|
// ---- restore a previous session -----------------------------------------
|
||||||
refreshSlotStates()
|
refreshSlotStates()
|
||||||
renderFitControls()
|
renderFitControls()
|
||||||
if (idbAvailable()) {
|
if (canSave) {
|
||||||
const restored = await workshop.restore()
|
const { restored, unloadable } = await workshop.restore()
|
||||||
if (restored.length) {
|
if (restored.length || unloadable.length) refreshSlotStates()
|
||||||
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.`)
|
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,
|
exportManifest,
|
||||||
clearAll,
|
clearAll,
|
||||||
status: () => statusLine.textContent ?? '',
|
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.
|
// `data-manual` and calls bootEditor itself, so it never gets two editors.
|
||||||
const host = document.getElementById('workshop')
|
const host = document.getElementById('workshop')
|
||||||
if (host && !host.hasAttribute('data-manual')) {
|
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)
|
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)})`)
|
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
|
// 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
|
// can't run here, but the registration calls are greppable.
|
||||||
// with nothing behind it would silently do nothing when clicked.
|
//
|
||||||
|
// 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 stageSource = readFileSync(new URL('./stage.ts', import.meta.url), 'utf8')
|
||||||
const registered = new Set(
|
const registered = new Set(
|
||||||
[...stageSource.matchAll(/register\('([^']+)'/g)].map((m) => m[1]!))
|
[...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) {
|
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) {
|
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 reset instant, keeps every reference other systems captured alive, and
|
||||||
* keeps the manifest's offset/rotation/scale on a node nobody else writes to
|
* 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).
|
* (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 * as THREE from 'three'
|
||||||
import { composeFit } from './manifest-io'
|
import { composeFit } from './manifest-io'
|
||||||
@ -15,15 +23,30 @@ import type { BaseTransform, SlotEntry } from './manifest-io'
|
|||||||
|
|
||||||
export const DEG = Math.PI / 180
|
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 {
|
export interface SlotStageEntry {
|
||||||
id: string
|
id: string
|
||||||
/** What the game builds today. Hidden — never removed — while a swap is in. */
|
/** What the game builds today. Hidden — never removed — while a swap is in. */
|
||||||
procedural: THREE.Object3D[]
|
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
|
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
|
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
|
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 {
|
export class SlotSwap {
|
||||||
readonly slots = new Map<string, SlotStageEntry>()
|
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. */
|
/** 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]
|
const first = objects[0]
|
||||||
if (!first) return
|
if (!first) return
|
||||||
|
const bases = objects.map(readBase)
|
||||||
this.slots.set(id, {
|
this.slots.set(id, {
|
||||||
id,
|
id,
|
||||||
procedural: objects,
|
procedural: objects,
|
||||||
parent: first.parent ?? fallbackParent,
|
parent: first.parent ?? fallbackParent,
|
||||||
base: readBase(first),
|
base: bases[0]!,
|
||||||
|
bases,
|
||||||
|
extraScale: objects.map((_, i) => opts.extraScale?.[i] ?? [1, 1, 1]),
|
||||||
|
fitNodes: [],
|
||||||
fitNode: null,
|
fitNode: null,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -71,49 +115,65 @@ export class SlotSwap {
|
|||||||
return this.slots.has(id)
|
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 {
|
setCustom(id: string, asset: THREE.Object3D, entry: SlotEntry): void {
|
||||||
const s = this.slots.get(id)
|
const s = this.slots.get(id)
|
||||||
if (!s) return
|
if (!s) return
|
||||||
this.clearCustom(id)
|
this.clearCustom(id)
|
||||||
const fitNode = new THREE.Group()
|
s.procedural.forEach((original, i) => {
|
||||||
fitNode.name = `fit:${id}`
|
const fitNode = new THREE.Group()
|
||||||
dressAsset(asset)
|
fitNode.name = s.procedural.length > 1 ? `fit:${id}#${i}` : `fit:${id}`
|
||||||
fitNode.add(asset)
|
// Always a copy, never `asset` itself: the caller caches parsed scenes and
|
||||||
s.parent.add(fitNode)
|
// re-uses them across slots, and a scene graph node can only have one
|
||||||
s.fitNode = fitNode
|
// parent — adopting the original would tear it out of wherever it was.
|
||||||
for (const p of s.procedural) p.visible = false
|
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)
|
this.applyFit(id, entry)
|
||||||
}
|
}
|
||||||
|
|
||||||
clearCustom(id: string): void {
|
clearCustom(id: string): void {
|
||||||
const s = this.slots.get(id)
|
const s = this.slots.get(id)
|
||||||
if (!s) return
|
if (!s) return
|
||||||
if (s.fitNode) {
|
for (const node of s.fitNodes) node.parent?.remove(node)
|
||||||
s.parent.remove(s.fitNode)
|
s.fitNodes = []
|
||||||
s.fitNode = null
|
s.fitNode = null
|
||||||
}
|
|
||||||
for (const p of s.procedural) p.visible = true
|
for (const p of s.procedural) p.visible = true
|
||||||
}
|
}
|
||||||
|
|
||||||
applyFit(id: string, entry: SlotEntry): void {
|
applyFit(id: string, entry: SlotEntry): void {
|
||||||
const s = this.slots.get(id)
|
const s = this.slots.get(id)
|
||||||
if (!s?.fitNode) return
|
if (!s) return
|
||||||
const fit = composeFit(s.base, entry)
|
s.fitNodes.forEach((node, i) => {
|
||||||
s.fitNode.position.set(fit.position[0], fit.position[1], fit.position[2])
|
const base = s.bases[i] ?? s.base
|
||||||
s.fitNode.rotation.set(
|
const extra = s.extraScale[i] ?? [1, 1, 1]
|
||||||
fit.rotationDeg[0] * DEG, fit.rotationDeg[1] * DEG, fit.rotationDeg[2] * DEG)
|
const fit = composeFit(base, entry)
|
||||||
s.fitNode.scale.set(fit.scale[0], fit.scale[1], fit.scale[2])
|
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. */
|
/** What the fit panel is acting on: the custom asset if any, else the original. */
|
||||||
currentObject(id: string): THREE.Object3D | null {
|
currentObject(id: string): THREE.Object3D | null {
|
||||||
const s = this.slots.get(id)
|
const s = this.slots.get(id)
|
||||||
if (!s) return null
|
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. */
|
/** True when this slot is showing a custom asset. */
|
||||||
isCustom(id: string): boolean {
|
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.
|
* 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
|
* The VOCABULARY is not defined here. `SLOT_IDS` and `SLOT_LABELS` come from
|
||||||
* out of scope for v1); Lane I's registry answers to the same strings, so this
|
* src/assets/slots.ts, which is the same list the runtime registry answers to —
|
||||||
* list is the contract between the two lanes. `label`/`hint` are written for
|
* an editor that offered a slot the game discards (or hid one the game supports)
|
||||||
* John, not for a programmer — no slot ids appear on screen.
|
* 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
|
* 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.
|
* 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 type SlotGroupName = 'The blob' | 'Machines' | 'The course' | 'Effects'
|
||||||
|
|
||||||
export interface SlotSpec {
|
export interface SlotSpec {
|
||||||
id: string
|
id: SlotId
|
||||||
group: SlotGroupName
|
group: SlotGroupName
|
||||||
/** Plain-language name shown in the list. */
|
/** Plain-language name shown in the list. */
|
||||||
label: string
|
label: string
|
||||||
@ -27,127 +38,140 @@ export interface SlotSpec {
|
|||||||
* Derived slots have no drop zone of their own: they copy another slot.
|
* 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.)
|
* (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[] = [
|
interface Presentation {
|
||||||
{
|
label?: string
|
||||||
id: 'blob.body',
|
hint: string
|
||||||
group: 'The blob',
|
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',
|
label: 'Blob body',
|
||||||
hint: 'The thing you play as. Paint sticks to this one, so it needs tidy UVs.',
|
hint: 'The thing you play as. Paint sticks to this one, so it needs tidy UVs.',
|
||||||
focus: { target: [0, 3.5, 30], distance: 6 },
|
focus: { target: [0, 3.5, 30], distance: 6 },
|
||||||
paintable: true,
|
paintable: true,
|
||||||
},
|
},
|
||||||
{
|
'blob.face': {
|
||||||
id: 'blob.face',
|
|
||||||
group: 'The blob',
|
|
||||||
label: 'Blob face',
|
label: 'Blob face',
|
||||||
hint: 'Just the eyes. Kept separate so paint never covers them.',
|
hint: 'Just the eyes. Kept separate so paint never covers them.',
|
||||||
focus: { target: [0, 3.7, 30], distance: 4 },
|
focus: { target: [0, 3.7, 30], distance: 4 },
|
||||||
},
|
},
|
||||||
{
|
'ghost.body': {
|
||||||
id: 'ghost.body',
|
|
||||||
group: 'The blob',
|
|
||||||
label: 'Ghost racer',
|
label: 'Ghost racer',
|
||||||
hint: 'Your best-run ghost. It copies the blob body automatically.',
|
hint: 'Your best-run ghost. It copies the blob body automatically.',
|
||||||
focus: { target: [3, 3.5, 30], distance: 6 },
|
focus: { target: [3, 3.5, 30], distance: 6 },
|
||||||
derivedFrom: 'blob.body',
|
derivedFrom: 'blob.body',
|
||||||
},
|
},
|
||||||
{
|
'cannon.barrel': {
|
||||||
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',
|
|
||||||
label: 'Paint 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 },
|
focus: { target: [-9, 3, 14], distance: 6 },
|
||||||
|
multi: true,
|
||||||
},
|
},
|
||||||
{
|
'machine.plate': {
|
||||||
id: 'machine.plate',
|
|
||||||
group: 'Machines',
|
|
||||||
label: 'Pressure 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 },
|
focus: { target: [-6, 0.5, -47], distance: 9 },
|
||||||
},
|
},
|
||||||
{
|
'machine.boot': {
|
||||||
id: 'machine.boot',
|
|
||||||
group: 'Machines',
|
|
||||||
label: 'Spring 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 },
|
focus: { target: [-6, 1, -49.5], distance: 9 },
|
||||||
},
|
},
|
||||||
{
|
'machine.bucket': {
|
||||||
id: 'machine.bucket',
|
|
||||||
group: 'Machines',
|
|
||||||
label: 'Paint bucket',
|
label: 'Paint bucket',
|
||||||
hint: 'Tips a colour over you. Its model must pivot at the lip and pour toward +X.',
|
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 },
|
focus: { target: [-1.4, 6, -50], distance: 10 },
|
||||||
},
|
},
|
||||||
{
|
'machine.arch': {
|
||||||
id: 'machine.arch',
|
|
||||||
group: 'Machines',
|
|
||||||
label: 'Bubble wash',
|
label: 'Bubble wash',
|
||||||
hint: 'Walk through it and paint comes off.',
|
hint: 'Walk through it and paint comes off.',
|
||||||
focus: { target: [-4.5, 2, -56], distance: 9 },
|
focus: { target: [-4.5, 2, -56], distance: 9 },
|
||||||
},
|
},
|
||||||
{
|
'machine.belt': {
|
||||||
id: 'machine.belt',
|
|
||||||
group: 'Machines',
|
|
||||||
label: 'Conveyor 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 },
|
focus: { target: [0, 0.5, -50], distance: 12 },
|
||||||
},
|
},
|
||||||
{
|
'machine.fan': {
|
||||||
id: 'course.scenery.cereal',
|
label: 'Fan',
|
||||||
group: 'The course',
|
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',
|
label: 'Giant cereal box',
|
||||||
hint: 'Big silly prop next to the start. Nothing depends on it — go wild.',
|
hint: 'Big silly prop next to the start. Nothing depends on it — go wild.',
|
||||||
focus: { target: [-20, 7, 26], distance: 22 },
|
focus: { target: [-20, 7, 26], distance: 22 },
|
||||||
},
|
},
|
||||||
{
|
'course.scenery.block': {
|
||||||
id: 'course.scenery.block',
|
|
||||||
group: 'The course',
|
|
||||||
label: 'Purple block',
|
label: 'Purple block',
|
||||||
hint: 'The other roadside prop. Also purely decorative.',
|
hint: 'The other roadside prop. Also purely decorative.',
|
||||||
focus: { target: [20, 5, 12], distance: 20 },
|
focus: { target: [20, 5, 12], distance: 20 },
|
||||||
},
|
},
|
||||||
{
|
'fx.puddle': {
|
||||||
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',
|
|
||||||
label: 'Paint 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 },
|
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 =>
|
export const slotById = (id: string): SlotSpec | undefined =>
|
||||||
SLOTS.find((s) => s.id === id)
|
SLOTS.find((s) => s.id === id)
|
||||||
|
|||||||
@ -29,13 +29,22 @@ import { SlotSwap } from './slot-swap'
|
|||||||
import type { SlotStageEntry } from './slot-swap'
|
import type { SlotStageEntry } from './slot-swap'
|
||||||
import { SLOTS } from './slots'
|
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 type { SlotStageEntry } from './slot-swap'
|
||||||
|
|
||||||
export interface EditorStage {
|
export interface EditorStage {
|
||||||
world: World
|
world: World
|
||||||
controls: OrbitControls
|
controls: OrbitControls
|
||||||
slots: Map<string, SlotStageEntry>
|
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
|
setCustom(slot: string, asset: THREE.Object3D, entry: SlotEntry): void
|
||||||
/** Drop the custom asset and show today's procedural build again. */
|
/** Drop the custom asset and show today's procedural build again. */
|
||||||
clearCustom(slot: string): void
|
clearCustom(slot: string): void
|
||||||
@ -54,10 +63,11 @@ export async function createEditorStage(container: HTMLElement): Promise<EditorS
|
|||||||
const world = await createWorld(container)
|
const world = await createWorld(container)
|
||||||
world.scene.fog = null // an editor wants to see the far end of the course
|
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 slots = swap.slots
|
||||||
const register = (id: string, objects: THREE.Object3D[]): void =>
|
const register = (
|
||||||
swap.register(id, objects, world.scene)
|
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. */
|
/** Everything a builder adds straight to the scene, recovered by diffing. */
|
||||||
const capture = <T>(fn: () => T): { result: T; added: THREE.Object3D[] } => {
|
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)
|
const zoneGroups = zoned.added.filter((o) => o instanceof THREE.Group)
|
||||||
if (zoneGroups[0]) register('machine.plate', [zoneGroups[0]])
|
if (zoneGroups[0]) register('machine.plate', [zoneGroups[0]])
|
||||||
if (zoneGroups[1]) register('machine.boot', [zoneGroups[1]])
|
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
|
// 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
|
// for — installPuddles runs FIRST and also adds bare Meshes to the scene, so
|
||||||
// filtering on "not a Group" alone would swallow all ten puddles.
|
// 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 { cleanManifest, idbKey, isIdbUrl, parseManifest } from './manifest-io'
|
||||||
import type { Manifest } 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 {
|
export interface StoredGlb {
|
||||||
name: string
|
name: string
|
||||||
bytes: ArrayBuffer
|
bytes: ArrayBuffer
|
||||||
savedAt: number
|
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> {
|
export async function loadOverrideManifest(): Promise<Manifest> {
|
||||||
const raw = await idbGet<unknown>(MANIFEST_STORE, MANIFEST_KEY)
|
const raw = await idbGet<unknown>(MANIFEST_STORE, MANIFEST_KEY)
|
||||||
if (typeof raw === 'string') return parseManifest(raw)
|
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 =>
|
export const blobKeyFor = (slot: string, fileName: string): string =>
|
||||||
`${slot}-${fileName}`.replace(/[^a-zA-Z0-9._-]/g, '_')
|
`${slot}-${fileName}`.replace(/[^a-zA-Z0-9._-]/g, '_')
|
||||||
|
|
||||||
export async function putGlb(key: string, name: string, bytes: ArrayBuffer): Promise<string> {
|
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, bytes)
|
||||||
await idbPut(BLOB_STORE, key, record)
|
|
||||||
return `idb:${key}`
|
return `idb:${key}`
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getGlb(key: string): Promise<StoredGlb | undefined> {
|
export async function getGlb(key: string): Promise<StoredGlb | undefined> {
|
||||||
const raw = await idbGet<StoredGlb>(BLOB_STORE, key)
|
const raw = await idbGet<unknown>(BLOB_STORE, key)
|
||||||
if (!raw || !(raw.bytes instanceof ArrayBuffer)) return undefined
|
if (raw instanceof ArrayBuffer) {
|
||||||
return raw
|
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. */
|
/** Every override gone: manifest record and all stored GLB bytes. */
|
||||||
@ -52,7 +83,14 @@ export async function clearOverrides(): Promise<void> {
|
|||||||
await idbClear(BLOB_STORE)
|
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[]> {
|
export async function orphanBlobKeys(manifest: Manifest): Promise<string[]> {
|
||||||
const live = new Set(
|
const live = new Set(
|
||||||
Object.values(manifest).filter((e) => isIdbUrl(e.url)).map((e) => idbKey(e.url)),
|
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]
|
color?: [number, number, number, number]
|
||||||
/** Omit UVs to produce a deliberately unpaintable asset (for testing warnings). */
|
/** Omit UVs to produce a deliberately unpaintable asset (for testing warnings). */
|
||||||
withUv?: boolean
|
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 {
|
export function buildTinyGlb(opts: TinyGlbOptions = {}): ArrayBuffer {
|
||||||
const h = opts.half ?? 0.5
|
const h = opts.half ?? 0.5
|
||||||
const color = opts.color ?? [1, 0.58, 0, 1]
|
const color = opts.color ?? [1, 0.58, 0, 1]
|
||||||
const withUv = opts.withUv !== false
|
const withUv = opts.withUv !== false
|
||||||
|
const withMesh = opts.withMesh !== false
|
||||||
|
|
||||||
const positions = new Float32Array([
|
const positions = new Float32Array([
|
||||||
-h, 0, -h,
|
-h, 0, -h,
|
||||||
@ -82,11 +90,16 @@ export function buildTinyGlb(opts: TinyGlbOptions = {}): ArrayBuffer {
|
|||||||
asset: { version: '2.0', generator: 'BLOBBO workshop tiny-glb' },
|
asset: { version: '2.0', generator: 'BLOBBO workshop tiny-glb' },
|
||||||
scene: 0,
|
scene: 0,
|
||||||
scenes: [{ nodes: [0] }],
|
scenes: [{ nodes: [0] }],
|
||||||
nodes: [{ mesh: 0, name: 'TinyQuad' }],
|
nodes: withMesh ? [{ mesh: 0, name: 'TinyQuad' }] : [{ name: 'EmptyNode' }],
|
||||||
meshes: [{
|
// glTF forbids an empty `meshes` array, so the key is dropped entirely.
|
||||||
name: 'TinyQuad',
|
...(withMesh
|
||||||
primitives: [{ attributes, indices: indexAccessor, material: 0, mode: 4 }],
|
? {
|
||||||
}],
|
meshes: [{
|
||||||
|
name: 'TinyQuad',
|
||||||
|
primitives: [{ attributes, indices: indexAccessor, material: 0, mode: 4 }],
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
materials: [{
|
materials: [{
|
||||||
name: 'TinyMat',
|
name: 'TinyMat',
|
||||||
pbrMetallicRoughness: { baseColorFactor: color, metallicFactor: 0, roughnessFactor: 0.6 },
|
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')
|
ok(/blob body/i.test(ghost.message), 'it points you at the blob body slot instead')
|
||||||
|
|
||||||
// ---- save + restore --------------------------------------------------------
|
// ---- save + restore --------------------------------------------------------
|
||||||
const savedCount = await workshop.saveLocally()
|
const saveResult = await workshop.saveLocally()
|
||||||
ok(savedCount === 1, `saving wrote the one live swap (got ${savedCount})`)
|
ok(saveResult.ok, 'saving succeeded')
|
||||||
|
ok(saveResult.saved === 1, `saving wrote the one live swap (got ${saveResult.saved})`)
|
||||||
const persisted = await loadOverrideManifest()
|
const persisted = await loadOverrideManifest()
|
||||||
ok(manifestsEqual(workshop.getManifest(), persisted),
|
ok(manifestsEqual(workshop.getManifest(), persisted),
|
||||||
'what was saved is exactly what the live game reads back')
|
'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)
|
const reopened = new Workshop(stage)
|
||||||
swap.clearCustom(BOOT)
|
swap.clearCustom(BOOT)
|
||||||
const restored = await reopened.restore()
|
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(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')
|
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
|
* 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
|
* 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).
|
* 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 * as THREE from 'three'
|
||||||
import type { EditorStage } from './stage'
|
import type { EditorStage } from './stage'
|
||||||
import { cloneAsset, loadGlb } from './glb'
|
import { loadGlb } from './glb'
|
||||||
import { inspectPaintability } from './paintability'
|
import { firstMesh, inspectPaintability } from './paintability'
|
||||||
import type { PaintableInfo } from './paintability'
|
import type { PaintableInfo } from './paintability'
|
||||||
import {
|
import {
|
||||||
cleanManifest, idbKey, isIdbUrl, serializeManifest,
|
cleanManifest, idbKey, isIdbUrl, serializeManifest,
|
||||||
@ -31,11 +44,36 @@ export interface DropResult {
|
|||||||
message: string
|
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 {
|
export class Workshop {
|
||||||
private manifest: Manifest = {}
|
private manifest: Manifest = {}
|
||||||
/** Parsed GLB scenes by url, so a slot switch never re-parses. */
|
/** Parsed GLB scenes by url, so a slot switch never re-parses. */
|
||||||
private readonly cache = new Map<string, THREE.Object3D>()
|
private readonly cache = new Map<string, THREE.Object3D>()
|
||||||
private readonly paintReports = new Map<string, PaintableInfo>()
|
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
|
private readonly stage: EditorStage
|
||||||
|
|
||||||
// Plain assignment rather than a parameter property: node's type-stripping
|
// Plain assignment rather than a parameter property: node's type-stripping
|
||||||
@ -60,19 +98,49 @@ export class Workshop {
|
|||||||
return this.manifest[slot] !== undefined
|
return this.manifest[slot] !== undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Re-hydrate everything the browser remembered from a previous session. */
|
/** True when this slot's saved file is missing or unreadable. */
|
||||||
async restore(): Promise<string[]> {
|
isUnloadable(slot: string): boolean {
|
||||||
const stored = await loadOverrideManifest()
|
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 restored: string[] = []
|
||||||
|
const unloadable: string[] = []
|
||||||
for (const [slot, entry] of Object.entries(stored)) {
|
for (const [slot, entry] of Object.entries(stored)) {
|
||||||
if (!this.stage.slots.has(slot)) continue
|
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) {
|
if (ok) {
|
||||||
this.manifest[slot] = entry
|
this.unloadable.delete(slot)
|
||||||
restored.push(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> {
|
async dropFile(slot: string, fileName: string, bytes: ArrayBuffer): Promise<DropResult> {
|
||||||
const spec = slotById(slot)
|
const spec = slotById(slot)
|
||||||
if (!spec || !this.stage.slots.has(slot)) {
|
if (!spec) {
|
||||||
return { slot, entry: { url: '' }, rejected: true, message: `No such slot: ${slot}` }
|
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) {
|
if (spec.derivedFrom) {
|
||||||
return {
|
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
|
let paint: PaintableInfo | undefined
|
||||||
if (spec.paintable) {
|
if (spec.paintable) {
|
||||||
paint = inspectPaintability(scene)
|
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 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)
|
this.cache.set(url, scene)
|
||||||
|
|
||||||
const previous = this.manifest[slot]
|
const previous = this.manifest[slot]
|
||||||
@ -128,10 +222,14 @@ export class Workshop {
|
|||||||
scale: previous?.scale ?? 1,
|
scale: previous?.scale ?? 1,
|
||||||
}
|
}
|
||||||
this.manifest[slot] = entry
|
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 {
|
return {
|
||||||
slot, entry, paint, rejected: false,
|
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 {
|
reset(slot: string): void {
|
||||||
delete this.manifest[slot]
|
delete this.manifest[slot]
|
||||||
this.paintReports.delete(slot)
|
this.paintReports.delete(slot)
|
||||||
|
this.unloadable.delete(slot)
|
||||||
this.stage.clearCustom(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 clean = cleanManifest(this.manifest)
|
||||||
await saveOverrideManifest(clean)
|
const saved = Object.keys(clean).length
|
||||||
await pruneOrphans(clean)
|
try {
|
||||||
return Object.keys(clean).length
|
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. */
|
/** 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)
|
for (const slot of Object.keys(this.manifest)) this.stage.clearCustom(slot)
|
||||||
this.manifest = {}
|
this.manifest = {}
|
||||||
this.paintReports.clear()
|
this.paintReports.clear()
|
||||||
|
this.unloadable.clear()
|
||||||
this.cache.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. */
|
/** 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)
|
console.warn(`[workshop] stored asset for ${slot} could not be read`, err)
|
||||||
return false
|
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)
|
this.cache.set(entry.url, scene)
|
||||||
const spec = slotById(slot)
|
const spec = slotById(slot)
|
||||||
if (spec?.paintable) this.paintReports.set(slot, inspectPaintability(scene))
|
if (spec?.paintable) this.paintReports.set(slot, inspectPaintability(scene))
|
||||||
}
|
}
|
||||||
this.stage.setCustom(slot, cloneAsset(scene), entry)
|
this.stage.setCustom(slot, scene, entry)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
private async readBytes(url: string): Promise<ArrayBuffer | null> {
|
private async readBytes(url: string): Promise<ArrayBuffer | null> {
|
||||||
if (isIdbUrl(url)) {
|
if (isIdbUrl(url)) {
|
||||||
const record = await getGlb(idbKey(url))
|
try {
|
||||||
return record?.bytes ?? null
|
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 {
|
try {
|
||||||
const res = await fetch(url)
|
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()
|
return await res.arrayBuffer()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn(`[workshop] could not fetch ${url}`, err)
|
console.warn(`[workshop] could not fetch ${url}`, err)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user