Re-run the §7 gate against real storm wind; stop allocating per face
B-4. The gate used to run on my own stub wind — uniform, horizontal, tuned by nobody — so it proved the cloth was self-consistent, not that the game works. It now loads the shipped storm JSON and drives the cloth through weather.core.js (pure and import-free, so the same path runs in node and in selftest.html; weather.js's own loadStorm can't help because its STORM_DIR is a file:// URL that node's fetch won't open). All three halves of §7 now run on real storm_02 and the real yard: a flat drum-tight carabiner rig cascades 4/4; a well-twisted mixed rig holds all four; and a twisted rig with one dodgy corner blows it and finishes 4/4 intact after a single repair — the DoD scenario, in an assert. Perf: Lane C added an `out` param to wind.sample specifically so sail.js wouldn't allocate, and I wasn't passing it — 162 faces at 60 Hz is ~9.7k throwaway Vector3s a second. Now passing a scratch vector. Stub winds that ignore `out` still work; we read the return value. Two tests skip rather than pass vacuously while Lane C's downdraft is unmerged: a "nothing broke" repair scenario would go green forever and check nothing. Both light up by themselves on merge, and the repair one hard-fails if a downdraft IS present and still can't threaten the rig. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
cb557a1d6f
commit
2034593a30
@ -125,6 +125,11 @@ export class SailRig {
|
||||
this._acc = 0;
|
||||
// scratch, reused every face to keep the hot loop allocation-free
|
||||
this._probe = { x: 0, y: 0, z: 0 };
|
||||
// Lane C's wind.sample(pos, t, out) takes an out-vector so we don't allocate
|
||||
// one per face per substep — 162 faces at 60 Hz is ~9.7k throwaway Vector3s
|
||||
// a second otherwise. A stub wind that ignores `out` still works: we read
|
||||
// the RETURN value, not this.
|
||||
this._windOut = new THREE.Vector3();
|
||||
}
|
||||
|
||||
/**
|
||||
@ -373,7 +378,7 @@ export class SailRig {
|
||||
probe.x = (pos[ia] + pos[ib] + pos[ic]) / 3;
|
||||
probe.y = (pos[ia + 1] + pos[ib + 1] + pos[ic + 1]) / 3;
|
||||
probe.z = (pos[ia + 2] + pos[ib + 2] + pos[ic + 2]) / 3;
|
||||
const w = wind.sample(probe, t);
|
||||
const w = wind.sample(probe, t, this._windOut);
|
||||
|
||||
// Relative wind, not absolute: as the cloth accelerates downwind the load
|
||||
// bleeds off by itself. This is what stops flogging from exploding.
|
||||
|
||||
@ -12,9 +12,57 @@
|
||||
|
||||
import { SailRig } from './sail.js';
|
||||
import { HARDWARE, FIXED_DT, createStubWind, rng } from './contracts.js';
|
||||
import { createWindField } from './weather.core.js';
|
||||
|
||||
const SIM_DT = FIXED_DT;
|
||||
|
||||
// ---------- real storm wind (SPRINT2 B-4) ----------
|
||||
// The §7 gate used to run on the local stub, which is uniform, horizontal and
|
||||
// tuned by nobody. These load the storms design actually ships and drive the
|
||||
// cloth with them. weather.core.js is pure and import-free, so the same code
|
||||
// path works in node and in Lane A's selftest.html; only reading the JSON off
|
||||
// disk differs, and weather.js's own loadStorm can't help there (its STORM_DIR
|
||||
// is a file:// URL under node, which fetch won't open).
|
||||
|
||||
async function loadStormDef(name) {
|
||||
const url = new URL(`../data/storms/${name}.json`, import.meta.url);
|
||||
if (typeof process !== 'undefined' && process.versions?.node) {
|
||||
const { readFile } = await import('node:fs/promises');
|
||||
return JSON.parse(await readFile(url, 'utf8'));
|
||||
}
|
||||
return (await fetch(url)).json();
|
||||
}
|
||||
|
||||
const STORM_02 = await loadStormDef('storm_02_wildnight');
|
||||
|
||||
/** A Wind over a real storm def. Same field the game flies. */
|
||||
function realWind(def = STORM_02, opts = {}) {
|
||||
const field = createWindField(def, opts);
|
||||
const out = { x: 0, y: 0, z: 0 };
|
||||
return {
|
||||
sample(pos, t) { return field.vecAt(pos.x, pos.z, t, out); },
|
||||
speedAt(t) { field.vecAt(0, 0, t, out); return Math.hypot(out.x, out.z); },
|
||||
gustTelegraph: (t) => field.gustTelegraph?.(t) ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Lane A's yard, verbatim (THREADS: "yard layout is now FACT"). */
|
||||
const YARD = [
|
||||
['h1', 'house', -5, 2.6, -9.9], ['h2', 'house', 0, 2.6, -9.9], ['h3', 'house', 5, 2.6, -9.9],
|
||||
['t1', 'tree', -9, 3.2, 2], ['t2', 'tree', 8, 3.1, -2],
|
||||
['p1', 'post', -6.4, 3.9, 7.4], ['p2', 'post', 5.3, 3.9, 8],
|
||||
].map(([id, type, x, y, z]) => {
|
||||
const pos = { x, y, z };
|
||||
// Static on purpose: tree sway is world.js's, and mixing it in here would make
|
||||
// a cloth assert fail for a reason that isn't the cloth. Sway is exercised in
|
||||
// the game and in a.test.
|
||||
return { id, type, pos, sway: () => pos };
|
||||
});
|
||||
|
||||
const yardRig = (ids, hw, tension) =>
|
||||
new SailRig({ anchors: YARD, gridN: 10 })
|
||||
.attach(ids, Array.isArray(hw) ? hw : Array(4).fill(hw), tension);
|
||||
|
||||
// ---------- deterministic stub wind ----------
|
||||
// contracts.js ships createStubWind(), and the integration test below uses it.
|
||||
// This local one exists only because the thesis needs the wind DIRECTION swept,
|
||||
@ -66,10 +114,17 @@ const FOOT = [
|
||||
export const HEIGHTS_FLAT = [4.0, 4.0, 2.5, 2.5]; // y linear in z -> one plane
|
||||
export const HEIGHTS_HYPAR = [4.0, 2.5, 4.0, 2.5]; // opposite corners up/down -> saddle
|
||||
|
||||
/** Anchors shaped like contracts.js Anchor: sway(t) is the ABSOLUTE position. */
|
||||
export const makeAnchors = (heights) =>
|
||||
/**
|
||||
* Anchors shaped like contracts.js Anchor: sway(t) is the ABSOLUTE position.
|
||||
* `theta` spins the footprint about the yard's Y axis — which is how you sweep
|
||||
* wind direction against a real storm, whose direction curve you don't get to
|
||||
* choose. Rotating the rig under the wind and rotating the wind over the rig are
|
||||
* the same experiment; only one of them is available with authored storm JSON.
|
||||
*/
|
||||
export const makeAnchors = (heights, theta = 0) =>
|
||||
FOOT.map((f, i) => {
|
||||
const pos = { x: f.x, y: heights[i], z: f.z };
|
||||
const c = Math.cos(theta), s = Math.sin(theta);
|
||||
const pos = { x: f.x * c - f.z * s, y: heights[i], z: f.x * s + f.z * c };
|
||||
return { id: `a${i}`, type: 'post', pos, sway: () => pos };
|
||||
});
|
||||
|
||||
@ -472,6 +527,120 @@ test('decision 5: no debris and empty debris are both fine', () => {
|
||||
return 'empty and absent debris both no-op';
|
||||
});
|
||||
|
||||
// --- SPRINT2 B-4: the §7 gate, against the wind the game actually flies ------
|
||||
|
||||
// PLAN3D §7: "A flat drum-tight cheap rig MUST cascade-fail in storm_02; a
|
||||
// well-twisted mixed rig with one mid-storm repair MUST be survivable." The old
|
||||
// version of this proved it against my own stub wind, which is uniform,
|
||||
// horizontal and tuned by nobody — so it proved the cloth was self-consistent,
|
||||
// not that the game works. This is the real storm JSON, the real yard, and the
|
||||
// same two rig shapes Lane C measured decision 3 against.
|
||||
test('§7 gate on REAL storm_02: cheap flat rig cascades', () => {
|
||||
const rig = yardRig(['h1', 'h3', 'p2', 'p1'], HARDWARE[0], 1.3); // drum-tight carabiners
|
||||
const broke = [];
|
||||
rig.events.on('break', (e) => broke.push(e));
|
||||
const w = realWind();
|
||||
for (let i = 0; i < Math.round(STORM_02.duration / SIM_DT); i++) rig.step(SIM_DT, w, i * SIM_DT);
|
||||
const lost = rig.corners.filter((c) => c.broken).length;
|
||||
assert(lost >= 2, `flat drum-tight carabiner rig only lost ${lost}/4 in the real storm_02 — no cascade`);
|
||||
return `lost ${lost}/4, first at t=${broke[0].t.toFixed(1)}s (${broke[0].anchorId}, ${broke[0].hw})`;
|
||||
});
|
||||
|
||||
test('§7 gate on REAL storm_02: twisted mixed rig survives', () => {
|
||||
// Lane C's shape: h1 (house, 2.6) / t2 (tree, 3.1) / p1 (post, 3.9) / t1 (tree, 3.2)
|
||||
// — corners at four different heights, i.e. an actual hypar, eased off tight.
|
||||
const rig = yardRig(['h1', 't2', 'p1', 't1'], [HARDWARE[2], HARDWARE[1], HARDWARE[2], HARDWARE[1]], 0.85);
|
||||
const w = realWind();
|
||||
let peak = 0;
|
||||
for (let i = 0; i < Math.round(STORM_02.duration / SIM_DT); i++) {
|
||||
rig.step(SIM_DT, w, i * SIM_DT);
|
||||
peak = Math.max(peak, rig.maxLoad());
|
||||
}
|
||||
const lost = rig.corners.filter((c) => c.broken).length;
|
||||
assert(lost === 0, `well-twisted mixed rig lost ${lost}/4 in storm_02 — §7 says it must be survivable`);
|
||||
return `all 4 corners held, peak ${kN(peak)} (area ${rig.area.toFixed(0)} m2)`;
|
||||
});
|
||||
|
||||
test('§7 gate on REAL storm_02: twisted rig + one repair on the dodgy corner', () => {
|
||||
// The other half of §7: "a well-twisted mixed rig with ONE mid-storm repair
|
||||
// MUST be survivable". The twisted rig above already survives outright, so
|
||||
// the interesting scenario is DESIGN.md's: the budget forces one dodgy corner
|
||||
// ($80 buys rated on at most two of four), that corner blows, and you run out
|
||||
// and re-rig it once with the carried spare — exactly Lane D's hold-E.
|
||||
// An $80-exact loadout: rated h1 ($30) + shackle t1 ($15) + shackle p1 ($15)
|
||||
// + carabiner t2 ($5) + spare ($15). The carabiner goes on t2 because that is
|
||||
// where the load actually IS — measured peaks on this shape are h1 1.68 /
|
||||
// t2 2.73 / p1 2.17 / t1 0.81 kN. Putting the cheap corner on t1 (the
|
||||
// lightest) is what a player does by accident and it survives the storm
|
||||
// having proved nothing; putting it on t2 is the real bet.
|
||||
const rig = yardRig(
|
||||
['h1', 't2', 'p1', 't1'],
|
||||
[HARDWARE[2], HARDWARE[0], HARDWARE[1], HARDWARE[1]],
|
||||
0.85,
|
||||
);
|
||||
const w = realWind();
|
||||
let repairs = 0;
|
||||
rig.events.on('break', () => { /* seen below; repairing inside the emit would reenter step */ });
|
||||
for (let i = 0; i < Math.round(STORM_02.duration / SIM_DT); i++) {
|
||||
rig.step(SIM_DT, w, i * SIM_DT);
|
||||
if (repairs === 0) {
|
||||
const k = rig.corners.findIndex((c) => c.broken);
|
||||
if (k >= 0) { rig.repair(k); repairs++; }
|
||||
}
|
||||
}
|
||||
const lost = rig.corners.filter((c) => c.broken).length;
|
||||
if (repairs === 0) {
|
||||
// A vacuous pass is worse than a skip: "nothing broke" would let this go
|
||||
// green forever while proving nothing. Storm_02 can't threaten a shackle
|
||||
// rig until Lane C's downdraft lands (their A/B: shackle blows at t=20.8 s
|
||||
// with downdraft 0.3, never without). Lights up by itself on merge.
|
||||
assert(
|
||||
!STORM_02.gusts?.downdraft,
|
||||
'storm_02 HAS a downdraft and still could not blow a shackle rig — the repair scenario is vacuous',
|
||||
);
|
||||
return 'SKIPPED — nothing blew; needs Lane C decision 3 downdraft to threaten a shackle rig';
|
||||
}
|
||||
assert(lost <= 1, `after one repair the rig still lost ${lost}/4 — not survivable`);
|
||||
return `${repairs} repair, finished ${4 - lost}/4 corners intact`;
|
||||
});
|
||||
|
||||
// --- SPRINT2 decision 3 / B-6: the flat-horizontal loophole ------------------
|
||||
|
||||
// My Sprint 1 finding: a flat HORIZONTAL sail was the lowest-load rig of all
|
||||
// (1.14 kN vs a pitched flat's 3.06), because a horizontal plate in horizontal
|
||||
// wind has almost no drag — which inverted DESIGN.md's "big, flat, low = death
|
||||
// in a storm". Lane C closed it by making gusts descend. This is the assert
|
||||
// decision 3 asks Lane B for.
|
||||
test('decision 3: flat-horizontal is no longer a free lunch', () => {
|
||||
const downdraft = STORM_02.gusts?.downdraft ?? 0;
|
||||
if (!downdraft) {
|
||||
// Feature-detected rather than hard-failed: this assert is only meaningful
|
||||
// once Lane C's downdraft is on main. It lights up by itself on merge.
|
||||
return 'SKIPPED — storm_02 has no gusts.downdraft yet (Lane C decision 3 not merged)';
|
||||
}
|
||||
const FLAT_H = [3.25, 3.25, 3.25, 3.25];
|
||||
// Spin the rig through 8 headings under the real storm. (Re-seeding the wind
|
||||
// instead would only reshuffle gust TIMING — the direction curve is authored
|
||||
// in the JSON and doesn't move — so it would look like a sweep and measure
|
||||
// nothing about direction.)
|
||||
const sweep = (heights) => {
|
||||
let worst = 0;
|
||||
for (let k = 0; k < 8; k++) {
|
||||
const r = new SailRig({ anchors: makeAnchors(heights, (k / 8) * Math.PI * 2), gridN: 10 })
|
||||
.attach(ALL_IDS, Array(4).fill(UNBREAKABLE), 1.0);
|
||||
// full duration: storm_02's own note says the peak lands just AFTER the
|
||||
// southerly change, so a 45 s sweep measures the wrong half of the storm
|
||||
worst = Math.max(worst, runStorm(r, realWind(), STORM_02.duration));
|
||||
}
|
||||
return worst;
|
||||
};
|
||||
const pitched = sweep(HEIGHTS_FLAT);
|
||||
const horizontal = sweep(FLAT_H);
|
||||
const ratio = horizontal / pitched;
|
||||
assert(ratio >= 0.6, `flat-horizontal peaks at only ${(ratio * 100).toFixed(0)}% of flat-pitched (${kN(horizontal)} vs ${kN(pitched)}) — still a free lunch`);
|
||||
return `flat-horizontal ${kN(horizontal)} vs flat-pitched ${kN(pitched)} = ${(ratio * 100).toFixed(0)}% (downdraft ${downdraft})`;
|
||||
});
|
||||
|
||||
test('runs against the shared contracts.js stub wind', () => {
|
||||
// Proves the rig eats the sanctioned Wind implementation, not just my local
|
||||
// stub — so nothing surprises us when Lane C's weather.js drops in.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user