HardYards/web/world/js/tests/e.test.js
type-two b9b64604b3 Palette audit + two temptation props: honest extras, in the factory
SPRINT14 gate 3.1. 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. Audited the placeable set, fixed the gaps in the factory (nothing
hand-edited), and added the two props D needs to author a yard worth playing.

THE AUDIT, and what it found:

- Four `*_anchor` nodes carried no rating_hint at all — door_anchor,
  pickup_anchor, grip_anchor, window_light_anchor. adoptAnchor does
  `rating_hint ?? 1`, so silence there does not mean "unrated", it means
  RATED PERFECT: a shed door skin outranking a concreted post, conjured out
  of a missing field. They now deny it explicitly (`tie_off: false`).
- The carport's price resolved only by coincidence: collateralFor() matches a
  STRUCTURE whose site id equals the anchors' collateral string, and site_02
  happens to name its structure "carport". The editor will generate
  "carport_2" for the second one, and the trap would have gone free — the
  gutter bug reborn in the sprint meant to bury it. The GLB now carries
  `collateral_key`, like the house does for "gutter". Lane A: the runtime
  half is yours (collateralFor could fall back to it).
- The trampoline is unpriced, and now says so rather than omitting it.

Three new rules make the audit permanent, and each is proven able to fail:
no silent anchors, no collateral string nobody prices, no baked anchor_type
outside the checked ANCHOR_TYPE enum. Rule 2 found the carport gap on its
first run — an unfaked negative control.

THE PROPS:

- swing_set_01 (+ wrecked) — the palette's honest middle. It had a ceiling
  (gum fork 1.0) and a trap (carport beam 0.22) and nothing in between. Two
  apex anchors at 0.45, typed `swing_frame` (NOT `post`: the type string is
  what the player reads before committing, and "post" promises concrete),
  and a crossbar that is the most anchor-looking object in the game and is
  not one. Priced at $140 — proposal, reasoning baked beside it, A rules the
  number: gnome 25 < gutter 90 < swing 140 < carport 180.
- tree_jacaranda_01 — a second species whose LADDER is the feature.
  Gum: 1.00/0.88/0.76, climb freely, pay 24%.
  Jacaranda: 0.95/0.52/0.40, climb at all, pay 58%.
  That makes tree choice a real decision instead of "is there a tree".
  Unpriced by ruling, same as the bike: no limb-failure event exists to see.

Both wreck/orientation claims are measured in browser coords, not reasoned —
`wreck_falls_toward: "+Z"` is baked AND asserted against the mesh.

Selftest 376/0/0. Two consecutive full factory runs: 44 files byte-identical.
2026-07-18 16:01:27 +10:00

803 lines
44 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';
// 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'] },
];
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']]) {
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`);
});
// 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`);
}
});
}