BLOBBO/src/assets/idb.ts
Claude 324d7d4568 Lane I: asset runtime — manifest, registry, slot hooks, IndexedDB overrides
Custom GLBs drop into 14 named slots without code changes; an empty manifest
produces today's game by construction (the fallback builders are the original
code moved into a closure, and an empty registry returns the caller's own
object by identity).

- src/assets/{slots,manifest,idb,registry,blobBody}.ts — schema + validation,
  GLTF cache with per-instance material cloning, fit nodes, IndexedDB override
  layer, paintability report.
- Slot hooks in createBlob, parts, cannon, greybox, puddles, ghost. Mesh-only:
  no collider, physics or logic line is touched. Animated sub-parts (plate cap,
  belt chevrons, fan blades, cannon pivot) stay procedural so a custom model
  cannot stop them moving.
- public/assets/ — the build had NO asset copy step at all, so every asset URL
  would have 404'd in production. public/ is vite's default publicDir, so this
  needs no vite.config change.
- Tests: 63 headless checks + a farm-GLB audit that fires PaintSkin's own
  raycast against the fitted body.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 11:37:08 +10:00

124 lines
4.0 KiB
TypeScript

/**
* Lane I — local override store (`blobbo-workshop`).
*
* Why IndexedDB and not a server: John must be able to try a custom asset on
* the LIVE site with zero deploys. The editor writes a manifest + the raw .glb
* bytes here; the game reads them at boot and they win over the shipped pack.
*
* Two stores:
* `manifest` — single record under key 'current', a Manifest object.
* `blobs` — key -> ArrayBuffer of a .glb, addressed as `idb:<key>` urls.
*
* Every function resolves rather than rejects when IndexedDB is unavailable
* (private mode, old browser, node): no override is a normal state, not an error.
*/
import { validateManifest, type Manifest } from './manifest'
export const DB_NAME = 'blobbo-workshop'
export const DB_VERSION = 1
export const STORE_MANIFEST = 'manifest'
export const STORE_BLOBS = 'blobs'
const MANIFEST_KEY = 'current'
export const IDB_URL_PREFIX = 'idb:'
export function isIdbUrl(url: string): boolean {
return url.startsWith(IDB_URL_PREFIX)
}
export function idbKeyFromUrl(url: string): string {
return url.slice(IDB_URL_PREFIX.length)
}
function openDb(): Promise<IDBDatabase | null> {
return new Promise((resolve) => {
if (typeof indexedDB === 'undefined') return resolve(null)
let req: IDBOpenDBRequest
try {
req = indexedDB.open(DB_NAME, DB_VERSION)
} catch {
return resolve(null)
}
req.onupgradeneeded = () => {
const db = req.result
if (!db.objectStoreNames.contains(STORE_MANIFEST)) db.createObjectStore(STORE_MANIFEST)
if (!db.objectStoreNames.contains(STORE_BLOBS)) db.createObjectStore(STORE_BLOBS)
}
req.onsuccess = () => resolve(req.result)
req.onerror = () => resolve(null)
req.onblocked = () => resolve(null)
})
}
function request<T>(store: IDBObjectStore, run: (s: IDBObjectStore) => IDBRequest): Promise<T | null> {
return new Promise((resolve) => {
let req: IDBRequest
try {
req = run(store)
} catch {
return resolve(null)
}
req.onsuccess = () => resolve(req.result as T)
req.onerror = () => resolve(null)
})
}
async function withStore<T>(
name: string,
mode: IDBTransactionMode,
run: (s: IDBObjectStore) => IDBRequest,
): Promise<T | null> {
const db = await openDb()
if (!db) return null
try {
const tx = db.transaction(name, mode)
const out = await request<T>(tx.objectStore(name), run)
return out
} catch {
return null
} finally {
db.close()
}
}
/** The editor's saved pack, or `{}` when there is none. Never throws. */
export async function loadOverrideManifest(): Promise<Manifest> {
const raw = await withStore<unknown>(STORE_MANIFEST, 'readonly', (s) => s.get(MANIFEST_KEY))
if (raw === null || raw === undefined) return {}
const { manifest, problems } = validateManifest(raw)
if (problems.length) console.warn('[assets] local override manifest problems:', problems)
return manifest
}
export async function saveOverrideManifest(manifest: Manifest): Promise<boolean> {
const out = await withStore<IDBValidKey>(STORE_MANIFEST, 'readwrite', (s) =>
s.put(manifest, MANIFEST_KEY),
)
return out !== null
}
export async function clearOverrides(): Promise<void> {
await withStore(STORE_MANIFEST, 'readwrite', (s) => s.clear())
await withStore(STORE_BLOBS, 'readwrite', (s) => s.clear())
}
export async function putBlob(key: string, data: ArrayBuffer): Promise<boolean> {
const out = await withStore<IDBValidKey>(STORE_BLOBS, 'readwrite', (s) => s.put(data, key))
return out !== null
}
export async function getBlob(key: string): Promise<ArrayBuffer | null> {
return withStore<ArrayBuffer>(STORE_BLOBS, 'readonly', (s) => s.get(key))
}
export async function listBlobKeys(): Promise<string[]> {
const keys = await withStore<IDBValidKey[]>(STORE_BLOBS, 'readonly', (s) => s.getAllKeys())
return (keys ?? []).map(String)
}
/** True when a local pack is active — the game shows a "custom assets" pill. */
export async function hasOverrides(): Promise<boolean> {
const m = await loadOverrideManifest()
return Object.keys(m).length > 0
}