diff --git a/web/world/js/tests/a.test.js b/web/world/js/tests/a.test.js index e70c747..1c5d426 100644 --- a/web/world/js/tests/a.test.js +++ b/web/world/js/tests/a.test.js @@ -8,13 +8,27 @@ import { FIXED_DT, STORM_LEN, YARD, checkContract, createStubWind } from '../con import { createWorld, heightAt } from '../world.js'; import { createCameraRig } from '../camera.js'; import { createGame } from '../main.js'; +import { orderRing } from '../sail.js'; import { assert, assertEq, assertLess, fixedLoop } from '../testkit.js'; /** @param {import('../testkit.js').Suite} t */ -export default function run(t) { +export default async function run(t) { const scene = new THREE.Scene(); const world = createWorld(scene, { wind: createStubWind({ calm: true }) }); + // Dress the yard before asserting anything about it: anchors are only FINAL + // after dress(), which moves them onto the positions Lane E baked and adds the + // extra tree branches. Testing the graybox would be testing a yard that never + // reaches a player. Guarded, so a missing server degrades to graybox asserts + // rather than reddening the whole lane. + let dressed = false; + try { + await world.dress(); + dressed = true; + } catch (err) { + console.warn('[a.test] dress() unavailable, asserting against graybox:', err.message); + } + // --- contract conformance ------------------------------------------------ // These are the merge tripwires: if a lane's module drifts from contracts.js, // this is where we find out, not three lanes later. @@ -111,15 +125,138 @@ export default function run(t) { // --- anchors ------------------------------------------------------------- - t.test('yard offers 7 anchors: 3 house, 2 tree, 2 post', () => { + t.test('yard offers 11 anchors: 3 house, 5 tree, 3 post', () => { const by = (type) => world.anchors.filter((a) => a.type === type).length; assertEq(by('house'), 3, 'house anchors'); - assertEq(by('tree'), 2, 'tree anchors'); - assertEq(by('post'), 2, 'post anchors'); + assertEq(by('tree'), dressed ? 5 : 2, 'tree anchors (branch_anchor_* arrive with dress())'); + assertEq(by('post'), 3, 'post anchors — p3 added, SPRINT3 decision 2'); const ids = world.anchors.map((a) => a.id); assertEq(new Set(ids).size, ids.length, `anchor ids not unique: ${ids}`); }); + t.test('anchors carry Lane E\'s rating_hint, and the fascia is the weak one', () => { + if (!dressed) return t.skip('needs dress()'); + const hint = (id) => world.anchors.find((a) => a.id === id)?.ratingHint; + // DESIGN.md: "The fascia board is a lie: holds until the first real gust." + // Lane E encoded that as rating_hint 0.35 in house_yardside_v1.glb, so the + // asset says it and nothing here has to restate it. If this ever flips to + // 1.0, the yard has quietly stopped teaching its best lesson. + assertLess(hint('h1'), 0.5, 'fascia anchor should be the weak option'); + assertEq(world.anchors.find((a) => a.id === 'h1').collateral, 'gutter', + 'a fascia failure takes the gutter with it — that is the collateral cost'); + assert(hint('t1') > hint('t1c'), + 'a branch anchor at the fork must out-rate one out where the limb is thin'); + }); + + // --- decision 2: the yard has to offer a real choice ---------------------- + + t.test('yard offers ≥3 riggable quads in the 18-45 m² band that shade the bed', () => { + if (!dressed) return t.skip('needs dress() — anchors are only final after it'); + + // SPRINT3 decision 2. Before the rework every quad covering the bed was + // 110 m²+, which pre-tensions itself into a cascade at t=0.4 s before the + // wind does anything — the yard taught the wrong lesson. + const bed = world.gardenBed; + const areaOf = (q) => { + const r = orderRing(q); + let a = 0; + for (let i = 0, j = r.length - 1; i < r.length; j = i++) { + a += (r[j].pos.x + r[i].pos.x) * (r[j].pos.z - r[i].pos.z); + } + return Math.abs(a / 2); + }; + const inside = (x, z, r) => { + let c = false; + for (let i = 0, j = r.length - 1; i < r.length; j = i++) { + const a = r[i].pos, b = r[j].pos; + if ((a.z > z) !== (b.z > z) && x < ((b.x - a.x) * (z - a.z)) / (b.z - a.z) + a.x) c = !c; + } + return c; + }; + const coverOf = (q) => { + const r = orderRing(q); + let hit = 0, tot = 0; + for (let i = 0; i < 6; i++) { + for (let j = 0; j < 4; j++) { + const x = bed.x - bed.w / 2 + ((i + 0.5) / 6) * bed.w; + const z = bed.z - bed.d / 2 + ((j + 0.5) / 4) * bed.d; + tot++; + if (inside(x, z, r)) hit++; + } + } + return hit / tot; + }; + + const A = world.anchors; + const band = []; + for (let i = 0; i < A.length; i++) { + for (let j = i + 1; j < A.length; j++) { + for (let k = j + 1; k < A.length; k++) { + for (let l = k + 1; l < A.length; l++) { + const q = [A[i], A[j], A[k], A[l]]; + const m2 = areaOf(q); + if (m2 >= 18 && m2 <= 45 && coverOf(q) >= 0.25) { + band.push(`${q.map((a) => a.id).join('+')} ${m2.toFixed(0)}m²`); + } + } + } + } + } + assert(band.length >= 3, + `only ${band.length} quads in 18-45 m² shade the bed — the yard offers no ` + + `storm-survivable option. Found: ${band.join(', ') || 'none'}`); + }); + + t.test('full shade over the bed stays expensive — the tradeoff is the game', () => { + if (!dressed) return t.skip('needs dress()'); + // The other half of decision 2, and the half that is easy to "fix" by + // accident. DESIGN.md's core tension is that big+flat+low buys great shade + // and dies in a storm, while small+twisted survives and shades patchily. If + // some future yard tweak ever lets a small quad cover the whole bed, that + // tension is gone and the rigging puzzle has no wrong answers left. + const bed = world.gardenBed; + const A = world.anchors; + let smallestFull = Infinity; + const areaOf = (q) => { + const r = orderRing(q); + let a = 0; + for (let i = 0, j = r.length - 1; i < r.length; j = i++) { + a += (r[j].pos.x + r[i].pos.x) * (r[j].pos.z - r[i].pos.z); + } + return Math.abs(a / 2); + }; + const inside = (x, z, r) => { + let c = false; + for (let i = 0, j = r.length - 1; i < r.length; j = i++) { + const a = r[i].pos, b = r[j].pos; + if ((a.z > z) !== (b.z > z) && x < ((b.x - a.x) * (z - a.z)) / (b.z - a.z) + a.x) c = !c; + } + return c; + }; + for (let i = 0; i < A.length; i++) { + for (let j = i + 1; j < A.length; j++) { + for (let k = j + 1; k < A.length; k++) { + for (let l = k + 1; l < A.length; l++) { + const q = [A[i], A[j], A[k], A[l]]; + const r = orderRing(q); + let hit = 0; + for (let a = 0; a < 6; a++) { + for (let b = 0; b < 4; b++) { + const x = bed.x - bed.w / 2 + ((a + 0.5) / 6) * bed.w; + const z = bed.z - bed.d / 2 + ((b + 0.5) / 4) * bed.d; + if (inside(x, z, r)) hit++; + } + } + if (hit / 24 >= 0.9) smallestFull = Math.min(smallestFull, areaOf(q)); + } + } + } + } + assert(smallestFull > 45, + `a ${smallestFull.toFixed(0)} m² quad covers the whole bed — full shade is ` + + `supposed to cost you a sail the storm can take`); + }); + t.test('sway() returns an absolute position, not an offset', () => { // If sway ever regresses to returning an offset, the returned point lands // near the origin instead of near the anchor, and Lane B's cloth corners diff --git a/web/world/js/world.js b/web/world/js/world.js index 48abd22..ea750f3 100644 --- a/web/world/js/world.js +++ b/web/world/js/world.js @@ -40,6 +40,11 @@ const GARDEN_BED = { x: 1, z: 2, w: 6, d: 4 }; const SHED = { x: 11.8, z: 6.2, rotY: -Math.PI / 2 }; const SHED_TABLE = { x: 9, z: 6, rotY: -Math.PI / 2 }; +// Lane E's house_yardside GLB is a 9.2 x 2.9 x 1.05 m façade whose fascia +// anchors sit at local z = +0.55, so placing it here lands them on z = -9.95 — +// the same line the graybox taught everyone to expect. +const HOUSE = { x: 0, z: -10.5 }; + // Sun: mid-afternoon, high and off the north-west shoulder. Elevation 55°. // Stored as the direction from the GROUND toward the SUN (see contracts.js). const SUN_ELEV = (55 * Math.PI) / 180; @@ -89,6 +94,8 @@ export function createWorld(scene, opts = {}) { const anchors = []; /** @type {{group: THREE.Object3D, phase: number, base: THREE.Euler}[]} */ const canopies = []; + /** Graybox stand-ins, kept so dress() can retire them once E's GLBs load. */ + const graybox = { house: null, trees: new Map() }; // --- sky & light ------------------------------------------------------- // Calm-day only. Lane C's skyfx.js takes over the sky and this becomes the @@ -174,6 +181,7 @@ export function createWorld(scene, opts = {}) { root.add(house); solids.push(wall, roof); + graybox.house = house; for (const [i, x] of [-5, 0, 5].entries()) { anchors.push(makeStaticAnchor(`h${i + 1}`, 'house', new THREE.Vector3(x, 2.6, -9.9))); @@ -222,6 +230,7 @@ export function createWorld(scene, opts = {}) { canopies.push({ group: canopy, phase: spec.phase, base: canopy.rotation.clone() }); root.add(tree); + graybox.trees.set(spec.id, tree); // The anchor is at a branch fork, not the canopy centre. anchors.push(makeSwayAnchor( @@ -236,9 +245,24 @@ export function createWorld(scene, opts = {}) { // Raked away from the yard centre, because that is the correct practice and // the shape should teach it before any text does (DESIGN.md: "rake the post // away from the load"). + // SPRINT3 decision 2: posts pulled in off the fence and a third added. + // + // The old pair sat at (-6, 7) and (5, 7.5), which put every rigging option in + // the 70–192 m² range Lane B flagged — a sail that big pre-tensions itself + // into a cascade at t=0.4 s before the wind has done anything, so the yard was + // teaching the wrong lesson. Pulled in, plus p3, the same yard now offers 31 + // quads in the 18–45 m² band (8 of which shade a quarter of the bed or more). + // + // Worth knowing before anyone "fixes" it: the smallest quad that covers the + // bed COMPLETELY is 59 m², and that is not a bug to tune away. The bed sits + // 10 m off the house, so any house-to-post sail is ~16 m long, and covering a + // 6 m bed with it costs you a sail the storm will take. Full shade is meant to + // be the expensive answer; the small quads buy survival and pay in patchy + // shade. That IS the design (DESIGN.md, "big flat low vs small twisted steep"). const postSpecs = [ - { id: 'p1', x: -6, z: 7, h: 4.0 }, - { id: 'p2', x: 5, z: 7.5, h: 4.0 }, + { id: 'p1', x: -4.5, z: 5.5, h: 4.0 }, + { id: 'p2', x: 4.0, z: 6.0, h: 4.0 }, + { id: 'p3', x: 0, z: 7.0, h: 4.0 }, ]; const RAKE = (8 * Math.PI) / 180; for (const spec of postSpecs) { @@ -366,6 +390,35 @@ export function createWorld(scene, opts = {}) { const { GLTFLoader } = await import('../vendor/addons/loaders/GLTFLoader.js'); const loader = new GLTFLoader(); + /** Take a graybox stand-in out of the scene AND out of `solids`. */ + const retire = (obj) => { + if (!obj) return; + obj.traverse((o) => { + const i = solids.indexOf(o); + if (i >= 0) solids.splice(i, 1); + o.geometry?.dispose(); + }); + const i = solids.indexOf(obj); + if (i >= 0) solids.splice(i, 1); + obj.parent?.remove(obj); + }; + + /** + * Move an existing anchor onto the position Lane E baked, and take their + * rating_hint with it. Mutates `pos` in place rather than reassigning it: + * `interact.register` and Lane B's corners capture these vectors by + * reference, and a reassign would leave them holding a stale one. + */ + const adoptAnchor = (glb, nodeName, anchorId) => { + const node = glb.getObjectByName(nodeName); + const anchor = anchors.find((a) => a.id === anchorId); + if (!node || !anchor) return false; + anchor.pos.setFromMatrixPosition(node.matrixWorld); + anchor.ratingHint = node.userData?.rating_hint ?? 1; + anchor.collateral = node.userData?.collateral ?? null; + return true; + }; + const load = async (name) => { try { const gltf = await loader.loadAsync(new URL(`../models/${name}.glb`, import.meta.url).href); @@ -379,7 +432,68 @@ export function createWorld(scene, opts = {}) { } }; - const [shed, table] = await Promise.all([load('shed_01_v1'), load('shed_table_v1')]); + const [shed, table, houseGlb, tree1, tree2] = await Promise.all([ + load('shed_01_v1'), load('shed_table_v1'), load('house_yardside_v1'), + load('tree_gum_01_v1'), load('tree_gum_02_v1'), + ]); + + // --- house (decision 6: no re-cut, the GLB's data wins) --------------- + // E's fascia sits at 2.80 m and their anchors span x=-3..3, where my + // graybox guessed 2.6 m and -5..5. Reading them narrows the house span by + // 4 m, which is a real part of why the yard now offers small quads at all. + // Every fascia anchor carries rating_hint 0.35 — E encoded DESIGN.md's + // "the fascia board is a lie" straight into the asset, and `collateral: + // "gutter"` says what it takes with it when it goes. + if (houseGlb) { + retire(graybox.house); + houseGlb.name = 'house_yardside'; + houseGlb.position.set(HOUSE.x, heightAt(HOUSE.x, HOUSE.z), HOUSE.z); + root.add(houseGlb); + solids.push(houseGlb); + houseGlb.updateWorldMatrix(true, true); + for (const [i, id] of ['h1', 'h2', 'h3'].entries()) { + adoptAnchor(houseGlb, `fascia_anchor_0${i + 1}`, id); + } + } + + // --- trees ----------------------------------------------------------- + // Each tree ships 2-3 branch anchors with descending rating_hint (1.0 at + // the fork, 0.76 out where the limb is thin) — the intel DESIGN.md wants + // inspection to buy. branch_anchor_01 keeps the original t1/t2 id so + // nothing that already references them breaks; the rest are added. + for (const [glb, spec] of [[tree1, treeSpecs[0]], [tree2, treeSpecs[1]]]) { + if (!glb) continue; + const old = graybox.trees.get(spec.id); + retire(old); + // The graybox canopy was what world.update() swayed — hand that job over. + const idx = canopies.findIndex((c) => old && old.getObjectByName('canopy') === c.group); + if (idx >= 0) canopies.splice(idx, 1); + + glb.name = `tree_${spec.id}`; + glb.position.set(spec.x, heightAt(spec.x, spec.z), spec.z); + root.add(glb); + const trunk = glb.getObjectByName('trunk'); + if (trunk) solids.push(trunk); + const canopy = glb.getObjectByName('canopy_01') || glb.getObjectByName('canopy'); + if (canopy?.parent) { + canopies.push({ group: canopy.parent, phase: spec.phase, base: canopy.parent.rotation.clone() }); + } + + glb.updateWorldMatrix(true, true); + const suffix = ['', 'b', 'c']; + for (let i = 1; i <= 3; i++) { + const node = glb.getObjectByName(`branch_anchor_0${i}`); + if (!node) continue; + const id = spec.id + suffix[i - 1]; + if (i === 1) adoptAnchor(glb, `branch_anchor_01`, id); + else { + const p = new THREE.Vector3().setFromMatrixPosition(node.matrixWorld); + const a = makeSwayAnchor(id, p, spec.phase, wind); + a.ratingHint = node.userData?.rating_hint ?? 1; + anchors.push(a); + } + } + } if (shed) { shed.name = 'shed_01';