Preserved verbatim from the interrupted session. NOTE: build_sail_post_corroded still carries the M1 mutation (clean galvanised steel, DROOP=0), so the two GLBs in this commit are the UNTELLED post, not the shipped asset. Restored in the next commit after the red-check is witnessed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1348 lines
77 KiB
JavaScript
1348 lines
77 KiB
JavaScript
/**
|
||
* 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 1–6 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;
|
||
};
|
||
|
||
t.test('the corroded post TELLS: rust area, duller steel, and a pad eye that sags — all measured', () => {
|
||
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');
|
||
|
||
// (b) The 20 m read: the corroded SHAFT is measurably duller/darker than
|
||
// galvanised. Compare the lightest non-rust material on each 'post' node —
|
||
// that's the shaft steel on both (dark trim is darker by construction).
|
||
const lightestL = (gl) => {
|
||
let best = -1;
|
||
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);
|
||
best = Math.max(best, hsl.l);
|
||
});
|
||
return best;
|
||
};
|
||
const lHonest = lightestL(honest), lCorroded = lightestL(g);
|
||
assert(lHonest >= 0 && lCorroded >= 0, 'could not find shaft materials on the post nodes');
|
||
assert(lCorroded < lHonest - 0.08,
|
||
`corroded shaft lightness ${lCorroded.toFixed(3)} vs galvanised ${lHonest.toFixed(3)} — ` +
|
||
'the shafts read the same at yard distance, which erases the long-range half of the tell');
|
||
|
||
// (c) The sag is geometry the sim SEES: the anchor sits at the drooped
|
||
// eye, below where the honest post carries its. Not set dressing.
|
||
const anchorY = (gl) => {
|
||
gl.scene.updateMatrixWorld(true);
|
||
return gl.scene.getObjectByName('top_anchor').getWorldPosition(new THREE.Vector3()).y;
|
||
};
|
||
const sag = anchorY(honest) - anchorY(g);
|
||
assert(sag > 0.05,
|
||
`the corroded top_anchor sits only ${sag.toFixed(3)} m below the honest one — ` +
|
||
'the pad eye is meant to have SAGGED, visibly and measurably');
|
||
});
|
||
|
||
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();
|
||
}
|