diff --git a/web/world/js/tests/e.test.js b/web/world/js/tests/e.test.js index 1b78c20..714e921 100644 --- a/web/world/js/tests/e.test.js +++ b/web/world/js/tests/e.test.js @@ -100,6 +100,22 @@ const ASSETS = [ 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'] }, ]; function sizeOf(gltf) { @@ -502,7 +518,9 @@ export default async function run(t) { 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']]) { + ['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, @@ -792,6 +810,246 @@ export default async function run(t) { `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'); + }); + // 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', () => {