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:
parent
823dbb942b
commit
0cabb19dc7
@ -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
|
||||
|
||||
@ -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';
|
||||
|
||||
Loading…
Reference in New Issue
Block a user