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>
93 lines
4.3 KiB
TypeScript
93 lines
4.3 KiB
TypeScript
/**
|
|
* Unit checks for the manifest schema. No test runner / no deps — run directly:
|
|
* node --experimental-strip-types src/assets/manifest.test.ts
|
|
* (Node ≥22; on Node ≥23 the flag is unnecessary.) Same pattern as
|
|
* paint/coverage-math.test.ts: console + throw only, never bundled.
|
|
*
|
|
* The manifest is hand-edited by a non-programmer, so the property under test
|
|
* is "one bad line never costs you the whole pack".
|
|
*/
|
|
import { validateManifest, mergeManifests, type Manifest } from './manifest'
|
|
import { SLOT_IDS, isSlotId, SLOT_LABELS } from './slots'
|
|
|
|
let passed = 0
|
|
function ok(cond: boolean, msg: string): void {
|
|
if (!cond) throw new Error('FAIL: ' + msg)
|
|
passed++
|
|
}
|
|
|
|
// ---- garbage in, empty manifest out (never throws) --------------------------
|
|
for (const junk of [null, undefined, 42, 'nope', [], true]) {
|
|
const r = validateManifest(junk)
|
|
ok(Object.keys(r.manifest).length === 0, `junk input ${JSON.stringify(junk)} -> {}`)
|
|
}
|
|
|
|
// ---- the shipped empty manifest ---------------------------------------------
|
|
ok(Object.keys(validateManifest(JSON.parse('{}')).manifest).length === 0, 'empty {} -> {}')
|
|
ok(validateManifest({}).problems.length === 0, 'empty {} has no problems')
|
|
|
|
// ---- a good entry survives intact -------------------------------------------
|
|
{
|
|
const r = validateManifest({
|
|
'machine.boot': {
|
|
url: 'assets/live/boot.glb',
|
|
offset: [0, 1, 0],
|
|
rotationDeg: [0, 90, 0],
|
|
scale: 2,
|
|
idleClip: 'idle',
|
|
},
|
|
})
|
|
const e = r.manifest['machine.boot']
|
|
ok(r.problems.length === 0, 'clean entry produces no problems')
|
|
ok(e?.url === 'assets/live/boot.glb', 'url kept')
|
|
ok(e?.offset?.[1] === 1, 'offset kept')
|
|
ok(e?.rotationDeg?.[1] === 90, 'rotationDeg kept')
|
|
ok(e?.scale === 2, 'scale kept')
|
|
ok(e?.idleClip === 'idle', 'idleClip kept')
|
|
}
|
|
|
|
// ---- one bad line does not cost you the pack --------------------------------
|
|
{
|
|
const r = validateManifest({
|
|
'machine.boot': { url: 'good.glb' },
|
|
'machine.bucket': { url: 'also-good.glb', offset: 'sideways' },
|
|
'not.a.slot': { url: 'x.glb' },
|
|
'machine.arch': { nourl: true },
|
|
'machine.fan': 'just a string',
|
|
})
|
|
ok(r.manifest['machine.boot'] !== undefined, 'good entry survives a bad neighbour')
|
|
ok(r.manifest['machine.bucket'] !== undefined, 'entry with a bad field still loads')
|
|
ok(r.manifest['machine.bucket']?.offset === undefined, 'bad offset dropped, not kept')
|
|
ok(r.manifest['machine.arch'] === undefined, 'entry without a url is dropped')
|
|
ok((r.manifest as Record<string, unknown>)['not.a.slot'] === undefined, 'unknown slot dropped')
|
|
ok(r.problems.length === 4, `4 problems reported (got ${r.problems.length})`)
|
|
ok(r.problems.some((p) => p.slot === 'not.a.slot'), 'unknown slot is named in problems')
|
|
}
|
|
|
|
// ---- scale accepts a number or a triple, rejects zero -----------------------
|
|
ok(validateManifest({ 'machine.fan': { url: 'a', scale: [1, 2, 3] } }).manifest['machine.fan']?.scale !== undefined, 'triple scale ok')
|
|
ok(validateManifest({ 'machine.fan': { url: 'a', scale: 0 } }).manifest['machine.fan']?.scale === undefined, 'zero scale rejected')
|
|
ok(validateManifest({ 'machine.fan': { url: 'a', offset: [1, 2, NaN] } }).manifest['machine.fan']?.offset === undefined, 'NaN offset rejected')
|
|
|
|
// ---- merge: later source wins whole entries ---------------------------------
|
|
{
|
|
const shipped: Manifest = {
|
|
'machine.boot': { url: 'shipped.glb', scale: 3 },
|
|
'machine.fan': { url: 'fan.glb' },
|
|
}
|
|
const local: Manifest = { 'machine.boot': { url: 'local.glb' } }
|
|
const m = mergeManifests(shipped, local)
|
|
ok(m['machine.boot']?.url === 'local.glb', 'local override wins')
|
|
ok(m['machine.boot']?.scale === undefined, 'override replaces the whole entry, not per-field')
|
|
ok(m['machine.fan']?.url === 'fan.glb', 'untouched shipped entry survives the merge')
|
|
ok(shipped['machine.boot']?.url === 'shipped.glb', 'merge does not mutate its inputs')
|
|
}
|
|
|
|
// ---- slot table is coherent --------------------------------------------------
|
|
ok(new Set(SLOT_IDS).size === SLOT_IDS.length, 'no duplicate slot ids')
|
|
ok(SLOT_IDS.every((s) => typeof SLOT_LABELS[s] === 'string'), 'every slot has a plain-language label')
|
|
ok(SLOT_IDS.every(isSlotId), 'every slot id passes isSlotId')
|
|
ok(!isSlotId('blob.bodyy') && !isSlotId(''), 'isSlotId rejects near-misses')
|
|
|
|
console.log(`manifest.test.ts: ${passed} checks passed`)
|