S18 integration: apply E's gate-5 hunks (E specified, A blessed) — the site names its own collateral target, the bake stops clobbering it, and validateSite refuses the clash

This commit is contained in:
type-two 2026-07-25 20:04:51 +10:00
parent ecd88f0183
commit 43724c7428

View File

@ -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