diff --git a/web/world/js/tests/e.test.js b/web/world/js/tests/e.test.js index fc71306..0506089 100644 --- a/web/world/js/tests/e.test.js +++ b/web/world/js/tests/e.test.js @@ -113,6 +113,16 @@ const ASSETS = [ nodes: ['frame', 'panes', 'door', 'ridge_anchor'] }, { name: 'glasshouse_01_wrecked', h: [2.15, 2.40], nodes: ['frame', 'door', 'panes_left', 'shards'] }, + // SPRINT18 gate 4 — the glasshouse tier's live half. The height floor is a + // design claim about the asset next to it: 2.70 sits ABOVE the glasshouse's + // 2.40 ceiling, because a net frame that does not clear the ridge is resting + // on the glass it exists to protect. The wreck shares the band — the +x + // portal stands, so a portal frame folds at one end rather than falling. + { name: 'hail_net_frame_01', h: [2.70, 2.85], + nodes: ['footings', 'legs', 'top_frame', 'net_roll', + 'net_anchor_01', 'net_anchor_02', 'net_anchor_03', 'net_anchor_04'] }, + { name: 'hail_net_frame_01_wrecked', h: [2.70, 2.85], + nodes: ['footings', 'legs', 'top_frame', 'net_down'] }, { name: 'pool_kit_01', h: [1.12, 1.32], nodes: ['pool_shell', 'pool_water', 'fence_rails', 'pool_gate', 'fence_post_01', 'fence_post_14'] }, @@ -1436,5 +1446,313 @@ export default async function run(t) { 'vacuous: no node-less declared anchors found across the shipped sites — this pin checked nothing'); }); + // ------------------------------------------------------------------------- + // SPRINT18 gate 4 — THE GLASSHOUSE TIER. Sprint 16 shipped the glass and + // priced it at 320; the gate asked for the tier, and reading the gate found + // that the price could not be REACHED. The comment two hundred lines up says + // it in this file's own words: "nothing in the sim wrecks a glasshouse yet — + // the value is inert data". main.js bills off a broken rig corner's baked + // collateral string, and a glasshouse correctly offers no anchors, so the + // most expensive object in the game could not cost anybody a cent. + // + // DESIGN.md line 179 named the missing asset: hail nets rigged OVER glass. + // You tie to the frame that straddles it. The tier's teaching is new — the + // frame's numbers are GOOD, and it is the most dangerous anchor in the game + // because of where it stands. So the tell being pinned below is a POSITION, + // measured off two GLBs, not a defect painted on one. + // ------------------------------------------------------------------------- + + t.test('the hail-net frame BRACKETS the glass — the tell is a position, and it is measured', () => { + const frame = loaded.get('hail_net_frame_01'); + const glass = loaded.get('glasshouse_01'); + assert(frame && glass, 'hail_net_frame_01 or glasshouse_01 did not load'); + const f = new THREE.Box3().setFromObject(frame.scene); + const g = new THREE.Box3().setFromObject(glass.scene); + // Both assets are built at the origin, so a shared-origin placement is the + // authored case and these comparisons are the real geometry. + assert(f.min.x < g.min.x && f.max.x > g.max.x, + `the frame spans x ${f.min.x.toFixed(2)}..${f.max.x.toFixed(2)} and the glasshouse ` + + `${g.min.x.toFixed(2)}..${g.max.x.toFixed(2)} — the frame must STRADDLE the glass on x. ` + + 'A frame that no longer brackets it is a frame whose anchors bill a building they are ' + + 'not standing over, and the $320 becomes a number with no geometry behind it'); + assert(f.min.z < g.min.z && f.max.z > g.max.z, + `the frame spans z ${f.min.z.toFixed(2)}..${f.max.z.toFixed(2)} and the glasshouse ` + + `${g.min.z.toFixed(2)}..${g.max.z.toFixed(2)} — it must straddle on z as well; ` + + 'legs standing ON the glass would be the joke version of this asset'); + assert(f.max.y > g.max.y + 0.35, + `the frame heads at y=${f.max.y.toFixed(2)} and the ridge is at ${g.max.y.toFixed(2)} — ` + + 'a net frame built to protect glass must CLEAR the ridge with room for the net to ' + + 'drape, or it is resting on the thing it exists to keep hail off'); + // Every eye is above the ridge: the load path arcs OVER the glass, which is + // the whole reason a failure here lands on panes. + for (const n of ['net_anchor_01', 'net_anchor_02', 'net_anchor_03', 'net_anchor_04']) { + const o = frame.scene.getObjectByName(n); + assert(o, `${n} missing`); + const p = new THREE.Vector3().setFromMatrixPosition(o.matrixWorld); + assert(p.y > g.max.y, + `${n} sits at y=${p.y.toFixed(2)}, below the ridge (${g.max.y.toFixed(2)}) — every corner ` + + 'of this frame must be above the glass or the tier is not about the glass'); + } + }); + + t.test('the frame\'s 20 m read is METALNESS, not lightness — S17\'s lesson, applied on purpose', () => { + // S17 measured this the hard way on the corroded post: galvanised ships at + // metalness 0.90, and with no environment map a near-mirror has nothing to + // mirror and renders NEAR-BLACK (rgb 49,51,49 at 20 m). So every steel the + // player is allowed to trust is dark at yard distance. This asset's entire + // long-range tell is being the opposite of that, and the mechanism is + // metalness — pin the mechanism, or the docstring gets "fixed" the wrong + // way by whoever repaints it next. + const pick = (asset, node) => { + let m = null; + loaded.get(asset)?.scene.getObjectByName(node)?.traverse((o) => { if (o.material) m = o.material; }); + return m; + }; + const paint = pick('hail_net_frame_01', 'top_frame'); + const gal = pick('sail_post', 'post'); + assert(paint && gal, 'could not reach the frame paint or the honest post steel'); + assert(paint.metalness <= 0.15, + `the frame paints at metalness ${paint.metalness} — powder-coat is PAINT, and above ~0.15 ` + + 'it starts mirroring an environment that does not exist and goes dark like every other ' + + 'steel. The tell dies silently: the frame is still white in the palette and black on screen'); + assert(gal.metalness >= 0.6, + `the honest post's steel is at metalness ${gal.metalness} — the control for this comparison ` + + 'is that trusted steel IS a near-mirror; if that changed, re-measure the whole claim'); + const lum = (c) => c.r * 0.2126 + c.g * 0.7152 + c.b * 0.0722; + assert(lum(paint.color) > lum(gal.color) * 1.3, + `frame paint luminance ${lum(paint.color).toFixed(3)} vs galvanised ${lum(gal.color).toFixed(3)} — ` + + 'the powder-coat must also be the brighter base colour, so the two halves of the tell ' + + 'agree instead of fighting'); + }); + + t.test('the hail-net frame is a NEW RUNG at 0.80: good steel, worst place, resolvable node-less', () => { + const T = FACTORY_ANCHOR_RATINGS.types; + const entry = T.hail_net_frame; + assert(entry, 'hail_net_frame is not in the ratings manifest — the type cannot resolve ' + + 'node-less, and a site declaring it plainly would play at the default hint (the ' + + 'sail_post bug shape, on the highest-collateral anchor in the game)'); + assert(!FACTORY_ANCHOR_RATINGS.ambiguous.hail_net_frame, + 'hail_net_frame went manifest-AMBIGUOUS — its four eyes disagree, and the runtime will ' + + 'refuse to guess; they are one frame and must bake one number'); + // Pinned against the manifest, never a literal — a literal would stay green + // through a whole reshuffle of the ladder. + assert(entry.rating_hint > T.pergola.rating_hint, + `the frame rates ${entry.rating_hint} and the pergola ${T.pergola.rating_hint} — the frame ` + + 'must out-rate it by a distance: a deck screwed to a house flexes, this is an engineered ' + + 'portal on four footings. De-rating honest steel for looking dangerous is the corroded ' + + 'post\'s lie inverted'); + assert(entry.rating_hint < T.post.rating_hint, + `the frame rates ${entry.rating_hint} against the honest post's ${T.post.rating_hint} — at or ` + + 'above the post there is no de-rate left for "32 mm sized for mesh, not for 25 m² of sail"'); + // THE FIELD THAT MAKES IT A TIER: what it takes with it is the GLASS. + assert(entry.collateral === 'glasshouse', + `the manifest says this type bills ${JSON.stringify(entry.collateral)} — it must bill ` + + '"glasshouse". That string is the entire tier: the anchor is fine, the thing underneath ' + + 'it is $320, and a node-less declaration has to carry that or the yard is free to break'); + assert(entry.sources.length === 4, + `the manifest cites ${entry.sources.length} eyes; the frame has four corners and a net ` + + 'ties at all of them — a missing source means a corner baked something different'); + }); + + // ⚠️ THE GATE, AS ONE MEASUREMENT. Before this sprint this assert was RED by + // construction: no node anywhere in the palette named the glasshouse's key, + // so the collateral apex was unbillable and the 320 was decoration. It is the + // free-failure bug's mirror image — RULE 2 catches a key with no price, and + // this catches a price with no key that can reach it. + t.test('⚠️ the collateral APEX is reachable: some anchor in the palette can actually bill the glass', () => { + const g = loaded.get('glasshouse_01')?.scene.getObjectByName('glasshouse_01'); + assert(g, 'glasshouse_01 root missing'); + const key = g.userData?.collateral_key; + assert(typeof key === 'string', 'the glasshouse carries no collateral_key to reach'); + const billers = []; + for (const [asset, o] of allNodes()) { + const u = o.userData ?? {}; + if (u.collateral !== key) continue; + if (typeof u.rating_hint !== 'number' || u.tie_off === false) continue; + billers.push(`${asset}/${o.name}`); + } + assert(billers.length > 0, + `NOTHING in the palette can bill "${key}" ($${g.userData?.collateral_value}). The apex is ` + + 'priced and unreachable: main.js bills collateral off a BROKEN RIG CORNER\'s baked key, ' + + 'a glasshouse rightly offers no anchors, so the most expensive thing in the game costs ' + + 'nobody anything. It needs an adoptable anchor whose collateral names the glass — that ' + + 'is what the hail-net frame is for (SPRINT18 gate 4)'); + // ...and the corollary that keeps gate 5 disarmed on my own asset: the + // frame POINTS at the key and must never CLAIM it. structFor() returns the + // FIRST structure matching a key, so a frame carrying collateral_key + // "glasshouse" would, in any yard authored frame-first, bill $320 for the + // panes and then wreck THE FRAME — the bill and the wreck agreeing with + // each other and both wrong, which is the only kind of wrong this repo + // cannot see from the outside. + const claimants = []; + for (const [asset, gltf] of loaded) { + const root = gltf.scene.getObjectByName(asset); + if (root?.userData?.collateral_key === key) claimants.push(asset); + } + for (const c of claimants) { + assert(c.startsWith('glasshouse_01'), + `${c} claims collateral_key "${key}", and it is not the glasshouse. Exactly one structure ` + + 'may OWN a key — the one that owns the price. structFor() takes the first match, so a ' + + 'second claimant means some yard bills the glass and wrecks something else'); + } + assert(claimants.length > 0, `vacuous: nothing owns "${key}" at all`); + }); + + t.test('the net-frame wreck folds at ONE end: keeps its height, carries no glass, offers nothing', () => { + const w = loaded.get('hail_net_frame_01_wrecked'); + const i = loaded.get('hail_net_frame_01'); + assert(w && i, 'the net frame or its wreck did not load'); + const bw = new THREE.Box3().setFromObject(w.scene); + const bi = new THREE.Box3().setFromObject(i.scene); + assert(Math.abs(bw.max.y - bi.max.y) < 0.10, + `the wreck tops out at y=${bw.max.y.toFixed(2)} vs the intact ${bi.max.y.toFixed(2)} — the +x ` + + 'portal stands and holds the eave rails, so a portal frame folds at one end. A wreck lying ' + + 'flat is claiming a failure mode this thing does not have'); + assert((bw.max.x - bw.min.x) > (bi.max.x - bi.min.x) + 0.6, + `the wreck spans ${(bw.max.x - bw.min.x).toFixed(2)} m on x against the intact ` + + `${(bi.max.x - bi.min.x).toFixed(2)} — the net has to end up OUT past the footing, or your ` + + 'own debris is tidier than the storm\'s and the tier stops teaching what it is for'); + // No glass here. The glasshouse has its own wreck variant and swaps + // independently; shards in both would draw twice the night both swap, and + // would quietly claim the glass's failure as this asset's. + const glassy = []; + w.scene.traverse((o) => { + const m = o.material; + if (m && m.transparent === true && m.opacity < 0.6) glassy.push(o.name); + }); + assert(glassy.length === 0, + `the net-frame wreck ships translucent geometry on [${glassy.join(', ')}] — the glass belongs ` + + 'to glasshouse_01_wrecked, which swaps on its own; shards in both double-draw'); + // No anchor survives the wreck (the fascia rule, sixth application). + 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 still offers [${offers.join(', ')}] — you cannot clip to a folded frame, and an ` + + 'anchor that outlives its structure is the free-failure bug in a costume'); + const wr = w.scene.getObjectByName('hail_net_frame_01_wrecked'); + assert(wr?.userData?.broken_variant_of === 'hail_net_frame_01', + 'the wreck must name its intact twin so A can pair the swap'); + // UNPRICED BY RULING — the bike/jacaranda pattern. One broken corner can + // only name one collateral key, and the player is being taught about the + // glass. Splitting a bill between the frame and the panes with no mechanism + // to split it is exactly the guess the invoice exists to refuse. + assert(wr?.userData?.collateral_value === undefined, + 'the net frame is UNPRICED BY RULING — build the mechanism that can bill two things off ' + + 'one broken corner before pricing the second one'); + }); + + // ------------------------------------------------------------------------- + // SPRINT18 gate 5 — THE SECOND-RUST-POST LANDMINE (D's filing, E+A). + // + // Two structures in one yard sharing a collateral key is currently ARMED and + // not firing: world.js's structFor() returns the FIRST structure whose key + // matches, main.js's billing loop dedupes by key, and C's scorecard envelope + // dedupes by key again — so the second one is invisible to the bill and to + // the wreck, while board.js's exposureOf() counts per STRUCTURE and prices + // both. D measured the split on a real two-post yard: $115 on the offer card + // against $45 on the failure route. + // + // The pool yard dodges it by shipping exactly one corroded post ON PURPOSE. + // That is a yard author remembering, which is not a defence — and gate 4 just + // made it likelier, because a glasshouse yard carries a structure that OWNS + // the key and a frame whose anchors POINT at it. Two glasshouses with a frame + // each trips this on the first night. + // + // So: the asset/manifest half is to make it impossible to SHIP quietly. The + // runtime half (a per-instance key a site can override) is A's and the exact + // wiring is filed in THREADS. + // ------------------------------------------------------------------------- + + const shippedSites = []; + for (const name of ['backyard_01', 'site_02_corner_block', 'site_03_swing_lawn', + 'site_04_pool_yard']) { + shippedSites.push({ name, site: await loadSite(name) }); + } + + t.test('⚠️ no shipped yard gives two structures the same collateral key, and ids are unique', () => { + // Mirrors world.js structKey() exactly: site JSON wins, then the baked + // GLB key, then the structure's own id as a last resort. + const resolvedKey = (s) => { + if (s.collateralKey) return s.collateralKey; + const base = String(s.model ?? '').replace(/_v1$/, ''); + return loaded.get(base)?.scene.getObjectByName(base)?.userData?.collateral_key ?? s.id; + }; + let checked = 0; + for (const { name, site } of shippedSites) { + const structs = site.structures ?? []; + const ids = structs.map((s) => s.id); + const dupeIds = ids.filter((v, i) => ids.indexOf(v) !== i); + assert(dupeIds.length === 0, + `${name}: duplicate structure ids [${[...new Set(dupeIds)].join(', ')}]. world.js keys its ` + + 'structures Map by id, so the second one SILENTLY REPLACES the first — it never renders, ' + + 'never bills, and never wrecks, and nothing anywhere says a word'); + const keys = structs.map(resolvedKey); + const dupeKeys = keys.filter((v, i) => keys.indexOf(v) !== i); + assert(dupeKeys.length === 0, + `${name}: two structures resolve to the same collateral key ` + + `"${[...new Set(dupeKeys)].join('", "')}" (keys: [${keys.join(', ')}]). structFor() ` + + 'returns the FIRST match, so the bill and the wreck would both land on the wrong one, ' + + 'agreeing with each other; meanwhile exposureOf() prices per structure and the offer ' + + 'card would quote a total the failure route can never reach (D measured $115 vs $45). ' + + 'Give each instance its own key in the site JSON — and until A lands the per-anchor ' + + 'override, the anchors bake the shared key, so the second one cannot be made to bill ' + + 'at all: see THREADS [E] SPRINT18 gate 5'); + checked += keys.length; + } + assert(checked > 0, 'vacuous: no structures found across the shipped yards'); + }); + + // The other half of the same seam, from the asset side: an anchor names the + // collateral it TAKES, and in a real yard something has to own that name. The + // hail-net frame is the first asset where those are different objects, so a + // frame placed in a yard with no glasshouse would bill a building that is not + // there — collateralFor() returns null and the corner is FREE. RULE 2 proves + // the key is priced SOMEWHERE in the palette; this proves it is owned in + // every yard that names it. + t.test('⚠️ every collateral an authored anchor names is OWNED by exactly one thing in that yard', () => { + const types = FACTORY_ANCHOR_RATINGS.types; + let checked = 0; + for (const { name, site } of shippedSites) { + const owners = new Map(); + const own = (key, what) => { + if (typeof key !== 'string') return; + owners.set(key, (owners.get(key) ?? 0) + 1); + void what; + }; + const houseBase = String(site.house?.model ?? '').replace(/_v1$/, ''); + own(site.house?.collateralKey + ?? loaded.get(houseBase)?.scene.getObjectByName(houseBase)?.userData?.collateral_key, + 'house'); + for (const s of site.structures ?? []) { + const base = String(s.model ?? '').replace(/_v1$/, ''); + own(s.collateralKey + ?? loaded.get(base)?.scene.getObjectByName(base)?.userData?.collateral_key ?? s.id, s.id); + } + // What every authored anchor on a structure will actually bill: the baked + // node's key when it names a node, else the manifest's key for its type. + for (const s of site.structures ?? []) { + const base = String(s.model ?? '').replace(/_v1$/, ''); + for (const a of s.anchors ?? []) { + const baked = a.node + ? loaded.get(base)?.scene.getObjectByName(a.node)?.userData?.collateral + : types[a.type]?.collateral; + if (typeof baked !== 'string') continue; // bills nothing, by design + assert(owners.get(baked) === 1, + `${name}/${a.id} will bill "${baked}" on failure, and ${owners.get(baked) ?? 0} things ` + + 'in this yard own that key. Zero means collateralFor() returns null and breaking ' + + 'this corner is FREE (the gutter bug, RULE 2\'s runtime twin); more than one means ' + + 'structFor() picks the first and the wreck lands on the wrong structure'); + checked += 1; + } + } + } + assert(checked > 0, + 'vacuous: no structure anchor in any shipped yard names collateral — this pin checked nothing'); + }); + for (const { world } of siteWorlds) world.dispose(); }