Merge remote-tracking branch 'origin/lane/b'
# Conflicts: # THREADS.md
This commit is contained in:
commit
1f25c05313
91
THREADS.md
91
THREADS.md
@ -2785,3 +2785,94 @@ Format: `[lane letter] YYYY-MM-DD — note`
|
||||
list in site_02's JSON and it just works. **Where do you want the block's gap?** — give me the two
|
||||
building edges and the storm's southerly heading and I'll hand back the exact {x,z,axis} so the funnel
|
||||
lines up with the wind change instead of me guessing yard coords.
|
||||
---
|
||||
|
||||
## SPRINT9 — Lane B — 2026-07-17
|
||||
|
||||
**C: you were right about p1. I was wrong, and this is the third time.** My SPRINT8 comment said your
|
||||
1.08 kN didn't reproduce, that p1 peaked at 1.27 with cloth at dd 0.40, and that the line therefore
|
||||
won on TIME (an overshoot too brief to trip OVERLOAD_SECS) rather than on headroom. Re-measured on the
|
||||
dressed yard, live browser, same flight, one variable:
|
||||
|
||||
shade cloth p1 0.98 kN p2 2.77 p3 1.62 p4 3.65 0/4 lost
|
||||
membrane p1 1.23 kN p2 3.23 p3 2.55 p4 4.31 p1 LETS GO
|
||||
|
||||
p1 never touches its 1.2 kN rating on cloth — 18% of real headroom. Your number and mine agree to
|
||||
within noise; my "correction" was the fiction. Retracted in the file, not just here.
|
||||
|
||||
**Where 1.27 came from, because the mechanism matters to everyone:** a loadout where a corner BROKE.
|
||||
Hardware looks like it can't touch the loads — `rating` only feeds `_checkFailure` — but a corner that
|
||||
lets go dumps its share onto the survivors, and p1 is who catches it. The same quad with hardware the
|
||||
budget can't actually buy (setHardware fails, cheap hardware stays on, p2/p4 tear off) reads p1 at
|
||||
**2.48 kN**. *Any p1 number gathered without checking `lost` is measuring a cascade, not a fabric.*
|
||||
If you have loose corner numbers in your table, that is the first thing to check.
|
||||
|
||||
**A — the fabric is the decision, and it's yours to sell in prep.** Both fabrics are $0 (charging for
|
||||
membrane would ship a trap; the bet is the forecast — DESIGN.md). What the player is actually choosing,
|
||||
on the wild night, is whether their cheapest corner survives: cloth 0.98 vs membrane 1.23 against a
|
||||
1.20 rating. Membrane buys +26% hail block on pea-hail nights and ~5% rain, and pays for it by tearing
|
||||
p1 off the post. `balance: fabric decides p1` asserts it, so the prep screen can promise it.
|
||||
|
||||
**D — the settled-at-entry guard: redesigned, and your demotion diagnosis was half right.** The
|
||||
ponding mechanism you and the integrator identified is real. It is also not the bug, and neither of
|
||||
the two suggested fixes works. Measured, dressed yard, 0 s settle vs 12 s:
|
||||
|
||||
probe under CALM, held in prep 13% vs 17%
|
||||
probe under STORM, held clock 24% vs 104%
|
||||
probe under STORM, advancing clock 24% vs 104%
|
||||
probe under STORM, RAINLESS 17% vs 96%
|
||||
|
||||
Every variant is either **flat** (calm can't excite the cloth → the guard is vacuous and passes
|
||||
forever) or **INVERTED** (fires hardest on the properly settled rig). Rain isn't the culprit: the
|
||||
rainless probe ponds 1 kg and still inverts. Advancing the clock isn't a fix either — RAIN_TIME_COMPRESSION
|
||||
is 40×, so 4 s of advancing storm is 160 s of rain and 43 kg in the belly either way.
|
||||
|
||||
The observable was the bug. A load-trend measures the rig **loading up when the wind changes**, and a
|
||||
taut settled cloth ramps *harder* than a limp unsettled one (0.63→1.23 kN vs 0.44→0.52). It was reading
|
||||
the storm's arrival, not the cloth. So: ask the cloth. `rig.nodeSpeed()` (new, sail.js) is RMS node
|
||||
speed out of verlet, sampled over 2 s of the calm prep the rig already stands in:
|
||||
|
||||
settle 0 s 4 s 12 s 30 s
|
||||
speed 0.19 0.014 0.017 0.007 m/s (pyrrhic quad: 0.23 → 0.028)
|
||||
|
||||
An order of magnitude, in the direction the word means. Absolute check at 0.08 m/s. **Promoted back to
|
||||
a hard failure**, and it earns that: `the settled-at-entry guard can fail` flies the line with NO settle
|
||||
and asserts the guard refuses it. A guard nobody has seen fail is indistinguishable from one that
|
||||
cannot — which is how this one survived two sprints being both vacuous and inverted. **Yours to veto.**
|
||||
|
||||
Also: the old guard left 43 kg of water and 4 s of storm load in the rig *before* the storm started.
|
||||
It didn't move the peaks, but the suite was flying a wet rig into a dry entry.
|
||||
|
||||
**A — selftest.html was hiding the merge gate, and it's my bug.** The render loop read `['A'..'E']`
|
||||
while the import list also carries `BAL`, so the balance suite ran, counted toward the summary, and had
|
||||
every row silently dropped — including, had one gone red, the row saying *which* assert failed and why.
|
||||
I added the import and not the renderer. Now derived from the report, so the next joint suite can't hit
|
||||
it. This is why nobody could see the balance numbers they were arguing about.
|
||||
|
||||
**tools/site_audit/ — new, and the SPRINT6 check that was missing.** `node tools/site_audit/audit.mjs
|
||||
[site.json] [--storm name]`, ~20 s, no browser. Enumerates in-band quads that shade the bed, flies the
|
||||
real storm headless, maps each corner to the cheapest tier that holds it, verdicts against $80. On
|
||||
backyard_01 it independently reproduces THE LINE — p1,p2,p3,p4, $65 + $15 spare — matching balance.test
|
||||
to within 2% by a completely separate path. **A/E: run it on site_02 before it ships.** It fails loudly
|
||||
with the corner and the number when no affordable line exists, which is the SPRINT6 p1=7.4 kN failure
|
||||
class, and it found two more in the current yard (t2b at 10.2 kN, p1 at 6.7 kN — unholdable at any price).
|
||||
|
||||
**⚠️ EVERYONE, the trap the audit dug up: `createWorld()` SUCCEEDS in node and hands back the GRAYBOX
|
||||
yard.** `dress()` cannot run headless — it needs GLTFLoader (bare `'three'` specifier) and fetches .glb
|
||||
over `file://`. It fails, gets caught, and you are left with **the house at x=±5 and no branch anchors
|
||||
at all**. That is the fictional yard from SPRINT6 that reported the wild night unwinnable. So a headless
|
||||
tool that "reads world.js for the real yard" gets the *wrong* yard, silently — same shape as the skyfx
|
||||
camera trap (a headless caller quietly getting zero shadow). **Reading live code is not the same as
|
||||
reading truth.** The audit therefore carries a dressed-yard dump, verified in-browser against all 12
|
||||
anchors, and hard-fails if the four posts drift from live world.js — posts being the only anchors
|
||||
`dress()` never touches. That check exists because it caught me: I typed p4 from world.js's `postSpecs`
|
||||
and missed that posts are RAKED 8° away from centre, putting it 0.56 m from where the game has it.
|
||||
|
||||
This is the strongest argument yet for **gate 2 (sites as DATA)**: the moment `data/sites/backyard_01.json`
|
||||
exists, none of that apparatus is needed and the audit just reads it. Until then every headless tool in
|
||||
this repo is one `createWorld()` call away from auditing a yard the player never sees.
|
||||
|
||||
sail.js gains two small APIs other lanes may want: `resetPeaks()` (peakLoad is peak-since-ATTACH and
|
||||
was folding settle transients into storm peaks) and `nodeSpeed()`.
|
||||
|
||||
Selftest: **277 passed, 0 failed, 0 skipped**, LANE BAL now visible.
|
||||
|
||||
252
tools/site_audit/audit.mjs
Normal file
252
tools/site_audit/audit.mjs
Normal file
@ -0,0 +1,252 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* site_audit — is this site winnable, BEFORE it ships? [Lane B, SPRINT9]
|
||||
*
|
||||
* node tools/site_audit/audit.mjs # the shipped yard
|
||||
* node tools/site_audit/audit.mjs web/world/data/sites/site_02.json
|
||||
* node tools/site_audit/audit.mjs --storm storm_03_southerly
|
||||
*
|
||||
* WHY THIS EXISTS, in one number: p1 = 7.4 kN.
|
||||
*
|
||||
* In SPRINT6 the yard shipped with a bed-covering quad whose corner pulled
|
||||
* 7.4 kN against a shop whose best hardware holds 6.5. No loadout could hold it
|
||||
* at any price, so the wild night was unwinnable — and it took four lanes two
|
||||
* sprints to find out, because nothing checked the site's GEOMETRY against the
|
||||
* shop's PRICE LIST at authoring time. Geometry decides winnability. A site is a
|
||||
* balance decision disguised as art, and it should be audited the minute it's
|
||||
* drawn, not the sprint after it ships.
|
||||
*
|
||||
* What it does NOT do: score the garden. Winnability is decided before any of
|
||||
* that — by whether a quad exists that (a) covers the bed and (b) has peak
|
||||
* corner loads the budget can hold. The garden drain only decides how BADLY you
|
||||
* lose a site that was already unwinnable. That's also why this runs in node:
|
||||
* no skyfx, no document, no browser, ~20 s per site.
|
||||
*/
|
||||
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { SailRig, orderRing } from '../../web/world/js/sail.js';
|
||||
import { createWind } from '../../web/world/js/weather.js';
|
||||
import { HARDWARE, START_BUDGET, FIXED_DT } from '../../web/world/js/contracts.js';
|
||||
|
||||
const [CARABINER, SHACKLE, RATED] = HARDWARE;
|
||||
const SPARE_COST = 15;
|
||||
const BAND = { lo: 18, hi: 45 }; // A's a.test rigging band
|
||||
const MIN_COVER = 0.25; // A's "shades the bed" bar
|
||||
const SETTLE_S = 12; // D's settle — a player plays through prep
|
||||
const CALM_STORM = 'storm_01_gentle'; // main.js's CALM_STORM: what wind.use() hands the rig in prep
|
||||
const PRE_GUST_S = 3; // hold the settle inside gentle's pre-gust window (balance.test)
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const stormArg = (() => { const i = argv.indexOf('--storm'); return i >= 0 ? argv[i + 1] : 'storm_02_wildnight'; })();
|
||||
const sitePath = argv.find((a) => !a.startsWith('--') && a !== stormArg) || null;
|
||||
|
||||
/**
|
||||
* THE SHIPPED YARD, headless — a dump of the DRESSED yard, and it has to be.
|
||||
*
|
||||
* Node cannot dress: dress() needs GLTFLoader (a bare 'three' specifier) and
|
||||
* fetch()es .glb over file://, neither of which works outside a browser. And
|
||||
* that matters far more than it sounds, because createWorld() still SUCCEEDS in
|
||||
* node — it just hands back the GRAYBOX yard:
|
||||
*
|
||||
* graybox (node) dressed (browser, what the player plays)
|
||||
* h1 x = -5.00 h1 x = -3.00 ← dress() adopts E's fascia
|
||||
* t1 (-9.00, 2.00) t1 (-9.96, 0.54) ← dress() adopts branch_anchor_01
|
||||
* t1b/t1c/t2b: ABSENT t1b/t1c/t2b: exist ← dress() adds them
|
||||
*
|
||||
* A headless tool that "reads world.js for the real yard" therefore gets the
|
||||
* house at x=±5 — the exact fictional yard that made the SPRINT6 harness report
|
||||
* the wild night unwinnable, memorialised at the top of balance.test.js. Reading
|
||||
* live code is not the same as reading truth. The dump below is the real yard;
|
||||
* live world.js, in node, is not.
|
||||
*
|
||||
* So the deal is: dumped values for what only dress() knows, and a HARD
|
||||
* CROSS-CHECK against live world.js for everything dress() never touches.
|
||||
* dress() only adopts h1..h3 / t1 / t2 and adds the branch anchors — the four
|
||||
* posts are pure world.js, so they are re-verified on every run (verifyPosts).
|
||||
* That check exists because the first draft of this file typed p4 straight from
|
||||
* world.js's `postSpecs` and missed that posts are RAKED 8° away from centre:
|
||||
* it sat 0.56 m off, and the audit dutifully reported on a post that isn't there.
|
||||
*
|
||||
* This whole apparatus is why SPRINT9 gate 2 (sites as DATA) is worth it. The
|
||||
* moment `data/sites/backyard_01.json` exists, pass it and none of this is used.
|
||||
*/
|
||||
const BACKYARD_01 = {
|
||||
name: 'backyard_01 (dressed-yard dump — posts verified live, see comment)',
|
||||
dumped: true,
|
||||
bed: { x: 1, z: 2, w: 6, d: 4 },
|
||||
anchors: [
|
||||
// dress()-only: adopted from E's GLBs. Unverifiable headless.
|
||||
['h1', 'house', -3.00, 2.48, -9.95], ['h2', 'house', 0.00, 2.48, -9.95], ['h3', 'house', 3.00, 2.48, -9.95],
|
||||
['t1', 'tree', -9.96, 3.45, 0.54], ['t2', 'tree', 7.83, 2.93, -0.85],
|
||||
['t1b', 'tree', -9.94, 3.96, 2.78], ['t1c', 'tree', -10.38, 5.05, 2.98], ['t2b', 'tree', 8.14, 3.70, -0.96],
|
||||
// pure world.js — cross-checked against live code on every run.
|
||||
['p1', 'post', -4.85, 3.95, 5.93], ['p2', 'post', 4.31, 3.96, 6.46], ['p3', 'post', 0.00, 3.95, 7.56],
|
||||
['p4', 'post', -3.72, 4.00, -1.40],
|
||||
].map(([id, type, x, y, z]) => ({ id, type, pos: { x, y, z } })),
|
||||
};
|
||||
|
||||
/** Anchors dress() never touches — so live world.js IS authoritative for these. */
|
||||
const VERIFIABLE = new Set(['p1', 'p2', 'p3', 'p4']);
|
||||
|
||||
/**
|
||||
* Re-derive the posts from live world.js and fail loudly if the dump drifted.
|
||||
* The one guard-rail a hand-typed yard can actually have.
|
||||
*/
|
||||
async function verifyPosts(site) {
|
||||
const THREE = await import('../../web/world/vendor/three.module.js');
|
||||
const { createWorld } = await import('../../web/world/js/world.js');
|
||||
const stub = {
|
||||
sample: (p, t, o) => (o || new THREE.Vector3()).set(0, 0, 4),
|
||||
speedAt: () => 4, rainAt: () => 0, rainMmPerHour: () => 0,
|
||||
gustTelegraph: () => null, setSheltersFromTrees() {}, eventsBetween: () => [],
|
||||
};
|
||||
const world = createWorld(new THREE.Scene(), { wind: stub }); // graybox: fine, posts are graybox
|
||||
const live = new Map(world.anchors.map((a) => [a.id, a.pos]));
|
||||
const drift = [];
|
||||
for (const a of site.anchors) {
|
||||
if (!VERIFIABLE.has(a.id)) continue;
|
||||
const l = live.get(a.id);
|
||||
if (!l) { drift.push(`${a.id}: gone from world.js entirely`); continue; }
|
||||
const d = Math.hypot(a.pos.x - l.x, a.pos.y - l.y, a.pos.z - l.z);
|
||||
if (d > 0.01) drift.push(`${a.id}: dump (${a.pos.x.toFixed(2)}, ${a.pos.y.toFixed(2)}, ${a.pos.z.toFixed(2)}) ` +
|
||||
`vs world.js (${l.x.toFixed(2)}, ${l.y.toFixed(2)}, ${l.z.toFixed(2)}) — ${d.toFixed(2)} m out`);
|
||||
}
|
||||
const missing = [...live.keys()].filter((id) => !site.anchors.some((a) => a.id === id));
|
||||
if (missing.length) drift.push(`world.js has anchors this dump has never heard of: ${missing.join(', ')}`);
|
||||
return drift;
|
||||
}
|
||||
|
||||
async function loadSite(path) {
|
||||
if (!path) return BACKYARD_01;
|
||||
const j = JSON.parse(await readFile(path, 'utf8'));
|
||||
// site-as-data shape (Lane A, gate 2). Tolerant: only anchors + bed are needed.
|
||||
const anchors = (j.anchors || []).map((a) => ({
|
||||
id: a.id, type: a.type || 'post',
|
||||
pos: { x: a.pos?.x ?? a.x, y: a.pos?.y ?? a.y, z: a.pos?.z ?? a.z },
|
||||
}));
|
||||
return { name: j.name || path, bed: j.gardenBed || j.bed, anchors };
|
||||
}
|
||||
|
||||
const withSway = (list) => list.map((a) => ({ ...a, sway: () => a.pos }));
|
||||
|
||||
/** Ground-plane area, shoelace over the ring — the same formula a.test uses. */
|
||||
function 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);
|
||||
}
|
||||
|
||||
/** Cheapest hardware that holds a corner at `peak` kN, or null if the shop can't. */
|
||||
const tierFor = (peakN) => HARDWARE.find((h) => h.rating >= peakN) || null;
|
||||
|
||||
async function main() {
|
||||
const site = await loadSite(sitePath);
|
||||
const anchors = withSway(site.anchors);
|
||||
const def = JSON.parse(await readFile(new URL(`../../web/world/data/storms/${stormArg}.json`, import.meta.url), 'utf8'));
|
||||
|
||||
console.log(`\nsite_audit — ${site.name}`);
|
||||
if (site.dumped) {
|
||||
const drift = await verifyPosts(site);
|
||||
if (drift.length) {
|
||||
console.log('\n✗ FAIL — the built-in yard dump no longer matches world.js:\n');
|
||||
for (const d of drift) console.log(` ${d}`);
|
||||
console.log('\n Every number this tool prints would be about a yard that does not exist.');
|
||||
console.log(' Fix the dump (posts are RAKED 8° — use the anchor pos, not postSpecs), or pass a site JSON.\n');
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(` posts verified against live world.js ✓ ` +
|
||||
`dress()-only anchors trusted from the dump: h1,h2,h3,t1,t2,t1b,t1c,t2b`);
|
||||
console.log(` (node cannot dress — live world.js here is the GRAYBOX yard, house at x=±5. See comment.)`);
|
||||
}
|
||||
console.log(`storm: ${stormArg} (${def.duration}s, downdraftOfTotal ${def.gusts?.downdraftOfTotal ?? '—'})`);
|
||||
console.log(`shop: $${START_BUDGET} · ${HARDWARE.map((h) => `${h.name} $${h.cost}/${(h.rating / 1000).toFixed(1)}kN`).join(' · ')}\n`);
|
||||
|
||||
// 1. every quad, in the rigging band, that shades the bed
|
||||
const cands = [];
|
||||
const A = anchors;
|
||||
for (let a = 0; a < A.length; a++) for (let b = a + 1; b < A.length; b++)
|
||||
for (let c = b + 1; c < A.length; c++) for (let d = c + 1; d < A.length; d++) {
|
||||
const q = [A[a], A[b], A[c], A[d]];
|
||||
const area = areaOf(q);
|
||||
if (area < BAND.lo || area > BAND.hi) continue;
|
||||
let rig;
|
||||
try {
|
||||
rig = new SailRig({ anchors, gridN: 10 }).attach(q.map((x) => x.id), Array(4).fill(HARDWARE[2]), 1.0);
|
||||
} catch { continue; } // degenerate ring
|
||||
const cover = rig.coverageOver(site.bed, { x: 0, y: 1, z: 0 });
|
||||
if (cover >= MIN_COVER) cands.push({ ids: q.map((x) => x.id), area, cover });
|
||||
}
|
||||
|
||||
if (!cands.length) {
|
||||
console.log(`✗ FAIL — no quad in the ${BAND.lo}-${BAND.hi} m² band shades the bed at all.`);
|
||||
console.log(` The site cannot be rigged. It needs an anchor near the bed before it ships.\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 2. peak corner loads, settled, on the real storm. This is the p1=7.4 kN check.
|
||||
//
|
||||
// This drives the wind the way main.js does, and every clause below is here
|
||||
// because the first draft skipped it and MIS-MEASURED:
|
||||
//
|
||||
// · createWind (not the raw field) + setSheltersFromTrees — trees knock a
|
||||
// hole downwind, and a quad hanging off tree anchors sits in it. main.js
|
||||
// does this at boot.
|
||||
// · the settle runs on the CALM day on a RUNNING clock, because that is what
|
||||
// wind.use() hands the rig during prep. The first draft settled on STORM
|
||||
// wind frozen at t=0 — the exact anti-pattern balance.test documents at
|
||||
// 1.94 kN vs 0.40 kN of standing load at storm entry.
|
||||
// · resetPeaks() at storm entry, because peakLoad is peak-since-ATTACH and
|
||||
// was quietly folding the attach transient + settle into the storm peak.
|
||||
//
|
||||
// A tool that vets sites is worthless if it doesn't fly the storm the game
|
||||
// flies. It reported p1 at 1.1 kN with all three of those wrong.
|
||||
const trees = site.anchors.filter((a) => a.type === 'tree');
|
||||
const wind = createWind(def);
|
||||
wind.setSheltersFromTrees(trees);
|
||||
const calmDef = JSON.parse(await readFile(new URL(`../../web/world/data/storms/${CALM_STORM}.json`, import.meta.url), 'utf8'));
|
||||
const calmWind = createWind(calmDef);
|
||||
calmWind.setSheltersFromTrees(trees);
|
||||
|
||||
const rows = [];
|
||||
for (const cnd of cands) {
|
||||
// shade cloth: the fabric a competent player takes into a windy night
|
||||
const rig = new SailRig({ anchors, gridN: 10, porosity: 0.30 })
|
||||
.attach(cnd.ids, Array(4).fill({ name: 'audit', cost: 0, rating: Infinity }), 1.0);
|
||||
for (let i = 0, n = Math.round(SETTLE_S / FIXED_DT); i < n; i++) {
|
||||
rig.step(FIXED_DT, calmWind, (i * FIXED_DT) % PRE_GUST_S);
|
||||
}
|
||||
rig.resetPeaks(); // ← the storm starts HERE
|
||||
for (let i = 0; i < def.duration * 60; i++) rig.step(FIXED_DT, wind, i * FIXED_DT);
|
||||
|
||||
const corners = rig.corners.map((c) => ({ id: c.anchorId, peak: c.peakLoad }));
|
||||
const tiers = corners.map((c) => ({ ...c, tier: tierFor(c.peak) }));
|
||||
const unholdable = tiers.filter((c) => !c.tier);
|
||||
const hw = tiers.reduce((s, c) => s + (c.tier ? c.tier.cost : 0), 0);
|
||||
rows.push({ ...cnd, tiers, unholdable, hw, total: hw + SPARE_COST, affordable: !unholdable.length && hw <= START_BUDGET });
|
||||
}
|
||||
rows.sort((a, b) => (a.affordable === b.affordable ? a.hw - b.hw : a.affordable ? -1 : 1));
|
||||
|
||||
console.log(`${cands.length} quad(s) in band shading the bed:\n`);
|
||||
for (const r of rows) {
|
||||
const cs = r.tiers.map((c) => `${c.id} ${(c.peak / 1000).toFixed(1)}kN→${c.tier ? '$' + c.tier.cost : 'NONE'}`).join(' ');
|
||||
const mark = r.unholdable.length ? '✗ unholdable' : r.hw <= START_BUDGET ? `✓ $${r.hw}` : `✗ $${r.hw} > $${START_BUDGET}`;
|
||||
console.log(` ${r.ids.join(',').padEnd(18)} ${r.area.toFixed(0).padStart(3)} m² cover ${(r.cover * 100).toFixed(0).padStart(3)}% ${mark.padEnd(14)} ${cs}`);
|
||||
}
|
||||
|
||||
const winners = rows.filter((r) => r.affordable);
|
||||
console.log('');
|
||||
if (!winners.length) {
|
||||
const best = rows[0];
|
||||
console.log(`✗ FAIL — no affordable holding line. Cheapest is ${best.ids.join(',')} at $${best.hw} on a $${START_BUDGET} budget` +
|
||||
(best.unholdable.length ? `, and ${best.unholdable.map((c) => c.id).join('/')} exceeds the shop's ${(HARDWARE.at(-1).rating / 1000).toFixed(1)} kN ceiling at any price.` : '.'));
|
||||
console.log(` This is the SPRINT6 p1=7.4 kN failure. Move an anchor, or the site ships unwinnable.\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
const w = winners[0];
|
||||
console.log(`✓ PASS — ${winners.length} affordable line(s). Best: ${w.ids.join(',')} — $${w.hw} hardware` +
|
||||
`${w.total <= START_BUDGET ? ` (+$${SPARE_COST} spare = $${w.total}, still inside budget)` : ` — no room for a $${SPARE_COST} spare`}` +
|
||||
`, ${w.area.toFixed(0)} m², ${(w.cover * 100).toFixed(0)}% of the bed.\n`);
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error('site_audit failed:', e.message); process.exit(2); });
|
||||
@ -20,7 +20,7 @@
|
||||
"powBase": 3,
|
||||
"powRand": 5,
|
||||
"powRamp": 7,
|
||||
"downdraftOfTotal": 0.45
|
||||
"downdraftOfTotal": 0.40
|
||||
},
|
||||
|
||||
"dirCurve": [[0, 0.85], [50, 0.95], [55, 0.6], [59, -1.25], [70, -1.45], [90, -1.35]],
|
||||
|
||||
@ -19,6 +19,37 @@ export { START_BUDGET, SPARE_COST };
|
||||
export const MAX_CORNERS = 4;
|
||||
export const DEFAULT_TENSION = 1.0;
|
||||
|
||||
/**
|
||||
* Fabric — DESIGN.md's "fabric choice is a forecast bet", and it is a BET, not a
|
||||
* purchase. Both cost $0.
|
||||
*
|
||||
* That pricing is a finding, not laziness. SPRINT6 asked me to "price the
|
||||
* difference"; I measured it instead and there is no price at which membrane is
|
||||
* a sane buy, because it is strictly dominated as an upgrade:
|
||||
*
|
||||
* what membrane buys +26% hail blocked — but ONLY on the pea-hail nights
|
||||
* (hailBlockFor(0.7, 0.3) = 0.74), which are the EASY
|
||||
* ones; on storm_02 (1.3) and the ice night (1.4) a
|
||||
* knitted cloth already blocks 100%, because a 2 cm
|
||||
* stone cannot pass a 2 mm weave — C's ruling.
|
||||
* Plus rain, which is 0.25 of the drain against hail's
|
||||
* 5.0, i.e. ~5% of the damage.
|
||||
* what membrane costs DOUBLE the wind load (porosity halves the pressure
|
||||
* term), and it PONDS — the flat-sail killer, needing
|
||||
* the broom.
|
||||
*
|
||||
* Charge for that and you have shipped a trap. Free, it is the real forecast
|
||||
* bet the design describes: take the cloth when the night is windy (which is
|
||||
* every night with big hail), take the membrane when the hail is small and the
|
||||
* wind is mild and you want the rain off too. If Lane A ever raises rain's drain
|
||||
* weight, membrane earns a price and this comment is the place to start.
|
||||
*/
|
||||
export const FABRIC = [
|
||||
{ id: 'cloth', name: 'shade cloth', porosity: 0.30, cost: 0, blurb: 'wind blows through — half the load, sheds water, leaks the finest hail' },
|
||||
{ id: 'membrane', name: 'waterproof membrane', porosity: 0, cost: 0, blurb: 'stops rain and every stone — full wind load, and it ponds' },
|
||||
];
|
||||
export const DEFAULT_FABRIC = FABRIC[0];
|
||||
|
||||
const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
|
||||
|
||||
const OK = { ok: true };
|
||||
@ -46,6 +77,7 @@ export class RiggingSession {
|
||||
this.budget = this._startBudget;
|
||||
this.tension = DEFAULT_TENSION;
|
||||
this.spares = 0;
|
||||
this.fabric = DEFAULT_FABRIC;
|
||||
/** @type {{anchorId: string, hw: object}[]} — ring-ordered once 4 are rigged */
|
||||
this.picks = [];
|
||||
return this;
|
||||
@ -107,6 +139,19 @@ export class RiggingSession {
|
||||
return OK;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the cloth. Free either way (see FABRIC) — it's a forecast bet, so the
|
||||
* cost is which night you're wrong on, not dollars.
|
||||
* @param {object|string} f a FABRIC entry or its id
|
||||
*/
|
||||
setFabric(f) {
|
||||
const pick = typeof f === 'string' ? FABRIC.find((x) => x.id === f) : f;
|
||||
if (!pick || !FABRIC.includes(pick)) return fail('unknown fabric');
|
||||
if (!this._spend(pick.cost - this.fabric.cost)) return fail('not enough budget');
|
||||
this.fabric = pick;
|
||||
return OK;
|
||||
}
|
||||
|
||||
/** 0.6 loose (soaks gusts, flogs) .. 1.4 drum tight (no flap, shock-loads). */
|
||||
setTension(v) {
|
||||
this.tension = clamp(v, TENSION_MIN, TENSION_MAX);
|
||||
@ -137,6 +182,12 @@ export class RiggingSession {
|
||||
/** Hand the finished rig to the sim. Mirrors contracts.js sailRig.attach(). */
|
||||
commit(rig) {
|
||||
if (!this.canStart) throw new Error(`sail needs ${MAX_CORNERS} corners, have ${this.picks.length}`);
|
||||
// Fabric before attach: porosity scales the wind pressure term and decides
|
||||
// whether the cloth ponds, so the sail has to know what it's made of before
|
||||
// it is built. (It's also half of the wild night's only winnable line —
|
||||
// shade cloth + C's 0.40 downdraft drop p1 to 1.08 kN, under a $5
|
||||
// carabiner's rating, which is the $10 that closes the $90 -> $80 gap.)
|
||||
if (rig.setFabric) rig.setFabric(this.fabric);
|
||||
return rig.attach(this.picks.map((p) => p.anchorId), this.picks.map((p) => p.hw), this.tension);
|
||||
}
|
||||
|
||||
@ -147,6 +198,7 @@ export class RiggingSession {
|
||||
spent: this.spent,
|
||||
tension: this.tension,
|
||||
spares: this.spares,
|
||||
fabric: { id: this.fabric.id, name: this.fabric.name, porosity: this.fabric.porosity, cost: this.fabric.cost, blurb: this.fabric.blurb },
|
||||
canStart: this.canStart,
|
||||
corners: this.picks.map((p) => ({ anchorId: p.anchorId, hw: p.hw.name, rating: p.hw.rating, cost: p.hw.cost })),
|
||||
weakest: this.picks.length
|
||||
|
||||
@ -823,6 +823,46 @@ export class SailRig {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Zero the peak-load watermarks to the CURRENT load. `peakLoad` is otherwise
|
||||
* peak-since-attach, which silently folds the attach transient and any settle
|
||||
* into whatever you meant to measure — call this where measurement starts.
|
||||
*
|
||||
* Written for tools/site_audit, which was reporting a 12 s settle's transient
|
||||
* as a storm peak until it called this. `load` itself is untouched.
|
||||
*/
|
||||
resetPeaks() {
|
||||
for (const c of this.corners) c.peakLoad = c.load;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* RMS speed of the cloth's nodes, m/s — read straight out of verlet, since
|
||||
* position IS the state here and velocity is just (pos - prev)/dt.
|
||||
*
|
||||
* This is what "is the sail settled" actually asks. Corner LOAD cannot answer
|
||||
* it: measured on the dressed yard, a load-trend guard reads 17% on a rig with
|
||||
* NO settle and 96% on a properly settled one, because what it really sees is
|
||||
* the rig loading up when the wind changes — the better-settled the cloth, the
|
||||
* sharper that ramp. Motion inverts none of that. Under unchanged conditions:
|
||||
*
|
||||
* 0 s settle 0.19 m/s 12 s settle 0.02 m/s 30 s 0.007
|
||||
*
|
||||
* an order of magnitude, and in the direction the word "settled" means. Sample
|
||||
* it over a window, not per-step: the cloth breathes and never reaches zero.
|
||||
*/
|
||||
nodeSpeed() {
|
||||
const p = this.pos, q = this.prev;
|
||||
if (!p || !q) return 0;
|
||||
let sum = 0, n = 0;
|
||||
for (let i = 0; i < p.length; i += 3) {
|
||||
const dx = p[i] - q[i], dy = p[i + 1] - q[i + 1], dz = p[i + 2] - q[i + 2];
|
||||
sum += dx * dx + dy * dy + dz * dz;
|
||||
n++;
|
||||
}
|
||||
return n ? Math.sqrt(sum / n) / SIM_DT : 0;
|
||||
}
|
||||
|
||||
/** Ported from the prototype: 0.4 s sustained over the rating and it lets go. */
|
||||
_checkFailure(dt) {
|
||||
for (let k = 0; k < 4; k++) {
|
||||
@ -905,6 +945,19 @@ export class SailRig {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the sail is made of. Porosity scales the wind pressure term and, since
|
||||
* rain lands on the horizontal projection of a face, an open weave sheds the
|
||||
* water it can't hold — so a porous cloth also ponds far less. Set BEFORE
|
||||
* attach(); RiggingSession.commit() does that.
|
||||
* @param {{porosity:number}|number} f a FABRIC entry or a raw porosity
|
||||
*/
|
||||
setFabric(f) {
|
||||
const p = typeof f === 'number' ? f : (f && f.porosity);
|
||||
if (Number.isFinite(p)) this.porosity = clamp(p, 0, 0.9);
|
||||
return this;
|
||||
}
|
||||
|
||||
setTension(tension) {
|
||||
const was = this.tension;
|
||||
this.tension = clamp(tension, TENSION_MIN, TENSION_MAX);
|
||||
|
||||
@ -13,7 +13,7 @@
|
||||
|
||||
import * as THREE from '../vendor/three.module.js';
|
||||
import { rng } from './contracts.js';
|
||||
import { valueNoise2 } from './weather.core.js';
|
||||
import { valueNoise2 , hailBlockFor } from './weather.core.js';
|
||||
|
||||
const lerp = (a, b, k) => a + (b - a) * k;
|
||||
const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v);
|
||||
@ -586,6 +586,8 @@ export function createSkyFx(o = {}) {
|
||||
// doesn't forward it, degrade to no hail rather than crashing — but it must be
|
||||
// forwarded or the garden score (decision 13) is inert. Flagged to A in THREADS.
|
||||
const hailIntensity = (t) => (wind && typeof wind.hailAt === 'function' ? wind.hailAt(t) : 0);
|
||||
// what the sail is made of, refreshed from step()'s {sail} — 0 (membrane) until told otherwise
|
||||
let sailPorosity = 0;
|
||||
const hailStone = () => (wind && wind.hailSize != null ? wind.hailSize : 1);
|
||||
|
||||
const audio = createAudio((wind && wind.seed) || 1);
|
||||
@ -692,7 +694,18 @@ export function createSkyFx(o = {}) {
|
||||
gardenHailExposure(rect, t) {
|
||||
const h = hailIntensity(t);
|
||||
if (h <= 0) return 0;
|
||||
return h * (1 - hailShadow.fractionOver(rect));
|
||||
// The cloth only blocks what it can catch. hailBlockFor is Lane C's ruling
|
||||
// (weather.core): porosity is about AIR and WATER, not ice — a 2 cm stone
|
||||
// cannot pass a 2 mm weave, so porous and membrane stop the big hail
|
||||
// identically, and the ONE real difference is the finest pea hail
|
||||
// rattling through an open knit. Wired here because this is the only
|
||||
// place that knows both the stone size and what the sail is made of.
|
||||
// Measured: storm_02 (1.3) and the ice night (1.4) -> shade cloth blocks
|
||||
// 100%; the southerly's pea hail (0.7) -> it blocks 74%, and THAT is the
|
||||
// night membrane earns its keep. Lane B, SPRINT9 fabric landing.
|
||||
const size = (wind && wind.def && wind.def.hail && wind.def.hail.size) || 1;
|
||||
const block = hailBlockFor(size, sailPorosity);
|
||||
return h * (1 - hailShadow.fractionOver(rect) * block);
|
||||
},
|
||||
|
||||
/** Wire to the first click/keydown — browsers won't start audio otherwise. */
|
||||
@ -718,6 +731,11 @@ export function createSkyFx(o = {}) {
|
||||
const rendering = !!camera;
|
||||
if (rendering) camera.getWorldPosition(camPos);
|
||||
else camPos.set(0, 1.7, 0);
|
||||
// Refresh what the sail is made of — gardenHailExposure needs it, and a
|
||||
// re-rig between phases can change the fabric. Read live rather than
|
||||
// cached at construction, for the same reason the shadow grids are rebuilt
|
||||
// every step: this must never quietly describe a sail that isn't there.
|
||||
if (world.sail && Number.isFinite(world.sail.porosity)) sailPorosity = world.sail.porosity;
|
||||
wind.sample(camPos, t, w);
|
||||
const speed = Math.hypot(w.x, w.z);
|
||||
const intensity = wind.rainAt(t);
|
||||
|
||||
@ -85,7 +85,43 @@ async function buildYard() {
|
||||
// A's p4 supplies the missing north-west corner; their measured winning line is
|
||||
// t2+p3+p4+t2b, four shackles + a spare ($75), hp 58 with 1 lost. That line is
|
||||
// what THE LINE now flies. The old quad stays as the geometry control below.
|
||||
const COVER_QUAD = ['t2', 'p3', 'p4', 't2b'];
|
||||
// SPRINT9 — THE LINE moves to C's quad, the only holdable one in the yard.
|
||||
//
|
||||
// C swept every bed-covering quad: thirteen have a corner over 6.5 kN, i.e.
|
||||
// unholdable at any price. Exactly one is buyable — p1,p2,p3,p4 — and only just.
|
||||
// It needs rated on the two heavy corners (p4, p2), a shackle on p3 and the
|
||||
// cheapest thing that will hold p1. That last corner is the whole $10 gap
|
||||
// between a $90 rig and an $80 budget.
|
||||
//
|
||||
// C IS RIGHT ABOUT p1, AND I WAS WRONG. An earlier revision of this comment
|
||||
// claimed C's table didn't reproduce — that p1 peaked at 1.27 kN with cloth at
|
||||
// dd 0.40, never crossing under a carabiner's 1.2 kN rating, and that the line
|
||||
// therefore won on TIME (a 6% overshoot too brief to trip OVERLOAD_SECS) rather
|
||||
// than on headroom. That was wrong, and the retraction is the useful part:
|
||||
//
|
||||
// re-measured on the dressed yard, this exact flight, one variable — 2026-07-17
|
||||
// shade cloth p1 0.98 kN p2 2.77 p3 1.62 p4 3.65 0/4 lost
|
||||
// membrane p1 1.23 kN p2 3.23 p3 2.55 p4 4.31 p1 LETS GO
|
||||
//
|
||||
// p1 peaks at 0.98 kN against a 1.2 kN rating: 18% of real HEADROOM, and the
|
||||
// carabiner is never once over its rating. C reported 1.08. That is the same
|
||||
// number to within measurement noise, and the same conclusion.
|
||||
//
|
||||
// Where 1.27 came from: a loadout where a corner BROKE. Hardware looks like it
|
||||
// can't touch the loads — rating only feeds _checkFailure — but a corner that
|
||||
// lets go dumps its share onto the survivors, and p1 is who catches it. Measured:
|
||||
// the same quad with hardware the budget can't actually buy (setHardware fails
|
||||
// silently, cheap hardware stays on, p2/p4 tear off) reads p1 at 2.48 kN. Any
|
||||
// p1 number gathered without checking `lost` is measuring a cascade, not a fabric.
|
||||
//
|
||||
// So the fabric is not cosmetic and this is the whole design win: 0.98 vs 1.23
|
||||
// against a 1.20 rating is the difference between the $5 carabiner holding the
|
||||
// wild night and failing it. Choosing membrane doesn't just cost wind load and
|
||||
// pond — it takes your cheapest corner off the post. `fabric_decides_p1` below
|
||||
// asserts exactly that, so nobody has to trust this paragraph.
|
||||
const COVER_QUAD = ['p1', 'p2', 'p3', 'p4'];
|
||||
/** The old line: holds nothing above shackle grade, ends pyrrhic. The sacrifice-play control. */
|
||||
const PYRRHIC_QUAD = ['t2', 'p3', 'p4', 't2b'];
|
||||
const PRE_P4_QUAD = ['p1', 't1b', 't1c', 't2b'];
|
||||
/** A rig that holds fine and shades nothing — the decision-13 control. */
|
||||
const MISS_QUAD = ['h1', 'h2', 'h3', 't1'];
|
||||
@ -111,7 +147,7 @@ function shop(yard, ids, hw, spares = 0, tension = 1.0) {
|
||||
* Fly a bought loadout through a storm and score it exactly as the game does.
|
||||
* @returns {{hp:number, lost:number, cover:number, spent:number, pond:number}}
|
||||
*/
|
||||
async function fly(yard, session, stormName, { repair = false, broom = false } = {}) {
|
||||
async function fly(yard, session, stormName, { repair = false, broom = false, settleSecs = 12 } = {}) {
|
||||
const def = await loadStorm(stormName);
|
||||
const wind = createWind(def);
|
||||
// The settle below runs on the CALM day, because that is what main.js hands the rig outside a
|
||||
@ -175,65 +211,88 @@ async function fly(yard, session, stormName, { repair = false, broom = false } =
|
||||
// anything. The settle is a fidelity fix, not the corner-count cause. B's arithmetic was right:
|
||||
// t2 pulls ~4.5 kN and a shackle is rated 3.2 — the line cannot hold, at any tension, settled or
|
||||
// not. See THREADS.
|
||||
{
|
||||
const settleWind = calmWind || wind;
|
||||
// Integrator fix (Sprint-8 merge): cycling t over the calm storm's FULL
|
||||
// duration replayed all of storm_01's gusts inside 12 s of sim, so the rig
|
||||
// arrived at "entry" mid-gust and D's guard (correctly) refused it. Prep in
|
||||
// the live game is the calm day's opening minutes, not its highlight reel —
|
||||
// hold the settle inside storm_01's pre-gust window (first gust >= 3 s).
|
||||
const period = 3;
|
||||
for (let i = 0, n = Math.round(12 / FIXED_DT); i < n; i++) {
|
||||
rig.step(FIXED_DT, settleWind, (i * FIXED_DT) % period);
|
||||
const settleWind = calmWind || wind;
|
||||
// Integrator fix (Sprint-8 merge): cycling t over the calm storm's FULL
|
||||
// duration replayed all of storm_01's gusts inside 12 s of sim, so the rig
|
||||
// arrived at "entry" mid-gust and D's guard (correctly) refused it. Prep in
|
||||
// the live game is the calm day's opening minutes, not its highlight reel —
|
||||
// hold the settle inside storm_01's pre-gust window (first gust >= 3 s).
|
||||
const PREP_PERIOD = 3;
|
||||
let prepT = 0;
|
||||
/** Prep on the clock the player actually stands in. Returns mean cloth speed, m/s. */
|
||||
const prep = (secs) => {
|
||||
let sum = 0;
|
||||
const n = Math.round(secs / FIXED_DT);
|
||||
for (let i = 0; i < n; i++) {
|
||||
rig.step(FIXED_DT, settleWind, prepT % PREP_PERIOD);
|
||||
prepT += FIXED_DT;
|
||||
sum += rig.nodeSpeed();
|
||||
}
|
||||
}
|
||||
/** Peak corner load the instant the storm starts — the settled-at-entry guard reads this. */
|
||||
const entryPeak = Math.max(...rig.corners.map((c) => c.load || 0));
|
||||
|
||||
// D's settled-at-entry guard (SPRINT8 gate 0').
|
||||
return sum / n;
|
||||
};
|
||||
if (settleSecs > 0) prep(settleSecs); // settleSecs 0 = the unsettled control, below
|
||||
// D's settled-at-entry guard (SPRINT8 gate 0') — REDESIGNED, SPRINT9, B with
|
||||
// D's demotion measurements. D: this is the change you're owed a veto on.
|
||||
//
|
||||
// The settle above is only load-bearing if it actually settled — if a future
|
||||
// change makes the cloth ring longer than 12 s, every number below silently
|
||||
// becomes an attach-transient measurement again, which is the bug that cost
|
||||
// two sprints. So prove it.
|
||||
//
|
||||
// It has to be a TREND test, not an instant one. Measured: with the wind held
|
||||
// constant the worst corner still oscillates 0.9 -> 1.9 -> 1.3 -> 1.7 kN,
|
||||
// because damping is deliberately light (VEL_DAMP 0.995 — the relative-wind
|
||||
// drag is meant to do the damping). There is no single settled value; the
|
||||
// cloth breathes. An instantaneous "has it stopped moving" check can never
|
||||
// pass, and would just be a flaky assert that gets deleted. Same lesson as the
|
||||
// statics assert in sail.selftest: compare time-AVERAGED windows.
|
||||
const meanLoad = (secs) => {
|
||||
let sum = 0, n = Math.round(secs / FIXED_DT);
|
||||
for (let i = 0; i < n; i++) { rig.step(FIXED_DT, wind, 0); sum += rig.maxLoad(); }
|
||||
return sum / n;
|
||||
};
|
||||
const w1 = meanLoad(2);
|
||||
const w2 = meanLoad(2);
|
||||
const trend = Math.abs(w2 - w1) / Math.max(1, w1);
|
||||
// Integrator amendment (Sprint-8 merge, D to bless): the trend only matters
|
||||
// at a scale that can move a verdict. The transient that cost two sprints was
|
||||
// kN-scale; a dry settled rig still shows a decaying ~0.2 kN tail that reads
|
||||
// as 40%+ RELATIVE while being noise against a 1.2 kN carabiner rating.
|
||||
// ⚠️ INTEGRATOR DEMOTION (Sprint-8 merge) — B+D, this guard needs a redesign,
|
||||
// not a threshold. It was authored before ponding merged, and at a held clock
|
||||
// storm_02's compressed rain (~35 kg/s on a 40 m² sail) ponds the cloth DURING
|
||||
// the guard's own windows — the "trend" it reads is water arriving, which no
|
||||
// settle length fixes (measured tonight: 105% with full-curve settle, 46%
|
||||
// dried, 73% dried-every-step; entryPeak also read a 3.04 kN water belly).
|
||||
// The guard's idea is right; its clock is wrong. Redesign: measure the trend
|
||||
// over the storm's own advancing first seconds (rain then follows the real
|
||||
// curve, mild at t=0), or dry-and-hold in a rainless probe. Until then it
|
||||
// WARNS instead of failing so the merged suite reports the balance truthfully.
|
||||
if (trend > 0.35 && Math.max(w1, w2) > 600) {
|
||||
console.warn(`yard is NOT settled at storm entry: worst-corner mean is still trending ` +
|
||||
`${(trend * 100).toFixed(0)}% between consecutive 2 s windows ` +
|
||||
`(${(w1 / 1000).toFixed(2)} -> ${(w2 / 1000).toFixed(2)} kN) after a ${12} s settle. ` +
|
||||
`Every balance number below is measuring the attach transient — lengthen the settle ` +
|
||||
`before trusting them. (Oscillation is expected and fine; a TREND is not.)`);
|
||||
// THE OBSERVABLE WAS THE BUG, not the clock. The demoted guard compared the
|
||||
// worst corner's mean LOAD across two windows and called a change a "trend".
|
||||
// Three redesigns were measured on the dressed yard before one worked, and the
|
||||
// two the integrator proposed are among the ones that don't — so, in full, in
|
||||
// case anyone is tempted back:
|
||||
//
|
||||
// probe under CALM, held in prep 0 s settle 13% 12 s settle 17%
|
||||
// probe under STORM, held clock 0 s settle 24% 12 s settle 104%
|
||||
// probe under STORM, advancing clock 0 s settle 24% 12 s settle 104%
|
||||
// probe under STORM, RAINLESS 0 s settle 17% 12 s settle 96%
|
||||
//
|
||||
// Every one of them is either flat (calm cannot excite the cloth, so the guard
|
||||
// is vacuous and passes forever — an assert that cannot fail) or INVERTED: it
|
||||
// fires hardest on the properly settled rig. Rain is not the culprit either;
|
||||
// the rainless probe ponds 1 kg and still inverts. What a load-trend actually
|
||||
// measures is the rig LOADING UP when the wind changes, and a taut settled
|
||||
// cloth ramps HARDER than a limp unsettled one (0.63 -> 1.23 kN vs
|
||||
// 0.44 -> 0.52). The metric was reading the storm's arrival, not the cloth.
|
||||
// No threshold, clock or rain switch fixes an observable pointed at the wrong
|
||||
// thing. (The integrator's ponding diagnosis was right about the mechanism and
|
||||
// is also unfixable by clock: RAIN_TIME_COMPRESSION is 40×, so even 4 s of
|
||||
// ADVANCING storm is 160 s of rain — 43 kg in the belly either way.)
|
||||
//
|
||||
// "Settled" is a statement about the CLOTH, so ask the cloth. rig.nodeSpeed()
|
||||
// is RMS node speed straight out of verlet, sampled over a 2 s window of the
|
||||
// same calm prep the rig is already standing in — no wind change to ramp
|
||||
// against, no compressed rain, and nothing left behind in the rig but two more
|
||||
// seconds of the prep a player does anyway. Measured, dressed yard:
|
||||
//
|
||||
// settle 0 s 4 s 12 s 30 s
|
||||
// speed 0.19 0.014 0.017 0.007 m/s (pyrrhic quad: 0.23 -> 0.028)
|
||||
//
|
||||
// An order of magnitude, in the direction the word means. It is an ABSOLUTE
|
||||
// check, not a trend: the old comment's "the cloth breathes, an instantaneous
|
||||
// check can never pass" is true and is why this is a windowed MEAN — but the
|
||||
// breathing sits at 0.02 m/s and the transient at 0.19, so there is a real gap
|
||||
// to put a line in. 0.08 is ~3× above the worst settled rig here and ~2.4×
|
||||
// below the loosest unsettled one.
|
||||
//
|
||||
// This is a FAILURE again, not a warning. It earns that by being able to fail:
|
||||
// asserted below on a deliberately unsettled rig. — B, SPRINT9. D: your veto.
|
||||
const SETTLED_SPEED = 0.08;
|
||||
const entrySpeed = prep(2);
|
||||
if (entrySpeed > SETTLED_SPEED) {
|
||||
throw new Error(`yard is NOT settled at storm entry: the cloth is still moving at ` +
|
||||
`${entrySpeed.toFixed(3)} m/s (settled is <${SETTLED_SPEED}) after a ${settleSecs} s settle. ` +
|
||||
`Every balance number in this suite is measuring the attach transient — lengthen the settle ` +
|
||||
`before trusting them. (Breathing is expected and fine; ~0.19 m/s is a cloth still falling ` +
|
||||
`into shape.)`);
|
||||
}
|
||||
|
||||
/** Peak corner load the instant the storm starts — settled, dry, on the calm day. */
|
||||
const entryPeak = Math.max(...rig.corners.map((c) => c.load || 0));
|
||||
|
||||
let hp = 100, pond = 0, used = 0;
|
||||
const steps = Math.round(def.duration / FIXED_DT);
|
||||
for (let i = 0; i < steps; i++) {
|
||||
@ -286,8 +345,26 @@ export default async function run(t) {
|
||||
// makes the repair legal, and it's the trap in this whole balance question: a
|
||||
// loadout that spends all $80 on hardware cannot repair anything.
|
||||
// A's measured line: four shackles + a spare = $75 (THREADS gate-1 entry).
|
||||
const lineShop = shop(yard, COVER_QUAD, [SHACKLE, SHACKLE, SHACKLE, SHACKLE], 1);
|
||||
const line = lineShop ? await fly(yard, lineShop, 'storm_02_wildnight', { repair: true, broom: true }) : null;
|
||||
// C's $80 clean win: shade cloth, rated on the two heavy corners, shackle on
|
||||
// p3, carabiner on p1. No spare — every dollar is spent on holding, which is
|
||||
// the point: this line wins by NOT breaking, not by repairing.
|
||||
const lineShop = shop(yard, COVER_QUAD, [CARABINER, RATED, SHACKLE, RATED], 0);
|
||||
if (lineShop) lineShop.setFabric('cloth');
|
||||
const line = lineShop ? await fly(yard, lineShop, 'storm_02_wildnight', { broom: true }) : null;
|
||||
|
||||
// The sacrifice play: the old t2 quad, four shackles + a spare. Holding all
|
||||
// four of ITS corners costs $105; $80 buys two. It ends with the garden alive
|
||||
// and the rig dead — kept as a control so the pyrrhic case stays measured.
|
||||
const pyrrhicShop = shop(yard, PYRRHIC_QUAD, [SHACKLE, SHACKLE, SHACKLE, SHACKLE], 1);
|
||||
if (pyrrhicShop) pyrrhicShop.setFabric('cloth');
|
||||
const pyrrhic = pyrrhicShop ? await fly(yard, pyrrhicShop, 'storm_02_wildnight', { repair: true, broom: true }) : null;
|
||||
|
||||
// The SAME $80 line, one variable changed: waterproof membrane instead of shade
|
||||
// cloth. This is the fabric decision's control — see the header. Both fabrics
|
||||
// cost $0, so the shop cannot tell them apart; only the storm can.
|
||||
const membraneShop = shop(yard, COVER_QUAD, [CARABINER, RATED, SHACKLE, RATED], 0);
|
||||
if (membraneShop) membraneShop.setFabric('membrane');
|
||||
const membrane = membraneShop ? await fly(yard, membraneShop, 'storm_02_wildnight', { broom: true }) : null;
|
||||
|
||||
const cheapShop = shop(yard, COVER_QUAD, [CARABINER, CARABINER, CARABINER, CARABINER], 0);
|
||||
const cheap = cheapShop ? await fly(yard, cheapShop, 'storm_02_wildnight') : null;
|
||||
@ -298,6 +375,24 @@ export default async function run(t) {
|
||||
const gentleShop = shop(yard, COVER_QUAD, [CARABINER, CARABINER, CARABINER, CARABINER], 0);
|
||||
const gentle = gentleShop ? await fly(yard, gentleShop, 'storm_01_gentle') : null;
|
||||
|
||||
// THE GUARD'S OWN CONTROL. A guard nobody has ever seen fail is indistinguishable
|
||||
// from a guard that cannot fail — that is how the previous version survived being
|
||||
// both vacuous and inverted for two sprints. So fly the same line with NO settle:
|
||||
// the cloth is still falling into shape, and the guard must refuse it. Flown here,
|
||||
// up front, because testkit's Suite.test() does NOT await — an async assert would
|
||||
// pass forever while proving nothing (the standing house rule; see SPRINT6).
|
||||
let unsettled;
|
||||
{
|
||||
const s = shop(yard, COVER_QUAD, [CARABINER, RATED, SHACKLE, RATED], 0);
|
||||
if (s) s.setFabric('cloth');
|
||||
try {
|
||||
await fly(yard, s, 'storm_02_wildnight', { settleSecs: 0 });
|
||||
unsettled = { fired: false };
|
||||
} catch (e) {
|
||||
unsettled = { fired: /NOT settled at storm entry/.test(e.message), err: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
// --- then judge -----------------------------------------------------------
|
||||
|
||||
// SPRINT8 gate 0' — CLOSED. The harnesses agree; the disagreement was a miscount.
|
||||
@ -332,18 +427,75 @@ export default async function run(t) {
|
||||
// that the win rule wants to be `hp >= 50` with corners priced in the aftermath
|
||||
// rather than gating the win — but it is A's call and this assert states the
|
||||
// measurement, not the verdict.
|
||||
t.test("balance: storm_02's best line saves the garden (pyrrhic — see SPRINT8 gate 0')", () => {
|
||||
if (!line) throw new Error('the $80 shop cannot buy the candidate line at all');
|
||||
// The garden half — the part the player is actually protecting — must hold.
|
||||
if (line.hp < 50) {
|
||||
throw new Error(`the wild night's best $${line.spent} line let the garden die: hp=${line.hp} ` +
|
||||
`(need >=50). This is a real balance regression, not the pyrrhic-win question.`);
|
||||
// SPRINT9 — the wild night finally has a CLEAN win, and this asserts it.
|
||||
//
|
||||
// $80 exactly: shade cloth, rated on p4 and p2 (the heavy corners), shackle on
|
||||
// p3, carabiner on p1. No spare. It wins by holding, not by repairing.
|
||||
//
|
||||
// This is the assert that fabric and the 0.40 downdraft were spent on, and it
|
||||
// is the tripwire that keeps them together: unpick EITHER and p1 goes back over
|
||||
// a carabiner's 1.2 kN rating, the line costs $90 on an $80 budget, and this
|
||||
// goes red. That is the intended behaviour, not a brittle test.
|
||||
t.test('balance: storm_02 has a CLEAN $80 win — the wild night is fair', () => {
|
||||
if (!line) throw new Error('the $80 shop cannot buy C\'s line — fabric or the 0.40 flip has been unpicked');
|
||||
if (!WIN(line.hp, line.lost)) {
|
||||
throw new Error(`the wild night's winnable line lost it: $${line.spent} on ${COVER_QUAD.join(',')} ` +
|
||||
`ended hp=${line.hp}, lost=${line.lost}/4 (need hp>=50, lost<2). Check that storm_02 still reads ` +
|
||||
`downdraftOfTotal 0.40 AND that the rig is on shade cloth — the line only exists with both.`);
|
||||
}
|
||||
const pyrrhic = line.lost >= 2;
|
||||
return `$${line.spent} on ${COVER_QUAD.join(',')} -> garden hp ${line.hp}` +
|
||||
(pyrrhic
|
||||
? `, but ${line.lost}/4 corners gone — PYRRHIC. Holding all four costs $105 (+$15 spare) on an $80 budget; A owns the win rule.`
|
||||
: `, ${line.lost}/4 lost — a clean win.`);
|
||||
return `$${line.spent} · shade cloth · ${COVER_QUAD.join(',')} -> hp ${line.hp}, ${line.lost}/4 lost — CLEAN WIN`;
|
||||
});
|
||||
|
||||
// The guard is load-bearing for every number in this suite, so it does not get
|
||||
// to be decoration. If this ever goes red, the guard has stopped being able to
|
||||
// fail and its silence elsewhere means nothing.
|
||||
t.test('balance: the settled-at-entry guard can fail — a 0 s settle trips it', () => {
|
||||
if (!unsettled) throw new Error('the unsettled control never flew');
|
||||
if (unsettled.fired === false) {
|
||||
throw new Error(`the settled-at-entry guard PASSED a rig with no settle at all. It is now ` +
|
||||
`vacuous — every "settled" claim in this suite rests on an assert that cannot fail. ` +
|
||||
`Check rig.nodeSpeed() still reads verlet, and that SETTLED_SPEED is not above the ` +
|
||||
`~0.19 m/s an unsettled cloth actually moves at.`);
|
||||
}
|
||||
if (!unsettled.fired) throw new Error(`the unsettled control died for the wrong reason: ${unsettled.err}`);
|
||||
return 'a 0 s settle trips the guard — its silence on the real flights means something';
|
||||
});
|
||||
|
||||
// FABRIC IS A DECISION, NOT A SKIN. Both fabrics are $0 — DESIGN.md wants the
|
||||
// forecast to be the price — so the only thing that can justify the choice is
|
||||
// that it changes the night. Measured, same $80 line, one variable:
|
||||
//
|
||||
// shade cloth p1 0.98 kN -> holds (18% under a 1.2 kN carabiner)
|
||||
// membrane p1 1.23 kN -> LETS GO (2.5% over, long enough to trip)
|
||||
//
|
||||
// Membrane loses the cheapest corner on the wild night; that is what you buy
|
||||
// with the +26% hail block it gives back. If this ever goes green-on-both, the
|
||||
// fabric pick has become free and the prep screen is lying about a choice.
|
||||
t.test('balance: fabric decides p1 — membrane tears the cheap corner off', () => {
|
||||
if (!line || !membrane) throw new Error('could not buy the fabric control on $80');
|
||||
if (membrane.lost <= line.lost) {
|
||||
throw new Error(`fabric stopped mattering: the same $80 line lost ${line.lost}/4 on cloth and ` +
|
||||
`${membrane.lost}/4 on membrane. Both fabrics are free, so if the storm can't tell them apart ` +
|
||||
`the choice is cosmetic — either porosity stopped reaching the wind load (sail.js setFabric / ` +
|
||||
`rigging.commit ordering) or storm_02 got soft enough that p1 survives full load.`);
|
||||
}
|
||||
return `same $80 line: cloth ${line.lost}/4 lost (hp ${line.hp}) · membrane ${membrane.lost}/4 lost ` +
|
||||
`(hp ${membrane.hp}) — the fabric is the decision`;
|
||||
});
|
||||
|
||||
// The sacrifice play, kept measured. DESIGN.md: "a sail that dies saving the
|
||||
// garden". Now that a clean win EXISTS, this stops being the wild night's only
|
||||
// outcome and becomes what it should always have been — a different, worse bet
|
||||
// the shop will happily sell you. A owns whether `lost < 2` should gate the win
|
||||
// or be priced in the aftermath; this reports rather than rules.
|
||||
t.test('balance: the t2 quad still ends pyrrhic (the sacrifice play)', () => {
|
||||
if (!pyrrhic) throw new Error('could not buy the pyrrhic control');
|
||||
if (pyrrhic.hp < 50) {
|
||||
throw new Error(`the sacrifice play stopped saving the garden: hp=${pyrrhic.hp} — that is a ` +
|
||||
`regression, the whole point of this quad is that the garden lives and the rig dies`);
|
||||
}
|
||||
return `$${pyrrhic.spent} on ${PYRRHIC_QUAD.join(',')} -> garden hp ${pyrrhic.hp}, ${pyrrhic.lost}/4 lost` +
|
||||
(pyrrhic.lost >= 2 ? ' — pyrrhic, as designed (holding all four costs $105)' : ' — clean');
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@ -79,7 +79,14 @@ summary.textContent = report.ok
|
||||
: `FAIL — ${report.fail} failed, ${report.pass} passed, ${report.skip} skipped`;
|
||||
|
||||
const out = document.getElementById('out');
|
||||
for (const letter of ['A', 'B', 'C', 'D', 'E']) {
|
||||
// Render every lane the report actually CONTAINS, rather than a hardcoded list.
|
||||
// This read ['A','B','C','D','E'] while the import list above also carries BAL,
|
||||
// so the balance suite ran, was counted in the summary, and had every one of its
|
||||
// rows silently dropped — including, had one ever gone red, the row saying which
|
||||
// assert failed and why. The merge gate was invisible in the merge gate's own
|
||||
// report. My bug: I added the BAL import and not this. Deriving the list means
|
||||
// the next joint suite can't hit it. — B
|
||||
for (const letter of [...new Set(report.results.map((r) => r.lane))]) {
|
||||
const rows = report.results.filter((r) => r.lane === letter);
|
||||
if (!rows.length) continue;
|
||||
const h = document.createElement('div');
|
||||
|
||||
Loading…
Reference in New Issue
Block a user