From 4718548c380e4259c92ca2c4aade49ec6b8b1c80 Mon Sep 17 00:00:00 2001 From: type-two Date: Sat, 25 Jul 2026 19:52:31 +1000 Subject: [PATCH 1/2] =?UTF-8?q?Lane=20B=20S18=20gates=202+3+1:=20=E2=9A=A0?= =?UTF-8?q?=EF=B8=8F=20THE=20VARIABLE=20IS=20POROSITY;=20the=20clean=20bou?= =?UTF-8?q?ndary=20is=20DWELL;=20the=20knife?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One landing, three gates and Lane C's clock finding. (Not split: sail.js, rigging.js and balance.test.js each carry work for more than one gate and a per-hunk split would have been fiction.) GATE 2 — THE VARIABLE IS `porosity`, AND THE LIAR WAS THE GAME. `rig.setFabric()` has exactly one call site, `RiggingSession.commit(rig)`, and the shipped commit path never reaches it: the rigging UI's commit() calls onCommit and main.js's rigSail goes straight to rig.attach(). So session.commit(rig) runs in TESTS AND BENCHES ONLY, and the sim's rig (built once at boot as `new SailRig({anchors})`, porosity defaulting to 0) has flown the waterproof membrane every night of every playtest while the panel, the SCORE IT card, the HUD row, the invoice and every audit number said shade cloth. The F key moved the paperwork and never the physics. shade cloth 0.30 (what every card promised) 0/4 lost p2 3.07 p3 1.78 membrane 0 (what the sim actually flew) 2/4 lost p2 3.48 p3 3.90 Row two is D's cold play AND the pin's own _playedReference (hp 55.7, p2 then p3) to the decimal, reproduced in the shipped game. It also retires C's "benches under-read the real UI by 5-15%, cause unfound": with the fabric held equal, the bench and the FULL game chain agree to three decimals, two sites, two storms. Debris hits on the sail: 0 — D's candidate ruled out by measurement. Fixed on my side: commit() carries the fabric as onCommit's 4th argument; dev_rigging.html picks it up, which is the shape of the one-line pickup filed for A's rigSail. GATE 3 — THE CLEAN BOUNDARY IS THE FUSE, NOT A PERCENTAGE. Same flight, two corners, identical $15 shackles, same 3.2 kN rating: p1 3.528 kN = 10.3% OVER -> HELD (over for 0.150 s) p2 3.493 kN = 9.2% OVER -> BROKE (over for 0.400 s) p1 peaks HIGHER and survives, so "peak within X% of rating" is wrong at every X. sail.js never used peak — it burns a 0.4 s overload fuse. OVERLOAD_SECS / OVERLOAD_RECOVER are exported and priceCandidate replays them per corner per tier, so the audit and the sim now answer "will it hold" with one arithmetic. AUDIT.MARGIN stops deciding anything and says why in its own comment. GATE 1 — THE RIG YOU DIDN'T BUILD, AND THE KNIFE. `_releaseCorner` is now the one definition of a corner letting go (it was open-coded twice); on top of it: `failCorner` for a corner that arrived down, `cutAway` for DESIGN.md line 165 emitting A's requested `{t, corners, reason}`, and `session.inherit()` which adopts somebody else's rig for $0 and keeps it out of the refund — because week.js pays back half of every intact corner, so counting a stranger's steel as yours would have the ledger PUNISHING the player for every corner they cut. Neither a cut nor an inherited corner emits `break`: warranty must not chase a corner you chose to let go or one you never hung. LANE C's CLOCK FINDING, fixed. `_substep` samples the wind at `this.t`, which attach() zeroes — safe only while the caller's clock starts with it, exactly as attach()'s own comment says. An emergency opens at t=30, so the cloth would fly the storm's first 60 s under a sky flying its last 60. attach() now takes {at, wind} (seeded, or genuinely pre-flown so the drape and the pond are real), seedClock() is the late door, and `clockSkew` makes any future drift readable instead of silent — measured 90 N vs 655 N for the same rig entering at t=0 vs t=40, which is the size of what was hiding. +15 asserts, all node-verified; the two gate-2 pins mutation-checked both ways. --- tools/site_audit/sweep.js | 85 ++++++++-- tools/site_audit/sweep.selftest.js | 68 ++++++-- web/world/dev_rigging.html | 10 +- web/world/js/rigging.js | 159 +++++++++++++++++- web/world/js/rigging.selftest.js | 70 ++++++++ web/world/js/sail.js | 255 ++++++++++++++++++++++++++--- web/world/js/sail.selftest.js | 154 +++++++++++++++++ web/world/js/tests/balance.test.js | 198 +++++++++++++++++++++- 8 files changed, 936 insertions(+), 63 deletions(-) diff --git a/tools/site_audit/sweep.js b/tools/site_audit/sweep.js index 44da425..1f45977 100644 --- a/tools/site_audit/sweep.js +++ b/tools/site_audit/sweep.js @@ -19,7 +19,11 @@ * are winners. The garden side of the audit lives in gardenfly.js (browser). */ -import { SailRig, orderRing } from '../../web/world/js/sail.js'; +// OVERLOAD_SECS / OVERLOAD_RECOVER are the failure law itself, imported rather +// than re-typed: gate 3 replaces a percentage of taste with sail.js's own rule, +// and a second copy of the rule would be the same drift the shared sweep exists +// to prevent. +import { SailRig, orderRing, OVERLOAD_SECS, OVERLOAD_RECOVER } from '../../web/world/js/sail.js'; import { windForSite } from '../../web/world/js/weather.js'; import { HARDWARE, START_BUDGET, FIXED_DT } from '../../web/world/js/contracts.js'; // gate 2 (SPRINT17): one validator for the constraint shapes, shared with the @@ -32,12 +36,23 @@ export const AUDIT = { MIN_COVER: 0.25, // A's "shades the bed" bar SPARE_COST: 15, // a $15 spare is the difference between a repair and a prayer /** - * C's margin rule (SPRINT13 correction): even a corrected bench under-reads - * the real UI's peaks by ~5–15% on site_02 (cause unfound, in the pool). - * Until it's found, a corner priced within this fraction of its effective - * rating "breaks in the game" — the bench held q4 at 1.24-vs-1.2 and read - * 91.9 FULL; the real game pushed it over and shipped D's 39.8 TATTERED. - * A line with a marginal corner is flagged, never sold as a clean PASS. + * ⚠️ C's margin rule — ITS CAUSE IS FOUND, AND IT NO LONGER DECIDES ANYTHING + * (SPRINT18 gate 3). + * + * It was invented because "even a corrected bench under-reads the real UI's + * peaks by ~5–15% on site_02, cause unfound". The cause was `porosity`: the + * shipped game never called `rig.setFabric`, so the sim flew the waterproof + * membrane (full wind load) every night while this sweep — correctly — flew the + * shade cloth every card promised. Gate 2's measurement, on two sites and two + * storms: with the fabric held equal the bench and the FULL game chain agree to + * three decimals on all four corners. There is no residual left to pad. + * + * So the 15% is not a physical fact and never was; it was a fudge factor + * cancelling a missing function call, and keeping it would now make the audit + * lie in the other direction (selling $15 of steel nobody needs). The + * clean/marginal boundary is ruled by sail.js's OWN failure law instead — see + * `priceCandidate`. This constant survives only as the DISPLAY band the + * front-ends have always printed, so their prose keeps a number to name. */ MARGIN: 0.15, /** @@ -231,7 +246,34 @@ export function priceCandidate(cnd, { anchors, stormDef, wind, budget = START_BU // windy night — the sweep charter, named on the card since SPRINT17 gate 0.3 const rig = new SailRig({ anchors, gridN: 10, porosity: AUDIT.POROSITY }) .attach(cnd.ids, Array(4).fill({ name: 'audit', cost: 0, rating: Infinity }), 1.0); - for (let i = 0; i < stormDef.duration * 60; i++) rig.step(FIXED_DT, wind, i * FIXED_DT); + + // SPRINT18 gate 3 — REPLAY THE FAILURE LAW, don't pad the peak. + // + // sail.js breaks a corner on DWELL, not on peak: `overload` fills at +dt above + // the effective rating, bleeds at −OVERLOAD_RECOVER·dt below it, and the corner + // lets go past OVERLOAD_SECS. A peak-only price cannot express that, which is + // why the audit needed a 15% pad to stand in for it. So run the real timer, for + // every corner against every tier in the shop, as the flight happens: 12 + // accumulators, no trace buffers, and no second copy of the law (the constants + // are imported from sail.js). + // + // `worst` is the closest that corner/tier pair ever came to letting go, in + // seconds of the 0.4 s it takes. worst === 0 means the load never once reached + // the rating; 0 < worst < OVERLOAD_SECS means it went over and survived on + // TIMING; worst >= OVERLOAD_SECS means the game breaks it. + const dwell = rig.corners.map(() => HARDWARE.map(() => ({ o: 0, worst: 0 }))); + for (let i = 0; i < stormDef.duration * 60; i++) { + rig.step(FIXED_DT, wind, i * FIXED_DT); + for (let k = 0; k < rig.corners.length; k++) { + const c = rig.corners[k], hint = c.anchor.ratingHint ?? 1, load = c.load; + for (let h = 0; h < HARDWARE.length; h++) { + const d = dwell[k][h]; + if (load > HARDWARE[h].rating * hint) d.o += FIXED_DT; + else d.o = Math.max(0, d.o - FIXED_DT * OVERLOAD_RECOVER); + if (d.o > d.worst) d.worst = d.o; + } + } + } // Price each corner against its anchor's EFFECTIVE strength. c.anchor is the // resolved anchor handed in above; `?? 1` mirrors sail.js for bare fixtures. @@ -245,15 +287,32 @@ export function priceCandidate(cnd, { anchors, stormDef, wind, budget = START_BU // A corner whose `tier` sits inside the margin band is `marginal`: it // holds on this bench and "breaks in the game" (the residual's working // rule). The row's clean price is what closing that gap costs. - const tiers = rig.corners.map((c) => { + const tiers = rig.corners.map((c, k) => { const hint = c.anchor.ratingHint ?? 1; - const tier = tierFor(c.peakLoad, hint); - return { id: c.anchorId, peak: c.peakLoad, hint, tier, - cleanTier: tierFor(c.peakLoad / (1 - AUDIT.MARGIN), hint), + const d = dwell[k]; + // The cheapest tier the LAW lets live — it may go over its rating, so long as + // no excursion lasts the 0.4 s that breaks it. This is the gamble price. + const tier = HARDWARE.find((h, h_i) => d[h_i].worst < OVERLOAD_SECS) ?? null; + // The cheapest tier that never reaches its rating at all. THIS is "clean", + // measured: the steel is simply stronger than the night, with no reliance on + // a gust being brief. It replaces `tierFor(peak / (1 - MARGIN))`. + const cleanTier = HARDWARE.find((h, h_i) => d[h_i].worst === 0) ?? null; + return { id: c.anchorId, peak: c.peakLoad, hint, tier, cleanTier, + // seconds of the 0.4 s fuse the CHOSEN tier burned — 0 is untouched steel + dwell: tier ? +d[HARDWARE.indexOf(tier)].worst.toFixed(3) : null, + // effective rating in newtons, so a reader never has to do hint arithmetic: + // a $5 carabiner is 1.2 kN on the price list and 1.056 on a 0.88 branch, and + // that haircut is the whole of the cheap tier's danger (gate 3). + effN: tier ? Math.round(tier.rating * hint) : null, + cleanEffN: cleanTier ? Math.round(cleanTier.rating * hint) : null, headroom: tier ? +(1 - c.peakLoad / (tier.rating * hint)).toFixed(3) : null }; }); const unholdable = tiers.filter((c) => !c.tier); - const marginal = tiers.filter((c) => c.tier && c.headroom < AUDIT.MARGIN); + // MARGINAL, re-ruled by measurement: the tier survives the night only because + // every excursion above its rating was shorter than the fuse. That is a real + // knife edge with a real mechanism — "it held on timing, not on strength" — and + // it is the thing the 15% pad was groping for. + const marginal = tiers.filter((c) => c.tier && c.dwell > 0); const hw = tiers.reduce((s, c) => s + (c.tier ? c.tier.cost : 0), 0); const cleanHw = tiers.every((c) => c.cleanTier) ? tiers.reduce((s, c) => s + c.cleanTier.cost, 0) : null; diff --git a/tools/site_audit/sweep.selftest.js b/tools/site_audit/sweep.selftest.js index f554efc..ac64833 100644 --- a/tools/site_audit/sweep.selftest.js +++ b/tools/site_audit/sweep.selftest.js @@ -119,21 +119,46 @@ test('pricing reads the ANCHOR, not just the steel — ratingHint reaches tierFo 'an over-ceiling corner must land in unholdable, or the verdict lies'); }); -test('the margin rule: a corner inside 15% of its effective rating is not a clean win', () => { - // SPRINT13, C's residual: even a corrected bench under-reads the real UI's - // peaks by ~5-15%, so "any corner within ~15% of rating breaks in the game". - // The sweep must therefore refuse to call a knife-edge line a winner — the - // wild-night 91.9-FULL-with-q4-at-1.24-vs-1.2 headline died of exactly this - // (D's UI: 39.8 TATTERED). Craft a hint that leaves the best steel ~8% - // headroom on a1's measured peak: still priced, still "holds" on paper, - // and the verdict must refuse to bless it. Delete the marginal logic from - // sweep.js and this goes red on the verdict code. +test('SPRINT18 gate 3: the boundary is DWELL against the fuse, not a percentage of peak', () => { + // WHAT THIS REPLACES. SPRINT13 priced every corner twice — `tier` (peak <= + // rating) and `cleanTier` (peak <= rating × 0.85) — because C had measured the + // bench under-reading the real UI by 5–15% with "cause unfound", so 15% of + // taste stood in for it. Gate 2 found the cause (the shipped game never called + // `rig.setFabric`, so it flew the membrane while every card said shade cloth); + // with the fabric held equal the bench and the full game chain agree to three + // decimals. There is no residual left, so the pad is not covering anything. + // + // But the real reason a percentage had to go is that NO percentage can work. + // Measured on the wildnight pin, one flight, two corners on identical $15 + // shackles (THREADS [B] SPRINT18 gate 3): + // + // p1 peak 3.528 kN / 3.20 = 10.3% OVER its rating -> HELD (dwell 0.150 s) + // p2 peak 3.493 kN / 3.20 = 9.2% OVER its rating -> BROKE (dwell 0.400 s) + // + // p1 goes HIGHER than p2 and survives. Ordering by peak is INVERTED against + // the outcome, so a rule of the form "peak within X% of rating" is wrong at + // every X: 15% condemns p1's perfectly good steel, 9% blesses p2's break. + // sail.js has never used peak — it fills an `overload` timer while a corner is + // over its rating, bleeds it at OVERLOAD_RECOVER, and lets go past + // OVERLOAD_SECS. `priceCandidate` replays exactly that, so the audit and the + // sim now answer "will it hold" with the same arithmetic. + // + // What this fixture can prove: a corner with 8% headroom is now CLEAN (its load + // never reaches the rating at all), where the old rule called it marginal — + // that IS the re-ruling. Take the dwell replay out of sweep.js and `cleanTier` + // falls back to a pad, `cleanHw` stops equalling `hw`, and this goes red. const sweep = (anchors) => auditSweep({ anchors, bed: BED, stormDef: STORM, venturi: [] }); const honest = sweep(ANCHORS); const a1h = honest.rows[0].tiers.find((c) => c.id === 'a1'); assert(a1h.tier, 'fixture drift: a1 must be holdable at hint 1'); assert(honest.verdict.ok && honest.verdict.code === 'pass', 'fixture drift: the honest yard must be a clean pass or this test asserts nothing'); + assert(honest.rows[0].tiers.every((c) => c.dwell === 0), + `the honest yard burned fuse on ${honest.rows[0].tiers.filter((c) => c.dwell > 0).map((c) => c.id)} — ` + + 'a yard nothing is over-loaded on must replay a dwell of exactly 0'); + assert(honest.rows[0].cleanHw === honest.rows[0].hw, + `cleanHw $${honest.rows[0].cleanHw} != hw $${honest.rows[0].hw} on a yard where no corner ever reaches ` + + 'its rating. That gap is the retired 15% pad — the audit is charging for steel the night cannot need'); const RATED = HARDWARE.at(-1); const hint = a1h.peak / (RATED.rating * 0.92); // → best steel holds a1 with 8% headroom @@ -142,11 +167,26 @@ test('the margin rule: a corner inside 15% of its effective rating is not a clea const a1 = row.tiers.find((c) => c.id === 'a1'); assert(a1.tier === RATED, `a1 at the crafted hint must price to the rated shackle, got ${a1.tier?.name}`); assert(a1.headroom > 0 && a1.headroom < AUDIT.MARGIN, - `fixture: a1's headroom ${a1.headroom} must sit inside (0, ${AUDIT.MARGIN})`); - assert(row.marginal.some((c) => c.id === 'a1'), 'a1 must land in row.marginal'); - assert(row.affordable && !row.clean, 'the line is affordable but must NOT be clean'); - assert(!lied.verdict.ok && lied.verdict.code === 'marginal-only', - `the only affordable line is marginal — verdict must say so, got ${lied.verdict.code}/${lied.verdict.ok}`); + `fixture: a1's headroom ${a1.headroom} must sit inside (0, ${AUDIT.MARGIN}) — the band the OLD rule condemned`); + assert(a1.dwell === 0, + `a1 burned ${a1.dwell}s of fuse at 8% headroom. Headroom means the load never reached the rating, so the ` + + 'fuse must never start — if this fires, the replay is reading the wrong quantity'); + assert(a1.cleanTier === RATED && row.clean, + `8% headroom must now read CLEAN (it did not, got clean=${row.clean}): the steel is simply stronger than ` + + 'the night, and calling it a knife edge was the pad talking'); + assert(lied.verdict.ok && lied.verdict.code === 'pass', + `the verdict must bless a line no corner is ever over, got ${lied.verdict.code}/${lied.verdict.ok}`); + + // and the other side of the boundary: drop the ceiling UNDER the peak and the + // tier is refused outright — this fixture's gusts are seconds long, so any + // excursion outlasts the 0.4 s fuse and there is no timing to survive on. + const tooWeak = sweep(ANCHORS.map((a) => (a.id === 'a1' + ? { ...a, ratingHint: a1h.peak / (RATED.rating * 1.05), sway: a.sway } : a))); + const weak = tooWeak.rows[0].tiers.find((c) => c.id === 'a1'); + assert(!weak.tier && tooWeak.rows[0].unholdable.some((c) => c.id === 'a1'), + `a1 priced to ${weak.tier?.name} with its ceiling 5% UNDER the peak — on a gust that lasts seconds the ` + + 'fuse burns through and the corner is simply unholdable'); + return 'clean = the fuse never starts (8% headroom passes); over the ceiling on a long gust = unholdable'; }); export const SWEEP_TESTS = TESTS; diff --git a/web/world/dev_rigging.html b/web/world/dev_rigging.html index a331d67..5286e55 100644 --- a/web/world/dev_rigging.html +++ b/web/world/dev_rigging.html @@ -95,7 +95,13 @@ windProxy.setSheltersFromTrees(world.anchors.filter((a) => a.type === 'tree')); const rig = new SailRig({ anchors: world.anchors }); let sailView = null; -async function rigSail(anchorIds, hwChoices, tension) { +async function rigSail(anchorIds, hwChoices, tension, fabric) { + // SPRINT18 gate 2: the fabric BEFORE the attach. Without this line the sail + // flies `new SailRig`'s default porosity (0 — the waterproof membrane) no + // matter what the F key says, which is exactly the bug the shipped game had: + // the panel sold shade cloth and the sim flew full wind load. This is the + // shape of the one-line pickup filed for main.js's own rigSail. + if (fabric) rig.setFabric(fabric); rig.attach(anchorIds, hwChoices, tension); if (sailView) { scene.remove(sailView); @@ -109,7 +115,7 @@ let msg = ''; let msgT = 0; const ui = await createRiggingUI({ scene, camera: cameraRig.object, domElement: canvas, world, - onCommit: (ids, hw, tension) => { rigSail(ids, hw, tension); wind = wildWind; t = 0; phase = 'storm'; }, + onCommit: (ids, hw, tension, fabric) => { rigSail(ids, hw, tension, fabric); wind = wildWind; t = 0; phase = 'storm'; }, onMessage: (m) => { msg = m; msgT = 2; }, }); diff --git a/web/world/js/rigging.js b/web/world/js/rigging.js index cc10aed..f7c474d 100644 --- a/web/world/js/rigging.js +++ b/web/world/js/rigging.js @@ -297,15 +297,101 @@ export class RiggingSession { return OK; } + /** + * ⚠️ SPRINT18 GATE 1 — THE RIG YOU DIDN'T BUILD. Adopt somebody else's work. + * [B rules the semantics; installs Lane A's `night.emergency.rig` data] + * + * An emergency callout drops you at t=30 under a sail you never quoted, never + * bought and never hung. Lane A's spine wanted to install it through `rig()` + + * `setHardware()` — the public-moves discipline, which is right — but those + * moves are a SHOP, and putting someone else's rig through the shop charges the + * player for it. Two things then go wrong, and the second one is the bad one: + * + * 1. the wallet. $40 of another contractor's shackles comes off tonight's + * bank, for steel that was already in the yard when the phone rang. + * 2. THE REFUND INVERTS THE KNIFE. week.js pays back half of every intact + * corner's hardware (`intactHardwareValue` × refundShare). If the inherited + * steel counts as the player's, then every corner they CUT costs them + * money — and DESIGN.md's signature decision becomes a thing the ledger + * punishes you for getting right. A rule that pays you to let a stranger's + * sail take out a glasshouse is not a hard trade-off, it's a bug. + * + * So: adopted corners cost $0, are flagged `inherited`, and are excluded from + * `intactHardwareValue`. The steel is real to the PHYSICS (its rating is what + * fails) and invisible to the WALLET, which is exactly what "someone else's + * work" means. `spent`/`budget` never move, so the invoice's spend line reads + * $0 on a callout with no branch anywhere in the ledger (A's acceptance §10). + * + * Client terms are still honoured — a house ban still refuses, because the ban + * is about what may be TOUCHED, not about who paid. A refusal here is a + * content bug (the author hung a sail off banned steel), so it comes back as a + * result for the caller to surface, not a throw: A installs this at the same + * boundary `setConstraints` uses and can ticker it. + * + * @param {{anchorId: string, hw: string, broken?: boolean}[]} spec A's shape: + * `hw` is a HARDWARE *name*, since this arrives from authored JSON + * @returns {{ok: boolean, reason?: string, broken?: string[]}} `broken` lists + * the corners that must arrive DOWN — hand them to `SailRig.failCorner` + * after commit (the pond and the freed node are the sail's business, not the + * session's) + */ + inherit(spec) { + if (!Array.isArray(spec) || spec.length !== MAX_CORNERS) { + return fail(`an inherited rig is ${MAX_CORNERS} corners, got ${Array.isArray(spec) ? spec.length : typeof spec}`); + } + const before = { budget: this.budget, picks: this.picks }; + this.picks = []; + const broken = []; + for (const s of spec) { + const a = this.anchors.find((x) => x.id === s.anchorId); + if (!a) { this.picks = before.picks; return fail(`inherit: no anchor ${s.anchorId} in this yard`); } + const ban = this._banFor(a); + if (ban) { this.picks = before.picks; return fail(`inherit: ${s.anchorId} is ${a.type} steel and the client's terms say "${ban.label}"`); } + const hw = HARDWARE.find((h) => h.name === s.hw); + if (!hw) { + this.picks = before.picks; + return fail(`inherit: "${s.hw}" is not shop hardware — the yard sells ${HARDWARE.map((h) => h.name).join(', ')}`); + } + if (this.picks.some((p) => p.anchorId === s.anchorId)) { + this.picks = before.picks; + return fail(`inherit: ${s.anchorId} named twice`); + } + this.picks.push({ anchorId: s.anchorId, hw, inherited: true }); + if (s.broken) broken.push(s.anchorId); + } + this._reorder(); + // the money never moved, and that is the whole point + this.budget = before.budget; + return { ...OK, broken }; + } + + /** + * What the player would get back for hardware still standing at dawn — with + * inherited steel excluded, because you cannot sell a shackle you never + * bought. main.js reads `rig.corners` for this today; a callout needs the + * SESSION's view, which is the only one that knows who paid. + */ + intactHardwareValue(rig) { + const corners = rig?.corners ?? []; + return corners.reduce((sum, c) => { + if (c.broken) return sum; + const p = this.pickOf(c.anchorId); + return p?.inherited ? sum : sum + c.hw.cost; + }, 0); + } + /** * Unrig a corner and refund its hardware. Not in the prototype (which had no * way back from a misclick) but it is a pure refund, so it costs the economy * nothing and saves the player a restart. + * + * SPRINT18: an INHERITED corner refunds nothing — pulling somebody else's + * shackle off their post is not a trip to the returns counter. */ unrig(anchorId) { const i = this.picks.findIndex((p) => p.anchorId === anchorId); if (i < 0) return fail('not rigged'); - this.budget += this.picks[i].hw.cost; + if (!this.picks[i].inherited) this.budget += this.picks[i].hw.cost; this.picks.splice(i, 1); return OK; } @@ -315,13 +401,29 @@ export class RiggingSession { const p = this.pickOf(anchorId); if (!p) return fail('not rigged'); const next = HARDWARE[(HARDWARE.indexOf(p.hw) + 1) % HARDWARE.length]; - const capNo = this._capRefusal(next.cost - p.hw.cost); + const price = this._priceOfChange(p, next); + const capNo = this._capRefusal(price); if (capNo) return capNo; - if (!this._spend(next.cost - p.hw.cost)) return fail('not enough budget'); + if (!this._spend(price)) return fail('not enough budget'); p.hw = next; + p.inherited = false; // you paid for this one; it is yours now return OK; } + /** + * What changing a corner's hardware costs. [SPRINT18 gate 1] + * + * Normally the difference — the shop takes the old piece back, because you + * bought it here ten seconds ago. On an INHERITED corner there is nothing to + * trade in: the carabiner on that post belongs to whoever rigged it last, and + * crediting its $5 against your shackle would have the player selling somebody + * else's steel to fund their own. So an inherited corner is priced at FULL + * whack, and replacing it is exactly as expensive as triage should feel. + */ + _priceOfChange(pick, next) { + return pick.inherited ? next.cost : next.cost - pick.hw.cost; + } + /** * Put specific hardware on a rigged corner, paying the difference. * @@ -350,10 +452,12 @@ export class RiggingSession { } const p = this.pickOf(anchorId); if (!p) return fail('not rigged'); - const capNo = this._capRefusal(hw.cost - p.hw.cost); + const price = this._priceOfChange(p, hw); + const capNo = this._capRefusal(price); if (capNo) return capNo; - if (!this._spend(hw.cost - p.hw.cost)) return fail('not enough budget'); + if (!this._spend(price)) return fail('not enough budget'); p.hw = hw; + p.inherited = false; // paid for, so it counts as yours at dawn return OK; } @@ -557,7 +661,14 @@ export class RiggingSession { anchorId: p.anchorId, hw: p.hw.name, rating: p.hw.rating, cost: p.hw.cost, effRating: this._effRating(p), hint: this.anchors.find((x) => x.id === p.anchorId)?.ratingHint ?? 1, + // SPRINT18 gate 1: whose steel this is. The dispatch card wants to say + // "somebody else's carabiner on a 0.88 branch" and the invoice wants to + // leave it out of the refund; both need the flag, not an inference. + inherited: !!p.inherited, })), + // $0 on an ordinary night; on a callout, what the previous contractor left + // hanging. A number the dispatch can print instead of a spend line. + inheritedValue: this.picks.reduce((s, p) => s + (p.inherited ? p.hw.cost : 0), 0), // The weak link is the lowest EFFECTIVE rating — hw.rating × the anchor's // ratingHint, the same product sail.js fails a corner on (SPRINT12, A's // ruling). D's #7: this used to reduce on hw.rating alone with strict <, @@ -591,7 +702,10 @@ export class RiggingSession { * @param {object} o.camera cameraRig.object — what we raycast from * @param {Element} o.domElement renderer.domElement — where clicks land * @param {object} o.world needs world.anchors - * @param {function} o.onCommit (anchorIds, hwChoices, tension) => void — Lane A's rigSail + * @param {function} o.onCommit (anchorIds, hwChoices, tension, fabric) => void — Lane A's + * rigSail. The FOURTH argument is the FABRIC entry, and it is + * not decoration — see commit() below for the sprint-18 gate-2 + * finding that put it there. * @param {function} [o.onMessage] (text) => void — refusals, for the event ticker * @param {boolean} [o.panel=true] draw the built-in prep panel; false if hud.js takes it over */ @@ -879,7 +993,37 @@ export async function createRiggingUI({ } }, - /** Hand the finished rig to Lane A's rigSail. Returns false if it isn't four corners. */ + /** + * Hand the finished rig to Lane A's rigSail. Returns false if it isn't four + * corners. + * + * ⚠️ SPRINT18 GATE 2 — THE FABRIC RIDES ALONG NOW, AND HERE IS WHY. + * + * `RiggingSession.commit(rig)` sets the fabric on the sail (`rig.setFabric`) + * and it is the ONLY place in the repo that does. This function — the + * shipped commit path, the one the player's Enter key reaches — does not + * call it: it calls `onCommit`, and Lane A's `rigSail` goes straight to + * `rig.attach()`. So `session.commit(rig)` runs in TESTS AND BENCHES ONLY, + * and the sim's rig (built once at main.js boot as `new SailRig({anchors})`, + * porosity defaulting to 0) has flown the WATERPROOF MEMBRANE every night of + * every playtest, while the panel, the SCORE IT card, the HUD row, the + * invoice and every audit number said "shade cloth". + * + * Measured, sprint 18 (the wildnight pin p5/p1/p2/p3, backyard_01): + * shade cloth 0.30 (what every card promised) 0/4 lost · p2 3.07 · p3 1.78 + * membrane 0 (what the sim actually flew) 2/4 lost · p2 3.48 · p3 3.90 + * The second row IS D's cold play and IS the pin's own `_playedReference` + * (hp 55.7, p2 then p3) — reproduced in the shipped game to the decimal. + * Two harnesses, one variable: `porosity`. The GAME was the liar; the bench + * flew what the card sold. This also retires C's "benches under-read the + * real UI by 5–15%, cause unfound": with the fabric held equal, the bench and + * the full game chain agree to three decimals on both sites. + * + * So the fabric is now an ARGUMENT, not a thing the caller must remember to + * go and fetch. Lane A's one-line pickup is filed in THREADS; until it + * lands, `rigSail` dropping a 4th argument is exactly as broken as it was + * before and no more, which is why this is safe to ship on its own. + */ commit() { if (!session.canStart) { onMessage(`rig ${MAX_CORNERS - session.picks.length} more corner(s) first`); @@ -889,6 +1033,7 @@ export async function createRiggingUI({ session.picks.map((p) => p.anchorId), session.picks.map((p) => p.hw), session.tension, + session.fabric, ); return true; }, diff --git a/web/world/js/rigging.selftest.js b/web/world/js/rigging.selftest.js index a02c9bb..bf37ef3 100644 --- a/web/world/js/rigging.selftest.js +++ b/web/world/js/rigging.selftest.js @@ -555,6 +555,76 @@ test('gate 3: a corroded corner costs MORE steel than a sound post at the same l return `same carabiner: sound holds ${sound} N, corroded holds ${cor} N — the rust costs a tier`; }); +// --- SPRINT18 gate 1: THE RIG YOU DIDN'T BUILD ----------------------------- + +const INHERITED = [ + { anchorId: 'h1', hw: 'carabiner' }, + { anchorId: 'h3', hw: 'shackle' }, + { anchorId: 'p1', hw: 'carabiner', broken: true }, + { anchorId: 'p2', hw: 'carabiner' }, +]; + +test('gate 1: an inherited rig costs the player nothing and names its dead corner', () => { + const s = session(); + const r = s.inherit(INHERITED); + assert(r.ok, `inherit refused: ${r.reason}`); + assert(s.budget === START_BUDGET, + `adopting somebody else's rig charged $${START_BUDGET - s.budget}. That steel was in the yard when the ` + + 'phone rang — the callout pays for the callout, not for the previous contractor\'s shackles'); + assert(s.spent === 0, `spent reads $${s.spent} on a rig the player did not buy`); + assert(s.picks.length === 4 && s.canStart, 'an inherited rig is not startable'); + assert(s.picks.every((p) => p.inherited), 'some adopted corners are not flagged inherited'); + assert(r.broken.join(',') === 'p1', + `inherit reported broken corners "${r.broken.join(',')}" — Lane A hands these to SailRig.failCorner`); + assert(s.summary.inheritedValue === CARABINER.cost * 3 + SHACKLE.cost, + `inheritedValue reads $${s.summary.inheritedValue} — the dispatch prints this where a spend line would go`); + return `4 corners adopted for $0 (worth $${s.summary.inheritedValue}), p1 arrives down, budget still $${s.budget}`; +}); + +test('gate 1: ⚠️ the refund cannot pay you for a stranger\'s steel — the knife stays honest', () => { + const s = session(); + assert(s.inherit(INHERITED).ok, 'inherit refused'); + // Stand in for the sail at dawn: nothing broken, so every corner is "intact". + const rig = { corners: s.picks.map((p) => ({ anchorId: p.anchorId, hw: p.hw, broken: false })) }; + assert(s.intactHardwareValue(rig) === 0, + `an all-inherited rig returned $${s.intactHardwareValue(rig)} of refundable hardware. week.js pays back half ` + + 'of that, so every corner the player CUT would cost them money — DESIGN.md\'s signature decision, ' + + 'punished by the ledger for being taken'); + // Buy one corner yourself and it becomes yours — at FULL price, no trade-in. + const before = s.budget; + assert(s.setHardware('p2', RATED).ok, 'buying an inherited corner up was refused'); + assert(before - s.budget === RATED.cost, + `upgrading an inherited carabiner cost $${before - s.budget}, not $${RATED.cost}. Crediting the old ` + + 'piece would have the player selling somebody else\'s steel to fund their own'); + const rig2 = { corners: s.picks.map((p) => ({ anchorId: p.anchorId, hw: p.hw, broken: false })) }; + assert(s.intactHardwareValue(rig2) === RATED.cost, + `after paying for p2, refundable value reads $${s.intactHardwareValue(rig2)} — it should be exactly the ${RATED.name} the player bought`); + // and a corner that is GONE at dawn refunds nothing either way + const rig3 = { corners: s.picks.map((p) => ({ anchorId: p.anchorId, hw: p.hw, broken: p.anchorId === 'p2' })) }; + assert(s.intactHardwareValue(rig3) === 0, 'a broken corner still came home'); + return `inherited steel refunds $0; the $${RATED.cost} the player bought refunds, and only that`; +}); + +test('gate 1: an inherited rig still obeys the client, and refuses a rig it cannot build', () => { + const s = session(); + s.setConstraints([HOUSE_BAN]); + const banned = s.inherit(INHERITED); // h1/h3 are house steel + assert(!banned.ok && /h1/.test(banned.reason), + `a banned anchor was adopted anyway: ${JSON.stringify(banned)} — the ban is about what may be TOUCHED, not who paid`); + assert(s.picks.length === 0, 'a refused inherit left half a rig behind'); + + const s2 = session(); + assert(!s2.inherit(INHERITED.slice(0, 3)).ok, 'a three-corner sail was adopted'); + assert(!s2.inherit([...INHERITED.slice(0, 3), { anchorId: 'p1', hw: 'gaffer tape' }]).ok, + 'hardware that is not in the shop was adopted — an authored typo would silently fly a carabiner'); + assert(!s2.inherit([...INHERITED.slice(0, 3), { anchorId: 'h1', hw: 'shackle' }]).ok, + 'the same anchor twice was adopted'); + assert(!s2.inherit([...INHERITED.slice(0, 3), { anchorId: 'nowhere', hw: 'shackle' }]).ok, + 'an anchor this yard does not have was adopted'); + assert(s2.budget === START_BUDGET, `a refused inherit moved the money to $${s2.budget}`); + return 'refuses banned steel, wrong corner counts, unknown hardware, duplicates and strangers — money never moves'; +}); + export const RIGGING_TESTS = TESTS; export function runRiggingSelftest() { diff --git a/web/world/js/sail.js b/web/world/js/sail.js index d045147..636f189 100644 --- a/web/world/js/sail.js +++ b/web/world/js/sail.js @@ -96,8 +96,20 @@ const DEBRIS_SKIN = 0.06; // contact margin, ~cloth thickness // real load; POND_BELLY_MAX below is what stops a sail bellying to 5 m first. const DIVERGENCE_N = 80000; // 80 kN — past here the solver has actually blown up const POND_BELLY_MAX = 4.0; // metres of sag before the sail tears and dumps (DESIGN.md "sudden dump… tear") -const OVERLOAD_SECS = 0.4; // prototype: 0.4 s sustained overload before it lets go -const OVERLOAD_RECOVER = 2.0; // prototype: overload timer bleeds off at 2x +/** + * THE FAILURE LAW, and it is EXPORTED because a second copy of it is the exact + * drift this repo keeps paying for (SPRINT18 gate 3). + * + * A corner does not fail the instant it passes its rating: `overload` fills at + * +dt while over and bleeds at −2dt while under, and the corner lets go when it + * passes OVERLOAD_SECS. So "will this hold" is a question about DWELL, not about + * peak load — a corner can peak 10% over its rating and survive if the gust is + * short, and the audit was answering it with a percentage of taste + * (AUDIT.MARGIN) instead of the law itself. tools/site_audit replays these two + * numbers against the load trace now; they must never be re-typed there. + */ +export const OVERLOAD_SECS = 0.4; // prototype: 0.4 s sustained overload before it lets go +export const OVERLOAD_RECOVER = 2.0; // prototype: overload timer bleeds off at 2x const LOAD_TAU = 0.11; // load meter smoothing time constant, s export const TENSION_MIN = 0.6; @@ -172,8 +184,16 @@ export class SailRig { * @param {string[]} anchorIds 4 anchor ids; reordered into a ring internally * @param {object[]} hwChoices hardware per anchor id, same order as anchorIds * @param {number} tension 0.6 (loose, flogs) .. 1.4 (drum tight, shock-loads) + * @param {object} [opts] + * @param {number} [opts.at=0] STORM SECONDS ALREADY ELAPSED when this sail + * enters the sim. 0 for every ordinary night — see the clock note below, and + * ⚠️ SPRINT18 gate 1 / Lane C's finding for the night where it is not 0. + * @param {object} [opts.wind=null] if given alongside `at`, the sail is FLOWN + * from 0 to `at` through this wind instead of teleported there — the + * difference between a sail that has been up all storm and one that appears + * mid-gale with no drape, no pond and no history. */ - attach(anchorIds, hwChoices, tension = 1.0) { + attach(anchorIds, hwChoices, tension = 1.0, { at = 0, wind = null } = {}) { if (anchorIds.length !== 4) throw new Error(`sail needs exactly 4 corners, got ${anchorIds.length}`); const picked = anchorIds.map((id) => { @@ -215,9 +235,29 @@ export class SailRig { * caller comes through (boot, the picking adapter, balance.test, the audit), * and commit → attach → game.advance() → storm all land in a single keypress, * so t=0 at attach IS t=0 at the first storm frame. + * + * ⚠️ SPRINT18 — THAT LAST SENTENCE IS A PRECONDITION, AND THE EMERGENCY + * CALLOUT IS THE FIRST NIGHT THAT BREAKS IT. [Lane C's finding, B's fix] + * + * `_substep` samples the wind at `this.t`, NOT at the `t` handed to step() — + * deliberately, because the internal fixed-step clock is what makes a 144 Hz + * browser and a fast-forwarded bench produce byte-equal traces. That is only + * safe while the two clocks START together. An emergency night opens its storm + * phase at t=30, so a sail attached the old way would fly the storm's FIRST + * 60 seconds while the sky, the hail and the garden fly its LAST 60. C + * measured the gap through that door: the sky blowing 9.074 m/s over a cloth + * feeling 4.468 (4.12x understated load) and raining 5.000 mm/hr onto a cloth + * ponding 0.054 (93x light) — an emergency that would demo fine and merge + * fine and simply read as "triage is easy". + * + * So the clock is SEEDED rather than assumed, and `clockSkew` below makes any + * future divergence readable instead of silent. `at` is 0 for every ordinary + * night, which is byte-for-byte the behaviour above. */ - this.t = 0; + this.t = at; this._acc = 0; + /** Largest |caller's t − this.t| seen since attach. 0 means the clocks agree. */ + this._skew = 0; this.corners = ring.map((a) => ({ anchorId: a.id, anchor: a, @@ -226,15 +266,66 @@ export class SailRig { peakLoad: 0, overload: 0, broken: false, + // SPRINT18 gate 1 — HOW a corner came to be down, not just that it is. + // Declared here with the rest so a re-rig clears them: a sail cut away on + // night 5 that still read `cut` on night 6 would put last night's knife on + // tonight's invoice, which is the phantom-sail bug wearing a new hat. + cut: false, // the player chose to let this one go (`cutAway`) + inherited: false, // it was already gone when you arrived (`failCorner`) trim: 1.0, loadVec: { x: 0, y: 0, z: 0 }, // reaction direction, not just magnitude — see _measureLoads })); this._build(ring); this.rigged = true; + + // A sail that has ALREADY been up for `at` seconds. Flown, not teleported: + // the drape, the pond and the peak loads of those seconds are the difference + // between inheriting somebody's rig and finding a brand-new one hanging in a + // gale. Costs `at`/SIM_DT substeps once (30 s ≈ 1800, single-digit ms) and it + // is the only way the pre-broken corner and the belly's water mean anything. + // Without a wind we can only seed the clock, which gets the WEATHER right and + // says nothing about the history — stated rather than hidden. + if (at > 0 && wind) { + this.t = 0; + for (let i = 0, n = Math.round(at / SIM_DT); i < n; i++) this._substep(SIM_DT, wind, this.t, null); + this.t = at; + this._skew = 0; + } return this; } + /** + * Move the sail's own clock to `t` seconds of storm. [SPRINT18 gate 1] + * + * The post-attach door, for a caller that only learns where the storm is after + * committing (Lane A's emergency flow: adopt the rig → commit → put the dead + * corner down → roll). `attach(..., {at})` is the better door when you know the + * number in time; this exists so the knowledge arriving late cannot mean the + * cloth flies the wrong sixty seconds. + */ + seedClock(t) { + if (!Number.isFinite(t)) return { ok: false, reason: `seedClock needs a finite time, got ${t}` }; + this.t = t; + this._acc = 0; + this._skew = 0; + return { ok: true }; + } + + /** + * How far the caller's storm clock has drifted from the cloth's own, in seconds. + * + * Reads 0 on every honest night. It is a DIAGNOSTIC rather than a throw because + * one legitimate harness diverges on purpose — balance.test's `fly()` settles + * 12 s on the calm day before the storm loop restarts its own `t` at 0, which is + * the phantom settle the pinned separation target is calibrated against. What + * this catches is the silent case: a phase that opens mid-storm while the cloth + * starts from scratch (Lane C's emergency finding). If it reads non-zero on a + * night you did not deliberately skew, the sail is in a different storm from the + * sky and every load you are looking at is fiction. + */ + get clockSkew() { return this._skew; } + _build(ring) { const N = this.N; const nodeCount = N * N; @@ -419,6 +510,15 @@ export class SailRig { */ step(dt, wind, t, debris = null) { if (!this.rigged) return; + // `t` is not used to sample the wind (that is `this.t`, on purpose — see + // attach) but it IS the caller telling us where it thinks the storm is, and + // comparing the two costs nothing. SPRINT18: this is the only reason Lane C's + // emergency-clock finding is detectable from inside the sail rather than by + // noticing that triage felt suspiciously easy. + if (Number.isFinite(t)) { + const d = Math.abs(t - this.t); + if (d > this._skew) this._skew = d; + } const pieces = debris ? (debris.pieces ?? debris) : null; this._acc += dt; let n = 0; @@ -925,6 +1025,130 @@ export class SailRig { * anchor (DEFAULT_RATING_HINT, a.test pins it), because `load > rating * * undefined` is `load > NaN`: always false, i.e. an UNBREAKABLE anchor. */ + /** + * ONE definition of what it means for a corner to stop holding — extracted in + * SPRINT18 because gate 1 adds two more ways for it to happen (a corner that + * arrives already gone, and the knife) and three copies of this body would be + * exactly the drift this repo keeps paying for. + * + * Every caller gets the same five facts: the corner is broken, its overload + * fuse and load are zeroed, THE NODE GETS ITS MASS BACK (this is the line every + * good thing about a failure comes from — the freed corner stops being pinned, + * so it flies on the wind and the flogging is emergent rather than animated), + * the belly dumps whatever it was holding, and something is emitted. + * + * `reason` also decides WHICH event, and that is the design, not plumbing: + * · 'overload' / 'divergence' → `break`. Your hardware failed. Warranty, rep. + * · 'cut' / 'inherited' → no `break` at all. A cut is a DECISION and a + * corner you found already gone is somebody else's failure; billing either + * as your break would put another contractor's shackle on your invoice. + * `cutAway` carries the knife; the inherited case is silent by construction. + * + * The pond dump carries a reason for the same accounting: main.js only counts a + * REASONLESS dump as broom work (`if (!e.reason) pondDumped += e.kg`), so a + * belly that empties because the sail was cut is not credited to the player's + * arms. + * + * @returns {boolean} false if the corner was already gone (idempotent) + */ + _releaseCorner(k, reason) { + const c = this.corners[k]; + if (c.broken) return false; + c.broken = true; + c.overload = 0; + c.load = 0; + c.loadVec.x = c.loadVec.y = c.loadVec.z = 0; + if (reason === 'cut') c.cut = true; + if (reason === 'inherited') c.inherited = true; + this.invMass[this.cornerIdx[k]] = 1 / this.nodeMass; + this.dumpPond(reason === 'cut' ? 'sail cut away' + : reason === 'inherited' ? 'arrived down' : 'corner blew'); + if (reason === 'overload' || reason === 'divergence') { + this.events.emit('break', + { type: 'break', corner: c, anchorId: c.anchorId, hw: c.hw.name, t: this.t, reason }); + } + return true; + } + + /** Index of a corner by its anchor id, or −1. */ + _cornerIndex(anchorId) { + return this.corners.findIndex((c) => c.anchorId === anchorId); + } + + /** + * SPRINT18 gate 1 — A CORNER THAT WAS ALREADY GONE WHEN YOU GOT THERE. + * + * Lane A's emergency callout installs someone else's rig through the session's + * public moves and then needs one corner to arrive DOWN — which public moves + * cannot express, because nothing in the shop sells a broken shackle. A asked + * for the door on this side (their THREADS shapes, §2) and this is it. + * + * It is deliberately NOT a `break`: you did not break it, and an invoice that + * says you did is the same lie as billing the player for the previous + * contractor's steel. Silent, so every existing break listener is untouched. + * + * @param {string} anchorId + * @returns {{ok: boolean, reason?: string}} the session's result shape, so a + * caller can put a refusal in the ticker instead of catching a throw + */ + failCorner(anchorId) { + if (!this.rigged) return { ok: false, reason: 'there is no rig to fail a corner on' }; + const k = this._cornerIndex(anchorId); + if (k < 0) return { ok: false, reason: `no corner at ${anchorId} — this sail is on ${this.corners.map((c) => c.anchorId).join(', ')}` }; + if (!this._releaseCorner(k, 'inherited')) return { ok: false, reason: `${anchorId} is already down` }; + return { ok: true }; + } + + /** + * ⚠️ THE KNIFE — DESIGN.md line 165: *cut a sail loose: lose the sail, save the + * anchors.* The signature decision under load, and the physics half of it. + * [SPRINT18 gate 1; B exposes the cut, D owns the verb, the input and the HUD] + * + * There is no new physics here and that is the point. A cut corner is released + * through the same `_releaseCorner` an overload uses, so the cloth goes limp and + * flies on exactly the wind it already had — the difference is entirely in what + * it COSTS, and the economy falls out of rules the game already has: + * + * · what the cut SAVES is certain. A released corner carries zero load + * forever, so no anchor it hangs off can be torn out and no collateral that + * anchor reaches can be wrecked. On a yard whose steel holds a carport beam + * or a glasshouse, that is the whole wage of a callout (`clean`, week.js — + * "about THEIR property, not your success"). + * · what the cut COSTS is the garden, and it costs it honestly: the bed is + * bare for every second left in the storm, hail included, and `win` is + * `hp >= 50`. So the knife spends garden HP — and possibly the night's fee — + * to buy certainty on somebody's property. Ride it out for the full money + * and gamble their glasshouse, or cut and take the smaller cheque. + * + * NOT a `break` event, per `_releaseCorner`: cutting is a judgement call, not a + * hardware failure, and warranty must never chase a corner the player chose to + * let go. `cutAway` is the record — the payload is Lane A's requested shape + * (their §9) so their night tally and the invoice line need no new wiring. + * + * @param {string[]|null} [anchorIds] corners to cut; null/omitted = the whole + * sail, which is what the verb means in DESIGN.md and what D's key will send + * @param {string} [reason] free text for the paperwork ('the glasshouse') + * @returns {{ok: boolean, cut?: string[], reason?: string}} + */ + cutAway(anchorIds = null, reason = null) { + if (!this.rigged) return { ok: false, reason: 'there is no sail to cut' }; + const ids = anchorIds ?? this.corners.map((c) => c.anchorId); + const unknown = ids.filter((id) => this._cornerIndex(id) < 0); + if (unknown.length) { + return { ok: false, reason: `no corner at ${unknown.join(', ')} — this sail is on ${this.corners.map((c) => c.anchorId).join(', ')}` }; + } + const cut = []; + for (const id of ids) if (this._releaseCorner(this._cornerIndex(id), 'cut')) cut.push(id); + // Every named corner was already down. Refuse rather than emit an empty cut: + // a paperwork line saying you cut nothing loose is worse than no line. + if (!cut.length) return { ok: false, reason: 'the sail is already down' }; + this.events.emit('cutAway', { type: 'cutAway', t: this.t, corners: cut, reason }); + return { ok: true, cut }; + } + + /** Corners released by the knife tonight — the paperwork's own read. */ + get cutCorners() { return this.corners.filter((c) => c.cut).map((c) => c.anchorId); } + _checkFailure(dt) { let diverged = false; for (let k = 0; k < 4; k++) { @@ -937,32 +1161,13 @@ export class SailRig { // path, not just under the test-only watchDivergence tripwire. The event // carries reason:'divergence' so a log can tell it from an honest blow. if (!Number.isFinite(c.load)) { - c.broken = true; - c.overload = 0; - c.load = 0; - c.loadVec.x = c.loadVec.y = c.loadVec.z = 0; - this.invMass[this.cornerIdx[k]] = 1 / this.nodeMass; - this.dumpPond('corner blew'); - this.events.emit('break', { type: 'break', corner: c, anchorId: c.anchorId, hw: c.hw.name, t: this.t, reason: 'divergence' }); + this._releaseCorner(k, 'divergence'); diverged = true; continue; } if (c.load > c.hw.rating * (c.anchor.ratingHint ?? 1)) c.overload += dt; else c.overload = Math.max(0, c.overload - dt * OVERLOAD_RECOVER); - if (c.overload > OVERLOAD_SECS) { - c.broken = true; - c.overload = 0; - c.load = 0; - // Hand the node its mass back. Everything good about a failure comes - // from this one line: the freed corner stops being pinned, so it flies - // on the wind and the flogging is emergent rather than animated. - // Without it a "blown" corner stays welded in mid-air. - this.invMass[this.cornerIdx[k]] = 1 / this.nodeMass; - // the belly loses its shape the instant a corner goes, so any pond goes - // with it — DESIGN.md's "sudden dump", onto whatever is below. - this.dumpPond('corner blew'); - this.events.emit('break', { type: 'break', corner: c, anchorId: c.anchorId, hw: c.hw.name, t: this.t }); - } + if (c.overload > OVERLOAD_SECS) this._releaseCorner(k, 'overload'); } // SPRINT16 gate 2.1 — THE HEAL. The break above made divergence detectable // (the fresh-eyes review's fix); this makes it survivable to LOOK at. The diff --git a/web/world/js/sail.selftest.js b/web/world/js/sail.selftest.js index 034411d..7fe330f 100644 --- a/web/world/js/sail.selftest.js +++ b/web/world/js/sail.selftest.js @@ -1314,6 +1314,160 @@ test('ruling: at MAX tension the carport still goes first — and the hint is do return `max tension: ${breaks[firstCb].id} first at t=${breaks[firstCb].t.toFixed(1)}s; hint-free control held 4/4`; }); +// --- SPRINT18 gate 1: THE KNIFE, and the corner that was already gone ------- +// +// DESIGN.md line 165 — *cut a sail loose: lose the sail, save the anchors.* The +// physics half is B's; D owns the verb. These pin the two facts the rest of the +// game will be built on: a cut corner carries NO load ever again (that is the +// whole "save the anchors"), and a cut is NOT a break (warranty must never chase +// a corner the player chose to let go). + +test('gate 1: the knife releases the sail and the anchors stop being loaded', () => { + const r = new SailRig({ anchors: makeAnchors(HEIGHTS_FLAT), gridN: 10 }); + r.attach(ALL_IDS, Array(4).fill(HARDWARE[0]), 1.0); // cheapest steel, so it is genuinely at risk + const w = makeStubWind({ stormLen: 90 }); + const broke = [], cuts = []; + r.events.on('break', (e) => broke.push(e)); + r.events.on('cutAway', (e) => cuts.push(e)); + + runStorm(r, w, 3); + const before = r.corners.map((c) => c.load); + assert(before.some((l) => l > 1), `nothing was under load before the cut (${before.map((l) => l.toFixed(0))}) — this test would prove nothing`); + + const res = r.cutAway(null, 'the glasshouse'); + assert(res.ok && res.cut.length === 4, `cutAway refused or cut only ${res.cut?.length}: ${res.reason}`); + assert(cuts.length === 1 && cuts[0].corners.length === 4, + `cutAway emitted ${cuts.length} events with ${cuts[0]?.corners?.length} corners — Lane A's tally reads this payload`); + assert(cuts[0].reason === 'the glasshouse', 'the cut dropped its reason — the invoice line needs it'); + assert(Number.isFinite(cuts[0].t), 'the cut has no time on it'); + + // THE POINT: keep flying and no anchor can ever be torn out again. + runStorm(r, w, 20); + assert(r.corners.every((c) => c.load === 0), + `a cut corner is still carrying load (${r.corners.map((c) => c.load.toFixed(0))}) — "save the anchors" is not true`); + assert(broke.length === 0, + `the knife emitted ${broke.length} break event(s). A cut is a DECISION, not a hardware failure — ` + + 'billing it as a break puts warranty and rep damage on the player for doing exactly what the knife is for'); + assert(r.cutCorners.length === 4, `cutCorners reads ${r.cutCorners.length}, not 4`); + return `cut 4 corners on ${cuts[0].t.toFixed(1)}s of storm; 20 s more wind, 0 breaks, every corner at 0 N`; +}); + +test('gate 1: the knife refuses an empty cut, and a re-rig forgets last night\'s', () => { + const r = new SailRig({ anchors: makeAnchors(HEIGHTS_FLAT), gridN: 10 }); + const bare = r.cutAway(); + assert(!bare.ok && /no sail/.test(bare.reason), `cutting an unrigged sail gave ${JSON.stringify(bare)}`); + + r.attach(ALL_IDS, Array(4).fill(HARDWARE[1]), 1.0); + assert(!r.cutAway(['nope']).ok, 'cutting a corner that is not on this sail was allowed'); + assert(r.cutAway(['a0']).ok, 'a single-corner cut was refused'); + const again = r.cutAway(['a0']); + assert(!again.ok && /already down/.test(again.reason), + `cutting the same corner twice gave ${JSON.stringify(again)} — a second paperwork line for one knife stroke`); + + // the phantom-sail lesson, one field over: state that outlives its night lies. + r.attach(ALL_IDS, Array(4).fill(HARDWARE[1]), 1.0); + assert(r.cutCorners.length === 0, `a re-rig kept ${r.cutCorners.join(',')} cut — last night's knife on tonight's invoice`); + return 'refuses no-sail / unknown corner / already-cut; re-rig clears the flags'; +}); + +test('gate 1: a corner can arrive ALREADY GONE, and it is not your break', () => { + const r = new SailRig({ anchors: makeAnchors(HEIGHTS_FLAT), gridN: 10 }); + r.attach(ALL_IDS, Array(4).fill(HARDWARE[1]), 1.0); + const broke = []; + r.events.on('break', (e) => broke.push(e)); + + const res = r.failCorner('a2'); + assert(res.ok, `failCorner refused: ${res.reason}`); + const c = r.corners.find((x) => x.anchorId === 'a2'); + assert(c.broken && c.inherited, 'the corner is not marked broken-and-inherited'); + assert(!c.cut, 'a corner that arrived down was flagged as CUT — that is the player taking blame for a stranger'); + assert(broke.length === 0, + `failCorner emitted a break. You did not break it: on an emergency callout that line would bill the ` + + 'player warranty for the previous contractor\'s shackle'); + // and the freed node really is free — the sim must fly it, not weld it + const w = makeStubWind({ stormLen: 90 }); + runStorm(r, w, 5); + assert(c.load === 0, `a corner that arrived down is carrying ${c.load.toFixed(0)} N`); + assert(!r.failCorner('a2').ok, 'failing the same corner twice was allowed'); + assert(!r.failCorner('ghost').ok, 'failCorner accepted an anchor this sail has never heard of'); + return 'a2 arrives down: broken, inherited, 0 N, and NOT a break event'; +}); + +// --- SPRINT18: THE CLOCK A MID-STORM ARRIVAL BREAKS ------------------------ +// +// Lane C's finding, B's file. `_substep` samples the wind at `this.t`, not at the +// `t` step() is handed — deliberate (it is what makes 144 Hz and a bench agree), +// and safe only while the two clocks START together. `attach()`'s own comment +// says so: "commit → attach → storm all land in a single keypress, so t=0 at +// attach IS t=0 at the first storm frame." An emergency callout opens the storm +// phase at t=30 and that precondition is gone — the cloth would fly the storm's +// first 60 s under a sky flying its last 60. + +test('the sail can be attached INTO a storm already running, and feels that storm', () => { + const w = makeStubWind({ stormLen: 90 }); + // A wind whose speed climbs with t, so "which second is the cloth in" is + // readable straight off the load. If the two clocks agree, these differ. + const early = new SailRig({ anchors: makeAnchors(HEIGHTS_FLAT), gridN: 10 }); + early.attach(ALL_IDS, Array(4).fill(HARDWARE[2]), 1.0); + runStorm(early, w, 4); + const earlyLoad = Math.max(...early.corners.map((c) => c.load)); + + const late = new SailRig({ anchors: makeAnchors(HEIGHTS_FLAT), gridN: 10 }); + late.attach(ALL_IDS, Array(4).fill(HARDWARE[2]), 1.0, { at: 40 }); + assert(late.t === 40, `attach({at:40}) left the clock at ${late.t}`); + runStorm(late, w, 4, undefined); + const lateLoad = Math.max(...late.corners.map((c) => c.load)); + + assert(Math.abs(earlyLoad - lateLoad) > 1e-6, + `a sail attached at t=40 pulled exactly what one attached at t=0 pulled (${earlyLoad.toFixed(1)} N). ` + + 'The seed never reached the wind sampling, so an emergency callout is flying the wrong sixty seconds ' + + "of its own storm — C's 4.12x understated load, undetectable from the outside"); + return `t=0 entry pulls ${earlyLoad.toFixed(0)} N, t=40 entry pulls ${lateLoad.toFixed(0)} N — different storms, as they must be`; +}); + +test('clockSkew reads 0 when the clocks agree and NAMES the drift when they do not', () => { + const w = makeStubWind({ stormLen: 90 }); + const r = new SailRig({ anchors: makeAnchors(HEIGHTS_FLAT), gridN: 10 }); + r.attach(ALL_IDS, Array(4).fill(HARDWARE[2]), 1.0); + for (let i = 0; i < 120; i++) r.step(SIM_DT, w, i * SIM_DT); + assert(r.clockSkew < SIM_DT * 2, + `an honest night reads a skew of ${r.clockSkew.toFixed(3)}s — the diagnostic is crying wolf and will be ignored`); + + // now the emergency shape, done WRONG: phase at 30, cloth from scratch + const bad = new SailRig({ anchors: makeAnchors(HEIGHTS_FLAT), gridN: 10 }); + bad.attach(ALL_IDS, Array(4).fill(HARDWARE[2]), 1.0); + for (let i = 0; i < 120; i++) bad.step(SIM_DT, w, 30 + i * SIM_DT); + assert(bad.clockSkew > 25, + `a sail stepped with a caller 30 s ahead of it reported a skew of ${bad.clockSkew.toFixed(3)}s. This is the ` + + 'one thing that made the emergency-clock bug invisible — it must be readable'); + + // and seeding it closes the gap + const good = new SailRig({ anchors: makeAnchors(HEIGHTS_FLAT), gridN: 10 }); + good.attach(ALL_IDS, Array(4).fill(HARDWARE[2]), 1.0, { at: 30 }); + for (let i = 0; i < 120; i++) good.step(SIM_DT, w, 30 + i * SIM_DT); + assert(good.clockSkew < SIM_DT * 2, + `a correctly seeded mid-storm arrival still reads ${good.clockSkew.toFixed(3)}s of skew`); + return `honest 0.000s · unseeded mid-storm arrival ${bad.clockSkew.toFixed(1)}s · seeded ${good.clockSkew.toFixed(3)}s`; +}); + +test('a sail that has ALREADY been up carries the history, not just the clock', () => { + const w = makeStubWind({ stormLen: 90 }); + // seeded only: right weather, no past. flown: the 30 s actually happened. + const seeded = new SailRig({ anchors: makeAnchors(HEIGHTS_FLAT), gridN: 10 }); + seeded.attach(ALL_IDS, Array(4).fill(HARDWARE[2]), 1.0, { at: 30 }); + const flown = new SailRig({ anchors: makeAnchors(HEIGHTS_FLAT), gridN: 10 }); + flown.attach(ALL_IDS, Array(4).fill(HARDWARE[2]), 1.0, { at: 30, wind: w }); + + assert(seeded.t === 30 && flown.t === 30, `clocks are ${seeded.t} / ${flown.t}, both should be 30`); + assert(flown.corners.some((c) => c.peakLoad > 0), + 'a flown pre-history left every peak at 0 — the substeps never ran, so the sail did not live through anything'); + assert(seeded.corners.every((c) => c.peakLoad === 0), + 'the seeded-only sail has peaks already, so it is not the clean control this comparison needs'); + // and it must not have secretly advanced past where it was told to be + assert(flown.clockSkew === 0, `the pre-flight left ${flown.clockSkew} s of skew behind it`); + return `both at t=30; flown carries peaks up to ${Math.max(...flown.corners.map((c) => c.peakLoad)).toFixed(0)} N, seeded carries none`; +}); + export const SAIL_TESTS = TESTS; export function runSailSelftest() { diff --git a/web/world/js/tests/balance.test.js b/web/world/js/tests/balance.test.js index 5342a71..73a655d 100644 --- a/web/world/js/tests/balance.test.js +++ b/web/world/js/tests/balance.test.js @@ -71,13 +71,17 @@ async function buildYard() { // the real backyard_01 keeps this suite's "read the yard, never copy it" rule // intact — it's the same yard, from its source instead of from code. const { loadSite } = await import('../world.js'); - const world = createWorld(scene, { wind: calm, site: await loadSite('backyard_01') }); + // Kept, not just consumed: SPRINT18 gate 2 flies the site's own pinned + // `separation` recipe, and reading it from the site the yard was built from is + // the same "never copy the yard" rule one level up. + const siteDef = await loadSite('backyard_01'); + const world = createWorld(scene, { wind: calm, site: siteDef }); if (world.dress) { try { await world.dress(); } catch { /* graybox anchors are enough */ } } const anchors = world.anchors.map((a) => { const pos = { x: a.pos.x, y: a.pos.y, z: a.pos.z }; return { id: a.id, type: a.type, pos, sway: () => pos }; }); - return { anchors, bed: world.gardenBed, heightAt: world.heightAt }; + return { anchors, bed: world.gardenBed, heightAt: world.heightAt, siteDef }; } /** @@ -393,6 +397,54 @@ async function gardenHailByPorosity(yard, stormName, burstT) { return { cloth, membrane, lost: rig.corners.filter((c) => c.broken).length }; } +/** + * SPRINT18 GATE 2 — fly the site's PINNED separation recipe at one stated + * porosity and report only what the steel did. Game-true: no calm settle (the + * real game's commit→attach→storm is one keypress), no skyfx, no garden — the + * question is which corners let go, and corners are decided by `rig.step` alone. + * + * Deliberately NOT `fly()`: that helper settles 12 s on the calm day first, + * which is the phantom-settle skew gardenfly was rewritten to delete. A peak + * quoted off a settled rig is not the number the player's night produces. + * + * The fabric goes on through `RiggingSession.commit(rig)` — the same door that + * was missing from the shipped commit path, so this helper exercises the wire it + * is here to protect. + */ +async function flySeparationSteel(yard, porosity) { + const sep = yard.siteDef?.separation; + if (!sep) throw new Error('balance: backyard_01 lost its pinned separation block'); + const def = await loadStorm(sep.stormKey); + const wind = createWind(def); + wind.setSheltersFromTrees(yard.anchors.filter((a) => a.type === 'tree')); + + const s = new RiggingSession({ anchors: yard.anchors }); + for (const l of sep.line) if (!s.rig(l.anchor).ok) throw new Error(`balance: cannot rig ${l.anchor}`); + for (const l of sep.line) { + const hw = HARDWARE.find((h) => h.name === l.hw); + if (!s.setHardware(l.anchor, hw).ok) throw new Error(`balance: cannot buy ${l.hw} for ${l.anchor}`); + } + s.setTension(sep.tension ?? 1.0); + s.setFabric(porosity === 0 ? 'membrane' : 'cloth'); + const rig = s.commit(new SailRig({ anchors: yard.anchors, gridN: 10 })); + if (rig.porosity !== porosity) { + throw new Error(`balance: asked for porosity ${porosity}, the rig committed at ${rig.porosity} — ` + + 'the fabric did not reach the sail, which is gate 2 all over again'); + } + + const order = []; + rig.events.on('break', (e) => order.push(e.anchorId)); + for (let i = 0, n = Math.round(def.duration / FIXED_DT); i < n; i++) rig.step(FIXED_DT, wind, i * FIXED_DT); + + return { + porosity: rig.porosity, + lost: rig.corners.filter((c) => c.broken).length, + order, + peak: Object.fromEntries(rig.corners.map((c) => [c.anchorId, c.peakLoad])), + eff: Object.fromEntries(rig.corners.map((c) => [c.anchorId, c.hw.rating * (c.anchor.ratingHint ?? 1)])), + }; +} + /** * @param {import('../testkit.js').Suite} t * @@ -465,6 +517,38 @@ export default async function run(t) { } } + // SPRINT18 GATE 2 — the pinned recipe, flown at each fabric, nothing else + // changed. These two rows ARE the two-harness disagreement D reported, and the + // membrane row is D's cold play and the site's own `_playedReference`. + const sepCloth = await flySeparationSteel(yard, 0.30); + const sepMembrane = await flySeparationSteel(yard, 0); + + // GATE 2's SEAM, pinned on the door the shipped game actually walks through. + // `RiggingSession.commit(rig)` was never reached in play — the rigging UI's + // commit() calls onCommit, so whatever onCommit fails to forward, the sim never + // learns. The fabric is the 4th argument now; this is the assert that keeps it + // there. Browser-only (the UI needs THREE, a scene and a canvas), which is why + // it lives in this suite and not in rigging.selftest.js's node-safe set. + let seam; + { + const { createRiggingUI } = await import('../rigging.js'); + const scene = new THREE.Scene(); + const camera = new THREE.PerspectiveCamera(60, 1.6, 0.1, 400); + const canvas = document.createElement('canvas'); + const seen = []; + const ui = await createRiggingUI({ + scene, camera, domElement: canvas, + world: { anchors: yard.anchors }, + onCommit: (ids, hw, tension, fabric) => seen.push({ ids, hw, tension, fabric }), + panel: false, + }); + for (const id of COVER_QUAD) ui.session.rig(id); + const flipped = ui.session.setFabric('membrane').ok; + ui.commit(); + ui.dispose(); + seam = { seen: seen[0], flipped, sessionFabric: ui.session.fabric }; + } + // --- then judge ----------------------------------------------------------- // SPRINT8 gate 0' — CLOSED. The harnesses agree; the disagreement was a miscount. @@ -555,6 +639,116 @@ export default async function run(t) { `(hp ${membrane.hp}) — the fabric is the decision`; }); + // ⚠️ SPRINT18 GATE 2 — THE TWO HARNESSES, AND THE VARIABLE BETWEEN THEM. + // + // D flew backyard_01's pinned wildnight recipe by hand and lost p2 then p3; + // `gardenfly` holds the same recipe 0-lost. One variable, measured: + // + // shade cloth 0.30 0/4 lost p2 3.07 kN p3 1.78 p1 2.97 p5 5.11 + // membrane 0 2/4 lost p2 3.48 p3 3.90 p1 3.50 p5 5.65 + // + // The second row is D's cold play to the decimal, and it is also the site's own + // `_playedReference` (hp 55.7, p2 then p3, 2026-07-18). The bench was NOT + // lying: it flew the fabric every card in the game promised. The GAME was + // lying — `rig.setFabric` was never called on the sim's rig, so main.js's + // `new SailRig({anchors})` flew porosity 0 every night while the panel, the + // SCORE IT card, the HUD row and the invoice all said "shade cloth". Fixed by + // widening the commit seam (rigging.js) to carry the fabric. + // + // This assert is the tripwire that keeps the two rows apart. The break ORDER is + // pinned too, because p2-then-p3 is the physics: p2 goes at the change and p3 + // catches its share. If these ever converge, either porosity has stopped + // reaching the wind load or the wildnight went soft. + t.test('gate 2: the wildnight pin holds on the fabric it was priced on, and only that one', () => { + if (sepCloth.lost !== 0) { + throw new Error(`the pinned recipe lost ${sepCloth.lost}/4 on the shade cloth it was measured on ` + + `(${sepCloth.order.join(' then ')}). The pin's heldMustExceed target assumes 0 lost, so either ` + + `storm_02 hardened or porosity stopped bleeding the pressure term.`); + } + if (sepMembrane.lost !== 2) { + throw new Error(`the same recipe on membrane lost ${sepMembrane.lost}/4, not 2. D's cold play and ` + + `the site's own _playedReference both lost exactly p2 then p3 — this is the number that made ` + + `gate 2 findable, and moving it silently loses the receipt.`); + } + if (sepMembrane.order.join(',') !== 'p2,p3') { + throw new Error(`membrane broke ${sepMembrane.order.join(' then ') || 'nothing'}, not p2 then p3. ` + + `The order is the mechanism: p2 goes at the southerly change and p3 catches the share it drops.`); + } + // and the mechanism itself, not just the verdict: full wind load is HEAVIER. + const gain = sepMembrane.peak.p2 / sepCloth.peak.p2; + if (!(gain > 1.05)) { + throw new Error(`membrane's p2 peak is only ${gain.toFixed(3)}x the cloth's. Porosity scales the ` + + `pressure coefficient by (1 - porosity), so 0.30 cloth must read materially lighter than a ` + + `membrane. A ratio near 1.0 means setFabric is no longer reaching _accumulateWind.`); + } + return `same recipe, one variable: cloth ${sepCloth.lost}/4 (p2 ${(sepCloth.peak.p2 / 1000).toFixed(2)} kN) · ` + + `membrane ${sepMembrane.lost}/4 lost ${sepMembrane.order.join(' then ')} ` + + `(p2 ${(sepMembrane.peak.p2 / 1000).toFixed(2)} kN, ${gain.toFixed(2)}x) — the variable is POROSITY`; + }); + + // ⚠️ SPRINT18 GATE 3 — WHY THE CLEAN BOUNDARY CANNOT BE A PERCENTAGE. + // + // One flight, two corners, identical $15 shackles, same 3.2 kN rating: + // + // p1 3.528 kN = 10.3% OVER -> HELD (over the rating for 0.150 s) + // p2 3.493 kN = 9.2% OVER -> BROKE (over the rating for 0.400 s) + // + // p1 peaks HIGHER than p2 and survives it. Every rule of the shape "peak within + // X% of the rating breaks" is therefore wrong at every X — 15% (the retired + // AUDIT.MARGIN) condemns p1's perfectly adequate steel and demands $30 for it; + // 9% blesses p2 and watches it let go. sail.js never used peak: it burns a + // 0.4 s fuse while a corner is over, and DWELL is what decides. This assert is + // the receipt, taken off the same flight gate 2 uses, so the audit's re-ruled + // boundary (tools/site_audit/sweep.js — the fuse replayed per tier) has a + // measured fact under it in the game's own suite rather than a tool's fixture. + t.test('gate 3: the same steel at a HIGHER peak survives — so peak cannot price a corner', () => { + const eff = sepMembrane.eff, peak = sepMembrane.peak; + if (eff.p1 !== eff.p2) { + throw new Error(`p1 and p2 are on different steel now (${eff.p1} vs ${eff.p2} N). This test needs two ` + + `corners at the SAME rating for the comparison to mean anything — re-pick the pair or re-measure.`); + } + if (!(peak.p1 > eff.p1 && peak.p2 > eff.p2)) { + throw new Error(`both corners must go OVER their rating for this to say anything: p1 ${peak.p1} and ` + + `p2 ${peak.p2} against ${eff.p1} N.`); + } + if (!(peak.p1 > peak.p2)) { + throw new Error(`p1 (${peak.p1} N) no longer peaks higher than p2 (${peak.p2} N) — the inversion that ` + + `kills percentage pricing has gone, so re-measure before trusting the dwell rule's justification.`); + } + if (sepMembrane.order.includes('p1')) { + throw new Error(`p1 broke as well. The whole point is that the HIGHER peak held: if both go, peak and ` + + `outcome agree again and a percentage rule would be defensible.`); + } + const overP1 = ((peak.p1 / eff.p1) - 1) * 100, overP2 = ((peak.p2 / eff.p2) - 1) * 100; + return `same ${(eff.p1 / 1000).toFixed(1)} kN steel: p1 ${overP1.toFixed(1)}% over HELD, ` + + `p2 ${overP2.toFixed(1)}% over BROKE — dwell decides, not peak`; + }); + + // GATE 2's WIRE, not its symptom. The bug was never in the physics: it was that + // the only function which tells a sail what it is made of sat on a path the + // player never walked. So assert the path, on the object the player's Enter key + // reaches — the fabric must come OUT of commit(), or the sim cannot learn it. + t.test('gate 2: the commit seam carries the FABRIC, not just the steel', () => { + if (!seam.seen) throw new Error('the rigging UI committed nothing — four corners were rigged'); + if (!seam.flipped) throw new Error('setFabric refused the membrane; the control never changed'); + if (!seam.seen.fabric) { + throw new Error(`commit() handed onCommit (ids, hw, tension) and NO fabric. That is exactly the ` + + `sprint-18 gate-2 bug: main.js's rigSail can only call rig.setFabric with something it was ` + + `given, so a dropped 4th argument means every night flies new SailRig's default porosity (0, ` + + `the membrane) while every card in the game says shade cloth.`); + } + if (seam.seen.fabric.id !== seam.sessionFabric.id) { + throw new Error(`commit forwarded fabric "${seam.seen.fabric.id}" while the session holds ` + + `"${seam.sessionFabric.id}" — the paperwork and the sim would disagree again, one layer down.`); + } + if (!Number.isFinite(seam.seen.fabric.porosity)) { + throw new Error('the forwarded fabric has no finite porosity — setFabric would clamp NaN and the ' + + 'sail would fly a fabric nobody chose.'); + } + return `commit() -> onCommit(ids, hw, tension, fabric) with the session's own ` + + `"${seam.seen.fabric.id}" (porosity ${seam.seen.fabric.porosity}) — the wire the game was missing`; + }); + // The fabric's SECOND edge, through the REAL garden-hail chain, not a copy of it. // `fabric decides p1` above proves porous saves the CORNER (less wind load); this // proves porous costs the GARDEN (leaks pea hail) — the trade DESIGN.md promises From ae2f560ff2bff832c6a423c198bc31a479966eae Mon Sep 17 00:00:00 2001 From: type-two Date: Sat, 25 Jul 2026 19:55:35 +1000 Subject: [PATCH 2/2] =?UTF-8?q?Lane=20B=20S18:=20[B]=20THREADS=20=E2=80=94?= =?UTF-8?q?=20gate=202's=20variable,=20gate=203's=20re-ruled=20boundary,?= =?UTF-8?q?=20the=20cut=20API=20for=20D,=20C's=20clock?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- THREADS.md | 233 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 233 insertions(+) diff --git a/THREADS.md b/THREADS.md index 4f0fff1..7e7d1c9 100644 --- a/THREADS.md +++ b/THREADS.md @@ -9505,3 +9505,236 @@ anchors are your GLB), but the tooling is now waiting, not TODO. **Deploy still owes one hand:** tools/deploy_hardyards.sh is permission-gated in the integrator's session (ssh + docker to the games VPS). John runs it or green-lights it; S18 must not ship before S17 is live, per the standing one-sprint rail. + +[B] 2026-07-25 — ⚠️ **GATE 2 CLOSED: THE VARIABLE IS `porosity`, AND THE HARNESS THAT WAS LYING + WAS THE GAME.** D's cold play lost p2 then p3 on the wildnight flying the recipe `gardenfly` + holds 0-lost. Found, named, pinned, and it is worse and better than a sim bug: **`rig.setFabric()` + has exactly ONE call site in the repo — `RiggingSession.commit(rig)` — and the shipped commit + path never reaches it.** The rigging UI's `commit()` calls `onCommit(ids, hw, tension)`; main.js's + `rigSail` goes straight to `rig.attach()`. So `session.commit(rig)` runs in TESTS AND BENCHES + ONLY, and the sim's rig — built once at boot as `new SailRig({ anchors: world.anchors })`, the + constructor's `porosity = 0` — has flown the **WATERPROOF MEMBRANE at full wind load every night + of every playtest**, while the prep panel, the SCORE IT card, the HUD row, the invoice and every + audit number said "shade cloth". The F key has been honest exactly half the time, and only by + coincidence: pick membrane and the sim agrees with you by accident; pick cloth — the DEFAULT — + and it does not. + + **THE TABLE. One recipe (backyard_01's pinned p5/p1/p2/p3, $75, tension 1.0), one variable.** + + | fabric | lost | p5 | p2 | p3 | p1 | + |---|---|---|---|---|---| + | shade cloth 0.30 — what every card promised | 0/4 | 5.11 | 3.07 | 1.78 | 2.97 | + | membrane 0 — what the sim actually flew | **2/4** | 5.64 | **3.48 BROKE** | **3.90 BROKE** | 3.50 | + + Row two is D's cold play **to the decimal** (their 3.48 / 3.90), and it is also the site's own + `_playedReference` from 2026-07-18 (hp 55.7, p2 then p3). I reproduced it in the SHIPPED GAME — + real Enter commit, `SHADES.step(1/60)` × 5395 — and read hp **55.7**, breaks **p2@46.1s, + p3@74.8s**, `rig.porosity` **0** while `session.fabric` said shade cloth. Three independent + plays, one deterministic story. + + **⚖️ WHICH HARNESS WAS LYING: THE GAME.** The bench flew the fabric the game promised; the game + silently flew a different one. Every audit number stands, the pinned target stands, and D's play + found a real bug rather than a harness artefact. + + **AND IT RETIRES C's RESIDUAL.** "Benches under-read the real UI's peaks by ~5–15% on site_02, + cause unfound" was the justification for `AUDIT.MARGIN = 0.15`. The cause is found. With the + fabric held equal I measured the FULL game chain (real commit, `S.step`, world.update, player, + debris, sky, garden) against the rig-only bench on site_02 × earlybuster: **q1 1.778 · tr1b 1.540 + · tr1 0.467 · q3 0.884 — byte-identical, both harnesses, three decimals.** Debris hits on the + sail: **0**, maxPieces 1. So D's candidate (hail impact load reaching the rig only in-game) is + **ruled out by measurement** — sail.js contains no hail path at all, and the wildnight's three + crates (t=38, t=66, t=74) never touch this sail. `debris` read 0 on the dev line because pieces + DESPAWN out of bounds; the crate had already gone past. + + **THE PIN (2 asserts, both mutation-checked):** `gate 2: the wildnight pin holds on the fabric it + was priced on, and only that one` pins both rows and the break ORDER (p2 then p3 is the + mechanism — p2 goes at the change, p3 catches its share); `gate 2: the commit seam carries the + FABRIC, not just the steel` pins the wire on the object the player's Enter key reaches. Mutation + check 1: drop `session.fabric` from the seam → seam assert red with the exact diagnostic. + Mutation check 2: `coeff = PRESSURE_COEFF * (1 - 0)` → flight assert red, and it correctly + reddens S16's pre-existing `fabric decides p1` too. + + 🔌 **FILED FOR A — THE ONE-LINE PICKUP, and it is the whole fix.** My side is landed: `commit()` + now passes the fabric as a 4th argument, and `web/world/dev_rigging.html` picks it up so the seam + is proven working end to end in a page I own. Yours, in `main.js`'s `rigSail`: + + async function rigSail(anchorIds, hwChoices, tension = 1.0, fabric) { + if (fabric) rig.setFabric(fabric); // ← the line + rig.attach(anchorIds, hwChoices, tension); + + and at the call site (main.js ~824): + onCommit: (ids, hw, tension, fabric) => { void rigSail(ids, hw, tension, fabric); }, + + `rigging.session.fabric` also works — you already read that expression at main.js:917 for the + paperwork — but the argument is better: `dev_rigging.html` has no session to reach into, and it + had the identical bug. **Until this lands the sim still flies the membrane**, so please take it + even if you take nothing else this sprint. ⚠️ Note for whoever lands it: the $80 "clean win" + line and the wild night's balance were TUNED on cloth and PLAYED on membrane, so this makes the + shipped game *easier* than every playtest to date. That is the correct direction (the cards were + the promise) but D should re-fly the week after it lands, and `balance.test`'s + `fabric decides p1` already documents what membrane costs: it tears the cheap corner off. + +[B] 2026-07-25 — ⚖️ **GATE 3 RE-RULED BY MEASUREMENT: THE CLEAN BOUNDARY IS THE FUSE, NOT A + PERCENTAGE — and no percentage can ever be right.** Gate 3 asked me to re-rule the $5-tier + clean/marginal boundary by measurement instead of margin-of-taste. The measurement kills the + entire *shape* of the old rule. One flight, two corners, identical $15 shackles, same 3.2 kN: + + | corner | peak / effective | % over | worst dwell | verdict | + |---|---|---|---|---| + | p5 | 5.635 / 6.50 | −13.3% | 0.000 s | HELD | + | p2 | 3.493 / 3.20 | **+9.2%** | **0.400 s** | **BROKE** | + | p3 | 3.917 / 3.20 | +22.4% | 0.400 s | BROKE | + | p1 | 3.528 / 3.20 | **+10.3%** | **0.150 s** | **HELD** | + + **p1 goes 10.3% over its rating and holds. p2 goes 9.2% over — LESS — and breaks.** Ordering by + peak is INVERTED against the outcome, so "peak within X% of rating breaks" is wrong at every X: + 15% condemns p1's perfectly adequate steel and bills $30 for it, 9% blesses p2 and watches it let + go. sail.js has never used peak — `_checkFailure` fills an `overload` timer while a corner is + over, bleeds it at `OVERLOAD_RECOVER`, and lets go past `OVERLOAD_SECS`. **DWELL is the variable.** + + **THE RE-RULING, landed.** `OVERLOAD_SECS` / `OVERLOAD_RECOVER` are exported from sail.js (never + re-typed — the drift the shared sweep exists to prevent) and `priceCandidate` replays that exact + timer for every corner against every tier in the shop as the flight happens (12 accumulators, no + trace buffers). Per corner it now reports `dwell` (seconds of the 0.4 s fuse the chosen tier + burned), `effN` and `cleanEffN`. So: + · **clean** = the fuse never starts. The load never reaches the rating; the steel is simply + stronger than the night. This replaces `tierFor(peak / (1 − MARGIN))`. + · **marginal** = it went over and survived on TIMING, not strength — a real knife edge with a + real mechanism, which is what the 15% pad was groping for. + · **unholdable** = the fuse burns through. + `AUDIT.MARGIN` stops deciding anything (it survives only as the display band the front-ends + print) and its comment now says why. Consequence worth stating: the audit's clean price gets + CHEAPER, because the pad was cancelling a missing function call. That is only honest once A lands + gate 2's line — flagged there. + + **ALSO MEASURED, and it corrects the gate-3 premise.** D reported "the audit's own $40 clean line + broke at tr1b 1.17/1.2". I re-flew it on the real dressed yard: the audit's row for tr1,tr1b,q1,q3 + on the earlybuster is **$40, clean, ZERO marginal corners, headrooms 0.30–0.62**, and it holds in + BOTH fabrics — 0/4 lost. What breaks is a *different* $40: the same tiers with the cheap one on + the other tree. `tr1b`'s hint is **0.88**, so a $5 carabiner there holds **1.056 kN, not 1.2** — + and it broke at **1.168**, which is 10.6% over its REAL ceiling, not 2.5% under a nominal one. + The sim did nothing wrong. **So the cheap tier's defect is not the boundary, it is that $40 is + not a line:** the audit prints a memorable TOTAL and a forgettable per-corner assignment, two + different $40s exist on one quad, and one of them breaks. `effN` per corner is landed so no + reader has to do hint arithmetic to see it; the prep panel and HUD already print effective + ratings (SPRINT12), and D — who knows the hint system — still reasoned from the price list, which + is the tell that a total is the wrong headline. 📇 **Filed for A/D:** the SCORE IT card should + lead with the per-corner pairing, not the sum. + +[B] 2026-07-25 — 🚨 **GATE 1 (B's half): THE RIG YOU DIDN'T BUILD, THE ECONOMY OF TRIAGE, AND THE + CUT — API PUBLISHED FOR D, WHO RUNS LAST.** Built against A's shapes (origin/lane/a 27d9ebc, + fetched mid-flight). Everything below is landed and node-verified. + + **1 · THE CUT — `rig.cutAway(anchorIds = null, reason = null)`. D: this is yours to drive.** + rig.cutAway() // the whole sail — what the verb means in DESIGN.md + rig.cutAway(['q1'], 'the carport') // one corner + → { ok: true, cut: ['cb1','q1','q3'] } | { ok: false, reason: 'the sail is already down' } + Refusals come back as the session's `{ok, reason}` shape so your key can ticker them instead of + catching a throw: no sail (`'there is no sail to cut'`), a corner not on this sail, or every + named corner already down. Emits **`cutAway` on `rig.events`** with `{ type, t, corners, reason }` + — A's requested payload verbatim (their §9), so their tally and the invoice line need no new + wiring. `rig.cutCorners` is the HUD's read. It does **NOT** emit `break`: a cut is a DECISION, + and billing it as a break would put warranty and rep damage on the player for doing exactly what + the knife is for. The pond dumps with a reason, so main.js's `if (!e.reason) pondDumped += e.kg` + does not credit a cut belly to your arms. + **The physics is deliberately not new:** a cut corner is released through the same + `_releaseCorner` an overload uses, so the cloth goes limp on the wind it already had. What is new + is only what it COSTS — and that falls out of rules the game already has. + + **2 · ⚖️ THE ECONOMY OF TRIAGE — I RULE THE GARDEN BONUS OFF, AND THE REFUND OFF, AND HERE IS + WHY.** "Paid on the callout plus what you SAVE." + · **Garden BONUS: does not apply.** `pay.garden = 0` on the emergency night entry — **data, not + a branch**, so A's acceptance §10 holds exactly (`settle()` runs unchanged). Why: the bonus is + a per-HP payment for a bed you chose hardware to protect. You arrive at t=30 with thirty + seconds of hail already in that bed, under steel you did not buy. Paying per-HP prices the + PREVIOUS contractor's geometry and docks you for their mistakes. + · **The garden's WIN still applies, and that is the knife's price.** `win = hp >= 50` stays + untouched (A's rule, and it is HP-only in the shipped game — the `lost < 2` clause lives only + in the benches). So cutting spends garden HP, and possibly the night's fee, to buy certainty on + their property. **Ride it out for the full double rate and gamble their carport; cut and take + the smaller cheque.** The garden is the STAKE, not the wage — that is the sentence. + · **⚠️ The hardware REFUND must not apply to inherited steel, and this one is not taste.** + week.js pays back half of every intact corner (`intactHardwareValue × refundShare`). If a + stranger's shackles count as yours, **every corner you CUT costs you money** — the ledger would + pay you to let someone else's sail take out a glasshouse. DESIGN.md's signature decision, + punished for being taken. `session.intactHardwareValue(rig)` excludes inherited corners. + + **3 · `session.inherit(spec)` — A, this is the door you asked for (§2), and it does both jobs.** + session.inherit([{ anchorId: 'q1', hw: 'carabiner', broken: false }, …]) // your shape, 4 corners + → { ok: true, broken: ['tr1b'] } // hand these to rig.failCorner AFTER commit + Adopted corners cost **$0** and never move `budget`/`spent`, so the invoice's spend line reads $0 + on a callout with no ledger branch. They carry `inherited: true` (also on `summary.corners[]`, + plus `summary.inheritedValue` — a number the dispatch can print where a spend line would go). + Client terms still bite: a house ban still refuses, because a ban is about what may be TOUCHED, + not about who paid — and it comes back as a result, not a throw, so you can ticker it at the same + boundary `setConstraints` uses. Refuses wrong corner counts, unknown hardware, duplicate anchors + and strangers, and **never leaves half a rig behind**. + **Upgrading an inherited corner costs FULL price, not the difference** (`_priceOfChange`): there + is nothing to trade in, and crediting the old piece would have the player selling somebody else's + steel to fund their own. Once paid for, the corner stops being `inherited` and refunds normally. + + **4 · `rig.failCorner(anchorId)` — the pre-broken corner, the thing public moves cannot express** + (A's §2 ⚠️). Releases the corner silently: **no `break` event**, because you did not break it, + and `c.inherited` marks why. `{ok, reason}` shape; idempotent; refuses an unknown anchor. + + **5 · THE AUTHORED COWBOY RIG — site_02 × storm_03_southerly, measured, A's slot (§6).** + Hardware below the night, one corner already gone, the hints telling the truth: + [{ anchorId: 'q3', hw: 'shackle' }, // sound post, still holding + { anchorId: 'q1', hw: 'carabiner' }, // under-specced + { anchorId: 'tr1b', hw: 'carabiner', broken: true }, // 0.88 branch + cheapest steel = already gone + { anchorId: 'cb1', hw: 'shackle' }] // ⚠️ the CARPORT BEAM, hint 0.22 + `cb1` is the whole design: a shackle on a 0.22 beam holds **704 N**, and it is holding their roof. + The quad shades **0.92** of the bed, so cutting it genuinely costs the garden. + + **6 · ⚖️ IS THE INHERITED RIG TRIAGEABLE? YES — A, this answers your flagged question with + numbers.** Pre-flown to arrival (`attach(…, { at: 30, wind })`, `clockSkew` 0): + + | the player's choice | what happens | the carport ($180) | + |---|---|---| + | ride it out | cb1 blows @35.9 s, q1 @35.9 → 3 lost | **WRECKED — $180** | + | **cut at t=33** | **nothing breaks** | **saved** | + + Three genuinely different outcomes and a real clock: **the decisive window is t=30 → 35.9, so + 5.9 seconds.** The southerly puts cb1 over a shackle's ceiling from **t=35 to t=42** (peaking + 1564 N, over even a RATED shackle's 1430 N at t=42), and q1/q3 keep spiking to 2.2–2.9 kN through + t=55–80, so after the carport goes the survivors work hard all night. `arriveAt: 30` is the right + number — the whole decision lives in twelve seconds and you get five of them. + ⚠️ **One authoring ordering note for A:** `attach({at, wind})` pre-flies all four corners, so + `failCorner` runs after. Strictly the dead corner should be down DURING those 30 s; measured + difference is small (cb1 0.80 vs 0.83 kN, identical verdict) but it is a simplification, stated + rather than hidden. Say the word and I will take the broken set into `attach`. + +[B] 2026-07-25 — 🕐 **LANE C's CLOCK FINDING: CONFIRMED, FIXED, AND PINNED — and it is NOT gate 2's + variable.** C found that `_substep` samples the wind at `this.t`, not at the `t` handed to + `step()`. Correct, it is my file, and C is right that it bites the emergency callout hard. + + **Why it cannot be gate 2's variable, and I checked before ruling:** on an ordinary night both + clocks start at 0 and advance by the same `FIXED_DT`, so they are equal by construction — and the + proof is the measurement above, where the bench and the FULL game chain agreed to **three + decimals on four corners**. A wind-time skew cannot produce that. Gate 2 is `porosity`, + reproduced in the shipped game against D's own numbers. **Both findings are real and they are + different bugs**: mine is a fabric that never reached the sim on every night ever played; C's is + a clock that breaks on the one night that starts mid-storm. + + **Fixed.** `attach(ids, hw, tension, { at, wind })` — `at` seeds the sail's clock, and with a + `wind` the sail is genuinely **pre-flown** from 0 to `at` so the drape, the pond and the peaks of + those seconds are real rather than a sail teleported into a gale with no history (~1800 substeps, + single-digit ms). `seedClock(t)` is the late door for a caller that learns the number after + commit. `at` defaults to 0, which is byte-for-byte the old behaviour — every existing caller is + untouched, and the 522 baseline held. Measured size of what was hiding: the same rig entering the + same storm at **t=0 pulls 90 N, at t=40 pulls 655 N**. + + **And `rig.clockSkew` makes it un-hideable from now on** — the largest |caller's t − this.t| since + attach. 0 on every honest night. It is a diagnostic, not a throw, because one harness diverges + ON PURPOSE and its numbers are calibrated against it: **`balance.test`'s `fly()` settles 12 s on + the calm day and then restarts the storm loop's `t` at 0**, which is exactly the phantom settle + the pinned separation target was restated around (backyard_01's `_why`: "game-true 63.8/63.6, + skewed 68.4/68.3"). C is right that `fly()` carries the skew; it is pre-existing, documented, and + every number in that suite is pinned against it, so I did NOT change it this sprint — moving it + would move a dozen pinned balance numbers for no gate. 📇 **Filed:** `fly()`'s settle should + either seed the storm clock or drop the settle like `gardenfly` did; it is a real re-measure with + its own gate, and now it has a named mechanism instead of "the phantom settle". + **`gardenfly` and my gate-2 harness have NO settle** (deliberately), which is why they reproduce + the played game exactly — that is the part of C's warning that does not apply here. + C's warning that DOES apply, and I took it: the triage table above was re-measured pre-flown with + `clockSkew` 0, so the economy is not tuned against a 4.12× understated load.