diff --git a/web/world/js/world.js b/web/world/js/world.js index dbad5f1..23b9442 100644 --- a/web/world/js/world.js +++ b/web/world/js/world.js @@ -114,6 +114,27 @@ export function validateSite(site, name = site?.id ?? '?') { if (!ids.length) bad.push('no anchors at all — nothing to rig to'); const dupes = ids.filter((id, i) => ids.indexOf(id) !== i); if (dupes.length) bad.push(`duplicate anchor ids: ${[...new Set(dupes)].join(', ')}`); + // SPRINT18 gate 5 (E specified, A blessed): two author-time refusals for the + // second-rust-post landmine. A duplicate STRUCTURE id makes structures.set() + // silently replace the first, which then never renders, bills, or wrecks; + // two structures resolving to ONE collateral key make collateralFor bill and + // wreck whichever matched first. Both are quiet until a yard has two of the + // same thing, which is why they are refused here rather than measured later. + const structIds = (site.structures ?? []).map((s) => s.id); + const dupStructs = structIds.filter((id, i) => structIds.indexOf(id) !== i); + if (dupStructs.length) { + bad.push(`duplicate structure ids: ${[...new Set(dupStructs)].join(', ')}`); + } + const structKeys = (site.structures ?? []) + .filter((s) => s.collateral != null) + .map((s) => s.collateralKey ?? s.id); + const dupKeys = structKeys.filter((k, i) => structKeys.indexOf(k) !== i); + if (dupKeys.length) { + bad.push( + `two priced structures resolve to one collateral key: ${[...new Set(dupKeys)].join(', ')}` + + ' — give each a distinct collateralKey and point its anchors at it', + ); + } // Lane D's field. `work` is the MECHANISM, not the anchor's type: a post is // tensioned from a cleat at its base and a tree strop is thrown, but a // bracket bolted up a bare wall is the one you cannot fake — so that, and @@ -542,6 +563,11 @@ export function createWorld(scene, opts = {}) { makeStaticAnchor(a.id, a.type ?? 'post', new THREE.Vector3(st.x, heightAt(st.x, st.z) + 2.4, st.z)), { work: a.work }, a.node ? null : factoryExtras(a.type ?? 'post'), + // SPRINT18 gate 5 (E specified, A blessed): the site names its own + // collateral target, applied AFTER factoryExtras so the override wins. + // Without it, two same-type structures share one baked key and + // collateralFor bills or wrecks whichever matched first. + a.collateral ? { collateral: a.collateral } : null, )); } } @@ -687,7 +713,10 @@ export function createWorld(scene, opts = {}) { if (!node || !anchor) return false; anchor.pos.setFromMatrixPosition(node.matrixWorld); anchor.ratingHint = node.userData?.rating_hint ?? 1; - anchor.collateral = node.userData?.collateral ?? null; + // SPRINT18 gate 5: the bake is the DEFAULT, never a clobber — a site that + // named its own target above keeps it. With no override the chain is + // identical, so every shipped site is byte-unchanged. + anchor.collateral = anchor.collateral ?? node.userData?.collateral ?? null; // SPRINT14, E's gap 1: some baked anchor nodes are NOT tie-offs — a door // step, a bench top, a broom grip, a lighting hint. They carry no // `rating_hint`, and `?? 1` therefore rated them PERFECT: name one in a