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