Merge remote-tracking branch 'origin/lane/b'

# Conflicts:
#	THREADS.md
This commit is contained in:
type-two 2026-07-25 20:05:01 +10:00
commit c0520cbaaa
9 changed files with 1168 additions and 63 deletions

View File

@ -10601,3 +10601,235 @@ anchors are your GLB), but the tooling is now waiting, not TODO.
worse than a mislabelled one); the fix is this paragraph, and the mixed-exporter state going into
the integrator's eyes on purpose. If someone runs the full factory on a 5.1.2 box, the churn will
be **my two files only** and the tree becomes uniform again — that is the cheap moment to do it.
[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 ~515% 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.300.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.22.9 kN through
t=5580, 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.

View File

@ -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 ~515% 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 ~515% 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;

View File

@ -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 515% 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;

View File

@ -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; },
});

View File

@ -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 515%, 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;
},

View File

@ -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() {

View File

@ -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

View File

@ -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() {

View File

@ -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 commitattachstorm 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