Seven hardening fixes on the lane-J editor, several sharing one root cause.
- The demo ran against the LIVE database. Opening demos/lane-j.html deleted
every saved model (pruneOrphans treats anything the demo's manifest omits as
garbage) and an interrupted run left a fake machine.boot override the real
game would load. The database name is now a variable, the demo uses a
throwaway one, deletes it in a finally, and fingerprints the real store
before/after so the run FAILS if it moved.
- A .glb with no mesh was accepted on every non-paintable slot: original hidden,
nothing in its place, cheerful success message, savable. The mesh check now
runs for every slot, before anything is written.
- Multi-object slots (nine puddles, every cannon barrel) hid all instances and
added one replacement. One fit node per instance now, each at its own pose and
its own footprint scale — the same contract the runtime's per-instance
attachSlot already implements.
- Storage failures rejected into void-discarded promises, freezing the UI on
"Reading…". Nothing leaves Workshop as a rejection any more: failures come
back as results carrying a sentence a person can read, and every DOM handler
goes through one guard.
- idbAvailable() was a typeof check, so private windows passed it and then blew
up, replacing the whole built UI with a raw error string. It now probes open()
once and caches the boolean; failure means a labelled read-only editor.
- An override whose GLB failed to reload was dropped from the manifest, after
which the next save deleted its bytes for good. Failed entries are kept,
flagged unloadable, surfaced in the slot list, and never pruned.
- The editor kept its own slot list, which disagreed with the runtime's in both
directions. It now imports SLOT_IDS/SLOT_LABELS from src/assets/slots and
keeps only presentation data, keyed by SlotId so describing a slot the runtime
lacks is a compile error.
Also fixes a cross-lane mismatch found on the way: the editor stored GLBs as a
{name,bytes,savedAt} wrapper while the game's reader feeds the record straight
into new Blob([buf]). Writes are raw ArrayBuffer now (reads stay tolerant of the
old shape), so a saved swap can actually load in the game.
Build passes; 312 assertions across 9 headless test files, 49 of them new.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
350 lines
12 KiB
TypeScript
350 lines
12 KiB
TypeScript
/**
|
|
* Lane J — the editor's brain, with no DOM in it.
|
|
*
|
|
* Holds the working manifest, owns the drop → store → swap pipeline, and is the
|
|
* single code path both the real drag-drop and the demo's scripted drop go
|
|
* through (so a green demo means the real button works).
|
|
*
|
|
* TWO INVARIANTS, both learned the hard way:
|
|
*
|
|
* 1. NOTHING LEAVES THIS CLASS AS A REJECTED PROMISE. Every caller is a DOM
|
|
* handler that can only `void` the result, so a rejection used to vanish
|
|
* into an unhandled-rejection with the status line frozen mid-sentence.
|
|
* Storage failures come back as a `rejected` result carrying a sentence a
|
|
* person can read, and the slot stays procedural.
|
|
*
|
|
* 2. A SAVED SWAP IS NEVER DESTROYED BY THE EDITOR. An entry whose file will
|
|
* not re-load stays in the manifest, flagged unloadable, so the next save
|
|
* cannot prune its bytes away. Losing a file the user cannot regenerate is
|
|
* worse than showing them a broken row.
|
|
*/
|
|
import * as THREE from 'three'
|
|
import type { EditorStage } from './stage'
|
|
import { loadGlb } from './glb'
|
|
import { firstMesh, inspectPaintability } from './paintability'
|
|
import type { PaintableInfo } from './paintability'
|
|
import {
|
|
cleanManifest, idbKey, isIdbUrl, serializeManifest,
|
|
} from './manifest-io'
|
|
import type { Manifest, SlotEntry } from './manifest-io'
|
|
import {
|
|
blobKeyFor, clearOverrides, getGlb, loadOverrideManifest, pruneOrphans,
|
|
putGlb, saveOverrideManifest,
|
|
} from './store'
|
|
import { slotById } from './slots'
|
|
|
|
export interface DropResult {
|
|
slot: string
|
|
entry: SlotEntry
|
|
/** Only produced for the paintable body slot. */
|
|
paint?: PaintableInfo
|
|
/** True when the asset was rejected and the procedural build was kept. */
|
|
rejected: boolean
|
|
/** Plain-language outcome for the status line. */
|
|
message: string
|
|
}
|
|
|
|
export interface SaveResult {
|
|
/** How many swaps are now saved. */
|
|
saved: number
|
|
/** False when the browser refused to keep them. */
|
|
ok: boolean
|
|
/** Plain-language outcome for the status line. */
|
|
message: string
|
|
}
|
|
|
|
export interface RestoreResult {
|
|
/** Slots whose model came back and is on the stage. */
|
|
restored: string[]
|
|
/** Slots still in the manifest whose file could not be read back. */
|
|
unloadable: string[]
|
|
}
|
|
|
|
/** What the user is told when the browser will not keep a file. */
|
|
const NO_ROOM =
|
|
"There wasn't room in this browser to keep that file, so the original is still there. " +
|
|
'Try a smaller model, or clear some swaps you no longer want.'
|
|
|
|
const NO_MESH = "There's no model inside this file — the original is still there."
|
|
|
|
export class Workshop {
|
|
private manifest: Manifest = {}
|
|
/** Parsed GLB scenes by url, so a slot switch never re-parses. */
|
|
private readonly cache = new Map<string, THREE.Object3D>()
|
|
private readonly paintReports = new Map<string, PaintableInfo>()
|
|
/** Slots whose stored file would not load: kept, shown, never pruned. */
|
|
private readonly unloadable = new Set<string>()
|
|
private readonly stage: EditorStage
|
|
|
|
// Plain assignment rather than a parameter property: node's type-stripping
|
|
// mode (how the .test.ts files run) rejects parameter properties outright.
|
|
constructor(stage: EditorStage) {
|
|
this.stage = stage
|
|
}
|
|
|
|
getManifest(): Manifest {
|
|
return cleanManifest(this.manifest)
|
|
}
|
|
|
|
entry(slot: string): SlotEntry | undefined {
|
|
return this.manifest[slot]
|
|
}
|
|
|
|
paintReport(slot: string): PaintableInfo | undefined {
|
|
return this.paintReports.get(slot)
|
|
}
|
|
|
|
hasCustom(slot: string): boolean {
|
|
return this.manifest[slot] !== undefined
|
|
}
|
|
|
|
/** True when this slot's saved file is missing or unreadable. */
|
|
isUnloadable(slot: string): boolean {
|
|
return this.unloadable.has(slot)
|
|
}
|
|
|
|
unloadableSlots(): string[] {
|
|
return [...this.unloadable]
|
|
}
|
|
|
|
/**
|
|
* Re-hydrate everything the browser remembered from a previous session.
|
|
* Never throws: no storage is a normal state, not a reason to lose the editor.
|
|
*/
|
|
async restore(): Promise<RestoreResult> {
|
|
let stored: Manifest = {}
|
|
try {
|
|
stored = await loadOverrideManifest()
|
|
} catch (err) {
|
|
console.warn('[workshop] could not read what was saved last time', err)
|
|
return { restored: [], unloadable: [] }
|
|
}
|
|
const restored: string[] = []
|
|
const unloadable: string[] = []
|
|
for (const [slot, entry] of Object.entries(stored)) {
|
|
if (!this.stage.slots.has(slot)) continue
|
|
let ok = false
|
|
try {
|
|
ok = await this.applyEntry(slot, entry)
|
|
} catch (err) {
|
|
console.warn(`[workshop] stored asset for ${slot} could not be restored`, err)
|
|
}
|
|
// The entry is kept either way. Dropping it here is what used to make the
|
|
// next save delete the only copy of the file.
|
|
this.manifest[slot] = entry
|
|
if (ok) {
|
|
this.unloadable.delete(slot)
|
|
restored.push(slot)
|
|
} else {
|
|
this.unloadable.add(slot)
|
|
unloadable.push(slot)
|
|
}
|
|
}
|
|
return { restored, unloadable }
|
|
}
|
|
|
|
/**
|
|
* The one drop path. Bytes in, stage swapped, manifest updated.
|
|
* A body asset that paint can't stick to is REJECTED and the procedural
|
|
* sphere stays: broken paint is worse than a missing model.
|
|
*/
|
|
async dropFile(slot: string, fileName: string, bytes: ArrayBuffer): Promise<DropResult> {
|
|
const spec = slotById(slot)
|
|
if (!spec) {
|
|
return {
|
|
slot, entry: { url: '' }, rejected: true,
|
|
message: "That isn't something this game can swap out.",
|
|
}
|
|
}
|
|
if (!this.stage.slots.has(slot)) {
|
|
return {
|
|
slot, entry: { url: '' }, rejected: true,
|
|
message: `There isn't a ${spec.label.toLowerCase()} anywhere in the course at the ` +
|
|
'moment, so there is nothing here to replace.',
|
|
}
|
|
}
|
|
if (spec.derivedFrom) {
|
|
return {
|
|
slot, entry: { url: '' }, rejected: true,
|
|
message: `${spec.label} copies "${slotById(spec.derivedFrom)?.label}" — drop your model there instead.`,
|
|
}
|
|
}
|
|
|
|
let scene: THREE.Object3D
|
|
try {
|
|
scene = await loadGlb(bytes)
|
|
} catch (err) {
|
|
console.warn(`[workshop] ${fileName} could not be read as a .glb`, err)
|
|
return {
|
|
slot, entry: { url: '' }, rejected: true,
|
|
message: `${fileName} isn't a .glb file this browser can read. Try exporting again from Blender.`,
|
|
}
|
|
}
|
|
|
|
// EVERY slot, not just the paintable one: a file with no mesh in it used to
|
|
// be accepted, hide the original and put nothing in its place — a cheerful
|
|
// success message for an object that had just vanished from the course.
|
|
if (!firstMesh(scene)) {
|
|
return { slot, entry: { url: '' }, rejected: true, message: NO_MESH }
|
|
}
|
|
|
|
let paint: PaintableInfo | undefined
|
|
if (spec.paintable) {
|
|
paint = inspectPaintability(scene)
|
|
this.paintReports.set(slot, paint)
|
|
if (!paint.uvOk) {
|
|
return {
|
|
slot, entry: { url: '' }, rejected: true, paint,
|
|
message: 'Paint would not stick to this model, so the plain blob is still in. ' +
|
|
(paint.problems[0] ?? ''),
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
let url: string
|
|
try {
|
|
url = await putGlb(key, fileName, bytes)
|
|
} catch (err) {
|
|
console.warn(`[workshop] could not store ${fileName}`, err)
|
|
this.paintReports.delete(slot)
|
|
return { slot, entry: { url: '' }, rejected: true, paint, message: NO_ROOM }
|
|
}
|
|
this.cache.set(url, scene)
|
|
|
|
const previous = this.manifest[slot]
|
|
const entry: SlotEntry = {
|
|
url,
|
|
offset: previous?.offset ?? [0, 0, 0],
|
|
rotationDeg: previous?.rotationDeg ?? [0, 0, 0],
|
|
scale: previous?.scale ?? 1,
|
|
}
|
|
this.manifest[slot] = entry
|
|
this.unloadable.delete(slot)
|
|
this.stage.setCustom(slot, scene, entry)
|
|
const count = this.stage.slots.get(slot)?.procedural.length ?? 1
|
|
return {
|
|
slot, entry, paint, rejected: false,
|
|
message: count > 1
|
|
? `${fileName} is now all ${count} of the ${spec.label.toLowerCase()}s.`
|
|
: `${fileName} is in the ${spec.label.toLowerCase()} slot.`,
|
|
}
|
|
}
|
|
|
|
/** Nudge the fit. Re-applies to the stage immediately; nothing is re-parsed. */
|
|
setFit(slot: string, patch: Partial<SlotEntry>): SlotEntry | undefined {
|
|
const current = this.manifest[slot]
|
|
if (!current) return undefined
|
|
const next: SlotEntry = { ...current, ...patch }
|
|
this.manifest[slot] = next
|
|
this.stage.applyFit(slot, next)
|
|
return next
|
|
}
|
|
|
|
/** Back to what the game builds today. */
|
|
reset(slot: string): void {
|
|
delete this.manifest[slot]
|
|
this.paintReports.delete(slot)
|
|
this.unloadable.delete(slot)
|
|
this.stage.clearCustom(slot)
|
|
}
|
|
|
|
/**
|
|
* Write the working manifest where the live game will find it.
|
|
* Pruning happens only after the write succeeds, and only against a manifest
|
|
* that still holds every entry — including the ones that would not load.
|
|
*/
|
|
async saveLocally(): Promise<SaveResult> {
|
|
const clean = cleanManifest(this.manifest)
|
|
const saved = Object.keys(clean).length
|
|
try {
|
|
await saveOverrideManifest(clean)
|
|
} catch (err) {
|
|
console.warn('[workshop] could not save the manifest', err)
|
|
return { saved: 0, ok: false, message: NO_ROOM }
|
|
}
|
|
try {
|
|
await pruneOrphans(clean)
|
|
} catch (err) {
|
|
// Leftover bytes waste space but break nothing — never fail a save for it.
|
|
console.warn('[workshop] could not tidy up unused files', err)
|
|
}
|
|
return {
|
|
saved, ok: true,
|
|
message: saved === 0
|
|
? 'Saved — no swaps, so the game looks like it always did.'
|
|
: `Saved ${saved} swap${saved === 1 ? '' : 's'}. Press "Test drive" to play with them.`,
|
|
}
|
|
}
|
|
|
|
/** Wipe every override — the game goes back to its shipped look. */
|
|
async clearEverything(): Promise<boolean> {
|
|
for (const slot of Object.keys(this.manifest)) this.stage.clearCustom(slot)
|
|
this.manifest = {}
|
|
this.paintReports.clear()
|
|
this.unloadable.clear()
|
|
this.cache.clear()
|
|
try {
|
|
await clearOverrides()
|
|
return true
|
|
} catch (err) {
|
|
console.warn('[workshop] could not clear stored swaps', err)
|
|
return false
|
|
}
|
|
}
|
|
|
|
/** The exact text the download button hands over. */
|
|
exportText(): string {
|
|
return serializeManifest(this.manifest)
|
|
}
|
|
|
|
/** Re-apply a stored entry to the stage (used by restore and by the demo). */
|
|
private async applyEntry(slot: string, entry: SlotEntry): Promise<boolean> {
|
|
let scene = this.cache.get(entry.url)
|
|
if (!scene) {
|
|
const bytes = await this.readBytes(entry.url)
|
|
if (!bytes) return false
|
|
try {
|
|
scene = await loadGlb(bytes)
|
|
} catch (err) {
|
|
console.warn(`[workshop] stored asset for ${slot} could not be read`, err)
|
|
return false
|
|
}
|
|
if (!firstMesh(scene)) {
|
|
console.warn(`[workshop] stored asset for ${slot} has no model in it`)
|
|
return false
|
|
}
|
|
this.cache.set(entry.url, scene)
|
|
const spec = slotById(slot)
|
|
if (spec?.paintable) this.paintReports.set(slot, inspectPaintability(scene))
|
|
}
|
|
this.stage.setCustom(slot, scene, entry)
|
|
return true
|
|
}
|
|
|
|
private async readBytes(url: string): Promise<ArrayBuffer | null> {
|
|
if (isIdbUrl(url)) {
|
|
try {
|
|
const record = await getGlb(idbKey(url))
|
|
return record?.bytes ?? null
|
|
} catch (err) {
|
|
console.warn(`[workshop] could not read the stored file for ${url}`, err)
|
|
return null
|
|
}
|
|
}
|
|
try {
|
|
const res = await fetch(url)
|
|
if (!res.ok) {
|
|
console.warn(`[workshop] ${url} answered ${res.status} — keeping the original.`)
|
|
return null
|
|
}
|
|
return await res.arrayBuffer()
|
|
} catch (err) {
|
|
console.warn(`[workshop] could not fetch ${url}`, err)
|
|
return null
|
|
}
|
|
}
|
|
}
|