Anchor rework + house/tree GLBs: give the yard a real choice (decisions 2 & 6)

Posts pulled in to (-4.5,5.5)/(4.0,6.0) with p3 at (0,7), and dress() now swaps
Lane E's house_yardside and both gum trees over the graybox, adopting their baked
anchors rather than my constants (decision 6). E's fascia sits at x=-3..3, not my
guessed -5..5 — narrowing the house span by 4 m is a real part of why small quads
exist at all.

Every anchor now carries E's rating_hint: fascia 0.35 with collateral "gutter"
(they encoded DESIGN.md's "the fascia board is a lie" straight into the asset),
tree branches descending 1.0/0.88/0.76 from fork to thin limb. branch_anchor_01
keeps the t1/t2 ids so nothing referencing them breaks; the rest are added.

The yard went from 7 anchors offering nothing under 110 m² to 11 offering 34
quads in the 18-45 m² band, 8 of which shade a quarter of the bed or more.
Measured through the same storm_02: the big house-to-post span loses its
carabiner at t=3.7 s and cascades to 2/4, while a 37.7 m² tree-to-post rig at
0.85 tension survives all 90 s intact and shades 58% of the bed. That is
DESIGN.md's thesis finally standing up in the yard rather than in a doc.

Lane B's "cascade at t=0.4 s from pre-tension alone" is gone: calm peaks are now
634 N (big) and 200 N (small) against a 1200 N carabiner, and no rig breaks
before the storm starts.

Two asserts pin it, because both halves are easy to lose by accident: at least 3
riggable quads in 18-45 m² must shade the bed, AND full bed coverage must stay
above 45 m² — if a small quad ever covers the whole bed, the rigging puzzle has
no wrong answers left. Selftest 172/0/0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
m3ultra 2026-07-17 01:30:17 +10:00
parent 823dbb942b
commit 0cabb19dc7
2 changed files with 258 additions and 7 deletions

View File

@ -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)}`);
}
}
}
}
}
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

View File

@ -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 70192 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 1845 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';