/** * 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:` 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 { 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(store: IDBObjectStore, run: (s: IDBObjectStore) => IDBRequest): Promise { 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( name: string, mode: IDBTransactionMode, run: (s: IDBObjectStore) => IDBRequest, ): Promise { const db = await openDb() if (!db) return null try { const tx = db.transaction(name, mode) const out = await request(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 { const raw = await withStore(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 { const out = await withStore(STORE_MANIFEST, 'readwrite', (s) => s.put(manifest, MANIFEST_KEY), ) return out !== null } export async function clearOverrides(): Promise { 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 { const out = await withStore(STORE_BLOBS, 'readwrite', (s) => s.put(data, key)) return out !== null } export async function getBlob(key: string): Promise { return withStore(STORE_BLOBS, 'readonly', (s) => s.get(key)) } export async function listBlobKeys(): Promise { const keys = await withStore(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 { const m = await loadOverrideManifest() return Object.keys(m).length > 0 }