From e576f5cb6724b69b1d7d7c6c350ec92ae696d31f Mon Sep 17 00:00:00 2001 From: m3ultra Date: Fri, 17 Jul 2026 14:25:59 +1000 Subject: [PATCH 1/4] Fabric + 0.40 as one landing: the wild night has a clean $80 win MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Selftest 275/0/0. balance.test: **$80 · shade cloth · p1,p2,p3,p4 -> hp 63, 0/4 lost — CLEAN WIN.** First time the wild night has been winnable without sacrificing the rig. C found the line and the $10 gap; this spends both levers together, as they only work together. Fabric is priced at $0 BOTH WAYS, and that is a finding, not laziness. SPRINT6 asked me to price the difference; I measured it and there is no price at which membrane is a sane buy — it is dominated: buys: +26% hail, 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 knit already blocks 100%, because a 2 cm stone can't pass a 2 mm weave (C's ruling). Plus rain, at 0.25 of the drain vs hail's 5.0 — ~5% of the damage. costs: double the wind load, and it PONDS. Charge for that and you've shipped a trap. Free, it's DESIGN.md's actual words — "fabric choice is a forecast bet" — and the bet is real: cloth on the windy nights, membrane when the hail is small and the wind is mild. If A ever raises rain's weight, membrane earns a price. Wired C's hailBlockFor into skyfx.gardenHailExposure, which nothing consumed — without it the fabric choice would have been cosmetic, which is the bug class this repo keeps finding. I did NOT reproduce C's numbers, and the comment says so rather than repeating them. C reported p1 dropping to 1.08 kN (under a carabiner's 1.2) with cloth + 0.40. I measure 1.27, and p1 never crosses under 1.2 at any combo. The line still wins on a $5 carabiner — but because 1.27 vs 1.2 is a 6% overshoot and breaking needs 0.4 s SUSTAINED, not because the corner got under its rating. The margin is TIME, not headroom, and it's thinner than the table implies: anything that lengthens storm_02's gust HOLDS could take it. Flagged for C in THREADS. The old t2 quad stays as the sacrifice-play control (hp 56, 2/4 lost) — now a worse bet the shop will happily sell you, rather than the wild night's only outcome. Co-Authored-By: Claude Opus 4.8 --- web/world/data/storms/storm_02_wildnight.json | 2 +- web/world/js/rigging.js | 52 +++++++++++ web/world/js/sail.js | 13 +++ web/world/js/skyfx.js | 22 ++++- web/world/js/tests/balance.test.js | 86 ++++++++++++++++--- 5 files changed, 158 insertions(+), 17 deletions(-) diff --git a/web/world/data/storms/storm_02_wildnight.json b/web/world/data/storms/storm_02_wildnight.json index e95237e..3fa1e17 100644 --- a/web/world/data/storms/storm_02_wildnight.json +++ b/web/world/data/storms/storm_02_wildnight.json @@ -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]], diff --git a/web/world/js/rigging.js b/web/world/js/rigging.js index 9215e4e..77fbca2 100644 --- a/web/world/js/rigging.js +++ b/web/world/js/rigging.js @@ -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 diff --git a/web/world/js/sail.js b/web/world/js/sail.js index 5edec71..c76f640 100644 --- a/web/world/js/sail.js +++ b/web/world/js/sail.js @@ -905,6 +905,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); diff --git a/web/world/js/skyfx.js b/web/world/js/skyfx.js index e9ecd7c..10e74c9 100644 --- a/web/world/js/skyfx.js +++ b/web/world/js/skyfx.js @@ -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); diff --git a/web/world/js/tests/balance.test.js b/web/world/js/tests/balance.test.js index cf725f8..5e17af9 100644 --- a/web/world/js/tests/balance.test.js +++ b/web/world/js/tests/balance.test.js @@ -85,7 +85,34 @@ 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. +// +// MEASURED HERE, and it does not match C's table — flagged in THREADS. C +// reported p1 dropping to 1.08 kN with shade cloth + downdraft 0.40, i.e. under +// a carabiner's 1.2 kN rating. This harness gets p1 peaking at: +// +// dd 0.45 membrane 1.45 kN dd 0.45 cloth 1.42 kN +// dd 0.40 membrane 1.41 kN dd 0.40 cloth 1.27 kN +// +// so p1 never crosses UNDER 1.2 here. The line still wins on a $5 carabiner — +// verified, hp 63 with 0/4 lost — but for a different reason than C's: a 1.27 kN +// peak against a 1.2 kN rating is a 6% overshoot, and breaking needs 0.4 s +// SUSTAINED over rating (sail.js OVERLOAD_SECS). The carabiner is overloaded in +// flashes and never long enough to let go. +// +// That distinction matters for whoever tunes next: the line's margin is TIME, +// not headroom. It is thinner than C's numbers imply, and anything that widens +// storm_02's gust holds — not just raises them — could take it. The assert below +// is what will catch that. +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']; @@ -286,8 +313,19 @@ 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; const cheapShop = shop(yard, COVER_QUAD, [CARABINER, CARABINER, CARABINER, CARABINER], 0); const cheap = cheapShop ? await fly(yard, cheapShop, 'storm_02_wildnight') : null; @@ -332,18 +370,38 @@ 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 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'); }); /** From e4436248b054104084a0183fc7f662412ddd32b7 Mon Sep 17 00:00:00 2001 From: m3ultra Date: Fri, 17 Jul 2026 14:52:31 +1000 Subject: [PATCH 2/4] Add tools/site_audit; retract the p1 claim against C; show LANE BAL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit site_audit answers "is this site winnable?" before it ships — the check that was missing when SPRINT6 shipped a yard whose bed-covering quad pulled 7.4 kN against a shop that holds 6.5. It enumerates in-band quads, flies the real storm headless, maps each corner to the cheapest tier that holds it, and verdicts against the $80 budget. On backyard_01 it independently reproduces THE LINE: p1,p2,p3,p4 at $65 + $15 spare, matching balance.test to within 2%. It also caught three of my own bugs, which is the point of building it: · it settled on STORM wind frozen at t=0 — the exact anti-pattern balance.test documents at 1.94 kN vs 0.40 kN. Now settles on the calm day on a running clock, the way main.js does. · peakLoad is peak-since-ATTACH, so the settle transient was being reported as a storm peak. sail.js gains resetPeaks(). · its p4 was typed from world.js's postSpecs, missing that posts are RAKED 8° away from centre — 0.56 m off. The audit now re-derives all four posts from live world.js on every run and hard-fails on drift. That last check is deliberately narrow. Node CANNOT dress: dress() needs GLTFLoader (bare 'three') and fetch()es .glb over file://. createWorld() still SUCCEEDS in node — it just returns the GRAYBOX yard, house at x=±5, no branch anchors. So a headless tool that "reads world.js for the real yard" gets the fictional x=±5 house that cost two sprints. Reading live code is not reading truth. Verified in-browser: the dump matches the dressed yard on all 12 anchors. Posts are the only thing dress() never touches, so they're the only thing worth cross-checking — and that check is what caught p4. C IS RIGHT ABOUT p1 AND I WAS WRONG, for the third time. My comment claimed C's 1.08 kN didn't reproduce, that p1 peaked at 1.27 and the line won on TIME rather than headroom. Re-measured on the dressed yard, same flight: shade cloth p1 0.98 kN 0/4 lost membrane p1 1.23 kN p1 LETS GO p1 never touches its 1.2 kN rating on cloth — 18% of real headroom. Where 1.27 came from: a loadout where a corner BROKE and dumped its share onto p1 (the same quad with hardware the budget can't buy reads 2.48). Any p1 number gathered without checking `lost` measures a cascade, not a fabric. So the fabric is the decision, not a skin: 0.98 vs 1.23 against a 1.20 rating is the difference between the $5 carabiner holding the wild night and failing it. New assert `fabric decides p1` locks that in, so nobody has to trust the paragraph. selftest.html rendered ['A'..'E'] while importing BAL too, 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. The merge gate was invisible in its own report. My bug: I added the import, not the renderer. Now derived from the report. Selftest: 276 passed, 0 failed, 0 skipped (LANE BAL now visible). Co-Authored-By: Claude Opus 4.8 --- tools/site_audit/audit.mjs | 252 +++++++++++++++++++++++++++++ web/world/js/sail.js | 13 ++ web/world/js/tests/balance.test.js | 66 ++++++-- web/world/selftest.html | 9 +- 4 files changed, 325 insertions(+), 15 deletions(-) create mode 100644 tools/site_audit/audit.mjs diff --git a/tools/site_audit/audit.mjs b/tools/site_audit/audit.mjs new file mode 100644 index 0000000..0b17cd5 --- /dev/null +++ b/tools/site_audit/audit.mjs @@ -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); }); diff --git a/web/world/js/sail.js b/web/world/js/sail.js index c76f640..b31b0f2 100644 --- a/web/world/js/sail.js +++ b/web/world/js/sail.js @@ -823,6 +823,19 @@ 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; + } + /** Ported from the prototype: 0.4 s sustained over the rating and it lets go. */ _checkFailure(dt) { for (let k = 0; k < 4; k++) { diff --git a/web/world/js/tests/balance.test.js b/web/world/js/tests/balance.test.js index 5e17af9..0089484 100644 --- a/web/world/js/tests/balance.test.js +++ b/web/world/js/tests/balance.test.js @@ -93,23 +93,32 @@ async function buildYard() { // cheapest thing that will hold p1. That last corner is the whole $10 gap // between a $90 rig and an $80 budget. // -// MEASURED HERE, and it does not match C's table — flagged in THREADS. C -// reported p1 dropping to 1.08 kN with shade cloth + downdraft 0.40, i.e. under -// a carabiner's 1.2 kN rating. This harness gets p1 peaking at: +// 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: // -// dd 0.45 membrane 1.45 kN dd 0.45 cloth 1.42 kN -// dd 0.40 membrane 1.41 kN dd 0.40 cloth 1.27 kN +// 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 // -// so p1 never crosses UNDER 1.2 here. The line still wins on a $5 carabiner — -// verified, hp 63 with 0/4 lost — but for a different reason than C's: a 1.27 kN -// peak against a 1.2 kN rating is a 6% overshoot, and breaking needs 0.4 s -// SUSTAINED over rating (sail.js OVERLOAD_SECS). The carabiner is overloaded in -// flashes and never long enough to let 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. // -// That distinction matters for whoever tunes next: the line's margin is TIME, -// not headroom. It is thinner than C's numbers imply, and anything that widens -// storm_02's gust holds — not just raises them — could take it. The assert below -// is what will catch that. +// 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']; @@ -327,6 +336,13 @@ export default async function run(t) { 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; @@ -389,6 +405,28 @@ export default async function run(t) { return `$${line.spent} · shade cloth · ${COVER_QUAD.join(',')} -> hp ${line.hp}, ${line.lost}/4 lost — CLEAN WIN`; }); + // 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 diff --git a/web/world/selftest.html b/web/world/selftest.html index 7a073d6..fa2a2df 100644 --- a/web/world/selftest.html +++ b/web/world/selftest.html @@ -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'); From 6fa7644abac80b799899fef71b92445fb54957bc Mon Sep 17 00:00:00 2001 From: m3ultra Date: Fri, 17 Jul 2026 15:01:09 +1000 Subject: [PATCH 3/4] Redesign the settled-at-entry guard: ask the cloth, not the corners MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SPRINT9 task for B+D. The demoted guard compared the worst corner's mean LOAD across two windows and called the difference a "trend". The integrator's diagnosis was that its clock was wrong — held at t=0, storm_02's compressed rain ponds the cloth during the guard's own windows. That mechanism is real, but it is not the bug, and neither of the two suggested fixes works. Measured on the 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 cannot excite the cloth, so the guard is vacuous and passes forever — or INVERTED, firing hardest on the properly settled rig. Rain is not the culprit: the rainless probe ponds 1 kg and still inverts. Advancing the clock isn't a fix either, because RAIN_TIME_COMPRESSION is 40× and 4 s of storm is 160 s of rain regardless. What a load-trend 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 observable was pointed at the storm's arrival, not at the cloth. No threshold, clock or rain switch fixes that. "Settled" is a statement about the cloth, so ask the cloth. sail.js gains nodeSpeed(): RMS node speed out of verlet. Sampled over 2 s of the same calm prep the rig already stands in — no wind change to ramp against, no compressed rain, and nothing left in the rig but two more seconds of prep a player does anyway. Measured: 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, not a trend: the cloth breathes at 0.02 and the transient sits at 0.19, so there is real room for a line. Promoted back from warn to a hard failure — and it earns that by being able to fail. `the settled-at-entry guard can fail` flies the same line with NO settle and asserts the guard refuses it. A guard nobody has seen fail is indistinguishable from one that cannot, which is exactly how this one survived two sprints being both vacuous and inverted. The control is flown up front in run(), because Suite.test() does not await. The old guard also left 43 kg of water and 4 s of storm load in the rig before the storm started; the suite was flying a wet rig into a dry entry. It didn't move the peaks (the storm's own peak dwarfs it) but it was never true. D: this is yours to veto. Selftest 277 passed, 0 failed, 0 skipped. Co-Authored-By: Claude Opus 4.8 --- web/world/js/sail.js | 27 +++++ web/world/js/tests/balance.test.js | 158 +++++++++++++++++++---------- 2 files changed, 134 insertions(+), 51 deletions(-) diff --git a/web/world/js/sail.js b/web/world/js/sail.js index b31b0f2..f6a745d 100644 --- a/web/world/js/sail.js +++ b/web/world/js/sail.js @@ -836,6 +836,33 @@ export class SailRig { 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++) { diff --git a/web/world/js/tests/balance.test.js b/web/world/js/tests/balance.test.js index 0089484..e26e233 100644 --- a/web/world/js/tests/balance.test.js +++ b/web/world/js/tests/balance.test.js @@ -147,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 @@ -211,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++) { @@ -352,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. @@ -405,6 +446,21 @@ export default async function run(t) { 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: From 3e769dbae93d02372d78ff24a0fe7af64acda004 Mon Sep 17 00:00:00 2001 From: m3ultra Date: Fri, 17 Jul 2026 15:01:49 +1000 Subject: [PATCH 4/4] =?UTF-8?q?THREADS:=20SPRINT9=20Lane=20B=20=E2=80=94?= =?UTF-8?q?=20retract=20the=20p1=20claim=20to=20C,=20guard=20redesign=20fo?= =?UTF-8?q?r=20D,=20the=20graybox-yard=20trap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- THREADS.md | 92 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/THREADS.md b/THREADS.md index 6a59f03..86787eb 100644 --- a/THREADS.md +++ b/THREADS.md @@ -2650,3 +2650,95 @@ Format: `[lane letter] YYYY-MM-DD — note` on the record, A's "1 lost" was a miscount, and C found the REAL winnable line (p1,p2,p3,p4 — $80 exactly, hp 52, 1 lost) that needs B's porous fabric + C's 0.40 downdraft STACKED. The pyrrhic-win rule and the fabric+downdraft pairing are SPRINT9's first moves. + +--- + +## 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.