HardYards/web/world/js/tests/e.test.js
type-two 6e0608a6fb Lane E S17 gate 3.1: THE CORRODED TIER — mutations restored, tell verified at 20 m, droop pinned to geometry
Recovers an interrupted session and finishes its verification honestly.

RESTORED (the predecessor was killed mid-mutation-check):
- build_sail_post_corroded still had negative control M1 APPLIED — clean
  galvanised steel, DROOP=0 — so both shipped GLBs were the untelled post.
  Restored: steel_weathered/rust/rust_deep, DROOP_DEG = 38.
- asset_report.json held 1 of 37 assets and contact_sheet.png was a 196 KB
  truncated write. Both were interrupted artifacts, not decisions; a clean
  rebuild restores 37 assets and a 5.07 MB 1680x4200 sheet.

DETERMINISM: 37/37 GLBs byte-identical across two consecutive full runs;
report and contact sheet byte-identical too. Zero churn on the 35 pre-existing
GLBs.

ASSERT THAT COULD NOT FAIL, KILLED: the sag pin (honestY - corrodedY > 0.05)
reads 0.060 at ZERO droop, because the eye hangs off a weld line 160 mm down
the post — measured on the M-B build, anchor at (0, 3.920, 0). It measured the
weld offset and called it a sag. Replaced with the droop ANGLE derived from
the exported anchor and pinned to baked extras (padeye_droop_deg / arm_m /
weld_y_threejs / dir_threejs), all stated in three.js coords — the axis trap
closed the swing_set way, so note and mesh can only lie together.

ASSERT WHOSE REASON WAS BACKWARDS, FIXED: the 20 m pin claimed the corroded
shaft read darker. Measured off look.html's canvas at 20 m: honest lum 50,
corroded lum 94 — it renders twice as BRIGHT. Base colour is darker but
metalness drives the pixels (gal 0.90/0.35 goes near-black with no env map;
weathered 0.55/0.60 is diffuse). Test now pins both halves and names the
mechanism the player actually sees.

Three tells split into three tests so one failure cannot mask the others —
pre-split, the M-A mutation went red on rust area and never reached the
lightness or droop asserts at all.

Mutation-checked in the browser, all three witnessed red: M-A clean steel
(1/3 + 2/3 red, 3/3 green, factory GREEN throughout — the trap it cannot see,
confirmed); M-B flat eye (3/3 red, others green); M-C baked note lies while
mesh is honest (3/3 red, proving the pin reads the extra, not a literal).

Selftest 483/0/0 (474 baseline + 9 exactly). Tell verified by looking at
20 m at the game's own camera height, screenshots taken.

editor.js untouched — palette hunk filed verbatim in THREADS for A.
contracts.js ANCHOR_TYPE widening flagged for A.
Lane D flagged: the tier is placeable, gate 3.2 unblocked.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 01:51:57 +10:00

1441 lines
82 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Lane E selftests — asset sanity.
* Lane E owns this file. Other lanes: yours is js/tests/<letter>.test.js.
*
* Why this exists when build_yard_assets.py already verifies: the Blender side
* re-imports every GLB and asserts dims, tri budget and node names, but it
* CANNOT catch an axis error. Blender exports Z-up→Y-up and imports Y-up→Z-up,
* so a broken `export_yup` flips back on the way in and round-trips green. Only
* a native glTF reader can prove the file is right, and this is the only one in
* the repo. So this suite targets the failures that silently break other lanes:
* 1. the GLB loads at all through the vendored loader;
* 2. it's in metres with its height on +Y — a model exported in centimetres
* looks fine alone and absurd next to a person;
* 3. the nodes other lanes query by name survived the export. glTF has no
* "empty" type, so anchors arrive as bare Object3D and are exactly what an
* exporter prunes.
*
* Standalone version with a fuller report: tools/assetcheck/.
*/
import * as THREE from '../../vendor/three.module.js';
import { assert } from '../testkit.js';
import { ANCHOR_TYPE } from '../contracts.js';
import { createWorld, loadSite } from '../world.js';
import { FACTORY_ANCHOR_RATINGS } from '../anchor_ratings.gen.js';
// GLTFLoader is imported DYNAMICALLY, below, and that is deliberate.
//
// Every three.js addon imports the bare specifier `three`, which only resolves
// via an <script type="importmap">. No page in this repo has one yet — index.html
// and selftest.html both import `../vendor/three.module.js` by relative path and
// so never needed it. A static import here would throw at module load, and
// selftest.html turns an un-importable lane module into a hard FAIL, which would
// redden Lane A's merge gate over a harness gap rather than a real defect.
//
// So: try it at runtime and skip with an actionable message if it's absent. The
// day the importmap lands this suite lights up on its own, no edit needed.
// Need + exact fix are logged in THREADS.md [E] — it blocks Lane D too, which
// can't load a ped without GLTFLoader/SkeletonUtils.
const LOADER_PATH = '../../vendor/addons/loaders/GLTFLoader.js';
/** Resolve off import.meta.url, not the document — survives selftest.html moving. */
const url = (a) => new URL(`../../models/${a.sub ?? ''}${a.name}_v1.glb`, import.meta.url).href;
/**
* Height ranges rather than exact dims: this guards against unit and axis
* regressions, not against Lane E retuning a silhouette. Exact measurements
* live in tools/blender/asset_report.json. `nodes` are the names other lanes
* query — changing one is a contract break and should fail here.
*/
const ASSETS = [
{ name: 'ref_capsule', h: [1.68, 1.72], nodes: ['ref_capsule_mesh', 'head_height'] },
{ name: 'tree_gum_01', h: [4.0, 9.0],
nodes: ['trunk', 'canopy_01', 'canopy_02', 'canopy_03',
'branch_anchor_01', 'branch_anchor_02', 'branch_anchor_03'] },
{ name: 'tree_gum_02', h: [4.0, 9.0],
nodes: ['trunk', 'canopy_01', 'canopy_02', 'branch_anchor_01', 'branch_anchor_02'] },
{ name: 'fence_post', h: [1.8, 2.2], nodes: ['post'] },
{ name: 'fence_panel', h: [1.6, 2.0], nodes: ['palings', 'rails'] },
{ name: 'gate', h: [1.6, 2.0], nodes: ['gate_palings', 'gate_frame', 'hinges', 'hinge_axis'] },
{ name: 'house_yardside', h: [2.5, 3.5],
nodes: ['wall', 'door', 'window', 'roof', 'fascia', 'gutter',
'fascia_anchor_01', 'fascia_anchor_02', 'fascia_anchor_03'] },
{ name: 'house_yardside_wrecked', h: [2.5, 3.5],
nodes: ['wall', 'door', 'window', 'roof', 'fascia_torn',
'gutter_torn', 'gutter_down', 'downpipe_loose', 'debris_fascia'] },
{ name: 'shed_01', h: [1.9, 2.4], nodes: ['shell', 'roof', 'doors', 'door_anchor'] },
{ name: 'shed_table', h: [0.8, 1.0], nodes: ['table_top', 'table_frame', 'pickup_anchor'] },
{ name: 'garden_bed', h: [0.5, 1.1],
nodes: ['bed', 'soil', 'plants_full', 'plants_tattered', 'plants_dead'] },
{ name: 'sail_post', h: [3.8, 4.2],
nodes: ['footing', 'post', 'pad_eye', 'top_anchor', 'rake_pivot'] },
{ name: 'ladder_01', h: [2.8, 3.2], nodes: ['ladder', 'ladder_base', 'ladder_top'] },
{ name: 'shackle', h: [0.05, 0.15], nodes: ['bow', 'pin'] },
{ name: 'carabiner', h: [0.06, 0.15], nodes: ['body', 'gate'] },
{ name: 'turnbuckle', h: [0.12, 0.25], nodes: ['body', 'eye_a', 'eye_b'] },
{ name: 'tramp_01', h: [0.6, 1.0], nodes: ['mat', 'rim', 'pad', 'legs'], sub: 'debris/' },
{ name: 'wheelie_bin_01', h: [1.0, 1.2],
nodes: ['bin_body', 'lid', 'lid_plate', 'wheels'], sub: 'debris/' },
{ name: 'washing_line_01', h: [2.0, 2.4], nodes: ['mast', 'head', 'arms'] },
{ name: 'garden_gnome_01', h: [0.33, 0.42], nodes: ['gnome'] },
{ name: 'garden_gnome_01_broken', h: [0.08, 0.20],
nodes: ['stump', 'head', 'hat', 'shards'] },
{ name: 'fence_panel_snapped', h: [1.70, 1.90],
nodes: ['palings', 'rails', 'debris_palings'] },
{ name: 'broom_01', h: [1.35, 1.50],
nodes: ['handle', 'head', 'bristles', 'grip_anchor', 'poke_tip'] },
{ name: 'hail_stone_01', h: [0.012, 0.028], nodes: ['stone'] },
{ name: 'carport_01', h: [2.4, 2.8],
nodes: ['footings', 'posts', 'beams', 'roof',
'beam_anchor_01', 'beam_anchor_02', 'post_anchor_01', 'post_anchor_02'] },
{ name: 'carport_01_wrecked', h: [1.9, 2.45],
nodes: ['footings', 'posts', 'beams', 'roof_down'] },
{ name: 'bike_kid_01', h: [0.60, 0.84],
nodes: ['wheel_rear', 'wheel_front', 'frame', 'bars'] },
{ name: 'tree_jacaranda_01', h: [5.7, 6.5],
nodes: ['trunk', 'canopy', 'canopy_01', 'canopy_02', 'canopy_03', 'canopy_04',
'branch_anchor_01', 'branch_anchor_02', 'branch_anchor_03'] },
{ name: 'swing_set_01', h: [2.00, 2.15],
nodes: ['frame', 'crossbar', 'swings', 'frame_anchor_01', 'frame_anchor_02'] },
{ name: 'swing_set_01_wrecked', h: [0.80, 1.05],
nodes: ['frame', 'crossbar', 'swings'] },
// SPRINT16 gate 5 — the next temptations. Height floors carry design claims:
// the pergola's 2.55 floor sits ABOVE the swing set's 2.15 ceiling (the rung
// it out-rates must stand taller than the rung below it), and the pool
// fence's band brackets 1.2 m because the compliance height IS the flavour.
{ name: 'pergola_01', h: [2.55, 2.85],
nodes: ['deck', 'posts', 'beams', 'rafters',
'pergola_anchor_01', 'pergola_anchor_02'] },
{ name: 'pergola_01_wrecked', h: [2.55, 2.85],
nodes: ['deck', 'posts', 'beams', 'beam_down', 'rafters_down'] },
{ name: 'glasshouse_01', h: [2.15, 2.40],
nodes: ['frame', 'panes', 'door', 'ridge_anchor'] },
{ name: 'glasshouse_01_wrecked', h: [2.15, 2.40],
nodes: ['frame', 'door', 'panes_left', 'shards'] },
{ name: 'pool_kit_01', h: [1.12, 1.32],
nodes: ['pool_shell', 'pool_water', 'fence_rails', 'pool_gate',
'fence_post_01', 'fence_post_14'] },
// SPRINT17 gate 3.1 — the corroded tier. The intact variant stands POST
// height on purpose: its lie is "I'm a sail post", so it must stand like
// one, and only the colour and the drooped eye say otherwise. The wreck's
// ceiling is under a metre because the post FOLDED at the rust line — a
// corroded wreck still standing 4 m tall is a wreck that never told the
// truth about where it was weakest.
{ name: 'sail_post_corroded', h: [3.90, 4.15],
nodes: ['footing', 'post', 'rust', 'pad_eye', 'top_anchor', 'rake_pivot'] },
{ name: 'sail_post_corroded_wrecked', h: [0.45, 0.95],
nodes: ['footing', 'post_stub', 'post_down', 'pad_eye_down'] },
];
function sizeOf(gltf) {
const s = new THREE.Vector3();
new THREE.Box3().setFromObject(gltf.scene).getSize(s);
return s;
}
/** @param {import('../testkit.js').Suite} t */
export default async function run(t) {
let GLTFLoader;
try {
({ GLTFLoader } = await import(LOADER_PATH));
} catch (err) {
t.skip('needs an importmap for the bare `three` specifier — see THREADS [E]. ' +
'Assets ARE verified meanwhile: tools/assetcheck/ (16/16 green in three.js r175)');
return;
}
const loader = new GLTFLoader();
const loaded = new Map();
const failed = new Map();
await Promise.all(ASSETS.map(async (a) => {
try { loaded.set(a.name, await loader.loadAsync(url(a))); }
catch (err) { failed.set(a.name, err?.message ?? String(err)); }
}));
t.test('every yard GLB loads through the vendored GLTFLoader', () => {
const lost = [...failed].map(([n, e]) => `${n} (${e})`).join('; ');
assert(failed.size === 0, `failed to load: ${lost}`);
});
// The anchor of the whole scale system. If this is wrong, every judgement
// made against the contact sheet was made against a lie.
t.test('ref_capsule is 1.70 m tall on +Y — the scale everything is judged against', () => {
const g = loaded.get('ref_capsule');
assert(g, 'ref_capsule did not load');
const s = sizeOf(g);
assert(Math.abs(s.y - 1.70) < 0.02, `capsule is ${s.y.toFixed(3)} m on Y, want 1.70`);
assert(s.x < 0.6 && s.z < 0.6,
`capsule is ${s.x.toFixed(2)} x ${s.z.toFixed(2)} in plan — height is not on +Y`);
});
for (const a of ASSETS) {
const gltf = loaded.get(a.name);
if (!gltf) continue; // already reported by the load test
const s = sizeOf(gltf);
t.test(`${a.name}: metre-scale, height on +Y`, () => {
assert(s.y >= a.h[0] && s.y <= a.h[1],
`${a.name} stands ${s.y.toFixed(3)} m, want ${a.h[0]}${a.h[1]} m ` +
`(box ${s.x.toFixed(2)} x ${s.y.toFixed(2)} x ${s.z.toFixed(2)})`);
});
t.test(`${a.name}: named nodes survive the export`, () => {
const names = new Set();
gltf.scene.traverse((o) => names.add(o.name));
const missing = a.nodes.filter((n) => !names.has(n));
assert(missing.length === 0,
`${a.name} lost ${missing.join(', ')} — other lanes query these by name`);
});
}
// site_02's whole personality is "nowhere to tie off", and it lives in these
// four numbers rather than in any geometry. The carport is meant to LOOK like
// four free anchors and be the worst steel in the game — worse even than the
// house fascia, which DESIGN.md already calls a lie. If someone quietly
// "fixes" these ratings upward the site stops teaching anything and just
// becomes a smaller yard, so they're pinned here with the reason attached.
// The trap has to be scoreable or it isn't a trap, only a warning. The anchors
// said collateral="carport" from Sprint 9 but nothing said what a carport
// COSTS, so Lane A's aftermath had no number to reach for and the site's whole
// lesson was unpriced. main.js already reads world.gnome.collateralValue — this
// is the same shape in the same place.
t.test('the carport is priced, and it is the worst bill on the site', () => {
const c = loaded.get('carport_01')?.scene.getObjectByName('carport_01');
assert(c, 'carport_01 root missing');
const cost = c.userData?.collateral_value;
assert(typeof cost === 'number', `carport carries no collateral_value (${JSON.stringify(c.userData)})`);
const gnome = loaded.get('garden_gnome_01')?.scene
.getObjectByName('garden_gnome_01')?.userData?.collateral_value;
assert(cost > gnome, `carport (${cost}) must cost more than the gnome (${gnome})`);
// Wreckage carries the same price, so scoring can read either state.
const w = loaded.get('carport_01_wrecked')?.scene.getObjectByName('carport_01_wrecked');
assert(w?.userData?.collateral_value === cost,
'the wrecked carport must be priced the same as the one it used to be');
assert(w?.userData?.broken_variant_of === 'carport_01',
'wrecked carport must name its intact twin so A can pair the swap');
});
// The gutter, priced — the carport ruling's second application (SPRINT12
// §gate 3.3). backyard_01's fascia anchors have said collateral:"gutter"
// since Sprint 6 and nobody ever priced one, so collateralFor('gutter')
// returns null and the house is a FREE failure. The proposal is 90 and the
// reasoning lives beside GUTTER_COLLATERAL in build_yard_assets.py; what THIS
// pins is the calibration, not the digit: the gutter must cost more than the
// ornament and less than the structure, because the fascia (0.35) is honestly
// better steel than the carport beam (0.22) and the tutorial yard's trap must
// cost you a night, not the week. A rules the number; if it moves, move it in
// the factory and re-export — do not let site JSON and the GLB tell two prices.
t.test('the gutter is priced, inside the band, and keyed to what the anchors say', () => {
const h = loaded.get('house_yardside')?.scene.getObjectByName('house_yardside');
assert(h, 'house_yardside root missing');
const cost = h.userData?.collateral_value;
assert(typeof cost === 'number', `house carries no collateral_value (${JSON.stringify(h.userData)})`);
// collateral_key exists because, unlike the carport, the structure and the
// thing you take have different names: the key says WHICH collateral string
// this value prices, and it must be the one the fascia anchors carry or
// Lane A's wiring prices nothing.
assert(h.userData?.collateral_key === 'gutter',
`collateral_key is ${JSON.stringify(h.userData?.collateral_key)} — must name the anchors' collateral string`);
const a1 = loaded.get('house_yardside')?.scene.getObjectByName('fascia_anchor_01');
assert(a1?.userData?.collateral === h.userData.collateral_key,
`fascia anchor says collateral=${JSON.stringify(a1?.userData?.collateral)} but the price is keyed ${JSON.stringify(h.userData.collateral_key)} — the chain is broken`);
const gnome = loaded.get('garden_gnome_01')?.scene
.getObjectByName('garden_gnome_01')?.userData?.collateral_value;
const carport = loaded.get('carport_01')?.scene
.getObjectByName('carport_01')?.userData?.collateral_value;
assert(cost > gnome, `gutter (${cost}) must cost more than the gnome (${gnome}) — structural collateral beats ornament`);
assert(cost < carport, `gutter (${cost}) must cost less than the carport (${carport}) — the backyard's trap is the gentler one`);
// Wreckage carries the same price, so scoring can read either state.
const w = loaded.get('house_yardside_wrecked')?.scene.getObjectByName('house_yardside_wrecked');
assert(w?.userData?.collateral_value === cost,
'the wrecked house must price its gutter the same as the one it used to be');
assert(w?.userData?.broken_variant_of === 'house_yardside',
'wrecked house must name its intact twin so A can pair the swap');
});
// The wreck's whole message is vertical: the gutter that was UP at the eave is
// DOWN. Both halves are measured, and the intact half is the negative control
// that proves the measurement measures — if someone rebuilds the wreck with
// the run back at the eave (or flips the export axis), gutter_down's box
// lands ~2.6 m up and this goes red. Verified red once already: built with
// the ground run at eave height as a negative control, this failed at
// "tops out at y=2.68" (1 failed / 319 passed) while the Blender verify said
// [PASS] 9.46 x 2.10 x 2.92 on the same bad file — the run's PLAN position
// didn't move, so no box span changed. A bounding box cannot answer "did it
// fall" unless something pins which height it fell FROM.
t.test('the torn gutter is DOWN in the wreck, UP in the intact house', () => {
const up = loaded.get('house_yardside')?.scene.getObjectByName('gutter');
assert(up, 'intact house lost its gutter node');
const upBox = new THREE.Box3().setFromObject(up);
assert(upBox.max.y > 2.5,
`intact gutter tops out at y=${upBox.max.y.toFixed(2)} — the control is broken, nothing below can mean anything`);
const wreck = loaded.get('house_yardside_wrecked');
assert(wreck, 'house_yardside_wrecked did not load');
const down = new THREE.Box3().setFromObject(wreck.scene.getObjectByName('gutter_down'));
assert(down.max.y < 0.5,
`gutter_down tops out at y=${down.max.y.toFixed(2)} — the run is not on the grass`);
const hang = new THREE.Box3().setFromObject(wreck.scene.getObjectByName('gutter_torn'));
assert(hang.max.y > 2.3,
`gutter_torn tops out at y=${hang.max.y.toFixed(2)} — it should still reach the eave it tore from`);
assert(hang.min.y < 1.6,
`gutter_torn bottoms out at y=${hang.min.y.toFixed(2)} — the torn end should sag well below the fascia line`);
// And the house itself did NOT fall: unlike the carport, this wreck keeps
// its full height, because only the eave line failed. A shorter (or taller)
// wreck here means someone wrecked the building instead of the gutter.
const hi = new THREE.Box3().setFromObject(loaded.get('house_yardside').scene);
const hw = new THREE.Box3().setFromObject(wreck.scene);
const dh = Math.abs((hi.max.y - hi.min.y) - (hw.max.y - hw.min.y));
assert(dh < 0.05, `wreck height differs from the house by ${dh.toFixed(3)} m — a house does not fall when its gutter does`);
});
// You cannot re-tie to a ripped eave. An anchor that outlives its fascia
// would be the free-failure bug back again, wearing a wreck for a costume —
// world.anchors would keep offering the tie-off after the aftermath swap.
t.test('no fascia anchor survives into the wreck', () => {
const w = loaded.get('house_yardside_wrecked');
assert(w, 'house_yardside_wrecked did not load');
for (const n of ['fascia_anchor_01', 'fascia_anchor_02', 'fascia_anchor_03']) {
assert(!w.scene.getObjectByName(n),
`${n} survives in the wreck — the eave it anchored is gone, so it must be too`);
}
});
// A wreck that stands taller than the thing it was is a bug, not a wreck.
t.test('the wrecked carport is shorter than the carport', () => {
const a = new THREE.Box3().setFromObject(loaded.get('carport_01').scene);
const b = new THREE.Box3().setFromObject(loaded.get('carport_01_wrecked').scene);
const hi = a.max.y - a.min.y, lo = b.max.y - b.min.y;
assert(lo < hi, `wreck stands ${lo.toFixed(2)} m vs the intact ${hi.toFixed(2)} m`);
assert(Math.abs(b.min.y) < 0.05, `wreck sits at y=${b.min.y.toFixed(2)} — roof sheet through the ground?`);
});
// A bike does not stand up on its own, so the lean is baked into the GLB
// rather than left as a rotation for Lane A to remember. That makes the lean
// DIRECTION an export-time fact, and export-time facts about axes are exactly
// what this file exists to catch (see the header): Blender is Z-up and the
// bike leans +Y there, but the exporter maps (x,y,z) -> (x,z,-y), so by the
// time A places it in three.js it leans -Z.
//
// Nothing else can catch this. The Blender-side verify re-imports through the
// same mapping and round-trips green, and the bounding box is the identical
// size whichever way the thing tips. If someone flips the export convention or
// quietly un-tilts the bike, the yard still loads, the dims still pass, and the
// only symptom is a bike leaning into open air next to a fence — which reads as
// a physics bug and would get chased in the wrong lane entirely.
t.test('the kid\'s bike leans, and it leans toward -Z (fence side)', () => {
const g = loaded.get('bike_kid_01');
assert(g, 'bike_kid_01 did not load');
const bars = g.scene.getObjectByName('bars');
assert(bars, 'bike_kid_01 has no bars group to measure the lean by');
// The bars are the top of the bike, so they travel furthest when it tips.
const b = new THREE.Box3().setFromObject(bars);
const z = (b.max.z + b.min.z) / 2;
assert(z < -0.05, `bars sit at z=${z.toFixed(3)} — the bike is upright or leaning the wrong way`);
// ...and it must still be ON the ground: the tyre contact points are the
// pivot, so an un-pivoted lean would push the wheels through or above it.
const all = new THREE.Box3().setFromObject(g.scene);
assert(Math.abs(all.min.y) < 0.03,
`bike sits at y=${all.min.y.toFixed(3)} — wheels through the ground or floating`);
});
t.test('carport anchors stay a trap — the corner block has no good tie-off', () => {
const g = loaded.get('carport_01');
assert(g, 'carport_01 did not load');
const FASCIA = 0.35; // house_yardside's, the previous worst
for (const n of ['beam_anchor_01', 'beam_anchor_02']) {
const o = g.scene.getObjectByName(n);
assert(o, `${n} missing`);
assert(o.userData?.rating_hint < FASCIA,
`${n} rates ${o.userData?.rating_hint} — the carport beam must be worse than the fascia (${FASCIA})`);
assert(o.userData?.collateral === 'carport',
`${n} must carry collateral="carport" — taking it takes the roof`);
}
for (const n of ['post_anchor_01', 'post_anchor_02']) {
const o = g.scene.getObjectByName(n);
assert(o?.userData?.rating_hint < FASCIA,
`${n} rates ${o?.userData?.rating_hint} — still a 90 mm post on a small pad`);
}
});
// These five numbers are load-bearing for the whole repo: Lane A's winning
// line rigs off t2, and every balance figure B and C measured assumes the
// branch anchors sit exactly here. I reshaped the limbs in Sprint 7 for looks
// and pinned the tips bit-for-bit to do it; this is the tripwire that keeps
// the next bit of art from quietly invalidating a sprint of physics.
// If it fails, the art moved an anchor — fix the art, don't touch the numbers.
t.test('branch anchors have not moved — the balance numbers depend on them', () => {
const EXPECT = {
tree_gum_01: {
branch_anchor_01: [-0.960, 3.636, -1.460], // Blender (x,y,z) -> glTF (x,z,-y)
branch_anchor_02: [-0.941, 4.145, 0.777],
branch_anchor_03: [-1.382, 5.227, 0.978],
},
tree_gum_02: {
branch_anchor_01: [-0.167, 2.950, 1.154],
branch_anchor_02: [0.142, 3.722, 1.043],
},
};
for (const [tree, anchors] of Object.entries(EXPECT)) {
const g = loaded.get(tree);
assert(g, `${tree} did not load`);
g.scene.updateWorldMatrix(true, true);
for (const [n, want] of Object.entries(anchors)) {
const o = g.scene.getObjectByName(n);
assert(o, `${tree}/${n} missing`);
const p = new THREE.Vector3().setFromMatrixPosition(o.matrixWorld);
const got = [p.x, p.y, p.z];
for (let i = 0; i < 3; i++) {
assert(Math.abs(got[i] - want[i]) < 0.005,
`${tree}/${n} moved on ${'xyz'[i]}: ${got[i].toFixed(3)} vs ${want[i]} ` +
`(full ${got.map((v) => v.toFixed(3)).join(', ')})`);
}
}
}
});
// Anchors are the actual product here: Lane B pins cloth corners to them and
// Lane A builds world.anchors from them. A surviving name isn't enough — the
// position has to be usable.
t.test('branch_anchor_01 resolves to a usable world position up the tree', () => {
const g = loaded.get('tree_gum_01');
assert(g, 'tree_gum_01 did not load');
const anchor = g.scene.getObjectByName('branch_anchor_01');
assert(anchor, 'branch_anchor_01 missing — glTF has no "empty", check it was not pruned');
g.scene.updateWorldMatrix(true, true);
const p = new THREE.Vector3().setFromMatrixPosition(anchor.matrixWorld);
assert(p.y > 1.0 && p.y < 6.0,
`anchor sits at y=${p.y.toFixed(2)} — want 16 m up the trunk`);
assert(Number.isFinite(p.x) && Number.isFinite(p.z), 'anchor world position is not finite');
});
// A canopy that can't sway is just decoration, and the gust front the player
// reads a beat before it hits the sail (world.js) is the canopy leaning. Lane A
// rotates a `canopy` group; the blobs must swing about the TRUNK, not spin
// about their own centres — a sphere spinning in place is invisible, which is
// exactly the failure this catches.
t.test('canopy sways about the trunk when Lane A rotates it', () => {
const g = loaded.get('tree_gum_01');
assert(g, 'tree_gum_01 did not load');
const canopy = g.scene.getObjectByName('canopy');
assert(canopy, 'no `canopy` group node — world.js rotates this to sway the tree');
const blob = g.scene.getObjectByName('canopy_01');
assert(blob, 'canopy_01 missing');
g.scene.updateWorldMatrix(true, true);
const before = new THREE.Vector3().setFromMatrixPosition(blob.matrixWorld);
const restZ = canopy.rotation.z;
canopy.rotation.z = restZ + 0.20; // ≈ world.js's max lean of 0.22 rad
canopy.updateWorldMatrix(true, true);
const after = new THREE.Vector3().setFromMatrixPosition(blob.matrixWorld);
canopy.rotation.z = restZ;
canopy.updateWorldMatrix(true, true);
const moved = before.distanceTo(after);
assert(moved > 0.15,
`canopy_01 moved only ${moved.toFixed(3)} m under a 0.2 rad lean — the pivot is at ` +
'the blob centre, not the trunk top, so the tree cannot visibly sway');
});
// DESIGN.md: rake the post away from the load — so rake is a runtime rotation,
// not baked. Rotating rake_pivot must carry the post and its top_anchor over
// while the concrete footing stays level in the ground. A post whose footing
// tips out of the dirt with it isn't raked, it's falling.
t.test('sail_post rakes about rake_pivot with the footing left planted', () => {
const g = loaded.get('sail_post');
assert(g, 'sail_post did not load');
const rake = g.scene.getObjectByName('rake_pivot');
const anchor = g.scene.getObjectByName('top_anchor');
const footing = g.scene.getObjectByName('footing');
assert(rake && anchor && footing, 'sail_post needs rake_pivot, top_anchor and footing');
const at = () => {
g.scene.updateWorldMatrix(true, true);
return [new THREE.Vector3().setFromMatrixPosition(anchor.matrixWorld),
new THREE.Vector3().setFromMatrixPosition(footing.matrixWorld)];
};
const [a0, f0] = at();
rake.rotation.x += (8 * Math.PI) / 180; // Lane A rakes 8°
const [a1, f1] = at();
rake.rotation.x -= (8 * Math.PI) / 180;
g.scene.updateWorldMatrix(true, true);
const head = a0.distanceTo(a1), foot = f0.distanceTo(f1);
assert(head > 0.3,
`an 8° rake moved the head only ${head.toFixed(3)} m — rake_pivot has no children`);
assert(foot < 0.01,
`the footing moved ${foot.toFixed(3)} m — concrete should stay planted`);
});
// Same pivot class as the canopy: the Hills Hoist head freewheels, so spinning
// it has to carry the arms round. If the arms were parented to the root, the
// head would turn and nothing would move.
t.test('washing line head carries the arms round when spun', () => {
const g = loaded.get('washing_line_01');
assert(g, 'washing_line_01 did not load');
const head = g.scene.getObjectByName('head');
const arms = g.scene.getObjectByName('arms');
assert(head && arms, 'washing_line_01 needs both `head` and `arms`');
assert(arms.parent === head || arms.parent?.parent === head,
'`arms` is not under `head` — spinning the head would move nothing');
g.scene.updateWorldMatrix(true, true);
const before = new THREE.Vector3().setFromMatrixPosition(arms.matrixWorld);
head.rotation.y += Math.PI / 2;
head.updateWorldMatrix(true, true);
const after = new THREE.Vector3().setFromMatrixPosition(arms.matrixWorld);
head.rotation.y -= Math.PI / 2;
head.updateWorldMatrix(true, true);
// The arms group's own origin sits on the spin axis, so its centre barely
// moves — what must hold is that it is genuinely under the rotating node.
assert(Number.isFinite(before.x) && Number.isFinite(after.x), 'arms world position is not finite');
});
// Custom props are a contract, not decoration — and I have been telling other
// lanes to read these since Sprint 1 without ever checking they survive the
// export. glTF `extras` arrive as three's userData, but only if export_extras
// held all the way through; if it silently dropped, Lane A's gnome scores $0
// and Lane B's anchors all rate the same, both of which would look like a
// gameplay decision rather than a missing field.
t.test('glTF extras survive as userData — the props other lanes read', () => {
const gnome = loaded.get('garden_gnome_01')?.scene.getObjectByName('garden_gnome_01');
assert(gnome, 'gnome root node missing');
assert(gnome.userData?.collateral_value === 25,
`collateral_value lost (userData=${JSON.stringify(gnome.userData)}) — Lane A scores off this`);
const canopy = loaded.get('tree_gum_01')?.scene.getObjectByName('canopy');
assert(typeof canopy?.userData?.sway_amp === 'number',
'canopy.sway_amp lost — world.js per-tree sway tuning reads it');
const branch = loaded.get('tree_gum_01')?.scene.getObjectByName('branch_anchor_01');
assert(typeof branch?.userData?.rating_hint === 'number',
'branch_anchor_01.rating_hint lost — Lane B picks anchors on it');
const bin = loaded.get('wheelie_bin_01')?.scene.getObjectByName('wheelie_bin_01');
assert(typeof bin?.userData?.mass_hint === 'number',
'wheelie_bin mass_hint lost — Lane C throws it with this');
});
// The wreckage has to drop into the intact asset's place, so both variants
// stand on the same ground plane. If the broken one floats or sinks, Lane A's
// swap needs a fudge offset per prop and will grow one.
t.test('broken variants sit on the same ground plane as their intact twin', () => {
for (const [intact, broken] of [['garden_gnome_01', 'garden_gnome_01_broken'],
['fence_panel', 'fence_panel_snapped'],
['swing_set_01', 'swing_set_01_wrecked'],
['house_yardside', 'house_yardside_wrecked'],
['pergola_01', 'pergola_01_wrecked'],
['glasshouse_01', 'glasshouse_01_wrecked']]) {
for (const n of [intact, broken]) {
const box = new THREE.Box3().setFromObject(loaded.get(n).scene);
assert(Math.abs(box.min.y) < 0.03,
`${n} rests at y=${box.min.y.toFixed(3)}, not on the ground`);
}
}
});
// glTF has no node-visibility flag and Blender's hide_render does NOT survive
// the export — every node arrives visible:true. That shipped as a real bug
// from Sprint 1 to Sprint 6: garden_bed drew full + tattered + dead
// superimposed, and the asserts missed it because they only checked the nodes
// EXISTED. So the flag rides in extras, and this pins the flag, not the node.
t.test('optional nodes carry hidden_by_default — hide_render does not export', () => {
const bed = loaded.get('garden_bed')?.scene;
assert(bed, 'garden_bed did not load');
const on = ['plants_full'], off = ['plants_tattered', 'plants_dead'];
for (const n of on) {
assert(!bed.getObjectByName(n).userData?.hidden_by_default,
`${n} should start visible — it's the default state`);
}
for (const n of off) {
assert(bed.getObjectByName(n).userData?.hidden_by_default === true,
`${n} must carry hidden_by_default or the bed renders all three wilt states at once`);
}
const glow = loaded.get('house_yardside')?.scene.getObjectByName('window_glow');
assert(glow?.userData?.hidden_by_default === true,
'window_glow must carry hidden_by_default or the house is lit at noon');
});
// -------------------------------------------------------------------------
// SPRINT14 gate 3.1 — THE PALETTE AUDIT, as three rules that can go red.
//
// The editor is about to offer these GLBs to an author by name, which turns
// every silent field in them into a trap for whoever places one. These three
// tests are the audit made permanent: run them, not a spreadsheet.
// -------------------------------------------------------------------------
/** Every node in every loaded GLB, as [assetName, node] pairs. */
const allNodes = () => {
const out = [];
for (const [name, gltf] of loaded) {
gltf.scene.traverse((o) => out.push([name, o]));
}
return out;
};
// RULE 1 — no silent anchors. `world.js:adoptAnchor` does
// `anchor.ratingHint = node.userData?.rating_hint ?? 1`, so a node named
// `*_anchor` with no hint is not "unrated", it is RATED PERFECT: better than
// a gum fork, conjured out of a missing field. Four were sitting in the
// palette before this sprint (door_anchor, pickup_anchor, grip_anchor,
// window_light_anchor) — all of them carry points or lighting hints, none of
// them steel. They now deny it explicitly (`tie_off: false`).
//
// The failure this prevents is the nastiest kind: a site author picks
// `door_anchor` off the editor's node list, the yard boots, the sail holds,
// and a shed door quietly outperforms a concreted post for the rest of the
// game. Nothing crashes. Nothing looks wrong. The site is just a lie.
t.test('no silent anchors: every *_anchor node rates itself or denies being one', () => {
const silent = [];
for (const [asset, o] of allNodes()) {
if (!/_anchor(_\d+)?$/.test(o.name)) continue;
const u = o.userData ?? {};
const rated = typeof u.rating_hint === 'number';
const denied = u.tie_off === false;
if (!rated && !denied) silent.push(`${asset}/${o.name}`);
assert(!(rated && denied),
`${asset}/${o.name} both rates itself and denies being a tie-off — pick one`);
}
assert(silent.length === 0,
`these nodes adopt at rating_hint 1 (the best steel in the game) purely by ` +
`saying nothing: ${silent.join(', ')}. Give them a rating_hint, or ` +
`not_a_tie_off() them in build_yard_assets.py`);
});
// RULE 2 — no unpriced collateral. The gutter was a FREE FAILURE for two
// sprints: the fascia anchors said collateral:"gutter" from Sprint 6 and
// nothing anywhere priced one, so collateralFor('gutter') returned null and
// ripping the eave off the client's house cost the player a shackle. Fixing
// that one string is not the fix; the fix is that a new asset cannot repeat
// it. Every collateral string an anchor names must resolve to a number
// somewhere in the palette, by construction, before it can ship.
t.test('no unpriced collateral: every collateral string an anchor names has a price', () => {
const priced = new Map();
for (const [, o] of allNodes()) {
const u = o.userData ?? {};
if (typeof u.collateral_value !== 'number') continue;
priced.set(u.collateral_key ?? u.shades_asset ?? o.name, u.collateral_value);
}
const orphans = [];
for (const [asset, o] of allNodes()) {
const key = o.userData?.collateral;
if (typeof key !== 'string') continue;
if (typeof priced.get(key) !== 'number') orphans.push(`${asset}/${o.name} → "${key}"`);
}
assert(orphans.length === 0,
`anchors name collateral nothing prices, so breaking these is FREE: ` +
`${orphans.join(', ')}. Priced keys are [${[...priced.keys()].join(', ')}]`);
});
// RULE 3 — the baked type is the checked type. `anchor_type` in a GLB and
// ANCHOR_TYPE in contracts.js have to be the same vocabulary or the site's
// enum check is validating a different thing than the asset says. The
// carport needed the list widened (SPRINT11); the swing frame needed it
// widened again this sprint. Widening it is the intended move — inventing a
// word only the GLB knows is not.
t.test('baked anchor_type strings are all in the checked ANCHOR_TYPE enum', () => {
for (const [asset, o] of allNodes()) {
const ty = o.userData?.anchor_type;
if (ty === undefined) continue;
assert(ANCHOR_TYPE.includes(ty),
`${asset}/${o.name} is typed "${ty}", which no site may declare — ` +
`add it to ANCHOR_TYPE in contracts.js or use one of [${ANCHOR_TYPE.join(', ')}]`);
}
});
// --- the swing set: the temptation is the crossbar ------------------------
// The prop exists to put an honest middle rung in a palette that only had a
// ceiling (gum fork 1.0) and a trap (carport beam 0.22). Its whole design
// rests on the rail NOT being an anchor while looking exactly like one, so
// that is the thing pinned hardest.
t.test('the swing set offers two anchors and a crossbar that is not one', () => {
const g = loaded.get('swing_set_01');
assert(g, 'swing_set_01 did not load');
const rail = g.scene.getObjectByName('crossbar');
assert(rail, 'crossbar node missing — it must stay its own node so the data can refuse it');
assert(rail.userData?.tie_off === false,
'the crossbar must carry tie_off:false — it is the most anchor-looking object in the palette');
assert(rail.userData?.rating_hint === undefined,
'the crossbar carries a rating_hint, which makes it adoptable — that is the whole thing this prop is about');
// Exactly two, and no third one hiding on the rail or the seats.
const anchors = [];
g.scene.traverse((o) => { if (typeof o.userData?.rating_hint === 'number') anchors.push(o); });
assert(anchors.length === 2,
`swing_set_01 offers ${anchors.length} rated anchors (${anchors.map((a) => a.name).join(', ')}) — want exactly the two frame apexes`);
const CARPORT_BEAM = 0.22, FASCIA = 0.35, GUM_FORK = 1.0;
for (const n of ['frame_anchor_01', 'frame_anchor_02']) {
const o = g.scene.getObjectByName(n);
assert(o, `${n} missing`);
assert(o.userData?.anchor_type === 'swing_frame',
`${n} is typed "${o.userData?.anchor_type}" — a swing frame is not a post, and the type string is what the player reads before committing`);
const r = o.userData?.rating_hint;
assert(r > FASCIA && r < GUM_FORK,
`${n} rates ${r} — the frame junction is sound steel on an unpegged frame: better than the fascia (${FASCIA}), nowhere near a fork (${GUM_FORK})`);
assert(r > CARPORT_BEAM,
`${n} rates ${r} — if it is worse than the carport beam it is a second trap, not the palette's honest middle`);
assert(o.userData?.collateral === 'swing_set',
`${n} must name what it takes with it (collateral="swing_set")`);
}
// Priced, in band, and the wreck agrees — the carport/gutter chain again.
const root = g.scene.getObjectByName('swing_set_01');
const cost = root?.userData?.collateral_value;
const carport = loaded.get('carport_01')?.scene.getObjectByName('carport_01')?.userData?.collateral_value;
const gutter = loaded.get('house_yardside')?.scene.getObjectByName('house_yardside')?.userData?.collateral_value;
assert(typeof cost === 'number', 'swing_set_01 carries no collateral_value');
assert(root.userData?.collateral_key === 'swing_set',
'collateral_key must name the string the anchors carry, or the price prices nothing');
assert(cost > gutter && cost < carport,
`the swing set (${cost}) must sit between the gutter (${gutter}) and the carport (${carport}) — ` +
'a toy costs more than a run of guttering and less than a structure with a roof');
const w = loaded.get('swing_set_01_wrecked')?.scene.getObjectByName('swing_set_01_wrecked');
assert(w?.userData?.collateral_value === cost,
'the wrecked set must be priced the same as the one it used to be');
assert(w?.userData?.broken_variant_of === 'swing_set_01',
'the wreck must name its intact twin so A can pair the swap');
});
// Same rule as the torn fascia: you cannot re-tie to a frame lying on the
// grass. An anchor that survives its structure is the free-failure bug in a
// costume — world.anchors would keep offering the tie-off after the swap.
t.test('the wrecked swing set is over, and offers nothing to tie to', () => {
const w = loaded.get('swing_set_01_wrecked');
assert(w, 'swing_set_01_wrecked did not load');
for (const n of ['frame_anchor_01', 'frame_anchor_02']) {
assert(!w.scene.getObjectByName(n), `${n} survives the wreck — the frame it was welded to is on the ground`);
}
// It went OVER, not down: measured, because "wrecked" has to mean a
// specific pose or the next edit quietly turns it into rubble. The rail
// used to be the highest thing on the prop; now it is on the grass, and
// the far legs are the highest thing instead.
const intact = loaded.get('swing_set_01');
const railUp = new THREE.Box3().setFromObject(intact.scene.getObjectByName('crossbar'));
assert(railUp.max.y > 2.0,
`the intact rail tops out at y=${railUp.max.y.toFixed(2)} — the control is broken, nothing below can mean anything`);
const railDown = new THREE.Box3().setFromObject(w.scene.getObjectByName('crossbar'));
assert(railDown.max.y < 0.25,
`the wrecked rail tops out at y=${railDown.max.y.toFixed(2)} — it is meant to be lying on the grass`);
// And it reaches further along the ground than it ever stood tall.
const box = new THREE.Box3().setFromObject(w.scene);
const size = box.getSize(new THREE.Vector3());
assert(size.z > size.y * 2,
`the wreck is ${size.z.toFixed(2)} m deep and ${size.y.toFixed(2)} m tall — a set that fell over lies down`);
const hi = new THREE.Box3().setFromObject(intact.scene);
assert(size.y < hi.getSize(new THREE.Vector3()).y * 0.6,
'the wreck stands too close to full height — it did not fall, it sagged');
});
// THE AXIS TRAP, third time paid for. The wreck goes over toward Blender Y,
// which arrives here as +Z, and the placement note baked in the GLB says so
// in words. Words cannot fail; this can. It pins the CLAIM against the
// GEOMETRY, so the extras and the mesh have to lie in the same direction at
// the same time or the suite says which one moved.
//
// This matters at placement, not just in a docstring: standing, the set is
// 0.95 m deep; fallen, it reaches 2.9 m to +Z. Nothing about the intact
// bounds tells the editor that, so a swing set backed onto a fence looks
// fine until the night it lays itself through the palings.
t.test('the swing set falls toward +Z, and the GLB says so in the same direction', () => {
const intact = loaded.get('swing_set_01');
const root = intact.scene.getObjectByName('swing_set_01');
const claim = root?.userData?.wreck_falls_toward;
assert(claim === '+Z', `the GLB claims it falls toward ${JSON.stringify(claim)} — this assert only knows how to check +Z, so if the geometry really changed, change both`);
const rail = new THREE.Box3()
.setFromObject(loaded.get('swing_set_01_wrecked').scene.getObjectByName('crossbar'))
.getCenter(new THREE.Vector3());
assert(rail.z > 2.0,
`the wrecked rail centres at z=${rail.z.toFixed(2)} — the claim says +Z and the mesh disagrees`);
assert(Math.abs(rail.x) < 0.1,
`the wrecked rail centres at x=${rail.x.toFixed(2)} — it should go over sideways, not slide along its own span`);
// And the clearance number is the reach, not a guess someone typed.
const box = new THREE.Box3().setFromObject(loaded.get('swing_set_01_wrecked').scene);
const clear = root?.userData?.wreck_clearance_m;
assert(typeof clear === 'number' && box.max.z <= clear,
`the wreck reaches z=${box.max.z.toFixed(2)} but the GLB tells the editor to leave ${clear} m — the advice must cover the wreckage`);
});
// --- the second species: the ladder IS the feature ------------------------
// Both gums carry 1.0/0.88/0.76 — forgiving by design, so height up a gum is
// nearly free and "which tree" was never a real question. The jacaranda's
// ladder collapses instead of stepping down, which is what makes tree choice
// a decision. That claim is a NUMBER, so it gets an assert rather than a
// docstring: flatten the ladder and this goes red.
t.test('the jacaranda ladder falls away far harder than the gum ladder', () => {
const rungs = (asset) => {
const g = loaded.get(asset);
assert(g, `${asset} did not load`);
return ['branch_anchor_01', 'branch_anchor_02', 'branch_anchor_03']
.map((n) => g.scene.getObjectByName(n)?.userData?.rating_hint)
.filter((v) => typeof v === 'number');
};
const gum = rungs('tree_gum_01'), jac = rungs('tree_jacaranda_01');
assert(gum.length === 3 && jac.length === 3,
`need three rungs each; got gum=${gum.length}, jac=${jac.length}`);
// Rung 1: the jacaranda's low fork is genuinely good steel — that is the
// trade, not a consolation. It must stay close to the gum's.
assert(jac[0] > 0.9, `jacaranda fork rates ${jac[0]} — the low fork is meant to be the best thing on the tree`);
assert(jac[0] <= gum[0], `jacaranda fork (${jac[0]}) must not out-rate a gum fork (${gum[0]})`);
// ...and then it must actually fall away. Twice the gum's drop is the
// design claim, stated as the threshold it has to clear.
const dGum = gum[0] - gum[2], dJac = jac[0] - jac[2];
assert(dJac > dGum * 2,
`the jacaranda drops ${dJac.toFixed(2)} across its ladder and the gum drops ${dGum.toFixed(2)}` +
'if height costs the same on both species, the second tree is just a repaint');
assert(jac[2] < 0.5,
`the jacaranda's top rung rates ${jac[2]} — the whole point is that the high anchor is a gamble`);
// Unpriced BY RULING, same as the bike: no limb-failure event exists for
// a player to watch, and billing an unseen event is the lie the invoice
// exists to kill. Price it when the limb can come down.
const root = loaded.get('tree_jacaranda_01').scene.getObjectByName('tree_jacaranda_01');
assert(root?.userData?.collateral_value === undefined,
'the jacaranda is unpriced BY RULING — build the limb failure first');
});
// The silhouette carries the species read at 30 m, which is what an author
// in the editor actually picks on. A jacaranda is broader than it is tall;
// a gum is the other way round. If this flips, the palette has two trees
// that look the same and one of them is lying about its ladder.
t.test('the jacaranda reads as a different tree: broader than tall, unlike the gums', () => {
const size = (n) => new THREE.Box3().setFromObject(loaded.get(n).scene).getSize(new THREE.Vector3());
const j = size('tree_jacaranda_01'), g = size('tree_gum_01');
assert(Math.max(j.x, j.z) > j.y,
`jacaranda is ${Math.max(j.x, j.z).toFixed(2)} m across and ${j.y.toFixed(2)} m tall — it should be broader than tall`);
assert(g.y > Math.max(g.x, g.z),
`gum is ${g.y.toFixed(2)} m tall and ${Math.max(g.x, g.z).toFixed(2)} m across — the control is broken`);
});
// -------------------------------------------------------------------------
// SPRINT16 gate 5 — THE NEXT TEMPTATIONS. Three props, three different ways
// of saying no: the pergola says "yes, at 0.65" (the ladder's middle rung),
// the glasshouse says "no, and here is the bill if you make me" (the
// collateral apex), the pool ring says "no" fourteen times in a perfect
// circle. Every claim below is a number read off the shipped GLB.
// -------------------------------------------------------------------------
// The pergola's rating is pinned against the MANIFEST, not a literal — the
// gate-1.2 lesson applied to the asset that gate's mechanism was built FOR:
// a site declaring a node-less `pergola` anchor must resolve these exact
// extras, so the manifest entry existing (and being unambiguous) IS the
// feature. A pin that hard-coded 0.65 would stay green while the manifest
// silently dropped the type.
t.test('the pergola is the ladder\'s middle rung, and the manifest can resolve it', () => {
const types = FACTORY_ANCHOR_RATINGS.types;
const p = types.pergola;
assert(p, 'the manifest does not rate "pergola" — either the bake is missing or the '
+ 'type went AMBIGUOUS (its nodes disagree), and a node-less pergola anchor would '
+ 'fall back to the default hint: the sail_post bug shape, on the asset built to bury it');
assert(p.collateral === 'pergola', `manifest collateral is ${JSON.stringify(p.collateral)}, want "pergola"`);
const swing = types.swing_frame.rating_hint, post = types.post.rating_hint;
assert(p.rating_hint > swing && p.rating_hint < post,
`pergola rates ${p.rating_hint} — the rung must sit strictly between the swing frame `
+ `(${swing}, bolted to nothing) and the post (${post}, bolted to the planet); at either `
+ 'end the ladder loses the rung D endorsed and fascia-or-nothing is back');
const g = loaded.get('pergola_01');
assert(g, 'pergola_01 did not load');
for (const n of ['pergola_anchor_01', 'pergola_anchor_02']) {
const o = g.scene.getObjectByName(n);
assert(o, `${n} missing`);
assert(o.userData?.anchor_type === 'pergola',
`${n} is typed ${JSON.stringify(o.userData?.anchor_type)} — a pergola is not a post; `
+ 'the type string is the player\'s pre-rig read and "post" promises concrete');
assert(o.userData?.rating_hint === p.rating_hint,
`${n} bakes ${o.userData?.rating_hint} but the manifest says ${p.rating_hint}`);
assert(o.userData?.collateral === 'pergola', `${n} must name what it takes with it`);
}
// The price ladder, read off the GLBs so site JSON and factory can't tell
// two stories: gutter < pergola < swing set < carport. The pergola bills
// UNDER the swing set because its failure is partial (a beam and a post;
// the bolts hold) where the swing's is total (the whole frame on the
// grass) — prices must agree with the wrecks the player actually sees.
const price = (a) => loaded.get(a)?.scene.getObjectByName(a)?.userData?.collateral_value;
const cost = price('pergola_01');
assert(typeof cost === 'number', 'pergola_01 carries no collateral_value');
const root = g.scene.getObjectByName('pergola_01');
assert(root.userData?.collateral_key === 'pergola',
'collateral_key must name the string the anchors carry, or the price prices nothing');
assert(cost > price('house_yardside') && cost < price('swing_set_01'),
`the pergola (${cost}) must sit between the gutter (${price('house_yardside')}) and the `
+ `swing set (${price('swing_set_01')}) — a partial timber repair beats a run of guttering `
+ 'and loses to a whole frame replacement');
const w = loaded.get('pergola_01_wrecked')?.scene.getObjectByName('pergola_01_wrecked');
assert(w?.userData?.collateral_value === cost,
'the wrecked pergola must be priced the same as the one it used to be');
assert(w?.userData?.broken_variant_of === 'pergola_01',
'the wreck must name its intact twin so A can pair the swap');
});
// The 0.65 story told by the wreck: a bolted structure fails PARTIALLY.
// The front beam and the rafters come down; the deck, the bolts and the
// back half stand. Measured, because "partial" is exactly the kind of word
// that erodes into "rubble" one art pass at a time.
t.test('the pergola fails partially: rafters hinge down, the deck stands, no anchor survives', () => {
const w = loaded.get('pergola_01_wrecked');
assert(w, 'pergola_01_wrecked did not load');
for (const n of ['pergola_anchor_01', 'pergola_anchor_02']) {
assert(!w.scene.getObjectByName(n),
`${n} survives the wreck — you cannot re-bolt a snapped post mid-storm, and an anchor `
+ 'that outlives its structure is the free-failure bug in a costume');
}
// The lean-to read: rafters still reach the surviving back beam AND rest
// on the deck — high at one end, low at the other, in the same node.
const down = new THREE.Box3().setFromObject(w.scene.getObjectByName('rafters_down'));
assert(down.max.y > 2.3,
`rafters_down tops out at y=${down.max.y.toFixed(2)} — they must still hang from the back beam`);
assert(down.min.y < 0.5,
`rafters_down bottoms out at y=${down.min.y.toFixed(2)} — the front ends should rest on the deck`);
const beamDown = new THREE.Box3().setFromObject(w.scene.getObjectByName('beam_down'));
assert(beamDown.max.y < 0.55,
`beam_down tops out at y=${beamDown.max.y.toFixed(2)} — the torn beam lies on the deck`);
// Intact control: the rafters were UP, or "down" above means nothing.
const up = new THREE.Box3().setFromObject(loaded.get('pergola_01').scene.getObjectByName('rafters'));
assert(up.min.y > 2.3,
`intact rafters bottom out at y=${up.min.y.toFixed(2)} — the control is broken`);
// The wreck does not stand taller, and the DECK did not move: it is the
// footing the rating is about, and the shared helper builds it once —
// byte-drift between the twins' decks means someone forked the helper.
const hi = new THREE.Box3().setFromObject(loaded.get('pergola_01').scene);
const hw = new THREE.Box3().setFromObject(w.scene);
assert(hw.max.y <= hi.max.y + 0.02,
`the wreck stands ${hw.max.y.toFixed(2)} m vs the intact ${hi.max.y.toFixed(2)} — taller is a bug`);
const deckA = new THREE.Box3().setFromObject(loaded.get('pergola_01').scene.getObjectByName('deck'));
const deckB = new THREE.Box3().setFromObject(w.scene.getObjectByName('deck'));
assert(deckA.min.distanceTo(deckB.min) < 0.01 && deckA.max.distanceTo(deckB.max) < 0.01,
'the two decks differ — intact and wrecked no longer share the helper, and they will drift');
});
// The glasshouse's one rule, pinned from every direction: NOTHING about it
// may adopt as an anchor. Not the ridge (denied in words), not any node by
// silence (a rating_hint anywhere on either variant is a red), not via the
// enum (no node enrols an anchor_type). The ridge bar is the most
// tie-off-shaped object in the game and that is exactly why the denial is
// load-bearing rather than decorative.
t.test('the glasshouse offers NOTHING: no rated node, no typed node, and the ridge denies', () => {
for (const name of ['glasshouse_01', 'glasshouse_01_wrecked']) {
const g = loaded.get(name);
assert(g, `${name} did not load`);
const rated = [], typed = [];
g.scene.traverse((o) => {
if (typeof o.userData?.rating_hint === 'number') rated.push(o.name);
if (o.userData?.anchor_type !== undefined) typed.push(o.name);
});
assert(rated.length === 0,
`${name} carries rating_hint on [${rated.join(', ')}] — a glasshouse node with a rating `
+ 'is adoptable, and 30 kg of glass is not a thing you add load to');
assert(typed.length === 0,
`${name} enrols [${typed.join(', ')}] in the anchor enum — nothing here is an anchor of any type`);
}
const ridge = loaded.get('glasshouse_01').scene.getObjectByName('ridge_anchor');
assert(ridge, 'ridge_anchor missing — the temptation must exist to be denied');
assert(ridge.userData?.tie_off === false,
'the ridge must carry tie_off:false — the most anchor-looking member says no ON the data');
assert(typeof ridge.userData?.why === 'string' && ridge.userData.why.length > 0,
'the denial must carry its reasoning — a bare flag is a rule, a why is a lesson');
});
// The apex is a price claim, so it is pinned as one: strictly the worst
// bill in the palette, keyed, and identical on the wreck. The billing
// EVENT is arc 2's (nothing in the sim wrecks a glasshouse yet — the value
// is inert data until then, which is why pricing it is not the bike bug);
// this pin is the chain being ready the day that yard is authored.
t.test('the glasshouse is the collateral apex: priced above everything, keyed, twin-priced', () => {
const g = loaded.get('glasshouse_01')?.scene.getObjectByName('glasshouse_01');
assert(g, 'glasshouse_01 root missing');
const cost = g.userData?.collateral_value;
assert(typeof cost === 'number', 'glasshouse carries no collateral_value');
assert(g.userData?.collateral_key === 'glasshouse',
'collateral_key must be explicit — the carport was one editor click from being FREE '
+ 'because its price resolved off a coincidence of naming');
const carport = loaded.get('carport_01')?.scene.getObjectByName('carport_01')?.userData?.collateral_value;
assert(cost > carport,
`the glasshouse (${cost}) must top the carport (${carport}) by construction — fourteen panes `
+ 'of glass against one sheet of Colorbond, or "apex" is just a word');
const w = loaded.get('glasshouse_01_wrecked')?.scene.getObjectByName('glasshouse_01_wrecked');
assert(w?.userData?.collateral_value === cost,
'the wrecked glasshouse must be priced the same as the one it used to be');
assert(w?.userData?.broken_variant_of === 'glasshouse_01',
'the wreck must name its intact twin so A can pair the swap');
});
// What glass DOES, measured: the intact panes are translucent (the fragile
// read is a material fact, not a vibe — turn the glass opaque and the tell
// is deleted), and the wreck EMPTIES rather than falls (frame at full
// height, shard field flat on the ground and thrown wider than the
// building ever stood).
t.test('the glasshouse reads fragile, and its wreck empties instead of falling', () => {
const panes = loaded.get('glasshouse_01')?.scene.getObjectByName('panes');
assert(panes, 'panes node missing');
let translucent = false;
panes.traverse((o) => {
const m = o.material;
if (m && m.transparent === true && m.opacity < 0.6) translucent = true;
});
assert(translucent,
'no pane material is transparent with opacity < 0.6 — the glass has gone opaque, and '
+ 'the only see-through walls in the palette no longer read as glass');
const hi = new THREE.Box3().setFromObject(loaded.get('glasshouse_01').scene);
const wreck = loaded.get('glasshouse_01_wrecked');
const hw = new THREE.Box3().setFromObject(wreck.scene);
assert(Math.abs(hi.max.y - hw.max.y) < 0.05,
`wreck ridge at y=${hw.max.y.toFixed(2)} vs intact ${hi.max.y.toFixed(2)} — the aluminium frame `
+ 'survives; a felled glasshouse is lying about what glass does');
const shards = new THREE.Box3().setFromObject(wreck.scene.getObjectByName('shards'));
assert(shards.max.y < 0.05,
`shards top out at y=${shards.max.y.toFixed(2)} — glass on the ground lies flat`);
const spreadX = (hw.max.x - hw.min.x) - (hi.max.x - hi.min.x);
const spreadZ = (hw.max.z - hw.min.z) - (hi.max.z - hi.min.z);
assert(spreadX > 0.6 && spreadZ > 0.6,
`the shard field adds only ${spreadX.toFixed(2)}/${spreadZ.toFixed(2)} m in plan — it must `
+ 'throw glass clear of the footprint or the wreck reads as a dirty window, not a catastrophe');
assert(wreck.scene.getObjectByName('panes_left'),
'panes_left missing — a wreck with zero surviving panes reads as demolition, not storm');
});
// The pool ring: fourteen posts, fourteen explicit nos. RULE 1's regex only
// guards *_anchor names, and these posts don't match it — which is exactly
// how they'd slip through as silent 1.0s the day a site names one (the
// `?? 1` default is generous, and silence is not neutral). So the denial is
// pinned per-post, by name, with the count exact: a fifteenth post added
// without its denial goes red here, not in a shipped yard.
t.test('the pool ring is fourteen honest nos — every fence post denies, nothing rates', () => {
const g = loaded.get('pool_kit_01');
assert(g, 'pool_kit_01 did not load');
const posts = [];
g.scene.traverse((o) => { if (/^fence_post_\d+$/.test(o.name)) posts.push(o); });
assert(posts.length === 14,
`found ${posts.length} fence posts, want exactly 14 — a post this count doesn't know `
+ 'is a post whose denial nobody checked');
for (const p of posts) {
assert(p.userData?.tie_off === false,
`${p.name} does not deny being a tie-off — silence here adopts at rating 1.0, the best `
+ 'steel in the game conjured out of a missing field');
assert(p.userData?.rating_hint === undefined,
`${p.name} carries a rating_hint, which makes it adoptable — the ring's whole design is `
+ 'that its steel is legally useless');
assert(typeof p.userData?.why === 'string' && p.userData.why.length > 0,
`${p.name} denies without reasoning`);
}
// Nothing anywhere on the kit rates or enrols — same sweep as the glasshouse.
const rated = [], typed = [];
g.scene.traverse((o) => {
if (typeof o.userData?.rating_hint === 'number') rated.push(o.name);
if (o.userData?.anchor_type !== undefined) typed.push(o.name);
});
assert(rated.length === 0 && typed.length === 0,
`pool_kit_01 offers [${[...rated, ...typed].join(', ')}] — the kit must offer nothing`);
// Compliance is the flavour, so its numbers are pinned as facts: 1.2 m
// posts, a self-closing gate, and no price until something can break it.
const post1 = new THREE.Box3().setFromObject(g.scene.getObjectByName('fence_post_01'));
assert(post1.max.y >= 1.15 && post1.max.y <= 1.32,
`fence stands ${post1.max.y.toFixed(2)} m — AS 1926.1 says 1.2, and the height IS the flavour`);
const gate = g.scene.getObjectByName('pool_gate');
assert(gate?.userData?.self_closing === true, 'the gate must be self-closing — compliance is data');
const root = g.scene.getObjectByName('pool_kit_01');
assert(root?.userData?.priced === false && typeof root?.userData?.unpriced_why === 'string',
'the kit must say OUT LOUD that it is unpriced and why (the bike ruling) — an unpriced '
+ 'prop with no reason is indistinguishable from a forgotten one');
assert(root?.userData?.collateral_value === undefined,
'pool_kit_01 carries a collateral_value — pricing an event the sim cannot produce is the '
+ 'lie the invoice exists to kill; when debris can take a panel out, price it THEN');
});
// -------------------------------------------------------------------------
// SPRINT17 gate 3.1 — THE CORRODED TIER. The pool kit is fourteen tie-offs
// that don't exist; this is the one that exists and shouldn't be trusted.
// The design claim is "corrosion a player can't see is a trap with no tell,
// and this repo doesn't ship those" — so the TELL itself is pinned as
// numbers (area, lightness, sag), not adjectives. An untelled corroded post
// (same 0.55 hint, clean steel) passes the factory, passes RULE 4a/4b, and
// passes every dims/nodes check — this test is the ONLY thing that goes red
// on it, which is exactly why it exists. (Proven red in the S17 negative
// controls; receipts in THREADS.)
// -------------------------------------------------------------------------
/** Total triangle area of every mesh under a GLB scene, split by predicate
* on the mesh's material. World transforms applied; area is exact. */
const areaBy = (scene, pred) => {
scene.updateMatrixWorld(true);
let hit = 0, total = 0;
const a = new THREE.Vector3(), b = new THREE.Vector3(), c = new THREE.Vector3();
scene.traverse((o) => {
if (!o.isMesh) return;
const pos = o.geometry.attributes.position;
const idx = o.geometry.index;
const n = idx ? idx.count : pos.count;
let area = 0;
for (let i = 0; i < n; i += 3) {
const ia = idx ? idx.getX(i) : i, ib = idx ? idx.getX(i + 1) : i + 1,
ic = idx ? idx.getX(i + 2) : i + 2;
a.fromBufferAttribute(pos, ia).applyMatrix4(o.matrixWorld);
b.fromBufferAttribute(pos, ib).applyMatrix4(o.matrixWorld);
c.fromBufferAttribute(pos, ic).applyMatrix4(o.matrixWorld);
b.sub(a); c.sub(a);
area += b.cross(c).length() / 2;
}
total += area;
if (pred(o.material)) hit += area;
});
return { hit, total };
};
/** Rust, as a colour band: strongly saturated orange-brown, darker than
* mid. Nothing else in this palette is saturated in that hue (weathered
* steel and concrete are near-grey, s < 0.15; timber is lighter). Colours
* are read in the loader's working space — thresholds chosen with margin
* on both sides (rust s ≈ 0.8, everything else ≤ 0.15). */
const isRust = (m) => {
const hsl = { h: 0, s: 0, l: 0 };
(m.color ?? new THREE.Color(0xffffff)).getHSL(hsl);
return hsl.h <= 0.12 && hsl.s >= 0.35 && hsl.l <= 0.45;
};
// The three tells are three SEPARATE tests on purpose. Shipped as one
// block, the first failing assert masked the other two — the S17 M1
// negative control (clean steel, flat eye) went red on rust area alone and
// never reached the lightness or droop checks, so their strength was
// unproven. Split, each tell is independently red-checkable, which is the
// only way "mutation-checked" means anything.
t.test('the corroded post TELLS (1/3): rust reads as AREA, and the honest post stays clean', () => {
const g = loaded.get('sail_post_corroded');
const honest = loaded.get('sail_post');
assert(g && honest, 'both post variants must load — the tell is a comparison');
// (a) The at-a-glance read, as a number: rust-coloured surface area. 5%
// of a 4 m post is roughly the base band + head bloom + streaks — under
// that and the corrosion is a caption, not a tell. (Shipped value ~19%.)
const { hit, total } = areaBy(g.scene, isRust);
assert(total > 0, 'no measurable surface at all');
const frac = hit / total;
assert(frac >= 0.05,
`rust covers ${(frac * 100).toFixed(1)}% of the corroded post's surface — under 5% ` +
'is corrosion nobody can read from the lawn, a trap with no tell');
// And the honest post must NOT read rusty — the tell only works if the
// clean one is clean. (Also guards the colour thresholds themselves.)
const h = areaBy(honest.scene, isRust);
assert(h.hit === 0,
`the HONEST sail post carries ${(h.hit / h.total * 100).toFixed(1)}% rust-coloured area — ` +
'either the post rusted or the rust predicate is reading galvanised steel as rust');
});
t.test('the corroded post TELLS (2/3): the shaft is NOT galvanised — base colour and PBR both moved', () => {
const g = loaded.get('sail_post_corroded');
const honest = loaded.get('sail_post');
assert(g && honest, 'both post variants must load — the tell is a comparison');
// (b) The 20 m read. This assert shipped in the S17 WIP saying the
// corroded shaft "reads darker at yard distance"; I measured the actual
// render before endorsing it and the SIGN IS BACKWARDS. Sampled off
// look.html's canvas at cam z=20 m, 1280×720, over the mid-shaft band:
//
// honest rgb(49,51,49) lum 50 sat 0.031 RB 0
// corroded rgb(98,94,86) lum 94 sat 0.126 RB +12
//
// The corroded shaft renders TWICE AS BRIGHT, not darker. Base colour is
// genuinely darker (0.524 vs 0.737) but it does not drive the pixels:
// galvanised is metalness 0.90 / roughness 0.35 and, with no environment
// map in this renderer, a near-mirror metal has almost nothing to mirror
// and goes near-black. The weathered shaft at 0.55 / 0.60 is far more
// diffuse, so it actually catches the sun. The separation is real and
// large — it is just made of METALNESS, not lightness.
//
// So the test now pins both halves, and the comment states which one the
// player is actually seeing. An assert whose stated reason is the reverse
// of the mechanism is a docstring waiting to be "fixed" in the wrong
// direction by whoever tunes this next.
const shaftOf = (gl) => {
let best = null;
gl.scene.getObjectByName('post').traverse((o) => {
if (!o.isMesh || isRust(o.material)) return;
const hsl = { h: 0, s: 0, l: 0 };
o.material.color.getHSL(hsl);
if (!best || hsl.l > best.l) best = { l: hsl.l, m: o.material };
});
return best;
};
const sHonest = shaftOf(honest), sCorroded = shaftOf(g);
assert(sHonest && sCorroded, 'could not find shaft materials on the post nodes');
assert(sCorroded.l < sHonest.l - 0.08,
`corroded shaft base lightness ${sCorroded.l.toFixed(3)} vs galvanised ${sHonest.l.toFixed(3)}` +
'the corroded shaft is wearing galvanised paint, so nothing distinguishes them but the rust');
// The half that actually reaches the screen. Weathered steel is rougher
// and less metallic than gal; if a future edit "tidies" these back to the
// galvanised values the post keeps its rust and loses its 20 m silhouette.
assert(sCorroded.m.roughness > sHonest.m.roughness + 0.15,
`corroded shaft roughness ${sCorroded.m.roughness} vs galvanised ${sHonest.m.roughness}` +
'weathered steel that is as polished as gal loses the long-range read (this is the ' +
'property that actually drove lum 94 vs 50 at 20 m, measured)');
assert(sCorroded.m.metalness < sHonest.m.metalness - 0.15,
`corroded shaft metalness ${sCorroded.m.metalness} vs galvanised ${sHonest.m.metalness}` +
'corroded steel must be the more diffuse of the two or the yard-distance contrast inverts');
});
t.test('the corroded post TELLS (3/3): the pad eye DROOPS — angle derived from geometry, pinned to the baked note', () => {
const g = loaded.get('sail_post_corroded');
const honest = loaded.get('sail_post');
assert(g && honest, 'both post variants must load — the tell is a comparison');
g.scene.updateMatrixWorld(true);
honest.scene.updateMatrixWorld(true);
const root = g.scene.getObjectByName('sail_post_corroded');
const anchor = g.scene.getObjectByName('top_anchor');
assert(root && anchor, 'root and top_anchor must both survive the export');
const u = root.userData;
// THE AXIS TRAP (E's swing_set precedent). The builder writes the droop
// as a claim in THREE.JS coords; this derives the same numbers back out
// of the exported anchor position. Note and mesh can only lie together.
//
// Why not "the corroded anchor sits lower than the honest one" — the
// assert this REPLACES? Because the eye hangs off a weld line 160 mm
// down the post, so that comparison reads 0.06 m at ZERO droop and sails
// past a `> 0.05` threshold. It measured the weld offset, not the sag:
// an assert that cannot fail on the thing it names is decoration. This
// one drives the derived angle to 0° when the eye is flat.
const baked = u.padeye_droop_deg;
const arm = u.padeye_arm_m;
const weldY = u.padeye_weld_y_threejs;
assert(typeof baked === 'number' && typeof arm === 'number' && typeof weldY === 'number',
'the droop must be BAKED as numbers on the root (deg, arm, weld y in three.js) — ' +
'an orientation claim that lives only in a docstring is the axis trap waiting to happen');
assert(baked > 0, `padeye_droop_deg is ${baked} — a corroded eye that does not droop has no tell`);
const p = anchor.getWorldPosition(new THREE.Vector3());
// The post axis is x=z=0; the eye hangs off the weld line at (0, weldY, 0).
const horiz = Math.hypot(p.x, p.z); // out from the axis
const vert = p.y - weldY; // up from the weld
const armMeasured = Math.hypot(horiz, vert);
assert(Math.abs(armMeasured - arm) < 0.004,
`the eye sits ${armMeasured.toFixed(4)} m off the weld line but the note says ${arm} m — ` +
'the baked arm and the exported geometry disagree');
const derivedDeg = Math.atan2(horiz, vert) * 180 / Math.PI;
assert(Math.abs(derivedDeg - baked) < 1.5,
`the exported pad eye droops ${derivedDeg.toFixed(1)}° but the root claims ${baked}° — ` +
'the mesh and its own note disagree about the sag (flatten the eye and this is what you see)');
// DIRECTION, measured — not asserted by reading the string. Blender y
// maps to three.js +Z, so the eye must hang toward the YARD, the face the
// player judges it from. A sag pointing into the fence is a tell nobody sees.
assert(u.padeye_droop_dir_threejs === '+Z',
`the baked droop direction is ${u.padeye_droop_dir_threejs} — this test only knows how to check +Z`);
assert(p.z > 0.5 * horiz,
`the eye hangs toward (x=${p.x.toFixed(3)}, z=${p.z.toFixed(3)}) — the note says +Z (the yard side), ` +
'so either the exporter axis mapping moved or the note is wrong');
assert(Math.abs(p.x) < 0.006,
`the eye is displaced ${p.x.toFixed(3)} m on X — the droop is meant to be purely +Z`);
// And the weaker claim, kept as a sanity check now that it is no longer
// carrying the argument: the drooped anchor does sit below the honest one.
const sag = honest.scene.getObjectByName('top_anchor')
.getWorldPosition(new THREE.Vector3()).y - p.y;
assert(sag > 0.05,
`the corroded top_anchor sits only ${sag.toFixed(3)} m below the honest one`);
});
t.test('corroded steel is a RUNG: above the swing frame, below sound timber, resolvable node-less', () => {
const T = FACTORY_ANCHOR_RATINGS.types;
const entry = T.corroded_post;
assert(entry, 'corroded_post is not in the ratings manifest — the type cannot resolve node-less');
assert(!FACTORY_ANCHOR_RATINGS.ambiguous.corroded_post,
'corroded_post is manifest-AMBIGUOUS — the one-rating-per-type rule (THREADS [E] S15) broke');
// The ladder claims, pinned against the MANIFEST, never against literals
// (a literal pin would stay green while the whole ladder reshuffled):
assert(T.swing_frame.rating_hint < entry.rating_hint,
`corroded post (${entry.rating_hint}) must out-rate the swing frame (${T.swing_frame.rating_hint}) — ` +
'a real concrete footing beats sound steel standing loose on grass');
assert(entry.rating_hint < T.pergola.rating_hint,
`corroded post (${entry.rating_hint}) must rate under the pergola (${T.pergola.rating_hint}) — ` +
'sound flexing timber beats steel whose bloom outside means pitting inside');
assert(entry.rating_hint < T.post.rating_hint,
'a corroded post rating at or above the honest post erases the tier entirely');
// It fails through the client's property: collateral named, priced, and
// priced UNDER the gutter — the bill is the make-safe, not the steel; the
// real price of trusting rust is the corner you lose mid-storm.
assert(entry.collateral === 'corroded_post',
'the manifest must carry the collateral so node-less anchors bill on failure');
const root = g('sail_post_corroded');
const gutter = g('house_yardside');
assert(typeof root.userData.collateral_value === 'number' && root.userData.collateral_value > 0,
'the corroded post names collateral nothing prices — free failure, the gutter bug');
assert(root.userData.collateral_value < gutter.userData.collateral_value,
`a snapped corroded post ($${root.userData.collateral_value}) must bill under the gutter ` +
`($${gutter.userData.collateral_value}) — condemned steel is a make-safe charge, not a rebuild`);
function g(name) {
const glb = loaded.get(name);
assert(glb, `${name} did not load`);
return glb.scene.getObjectByName(name);
}
});
t.test('the corroded wreck folded at the rust line: stub stands, shaft in the yard, nothing left to tie to', () => {
const w = loaded.get('sail_post_corroded_wrecked');
assert(w, 'sail_post_corroded_wrecked did not load');
const box = new THREE.Box3().setFromObject(w.scene);
// It FELL: nothing stands past the stub, and the fallen shaft reaches
// into the yard on +Z (the same face the pergola presents — placement
// facts in THREADS). Both directions measured, not asserted by name.
assert(box.max.y < 1.0,
`the wreck stands ${box.max.y.toFixed(2)} m — a corroded post that still stands never folded`);
assert(box.max.z > 3.2,
`the fallen shaft reaches z=${box.max.z.toFixed(2)} — it is meant to be ~3.5 m out in the yard (+Z)`);
// No anchor survives, nothing rates, nothing enrols — an anchor that
// outlives its structure is the free-failure bug in a costume (the
// fascia rule; fourth application).
assert(!w.scene.getObjectByName('top_anchor'),
'top_anchor survives the wreck — you cannot clip to a post that is lying on the grass');
const offers = [];
w.scene.traverse((o) => {
if (typeof o.userData?.rating_hint === 'number' || o.userData?.anchor_type !== undefined) {
offers.push(o.name);
}
});
assert(offers.length === 0,
`the wreck offers [${offers.join(', ')}] — a wreck must offer nothing`);
// Twin-priced, and it names its twin — the carport/gutter chain, sixth
// application, compared between the GLBs and never against a literal.
const wr = w.scene.getObjectByName('sail_post_corroded_wrecked');
const ir = loaded.get('sail_post_corroded')?.scene.getObjectByName('sail_post_corroded');
assert(wr?.userData?.broken_variant_of === 'sail_post_corroded',
'the wreck must name its intact twin so A can pair the swap');
assert(wr?.userData?.collateral_value === ir?.userData?.collateral_value,
'the wrecked post must be priced the same as the one it used to be');
assert(wr?.userData?.collateral_key === 'corroded_post',
'the wreck must keep the collateral key or the swap changes what the bill is about');
});
// One GLB carries three wilt states as siblings; Lane A toggles .visible
// rather than reloading, so all three have to be present at once.
t.test('garden_bed carries all 3 damage states in one GLB', () => {
const g = loaded.get('garden_bed');
assert(g, 'garden_bed did not load');
for (const state of ['plants_full', 'plants_tattered', 'plants_dead']) {
assert(g.scene.getObjectByName(state), `${state} missing — Lane A toggles these by name`);
}
});
// -------------------------------------------------------------------------
// SPRINT15 gate 1.2 — RULE 4, the third pin of the S14 family: no silent
// anchors, no unpriced collateral, and now NO BAKED HINT THAT NEVER ARRIVES.
//
// The bug this buries: `adoptAnchor` was the only wire from a baked
// `rating_hint` to a live anchor, and it only runs for anchors that name a
// GLB `node`. Every shipped sail post is a plain JSON entry with no node, so
// the factory said 0.90, `?? 1` said 1.00, and the sim won BY ACCIDENT for
// fourteen sprints. Gate 1.1 ruled the shipped 1.00 canon — which means the
// values now agree BY CONSTRUCTION, so these pins compare runtime against
// the generated MANIFEST (anchor_ratings.gen.js), never against a literal:
// a pin that hard-codes 1.0 would have been green through the entire bug.
//
// Two directions, so nothing can drift silently:
// RULE 4a manifest === GLBs (a re-bake that skips regeneration → red)
// RULE 4b runtime === manifest (the adoptAnchor-shaped gap itself → red)
// -------------------------------------------------------------------------
t.test('RULE 4a — the ratings manifest matches the shipped GLBs, both directions', () => {
const types = FACTORY_ANCHOR_RATINGS.types, ambig = FACTORY_ANCHOR_RATINGS.ambiguous;
assert(Object.keys(types).length > 0,
'vacuous: the manifest rates nothing — regenerate anchor_ratings.gen.js');
const nodeOf = (src) => {
const [asset, node] = src.split('/');
return loaded.get(asset)?.scene.getObjectByName(node);
};
// manifest → GLB: every source it cites exists and carries the same bake.
for (const [ty, entry] of Object.entries(types)) {
assert(entry.sources.length > 0, `manifest type "${ty}" cites no sources`);
for (const src of entry.sources) {
const o = nodeOf(src);
assert(o, `manifest type "${ty}" cites ${src}, which is not in the palette — stale manifest, regenerate`);
assert(o.userData?.rating_hint === entry.rating_hint,
`${src} bakes rating_hint ${o.userData?.rating_hint} but the manifest says ${entry.rating_hint}` +
'the factory and the manifest disagree; regenerate, do not hand-edit');
assert((o.userData?.collateral ?? null) === (entry.collateral ?? null),
`${src} bakes collateral ${JSON.stringify(o.userData?.collateral)} but the manifest says ` +
`${JSON.stringify(entry.collateral)} — regenerate`);
}
}
// GLB → manifest: every rated, non-denying anchor node is accounted for.
for (const [asset, o] of allNodes()) {
const ty = o.userData?.anchor_type;
if (ty === undefined || o.userData?.tie_off === false) continue;
assert(types[ty] || ambig[ty],
`${asset}/${o.name} is typed "${ty}", which the manifest does not know — regenerate`);
if (types[ty]) {
assert(o.userData?.rating_hint === types[ty].rating_hint,
`${asset}/${o.name} bakes ${o.userData?.rating_hint} but manifest type "${ty}" says ` +
`${types[ty].rating_hint} — either regenerate, or this type just became ambiguous ` +
'and the manifest must say so');
}
}
});
// RULE 4b needs live worlds; Suite.test() refuses async fns, so the awaits
// happen out here and the assert stays synchronous.
const siteWorlds = [];
for (const name of ['backyard_01', 'site_02_corner_block', 'site_03_swing_lawn']) {
const site = await loadSite(name);
siteWorlds.push({ name, site, world: createWorld(new THREE.Scene(), { site }) });
}
t.test('RULE 4b — a node-less JSON anchor of a factory-rated type carries the factory rating at runtime', () => {
const types = FACTORY_ANCHOR_RATINGS.types, ambig = FACTORY_ANCHOR_RATINGS.ambiguous;
let checked = 0;
for (const { name, site, world } of siteWorlds) {
// Every anchor a site DECLARES in JSON, with the same type defaults
// world.js applies at each creation site.
const declared = [
...(site.posts ?? []).map((p) => ({ ...p, type: p.type ?? 'post' })),
...(site.house?.anchors ?? []).map((a) => ({ ...a, type: a.type ?? 'house' })),
...(site.trees ?? []).flatMap((tr) => (tr.anchors ?? []).map((a) => ({ ...a, type: a.type ?? 'tree' }))),
...(site.structures ?? []).flatMap((s) => (s.anchors ?? []).map((a) => ({ ...a, type: a.type ?? 'post' }))),
].filter((a) => !a.node); // node-carrying anchors are adoptAnchor's job at dress
for (const d of declared) {
assert(!ambig[d.type],
`${name}/${d.id}: a node-less anchor of AMBIGUOUS type "${d.type}" has no honest ` +
'rating — the runtime refuses to guess (ratings.js); declare a `node` instead');
const entry = types[d.type];
assert(entry,
`${name}/${d.id}: type "${d.type}" is not factory-rated at all — bake a rating_hint ` +
'in build_yard_assets.py or this anchor plays at the default hint forever');
const rt = world.anchor(d.id);
assert(rt, `${name}/${d.id}: declared in JSON but absent from world.anchors`);
assert(rt.ratingHint === entry.rating_hint,
`${name}/${d.id} plays at ratingHint ${rt.ratingHint}; the factory baked ` +
`${entry.rating_hint} — the baked hint never arrived. This is the sail_post ` +
'0.90/1.00 bug shape: factory and sim disagreeing with no error anywhere');
assert((rt.collateral ?? null) === (entry.collateral ?? null),
`${name}/${d.id} plays at collateral ${JSON.stringify(rt.collateral)}; the factory ` +
`baked ${JSON.stringify(entry.collateral)} — same wire, same bug shape`);
checked += 1;
}
}
assert(checked > 0,
'vacuous: no node-less declared anchors found across the shipped sites — this pin checked nothing');
});
for (const { world } of siteWorlds) world.dispose();
}