HardYards/web/world/js/tests/e.test.js
type-two b7f065cd57 e.test: pin the gutter price band, the DOWN/UP swap truth, and anchor absence in the wreck
Five new asserts (Lane E 67 -> 72; selftest 315 -> 320):
- price exists, sits between gnome and carport, keyed to the anchors'
  collateral string, wreck carries the same bill and names its twin
- the torn gutter is DOWN in the wreck and UP in the intact house — negative
  control run: rebuilt with the ground run at eave height, selftest went red
  ('gutter_down tops out at y=2.68', 1 failed / 319 passed) while the Blender
  verify PASSed the same bad file (plan position unmoved, no box span changed);
  restored build round-tripped to identical hashes
- wreck height equals house height (a house does not fall when its gutter does)
- no fascia_anchor_* survives into the wreck
- house pair added to the shared ground-plane test

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 20:21:40 +10:00

535 lines
29 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';
// 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'] },
];
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'],
['house_yardside', 'house_yardside_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');
});
// 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`);
}
});
}