The three that stopped a pack shipping: - a mesh-less GLB (armature-only export) fired onSwap anyway, hiding the primitive and adding nothing — an invisible prop with a live collider. Mesh count is now checked BEFORE the scene is touched, in one guard that covers every consumer including slotObject/blob.face. - the fulfilment path had no try/catch, so a throw became an unhandled rejection. Split in two: a build failure bails out before onSwap can hide anything; an onSwap failure keeps the replacement parented, because consumers hide their primitive on onSwap's first line and removing the fit node there would manufacture the one forbidden state. - preload() awaited every url forever. Each is now raced against a 10s deadline with allSettled semantics: a stalled host costs one warning and a fallback prop, not a game that never boots. Also: - instantiate/instanceSync never throw — createBlob's call site is frozen and unguarded, so a throw there is a black screen. - ghost and blob share fitBodyToRadius(); a borrowed blob.body was rendering the ghost 2.17x oversized with the farm mesh. - skinned blob.body is rejected loudly instead of silently half-working; the idle-clip mixer is gone (it animated an orphaned skeleton in the fixed step). - paintableInfo counts UV islands: blobbo-base.glb passes every other check and still paints wrong at 1140 charts. Warning, not rejection. - slots.ts gains cannon.base, course.finish, course.tunnel, course.tramp, all hooked except tramp (built in frozen game.ts). - manifest ignores _-prefixed metadata keys instead of calling them typos. Empty-manifest parity verified byte-for-byte: scripts/sacred-parity.check.ts fingerprints the built scene and reports 49df4f20 on main and on this branch.
64 lines
3.1 KiB
TypeScript
64 lines
3.1 KiB
TypeScript
// THE SACRED PROPERTY: empty manifest + empty IndexedDB must construct exactly
|
|
// what the game constructed before the workshop existed. Fingerprints the whole
|
|
// scene graph the course + zones + a cannon build, so any drift shows up as a
|
|
// changed hash. Run against main and against the branch; the numbers must match.
|
|
//
|
|
// It has to be BUNDLED rather than strip-typed: the game modules use TypeScript
|
|
// parameter properties, which node's --experimental-strip-types refuses.
|
|
//
|
|
// ./node_modules/.bin/esbuild scripts/sacred-parity.check.ts --bundle \
|
|
// --platform=node --format=esm --outfile=/tmp/sacred.mjs && node /tmp/sacred.mjs
|
|
//
|
|
// Measured on fix-runtime and on main at f827f6f, both:
|
|
// nodes=49 colliders=15 bodies=3 greyboxMeshes=11 blobRadius=0.500000
|
|
// sceneFingerprint=49df4f20
|
|
import * as THREE from 'three'
|
|
import RAPIER from '@dimforge/rapier3d-compat'
|
|
import { buildGreybox } from '../src/course/greybox.ts'
|
|
import { installZones } from '../src/course/zones.ts'
|
|
import { PaintCannon } from '../src/paint/cannon.ts'
|
|
import { AssetRegistry, setAssets } from '../src/assets/registry.ts'
|
|
import { createBlob } from '../src/blob/createBlob.ts'
|
|
|
|
setAssets(new AssetRegistry({})) // empty manifest, no IndexedDB
|
|
await RAPIER.init()
|
|
|
|
const scene = new THREE.Scene()
|
|
const physics = new RAPIER.World({ x: 0, y: -9.81, z: 0 })
|
|
const listeners: any = {}
|
|
const world: any = {
|
|
scene, physics, rapier: RAPIER,
|
|
events: { on: (n: string, f: any) => { (listeners[n] ||= []).push(f) }, emit: () => {} },
|
|
addSystem: () => {}, onFrame: () => {}, tick: () => {}, renderOnce: () => {}, start: () => {},
|
|
}
|
|
|
|
const gb = buildGreybox(world)
|
|
const blob = createBlob(world, {})
|
|
world.blob = blob
|
|
const skin: any = { boundingRadius: 0.5, splat: () => {}, splatAtWorldPoint: () => {}, coverage: () => ({ total: 0 }) }
|
|
installZones(world, blob, skin)
|
|
new PaintCannon({ world, color: 'red', position: new THREE.Vector3(0, 2, 0), target: blob.mesh, paint: skin, targetRadius: 0.5 })
|
|
|
|
await new Promise((r) => setTimeout(r, 50)) // let any async swap settle
|
|
|
|
function fp(root: THREE.Object3D) {
|
|
const parts: string[] = []
|
|
root.traverse((o) => {
|
|
const m = o as THREE.Mesh
|
|
const mat: any = Array.isArray(m.material) ? m.material[0] : m.material
|
|
parts.push([
|
|
o.type, o.name, o.visible ? 1 : 0,
|
|
o.position.toArray().map((n) => n.toFixed(4)).join(','),
|
|
o.scale.toArray().map((n) => n.toFixed(4)).join(','),
|
|
m.isMesh ? (m.geometry.getAttribute('position')?.count ?? 0) : '-',
|
|
mat ? `${mat.type}:${mat.color?.getHexString?.() ?? ''}:${mat.visible ? 1 : 0}` : '-',
|
|
].join('|'))
|
|
})
|
|
return parts
|
|
}
|
|
const lines = fp(scene)
|
|
let h = 0
|
|
for (const s of lines) for (let i = 0; i < s.length; i++) h = (Math.imul(31, h) + s.charCodeAt(i)) | 0
|
|
console.log(`nodes=${lines.length} colliders=${physics.colliders.len()} bodies=${physics.bodies.len()} greyboxMeshes=${gb.meshes.length} blobRadius=${blob.mesh.geometry.boundingSphere?.radius?.toFixed(6) ?? (blob.mesh.geometry.computeBoundingSphere(), blob.mesh.geometry.boundingSphere!.radius.toFixed(6))}`)
|
|
console.log(`sceneFingerprint=${(h >>> 0).toString(16)}`)
|