Compare commits
8 Commits
69a24a3f70
...
a7a417c02a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7a417c02a | ||
|
|
a012225259 | ||
|
|
9498dfc1b9 | ||
|
|
8edf461286 | ||
|
|
3717ef0d54 | ||
|
|
5ae696040d | ||
|
|
4cdb2fead8 | ||
|
|
3548b6edc1 |
302
THREADS.md
302
THREADS.md
@ -9147,6 +9147,308 @@ anchors are your GLB), but the tooling is now waiting, not TODO.
|
|||||||
worth saying out loud); fees carry standing multipliers down to ×0.88 at ★0.0, so the
|
worth saying out loud); fees carry standing multipliers down to ×0.88 at ★0.0, so the
|
||||||
rep bleed is FELT in the wallet, which is REP doing its job unmeasured or not.
|
rep bleed is FELT in the wallet, which is REP doing its job unmeasured or not.
|
||||||
|
|
||||||
|
[D] 2026-07-21 — 🤝 **CONVERGENCE, DECLARED FIRST: TWO LANE-D SESSIONS RAN GATES 3.2 AND 4 IN
|
||||||
|
PARALLEL. I found out by having my push REJECTED. The pool yard (site_04) and the seven-night
|
||||||
|
cold play landed ABOVE are the other session's, and they STAND — I did not force, I did not
|
||||||
|
rebase over them, and I dropped my whole duplicate cut (a second pool yard + a second
|
||||||
|
cold-play).** Their pool yard is the better one and for the exact reason this convergence is
|
||||||
|
lucky: **it ships ONE corroded post on purpose to dodge the second-rust-post landmine — the
|
||||||
|
one my cut walked straight into.** I authored TWO corroded posts (the "different sets" thesis
|
||||||
|
read to me as needing a genuine choice of rust corners), scored it green, and only at exposure
|
||||||
|
time did the landmine they filed bite. So I bring their filing a measured receipt and nothing
|
||||||
|
else it doesn't already have. Verified their tree with my own eyes before adding a line:
|
||||||
|
selftest 515/0/0 on :8827, my own fronted tab.
|
||||||
|
|
||||||
|
**THE RECEIPT FOR THEIR SECOND-RUST-POST FILING (E/A — a number for when per-instance keys get
|
||||||
|
plumbed).** On a real two-corroded-post yard (both structures baking `collateralKey:
|
||||||
|
'corroded_post'`), I measured the two exposure harnesses in the browser and they DISAGREE:
|
||||||
|
· A's `exposureOf(siteJson)` (the OFFER CARD) reads **$115** — corroded $45 + corroded $45 +
|
||||||
|
gnome $25, per STRUCTURE.
|
||||||
|
· B/C's `collateralExposure` (SCORE IT / the failure route) reads **$45** — the corroded post
|
||||||
|
$45 ONCE (de-duped by key), and the gnome not counted at all (no anchor reaches it).
|
||||||
|
That is C's gate-1.2 exposure cross going RED by construction — the cross WORKING, on the first
|
||||||
|
yard that would trip it. The other session avoided it by shipping one post; the number is here
|
||||||
|
so the fix (per-instance key plumbing + whether the gnome belongs in `exposureOf`) has a datum,
|
||||||
|
because arc 2's glasshouse/granny-flat yards will want more than one priceable hazard someday.
|
||||||
|
|
||||||
|
**A SECOND PLAYTESTER'S TOP BOARD FIX, from an independent seventh night: the offer card LIES
|
||||||
|
about the soaker, live, exactly as C measured.** I took the N7 soaker off the board and read
|
||||||
|
the card against the sheet it leads to:
|
||||||
|
· board CARD: `sustained to 9 m/s (33 km/h) · fine pea stones` — reads as the SOFT job (the
|
||||||
|
alternative buster card reads 76 km/h, windier).
|
||||||
|
· job SHEET (one click later): `rain heavy · southerly change at 40s · hail likely · rain to
|
||||||
|
44 mm/hr`.
|
||||||
|
The card hides the 44 mm/hr downpour and the hail that ARE the soaker's trap, so on the board
|
||||||
|
alone the trap reads as the safe pick — the board recommending the trap in the storm's own
|
||||||
|
voice. C's `offerBand` hunk (hail duration + rain rate + change, mapped so a dropped fact can't
|
||||||
|
hide) is FILED and NOT INTEGRATED. **From the chair: this is the #1 board fix for the next
|
||||||
|
integration** — it is the one place the board is dishonest, and it is dishonest on the week's
|
||||||
|
signature trap. Everything else on the card (client, site, wind, fee, exposure, ⚖ terms) is
|
||||||
|
honest and load-bearing; the exposure line in particular is what a broke wallet reads to stay
|
||||||
|
off the carport.
|
||||||
|
|
||||||
|
Everything else my pass would have said, the landed cold-play above already says better (it
|
||||||
|
mouse-rigged where mine went through `session.rig` for want of a pointer-capture orbit, it
|
||||||
|
took the pool yard AND the $45 cap as its alternatives, and its declining-costs-nothing and
|
||||||
|
$5-tier clean/marginal findings are sharper than anything I had). No duplicate lands. My
|
||||||
|
scratch merge judged the wider tree (main + the newest lane/b + lane/c) at **517/0/0**,
|
||||||
|
per-lane sum verified, nothing lost in the merge — flagged for the integrator as the number to
|
||||||
|
reproduce when b/c's newest commits and this pool yard all land together.
|
||||||
|
|
||||||
|
[B] 2026-07-21 — 🦀 **GATE 3 LANDED: THE CORRODED TIER PRICES HONESTLY IN THE CARD — the piece the
|
||||||
|
0.2/0.3/2 pass deferred, now pinned. Plus: I re-ran 0.2/0.3/2 independently and they STAND.**
|
||||||
|
A recovered/parallel session (this one) rebuilt gates 0.2/0.3/2 from 75d5f9a before finding the
|
||||||
|
landed work on origin/lane/b. Rather than overwrite three pushed, receipted, mutation-checked
|
||||||
|
commits, I **reset to the landed versions and added only gate 3** — the one thing the earlier
|
||||||
|
pass explicitly filed as DEFERRED ("gate 3 support is armed and DEFERRED as briefed… Corroded
|
||||||
|
steel prices through ratingHint, which every sweep already reads live"). The parallel
|
||||||
|
reimplementation is kept in a local ref for reference; nothing from it overwrote the landed
|
||||||
|
lane/b. selftest still green (node sail 47/47, rigging 29/29 = 27 + the 2 gate-3 pins).
|
||||||
|
|
||||||
|
**What gate 3 pins**, off E's REAL resolver, not a hand-typed 0.55:
|
||||||
|
· **The STEEL half** (`rigging.selftest`): `factoryExtras('corroded_post')` → hint 0.55 +
|
||||||
|
collateral key, and `_effRating` on a corroded corner is `rating × 0.55` — so a load a
|
||||||
|
carabiner holds on a sound post (1200 N) is OVER a carabiner on a corroded one (660 N). The
|
||||||
|
rust costs a tier, measured, and if it EVER prices as sound steel the trap inverts into the
|
||||||
|
safest anchor in the yard (E's load-bearing `_v1` warning).
|
||||||
|
· **The COLLATERAL half** (`gardenfly.selftest`, a real dressed world, ~40 ms): a node-less
|
||||||
|
corroded placement (E's "plain entry, no node field" case — the harder one, rating and
|
||||||
|
collateral both from the factory) → `world.collateralFor('corroded_post')` = $45 "the
|
||||||
|
corroded post", summed by `collateralExposure` — NOT the baked GLB value read directly, the
|
||||||
|
shortcut that priced the carport to null for five sprints.
|
||||||
|
**Mutations** (run on the scratch merge where the tier is live): `collateralFor` drops the
|
||||||
|
price → card reads null; `factoryExtras` returns hint 1 → the steel pin reddens. Both caught.
|
||||||
|
|
||||||
|
⚠️ **CROSS-LANE SEAM, disclosed.** The corroded tier (0.55, the GLB, the gen entry) is on
|
||||||
|
lane/e. On bare lane/b `factoryExtras('corroded_post')` is null, so **both gate-3 pins
|
||||||
|
SKIP-WITH-DISCLOSURE here** and go live at integration — the "build against the shape as
|
||||||
|
pushed" pattern. Measured GREEN on a scratch merge of lane/a + lane/e (hint 0.55, collateral
|
||||||
|
$45). Browser suite on bare lane/b: 0 failures, the corroded pins visibly skipped with their
|
||||||
|
reason in the run.
|
||||||
|
|
||||||
|
**D — your pool yard's corroded corners price right the moment you place them**, node or
|
||||||
|
node-less; the card names the $45 collateral and the 0.55 steel with no round-trip to me. The
|
||||||
|
tier is E's, the pricing surfaces are pinned, and the pin activates itself when your yard and
|
||||||
|
E's tier meet on the integrated tree.
|
||||||
|
|
||||||
|
**On 0.2/0.3/2 (the landed pass): reviewed, sound, nothing to change.** One optional
|
||||||
|
strengthening I found while reimplementing, filed NOT forced — the 0.2 negative control's
|
||||||
|
"over-arming is EQUIVALENT" finding is real (the heal is pure repair and emits only when it
|
||||||
|
healed, so an over-arm is a silent no-op to an event-counter). It IS observable if the control
|
||||||
|
spies the heal CALL rather than the event: a stub that arms on finite state calls
|
||||||
|
`_healNonFinite` every substep of a fully-broken-but-finite sail. If a future pass wants to
|
||||||
|
close that mutation gap, a `let calls=0; wrap _healNonFinite` in the finite-lost-sail control
|
||||||
|
turns the recorded "equivalent" into a red. Not this landing; the current control is honest
|
||||||
|
about what it does and does not catch.
|
||||||
|
|
||||||
|
[C] 2026-07-21 — 🤝 **CONVERGENCE, DECLARED FIRST: TWO LANE-C SESSIONS RAN GATE 1 IN PARALLEL,
|
||||||
|
and I found out by having my push REJECTED. What follows is landed ON TOP of `f029274`
|
||||||
|
with NOT ONE LINE OF IT CHANGED — I did not force, I did not rebase over it, and I dropped
|
||||||
|
every part of my own work that duplicated it.** Selftest **494/0/0** (their 490 + my 4).
|
||||||
|
|
||||||
|
The split turned out clean, which is luck rather than planning. `forecastHonest(def)`
|
||||||
|
answers *"can the forecast describe this STORM"* — validator, finite stats, the stone
|
||||||
|
vocabulary ceiling — and it is better than the storm-side half I had written, so mine is
|
||||||
|
gone. What it structurally cannot reach is the two things my brief named, because it takes
|
||||||
|
a DEF and not a PAIRING and it never touches the card:
|
||||||
|
1. **WHAT THE CARD PRINTS.** Their gate can pass a storm whose card then omits the fact
|
||||||
|
that matters. It does, tonight, on night 7 — see below.
|
||||||
|
2. **WHICH YARD the storm is printed over.** `forecastHonest` has no `site` argument, so
|
||||||
|
"the soaker only ever offers over site_02" was still enforced by nothing but a comment
|
||||||
|
in A's POOL.
|
||||||
|
My work is now exactly those two, added after a divider in weather.js that says so. What I
|
||||||
|
abandoned: my own `forecastRefusal` (theirs is better), my exposure cross (theirs crosses
|
||||||
|
A's `exposureOf` directly and item-wise — strictly stronger than mine, which could only
|
||||||
|
cross the data against the runtime), and the `forecast.js` pure-module split. **The split
|
||||||
|
is the one thing I still think was right and am NOT relitigating today** — it made the
|
||||||
|
whole worded surface run in the node one-second loop, and 4 of my 6 mutation checks ran
|
||||||
|
there in seconds instead of two minutes a go. It is on branch `s17-c-mine` (commit
|
||||||
|
`3884108`, 485/0/0 standalone) if the integrator ever wants it; forcing a file move onto a
|
||||||
|
landed design to win an argument about addresses is churn, and I am not spending the
|
||||||
|
sprint's goodwill on it.
|
||||||
|
|
||||||
|
[C] 2026-07-21 — 📇 **THE OFFER BAND — and the measured finding that A's BOARD LIES ABOUT
|
||||||
|
NIGHT 7 as pushed.** The single thing my brief was most explicit about: *"an offer card
|
||||||
|
that hides the hail is the board lying, and the soaker's whole design is that its wind
|
||||||
|
reads gentle while its hail is the trap."* It does, and it was.
|
||||||
|
|
||||||
|
**MEASURED FIRST, before a line was written.** A's card prints `f.wind` and `f.stones` — a
|
||||||
|
reasonable pair. Night 7's two offers under that pair:
|
||||||
|
| night 7 | wind line | stones line |
|
||||||
|
|---|---|---|
|
||||||
|
| **the soaker** (scripted, site_02) | gusts to ~55 km/h | fine pea stones |
|
||||||
|
| the early buster (alt, swing lawn) | gusts to ~76 km/h | pea stones |
|
||||||
|
**On both printed lines the soaker is the softer job.** It is not: 14.9 s of hail against
|
||||||
|
2.4 s, and 44 mm/hr of rain against 16.5. The board recommended the trap in the storm's own
|
||||||
|
voice, before a dollar is spent. Not A's mistake to have made — the fields were there and
|
||||||
|
the card picked two of them, which is exactly why the fix is not "also print rainRate".
|
||||||
|
|
||||||
|
**`offerBand(def, lead)` returns an ORDERED LIST** of `{key, text}` — wind, hail (iff the
|
||||||
|
storm hails), rain rate (always), change (always), confidence (iff hedged). The FORECAST
|
||||||
|
decides which facts a night carries and hands over all of them, so a card that maps the
|
||||||
|
list cannot drop the next fact somebody adds to a storm. Same construction and the same
|
||||||
|
reasoning as A's own `stormsToPreload(nights, pool)` split: an omission that is possible
|
||||||
|
will eventually happen. Night 7 now reads, in the real card, my own eyes on :8826 —
|
||||||
|
· soaker — `gusts to ~55 km/h` · **`hail likely · fine pea stones · 15s of it`** ·
|
||||||
|
**`rain to 44 mm/hr`** · `southerly change at 40s` · $46 · $205 at risk
|
||||||
|
· buster — `gusts to ~76 km/h` · `hail possible · pea stones · 2s of it` ·
|
||||||
|
`rain to 16 mm/hr` · `southerly change at 18s` · $57 · $255 at risk
|
||||||
|
The choice is now real and genuinely hard: **the soaker pays LESS, risks LESS, and is the
|
||||||
|
worse night.** That is the game arguing with the player instead of flattering them.
|
||||||
|
|
||||||
|
**⚠️ SECOND FINDING — THE CHANGE LINE IS NOT DECORATION.** `storm_03_southerly` and
|
||||||
|
`storm_03b_earlybuster` are IDENTICAL in every number the band prints — sustained 13.0,
|
||||||
|
gustPeak 21.4/21.2, rain 16.5 mm/hr, hail 2.4 s at size 0.70 — and differ ONLY in when the
|
||||||
|
change lands, 30 s against 18 s. **The board offers exactly that pair** (the buster is the
|
||||||
|
drawn alternative on nights 5 AND 7). Drop the change line and the board offers two jobs
|
||||||
|
whose weather it has just claimed to describe, described identically, and calls it a
|
||||||
|
choice. Pinned as its own case; it goes red with the two bands diffed in the message.
|
||||||
|
|
||||||
|
**A — THE HUNK, verbatim.** I applied it on a scratch tree to LOOK at the card, then
|
||||||
|
reverted it — hud.js and main.js are yours (the leadFor precedent).
|
||||||
|
1. `main.js` import: `import { loadStorm, createWind, forecastLines, offerBand } from './weather.js';`
|
||||||
|
2. `main.js` `pricedOffers()`, one line beside `forecast:` — which I left in place, so
|
||||||
|
nothing of yours has to move and the card can adopt on its own schedule:
|
||||||
|
`band: def ? offerBand(def, 0) : null,`
|
||||||
|
3. `hud.js` `offerHtml`, replacing your two forecast lines with:
|
||||||
|
``${(o.band ?? []).map((l) => `<div class="line wx ${l.key}"><span>${l.text}</span></div>`).join('')}``
|
||||||
|
plus two CSS rules in your SPRINT17 board block:
|
||||||
|
`#hud-card .offer .line.wx { color:#8ba0ad; }` and
|
||||||
|
`#hud-card .offer .line.hail span { color:#cbd8e0; }` — the hail line is the one a
|
||||||
|
player must not skim past. `const f = o.forecast;` then goes unused in `offerHtml`.
|
||||||
|
**lead 0 and only lead 0, and I am not proposing a dial.** Both offers are for TONIGHT —
|
||||||
|
you are choosing which yard to stand in, not which night to work. A hedged board would
|
||||||
|
invent uncertainty the job sheet contradicts one card later, and the sheet is the document
|
||||||
|
the fee is quoted on.
|
||||||
|
|
||||||
|
[C] 2026-07-21 — ⚖️ **THE PAIRING LAW — A's three POOL exclusions stop being a comment.**
|
||||||
|
`forecastHonest` asks whether the forecast can describe a STORM. A band can be perfectly
|
||||||
|
honest about a storm and still be an invitation to a night nobody can work — that is a fact
|
||||||
|
about GEOMETRY × STORM, it needs a `site`, and nothing enforced it before this.
|
||||||
|
|
||||||
|
`PAIRING_LAW` (weather.js): storm key → `{sites, owner, why}`, with the measurement in the
|
||||||
|
data. **A — two of the three entries are YOUR reasons and are recorded as yours** (wildnight
|
||||||
|
and icenight off backyard_01: the pinned separation, the measured beyond-saving flag). I did
|
||||||
|
not re-measure them; I moved your stated grounds into a structure that goes red. The
|
||||||
|
soaker's is mine, carrying the S16 receipt verbatim: backyard_01 caps hail cover over the
|
||||||
|
bed at ~31%, its full-cover quads pond-tear at 3.3–3.7 kN on BOTH fabrics, best membrane
|
||||||
|
37.5 against cloth 17.3 — a fabric bet with no win in it. **An absent key means
|
||||||
|
UNRESTRICTED, deliberately:** this table records refusals somebody PAID FOR, and inventing
|
||||||
|
one for a pairing nobody flew is the same offence in the other direction. Pinned in both
|
||||||
|
directions, including `pairingRefusal('storm_03_southerly', 'site_03_swing_lawn') === null`
|
||||||
|
— that is night 4, and a law that refused it would be refusing the shipped ladder.
|
||||||
|
|
||||||
|
`pairingRefusals(pairings)` is **deliberately NOT a filter.** Silently dropping a bad entry
|
||||||
|
leaves a morning with one offer and no explanation — a runtime surprise wearing a fix's
|
||||||
|
clothes. It returns the offences with the measurement in each message and lets the caller
|
||||||
|
decide how loud to be.
|
||||||
|
|
||||||
|
**RECEIPTS.** The check walks NIGHTS ∪ POOL carrying the SITE — the same union the other
|
||||||
|
session's storm walk uses, extended by the dimension that walk cannot see (their own reason
|
||||||
|
is the right one: today every POOL storm is also in NIGHTS, so a NIGHTS-only walk is green
|
||||||
|
by coincidence). **12 pairings, 0 refusals.** The gate accuses nobody today, which is the
|
||||||
|
correct outcome and exactly why it carries a vacuity guard in the same case: move the
|
||||||
|
soaker onto backyard_01 and at least one refusal must appear, or the clean result above is
|
||||||
|
being produced by a law that cannot say no.
|
||||||
|
|
||||||
|
**A — if you want it at DATA level rather than in my suite**, `import { pairingRefusals }
|
||||||
|
from './weather.js'` in `board.js` and throw on a non-empty result at load. My brief's words
|
||||||
|
were "a DATA-level constraint on the pool agreed with A in THREADS — never a runtime
|
||||||
|
surprise", and the suite is where I can put it without touching your file. Yours to promote.
|
||||||
|
|
||||||
|
[C] 2026-07-21 — 🔬 **MUTATIONS, THE DISCLOSURE, AND A FALSE ALARM I KILLED BY MEASURING IT.**
|
||||||
|
Six run on my own tree before the convergence, and the two that guard the surviving code
|
||||||
|
re-run on THIS tree after the code changed address (the whole point of re-running: a
|
||||||
|
mutation receipt is about the shipped file, not about a file that used to exist):
|
||||||
|
| mutation | result |
|
||||||
|
|---|---|
|
||||||
|
| offer band drops the HAIL line (the night-7 trap) | RED — "storm_02_wildnight: hails for 11.4s but the band's hail line is MISSING" |
|
||||||
|
| soaker's restriction deleted from `PAIRING_LAW` | RED ×2 — "the soaker over the backyard is offerable — the ~31% hail-cover measurement has stopped being enforced" |
|
||||||
|
| offer band drops the CHANGE line | RED — "the two busters differ on `sustained to 13 m/s…` — expected the CHANGE TIME to be the difference" |
|
||||||
|
| offer band drops the rain-rate line | RED — "storm_01_gentle: no rain-rate line on the offer band" |
|
||||||
|
| night 7's soaker moved onto backyard_01 in week.js | RED — the ladder gate names the yard AND the measurement; D's two week laws also went red, which is them working |
|
||||||
|
| `collateralKey: "swing_set"` stripped from site_03 JSON | RED — reachability; superseded by the other session's stronger cross, recorded because it was run |
|
||||||
|
All restored; final run green.
|
||||||
|
|
||||||
|
**⚠️ A HOLE IN MY OWN GATE, found by probing it rather than reading it** — and worth
|
||||||
|
recording even though the code it lived in is gone, because the OTHER session's
|
||||||
|
`STONE_WORD_CEILING` fixes the top of exactly this range and nothing yet fixes the bottom:
|
||||||
|
a def with `hail.size: 0` and a live hail curve worded itself **"fine pea stones"**, because
|
||||||
|
`stoneWord` has no FLOOR and `0 < 0.6`. The card names a stone the storm does not carry, on
|
||||||
|
the axis the entire fabric bet is decided on. Their ceiling refuses 2.6; nothing refuses 0.
|
||||||
|
**Filed for whoever holds the pen on `forecastHonest` next** (one line beside the ceiling
|
||||||
|
check): `if (s.hailSeconds > 0 && !(s.hailSize > 0)) errors.push(…)`. Not landed by me — it
|
||||||
|
is inside their predicate and this is the wrong sprint for two people editing one function.
|
||||||
|
|
||||||
|
**THE FALSE ALARM, recorded because the process is the point.** I thought my dressed worlds
|
||||||
|
had taken the selftest from ~25 s to ~115 s and was about to trim the harness. Measured it
|
||||||
|
instead: **baseline 485/0/0 takes 131 s; 496/0/0 with my work took 115 s.** The "25 s" was
|
||||||
|
never a measurement — it was the first number I happened to read off a poll. Dressing all
|
||||||
|
three sites costs ~90 ms total (loadSite 4–5 ms, dress 28–32 ms each). Nothing was trimmed,
|
||||||
|
and the suspicion is dead rather than parked. Measure, don't reason.
|
||||||
|
|
||||||
|
**Two smalls for A, neither urgent.** (1) Pool ids `corner_block_no_house` and
|
||||||
|
`corner_block_cheapest` — the first is `site_03_swing_lawn`, not the corner block (the
|
||||||
|
`_why`, the client and the brief are all right; only the id reads wrong, and ids surface in
|
||||||
|
`takenOffer` and the settlement). (2) On most nights the two offers carry DIFFERENT STORMS
|
||||||
|
for the same night — a fiction question, not a bug. Night 2 happens to offer the same
|
||||||
|
southerly over two yards and reads noticeably more coherent for it. Yours to rule if it
|
||||||
|
ever matters; the band tells the truth either way.
|
||||||
|
|
||||||
|
[I] 2026-07-21 — **SPRINT 17 INTEGRATION (main) — THE BOARD IS LIVE, SEVEN NIGHTS, arc 2 open.**
|
||||||
|
Selftest **522/0/0**, the exact sum (D's 515-integration + lane/b's 3 corroded cross-lane pins,
|
||||||
|
green now that lane/e is present + lane/c's 4 offerBand/pairing pins). Verified in the browser
|
||||||
|
and deployed to partly.party/hardyards. This integration was UNUSUAL and worth recording:
|
||||||
|
|
||||||
|
**Two full sets of lane sessions ran — mine (a workflow, on Opus after the Fable limit killed
|
||||||
|
the first wave) and a parallel set on John's other Mac.** Every lane hit the collision and
|
||||||
|
CONVERGED rather than clobbered: B reset to the landed 0.2/0.3/2 and added only gate 3; C reset
|
||||||
|
onto the other session's better forecast design and added only the offer band + pairing law; D
|
||||||
|
dropped their whole pool-yard cut and adopted the other session's (which ships ONE corroded post,
|
||||||
|
dodging the two-rust-post exposure-harness split their own cut walked into). E resumed the
|
||||||
|
Fable-killed session's uncommitted tree, found it was mid-negative-control (both GLBs were the
|
||||||
|
UNTELLED post, two asserts defective — one that couldn't fail, one whose stated reason was
|
||||||
|
backwards), and rebuilt it honestly. The convergence protocol C invented in S14 held across four
|
||||||
|
lanes and two machines. lane/d's own "pt-1 integration" commit did the heavy merge; I layered
|
||||||
|
lane/b and lane/c's newest commits on top.
|
||||||
|
|
||||||
|
**The one integrator hunk, applied verbatim (C filed it; hud/main are A's files):** C measured
|
||||||
|
that A's board LIED ABOUT NIGHT 7 — the soaker's offer read SOFTER than the buster's on the two
|
||||||
|
printed lines (55 vs 76 km/h gusts) while carrying 6× the hail (14.9 s vs 2.4) and 2.7× the rain.
|
||||||
|
The board was recommending its own trap in the storm's own voice. `offerBand(def, lead)` returns
|
||||||
|
the ORDERED list (wind · hail · rain · change · confidence) so the card can never drop a fact by
|
||||||
|
picking two fields. Applied the main.js `band:` line + the hud.js card map + two CSS rules (the
|
||||||
|
hail line lit brighter — the one a player must not skim). VERIFIED LIVE: night 7's two cards now
|
||||||
|
read the soaker as pays-less/risks-less/worse-night, the game arguing instead of flattering. The
|
||||||
|
fix is the same species as A's own `stormsToPreload(nights, pool)` split — an omission that is
|
||||||
|
possible eventually happens.
|
||||||
|
|
||||||
|
**What shipped, gate by gate:** the JOB BOARD (A) — two offers a morning, seeded deterministically
|
||||||
|
off week+night, the chosen job carrying a WHOLE night entry so sheet/quote/invoice/ledger/warranty/
|
||||||
|
rep read it through the seven surfaces they already had, ZERO changed lines; CONSTRAINTS (A data +
|
||||||
|
B enforcement) — house-ban refused at pick time in the client's own words, budget cap thrown at
|
||||||
|
commit, SCORE IT drops banned candidates and prices at the client's ceiling; the CORRODED TIER (E)
|
||||||
|
— 0.55 on the ladder because it is the only rung whose true capacity is unknowable by inspection,
|
||||||
|
$45, the tell verified to read at 20 m by the game's own renderer; the POOL YARD (D) — authored
|
||||||
|
cold, the legal tie-offs and the trustworthy tie-offs deliberately DIFFERENT SETS, in the game only
|
||||||
|
by being chosen off the board (the Karalis place, live on night 1's board with the corroded post
|
||||||
|
billed in its risk column).
|
||||||
|
|
||||||
|
**Rulings (A):** hail-light balance is CANON (the mild-night market buys the steel and the
|
||||||
|
collateral, not the bed — the refused separation pins already say so); constraint premiums +25%
|
||||||
|
(anchor ban) / +20% (budget cap), both flagged UNMEASURED and cut-first; the rain drain-weight
|
||||||
|
lever DECLINED at its proper price (its own gate, full re-measure).
|
||||||
|
|
||||||
|
**Carried to SPRINT18 (its callout types are John's gate-4 pick):** A's `stormsToPreload` preload
|
||||||
|
assert can't currently fail (every pool storm also lives in NIGHTS — the calm-day coincidence
|
||||||
|
again; split it so a pool-only storm can bite); C's `stoneWord` has no floor (size 0 words itself
|
||||||
|
"fine pea stones"); D's two-corroded-post exposure-harness split ($115 per-structure vs $45 per-key)
|
||||||
|
is the receipt for arc-2's multi-hazard yards; B's optional heal-control strengthening (spy the
|
||||||
|
call, not the event).
|
||||||
|
|
||||||
|
**Gate 4 stands and it now steers the next sprint: John plays the seven — partly.party/hardyards,
|
||||||
|
a board offering choices, a constrained client or two — and picks Sprint 18's callout type:
|
||||||
|
emergency mid-storm arrival, the cowboy audit, or warranty-as-a-full-night.**
|
||||||
[I] 2026-07-21 — **SPRINT 17 (THE BOOK OF CLIENTS, PART 1) INTEGRATION COMPLETE (main).** D's
|
[I] 2026-07-21 — **SPRINT 17 (THE BOOK OF CLIENTS, PART 1) INTEGRATION COMPLETE (main).** D's
|
||||||
second pass merged fast-forward; selftest **515/0/0** on the final tree (510 part-1 + D's
|
second pass merged fast-forward; selftest **515/0/0** on the final tree (510 part-1 + D's
|
||||||
5 pins, the exact sum), verified in the browser. Deploy NOT yet run: the session's
|
5 pins, the exact sum), verified in the browser. Deploy NOT yet run: the session's
|
||||||
|
|||||||
@ -33,6 +33,15 @@ import { createSkyFx } from '../../web/world/js/skyfx.js';
|
|||||||
import { createGarden } from '../../web/world/js/garden.js';
|
import { createGarden } from '../../web/world/js/garden.js';
|
||||||
import { FIXED_DT } from '../../web/world/js/contracts.js';
|
import { FIXED_DT } from '../../web/world/js/contracts.js';
|
||||||
import { flyGarden, flySeparation, hardwareByName } from './gardenfly.js';
|
import { flyGarden, flySeparation, hardwareByName } from './gardenfly.js';
|
||||||
|
import { buildScoringWorld, collateralExposure } from './scorecard.js';
|
||||||
|
import { factoryExtras } from '../../web/world/js/ratings.js';
|
||||||
|
|
||||||
|
// SPRINT17 gate 3 [B] — CROSS-LANE SEAM. The corroded tier (rating_hint 0.55,
|
||||||
|
// the GLB, the gen entry) lives on lane/e. On a bare lane/b it is absent, so
|
||||||
|
// this pin SKIPS WITH A DISCLOSURE and goes live at integration — the same
|
||||||
|
// "build against the shape as pushed" seam the sprint runs on. Measured GREEN
|
||||||
|
// on the scratch merge (lane/a + lane/e): hint 0.55, collateral $45.
|
||||||
|
const CORRODED_TIER_LIVE = !!(factoryExtras('corroded_post'));
|
||||||
|
|
||||||
const assert = (cond, msg) => { if (!cond) throw new Error(msg); };
|
const assert = (cond, msg) => { if (!cond) throw new Error(msg); };
|
||||||
|
|
||||||
@ -156,7 +165,62 @@ export async function buildGardenflyTests() {
|
|||||||
fabRun = { byDefault: flyFab(undefined), cloth: flyFab(0.30), membrane: flyFab(0) };
|
fabRun = { byDefault: flyFab(undefined), cloth: flyFab(0.30), membrane: flyFab(0) };
|
||||||
} catch (err) { fabErr = String((err && err.stack) || err); }
|
} catch (err) { fabErr = String((err && err.stack) || err); }
|
||||||
|
|
||||||
|
// SPRINT17 gate 3 support [B]: the corroded tier priced through the CARD'S
|
||||||
|
// OWN path, in a real dressed world. E landed the post; the 0.2/0.3/2 pass
|
||||||
|
// deferred gate 3; this pins that a corroded anchor prices honestly the
|
||||||
|
// moment it is placed. Node-less placement (E's THREADS: "place with a plain
|
||||||
|
// entry and no node field and the rating still lands") — the harder case,
|
||||||
|
// where the rating and the collateral both come from the factory, not a
|
||||||
|
// dress. buildScoringWorld only DRESSES (no sweep), so this is ~40 ms.
|
||||||
|
let corRun = null, corErr = null;
|
||||||
|
try {
|
||||||
|
if (!CORRODED_TIER_LIVE) throw { skip: true };
|
||||||
|
const corSite = await loadSite('backyard_01');
|
||||||
|
corSite.structures = [...(corSite.structures ?? []), {
|
||||||
|
id: 'cor1', model: 'sail_post_corroded_v1', wreckedModel: 'sail_post_corroded_wrecked_v1',
|
||||||
|
x: 6, z: 2, rotYDeg: 0, solid: true,
|
||||||
|
collateralKey: 'corroded_post', collateralValue: 45, collateralLabel: 'the corroded post',
|
||||||
|
anchors: [{ id: 'cor1_a1', type: 'corroded_post', work: 'cloth' }],
|
||||||
|
}];
|
||||||
|
const built = await buildScoringWorld(corSite);
|
||||||
|
const anc = built.anchors.find((a) => a.id === 'cor1_a1');
|
||||||
|
const col = {};
|
||||||
|
for (const a of built.anchors) {
|
||||||
|
if (!a.collateral) continue;
|
||||||
|
const p = built.world.collateralFor(a.collateral);
|
||||||
|
col[a.id] = p ? { key: a.collateral, cost: p.cost, label: p.label }
|
||||||
|
: { key: a.collateral, cost: null, label: a.collateral, unpriced: true };
|
||||||
|
}
|
||||||
|
corRun = { anchor: anc, priced: built.world.collateralFor('corroded_post'),
|
||||||
|
exposure: collateralExposure({ collateral: col }), dressed: built.dressed, dressError: built.dressError };
|
||||||
|
} catch (err) { if (err && err.skip) corErr = 'skip'; else corErr = String((err && err.stack) || err); }
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
['gate 3: a corroded post prices honestly in the card — 0.55 hint, $45 collateral, dressed', () => {
|
||||||
|
if (!CORRODED_TIER_LIVE) return 'SKIPPED — corroded tier lands with lane/e; measured GREEN on '
|
||||||
|
+ 'the scratch merge (hint 0.55, collateral $45 through collateralFor)';
|
||||||
|
if (corErr) throw new Error(`corroded pricing died: ${corErr}`);
|
||||||
|
assert(corRun.dressed, `the corroded world did not dress (${corRun.dressError}) — GLB missing or broke`);
|
||||||
|
const a = corRun.anchor;
|
||||||
|
assert(a, 'the corroded anchor cor1_a1 was not created');
|
||||||
|
// The STEEL half, node-less: factoryExtras had to reach it, or the tier
|
||||||
|
// math prices this trap as the best steel in the game (adoptAnchor's
|
||||||
|
// `?? 1`, E's load-bearing _v1 warning).
|
||||||
|
assert(a.ratingHint === 0.55, `corroded anchor rated ${a.ratingHint}, not 0.55 — a node-less placement `
|
||||||
|
+ 'lost E\'s factory hint and the card would price it as sound steel');
|
||||||
|
assert(a.collateral === 'corroded_post', `corroded anchor collateral "${a.collateral}", not "corroded_post"`);
|
||||||
|
// The COLLATERAL half, through the card's OWN resolver and exposure sum —
|
||||||
|
// not the baked GLB value read directly (the carport-to-null shortcut).
|
||||||
|
assert(corRun.priced && corRun.priced.cost === 45,
|
||||||
|
`world.collateralFor('corroded_post') gave ${JSON.stringify(corRun.priced)}, expected $45`);
|
||||||
|
assert(corRun.priced.label === 'the corroded post', `collateral label "${corRun.priced.label}"`);
|
||||||
|
const line = corRun.exposure.priced.find((e) => e.key === 'corroded_post');
|
||||||
|
assert(line && line.cost === 45,
|
||||||
|
`collateralExposure has no $45 corroded line (${JSON.stringify(corRun.exposure.priced.map((e) => [e.key, e.cost]))})`);
|
||||||
|
assert(!corRun.exposure.unpriced.some((e) => e.key === 'corroded_post'),
|
||||||
|
'the corroded post landed in UNPRICED — the exposure total silently drops its $45');
|
||||||
|
return `corroded_post: hint ${a.ratingHint}, collateral $${corRun.priced.cost} "${corRun.priced.label}", in the exposure sum`;
|
||||||
|
}],
|
||||||
['gardenfly: the pinned separation line, flown in BOTH chains — the 12s-skew disagreement, pinned', () => {
|
['gardenfly: the pinned separation line, flown in BOTH chains — the 12s-skew disagreement, pinned', () => {
|
||||||
// ⚠ THE STATE OF PLAY (2026-07-18, [B] THREADS, receipts in the entry):
|
// ⚠ THE STATE OF PLAY (2026-07-18, [B] THREADS, receipts in the entry):
|
||||||
// the pinned recipe HOLDS and rigging matters by a full state's width —
|
// the pinned recipe HOLDS and rigging matters by a full state's width —
|
||||||
|
|||||||
@ -331,6 +331,11 @@ const CSS = `
|
|||||||
#hud-card .offer .line { display:flex; justify-content:space-between; gap:14px;
|
#hud-card .offer .line { display:flex; justify-content:space-between; gap:14px;
|
||||||
font-size:12px; color:#8ba0ad; padding:2px 0; }
|
font-size:12px; color:#8ba0ad; padding:2px 0; }
|
||||||
#hud-card .offer .line b { color:#dde5ea; font-weight:700; }
|
#hud-card .offer .line b { color:#dde5ea; font-weight:700; }
|
||||||
|
/* SPRINT17 [C]: the weather band. The hail line is lit brighter than the rest
|
||||||
|
because the soaker's whole trap is that its wind reads gentle — the one line
|
||||||
|
a player must not skim past. */
|
||||||
|
#hud-card .offer .line.wx { color:#8ba0ad; }
|
||||||
|
#hud-card .offer .line.hail span { color:#cbd8e0; }
|
||||||
/* The two numbers a tradie decides on. The fee is the reason to say yes and
|
/* The two numbers a tradie decides on. The fee is the reason to say yes and
|
||||||
the exposure is the reason to say no, so they are weighted against each
|
the exposure is the reason to say no, so they are weighted against each
|
||||||
other rather than both being grey: money in, money at risk. */
|
other rather than both being grey: money in, money at risk. */
|
||||||
@ -816,14 +821,17 @@ export function createHud(d) {
|
|||||||
const offerHtml = (o, i) => {
|
const offerHtml = (o, i) => {
|
||||||
const n = o.night;
|
const n = o.night;
|
||||||
const cons = o.constraints ?? [];
|
const cons = o.constraints ?? [];
|
||||||
const f = o.forecast;
|
// SPRINT17 [C], applied by the integrator: the offer maps C's ORDERED
|
||||||
|
// band (wind · hail · rain · change · confidence) instead of picking
|
||||||
|
// two forecast fields, so an offer card can never hide a night's hail.
|
||||||
|
// The .hail line is lit brighter because it is the one a player must
|
||||||
|
// not skim past — the soaker's whole trap is a gentle-reading wind.
|
||||||
return `
|
return `
|
||||||
<button class="offer${cons.length ? ' constrained' : ''}" data-offer="${i}">
|
<button class="offer${cons.length ? ' constrained' : ''}" data-offer="${i}">
|
||||||
<span class="tag">${o.kind === 'scripted' ? 'tonight, as booked' : 'callout'}</span>
|
<span class="tag">${o.kind === 'scripted' ? 'tonight, as booked' : 'callout'}</span>
|
||||||
<div class="who">${n.client ?? 'no client'}</div>
|
<div class="who">${n.client ?? 'no client'}</div>
|
||||||
<div class="addr">${n.addr ?? ''}</div>
|
<div class="addr">${n.addr ?? ''}</div>
|
||||||
${f?.wind ? `<div class="line"><span>${f.wind}</span></div>` : ''}
|
${(o.band ?? []).map((l) => `<div class="line wx ${l.key}"><span>${l.text}</span></div>`).join('')}
|
||||||
${f?.stones ? `<div class="line"><span>${f.stones}</span></div>` : ''}
|
|
||||||
${o.fee != null ? `<div class="line fee"><span>the job pays${
|
${o.fee != null ? `<div class="line fee"><span>the job pays${
|
||||||
o.premium ? ` · +${Math.round(o.premium * 100)}% on their terms` : ''
|
o.premium ? ` · +${Math.round(o.premium * 100)}% on their terms` : ''
|
||||||
}</span><b>$${o.fee}</b></div>` : ''}
|
}</span><b>$${o.fee}</b></div>` : ''}
|
||||||
|
|||||||
@ -17,7 +17,7 @@ import * as THREE from '../vendor/three.module.js';
|
|||||||
import { FIXED_DT, PHASES, STORM_LEN, HARDWARE, SPARE_COST, Emitter } from './contracts.js';
|
import { FIXED_DT, PHASES, STORM_LEN, HARDWARE, SPARE_COST, Emitter } from './contracts.js';
|
||||||
import { createWorld, loadSite } from './world.js';
|
import { createWorld, loadSite } from './world.js';
|
||||||
import { createCameraRig, spawnYawFor } from './camera.js';
|
import { createCameraRig, spawnYawFor } from './camera.js';
|
||||||
import { loadStorm, createWind, forecastLines } from './weather.js';
|
import { loadStorm, createWind, forecastLines, offerBand } from './weather.js';
|
||||||
import { SailRig, createSailView } from './sail.js';
|
import { SailRig, createSailView } from './sail.js';
|
||||||
import { createPlayer } from './player.js';
|
import { createPlayer } from './player.js';
|
||||||
import { Interact, wireYardActions } from './interact.js';
|
import { Interact, wireYardActions } from './interact.js';
|
||||||
@ -1041,6 +1041,12 @@ export async function boot(opts = {}) {
|
|||||||
// C's honest lines at lead 0 — tonight, exact. The same call the job
|
// C's honest lines at lead 0 — tonight, exact. The same call the job
|
||||||
// sheet makes, so the board can never be more confident than the sheet.
|
// sheet makes, so the board can never be more confident than the sheet.
|
||||||
forecast: def ? forecastLines(def, 0) : null,
|
forecast: def ? forecastLines(def, 0) : null,
|
||||||
|
// SPRINT17 [C], applied by the integrator: the ORDERED band the offer
|
||||||
|
// card maps, so a night's hail can never be dropped by the card picking
|
||||||
|
// two fields. Night 7 read softer than the buster on wind+stones alone
|
||||||
|
// (55 vs 76 km/h) while carrying 6× the hail — the board recommending
|
||||||
|
// its own trap. offerBand carries every fact the storm has.
|
||||||
|
band: def ? offerBand(def, 0) : null,
|
||||||
fee: def ? calloutFee(def, week.rep, night.constraints) : null,
|
fee: def ? calloutFee(def, week.rep, night.constraints) : null,
|
||||||
exposure: exp.total,
|
exposure: exp.total,
|
||||||
exposureItems: exp.items,
|
exposureItems: exp.items,
|
||||||
|
|||||||
@ -9,6 +9,7 @@
|
|||||||
import { RiggingSession, FABRIC, fabricNoteFor } from './rigging.js';
|
import { RiggingSession, FABRIC, fabricNoteFor } from './rigging.js';
|
||||||
import { SailRig, TENSION_MIN, TENSION_MAX } from './sail.js';
|
import { SailRig, TENSION_MIN, TENSION_MAX } from './sail.js';
|
||||||
import { HARDWARE, START_BUDGET, SPARE_COST } from './contracts.js';
|
import { HARDWARE, START_BUDGET, SPARE_COST } from './contracts.js';
|
||||||
|
import { factoryExtras } from './ratings.js';
|
||||||
|
|
||||||
const [CARABINER, SHACKLE, RATED] = HARDWARE;
|
const [CARABINER, SHACKLE, RATED] = HARDWARE;
|
||||||
|
|
||||||
@ -494,6 +495,66 @@ test('gate 2: the constraint door is a checked enum, and the terms survive reset
|
|||||||
return 'five malformed shapes thrown out at the door; terms survive reset, cleared only explicitly';
|
return 'five malformed shapes thrown out at the door; terms survive reset, cleared only explicitly';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- SPRINT17 gate 3 support: THE CORRODED TIER PRICES HONESTLY -------------
|
||||||
|
// E landed the corroded post (rating_hint 0.55, its own ANCHOR_TYPE word, a
|
||||||
|
// $45 collateral); the gates-0.2/0.3/2 pass DEFERRED gate 3, and this is it.
|
||||||
|
// B owns the pricing surfaces the card reads, so the number the shop charges
|
||||||
|
// for a corroded corner is pinned HERE, off E's real resolver — not a
|
||||||
|
// hand-typed 0.55. The collateral half ($45 through world.collateralFor → the
|
||||||
|
// exposure line) is pinned in gardenfly.selftest, where a dressed world exists;
|
||||||
|
// this is the STEEL half: a corroded post is weaker, so the same load costs
|
||||||
|
// MORE steel to hold, which is what "prices honestly" means and is B's tier
|
||||||
|
// math (_effRating), not E's mesh.
|
||||||
|
//
|
||||||
|
// ⚠️ CROSS-LANE SEAM. The corroded tier lives on lane/e; on lane/b in
|
||||||
|
// isolation `factoryExtras('corroded_post')` is null (no gen entry, no GLB).
|
||||||
|
// These pins SKIP WITH A DISCLOSURE on a bare lane/b and go live the moment
|
||||||
|
// lane/e merges — the "build against the shape as pushed" seam the sprint runs
|
||||||
|
// on. Measured GREEN on the scratch merge (lane/a + lane/e): hint 0.55.
|
||||||
|
const CORRODED_TIER_LIVE = !!(factoryExtras('corroded_post'));
|
||||||
|
const CORRODED_SKIP = 'SKIPPED — corroded tier is on lane/e, absent on bare lane/b; this pin is '
|
||||||
|
+ 'inert here and goes live at integration (measured GREEN on the scratch merge, hint 0.55)';
|
||||||
|
|
||||||
|
test('gate 3: the corroded tier is 0.55, read off E\'s resolver not a literal', () => {
|
||||||
|
if (!CORRODED_TIER_LIVE) return CORRODED_SKIP;
|
||||||
|
const ex = factoryExtras('corroded_post');
|
||||||
|
assert(ex && ex.ratingHint === 0.55,
|
||||||
|
`factoryExtras('corroded_post') gave ${JSON.stringify(ex)} — the card reads this for a node-less `
|
||||||
|
+ 'corroded placement, and if E re-rules the number it must move here, not be re-typed');
|
||||||
|
assert(ex.collateral === 'corroded_post',
|
||||||
|
'the corroded anchor carries no collateral key — the exposure line would read "not scored" over a $45 trap');
|
||||||
|
return `corroded_post → hint ${ex.ratingHint}, collateral "${ex.collateral}"`;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('gate 3: a corroded corner costs MORE steel than a sound post at the same load', () => {
|
||||||
|
if (!CORRODED_TIER_LIVE) return CORRODED_SKIP;
|
||||||
|
// _effRating is what the panel's arrow and the audit's tier both price
|
||||||
|
// against: hardware.rating × the anchor's ratingHint. A corroded post fails
|
||||||
|
// at 0.55 of the steel's spec, so a load a carabiner holds on a sound post
|
||||||
|
// may need a shackle on a corroded one. Built off E's real hint.
|
||||||
|
const hint = factoryExtras('corroded_post').ratingHint;
|
||||||
|
const SOUND = { id: 'ps', type: 'post', pos: { x: -6.4, y: 3.9, z: 7.4 }, sway: () => ({ x: -6.4, y: 3.9, z: 7.4 }) };
|
||||||
|
const COR = { id: 'pc', type: 'corroded_post', pos: { x: 5.3, y: 3.9, z: 8 }, ratingHint: hint,
|
||||||
|
sway: () => ({ x: 5.3, y: 3.9, z: 8 }) };
|
||||||
|
const s = new RiggingSession({ anchors: [SOUND, COR, ...ANCHORS] });
|
||||||
|
s.rig('ps'); s.rig('pc'); s.rig('h1'); s.rig('t1');
|
||||||
|
const sound = s._effRating(s.pickOf('ps'));
|
||||||
|
const cor = s._effRating(s.pickOf('pc'));
|
||||||
|
// Both start on carabiners (rating 1200): the sound post holds 1200 N, the
|
||||||
|
// corroded holds 1200 × 0.55 = 660 N. Same steel, less strength — measured.
|
||||||
|
assert(sound === CARABINER.rating, `sound post effRating ${sound}, expected the carabiner's ${CARABINER.rating}`);
|
||||||
|
assert(Math.abs(cor - CARABINER.rating * hint) < 1e-9,
|
||||||
|
`corroded effRating ${cor}, expected ${CARABINER.rating} × ${hint} = ${CARABINER.rating * hint}`);
|
||||||
|
assert(cor < sound,
|
||||||
|
'a corroded post held as much as a sound one — the trap is priced as safe steel, which inverts it');
|
||||||
|
// The tier consequence: a load in the corroded gap (660–1200 N) is HELD by a
|
||||||
|
// carabiner on the sound post and OVER a carabiner on the corroded one.
|
||||||
|
const load = 900;
|
||||||
|
assert(load < sound && load > cor,
|
||||||
|
`pick a load in the corroded gap (${cor}..${sound} N) so the tier difference is real`);
|
||||||
|
return `same carabiner: sound holds ${sound} N, corroded holds ${cor} N — the rust costs a tier`;
|
||||||
|
});
|
||||||
|
|
||||||
export const RIGGING_TESTS = TESTS;
|
export const RIGGING_TESTS = TESTS;
|
||||||
|
|
||||||
export function runRiggingSelftest() {
|
export function runRiggingSelftest() {
|
||||||
|
|||||||
@ -19,12 +19,15 @@ import { FIXED_DT, checkContract, DEBRIS_PIECE_FIELDS, createStubWind } from '..
|
|||||||
import {
|
import {
|
||||||
loadStorm, createWind, windForSite, forecastLines, forecastFor, leadFor, hailBlockFor,
|
loadStorm, createWind, windForSite, forecastLines, forecastFor, leadFor, hailBlockFor,
|
||||||
forecastHonest, stormStats, STONE_WORD_CEILING,
|
forecastHonest, stormStats, STONE_WORD_CEILING,
|
||||||
|
// SPRINT17, the second half of gate 1 (see weather.js's divider): what the
|
||||||
|
// card PRINTS, and which yard a storm may be printed over.
|
||||||
|
offerBand, PAIRING_LAW, pairingRefusal, pairingRefusals,
|
||||||
} from '../weather.js';
|
} from '../weather.js';
|
||||||
import { loadSite, createWorld } from '../world.js';
|
import { loadSite, createWorld } from '../world.js';
|
||||||
// SPRINT17 gate 1 — the board's weather side. NIGHTS ∪ POOL is every storm an
|
// SPRINT17 gate 1 — the board's weather side. NIGHTS ∪ POOL is every storm an
|
||||||
// offer card can print; both are data, so the honesty walk below tracks them
|
// offer card can print; both are data, so the honesty walk below tracks them
|
||||||
// without a test edit the day D's pool yard lands.
|
// without a test edit the day D's pool yard lands.
|
||||||
import { NIGHTS } from '../week.js';
|
import { NIGHTS, nightAt } from '../week.js';
|
||||||
import { POOL, exposureOf } from '../board.js';
|
import { POOL, exposureOf } from '../board.js';
|
||||||
// GATE 2.3 — the GAME's own wind wiring, IMPORTED rather than re-typed. A pin
|
// GATE 2.3 — the GAME's own wind wiring, IMPORTED rather than re-typed. A pin
|
||||||
// that copied main.js's two lines would agree with a copy of the game forever,
|
// that copied main.js's two lines would agree with a copy of the game forever,
|
||||||
@ -1382,6 +1385,133 @@ export default async function run(t) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
t.test('GATE 1 (S17): the offer band carries every fact the storm delivers, and none it does not', () => {
|
||||||
|
// ⚠️ THE FINDING THIS CASE EXISTS FOR. A's board prints `f.wind` and
|
||||||
|
// `f.stones` — a reasonable pair — and under that pair NIGHT 7 LIES:
|
||||||
|
// soaker (scripted) gusts to ~55 km/h · fine pea stones
|
||||||
|
// early buster (alt) gusts to ~76 km/h · pea stones
|
||||||
|
// On both printed lines the soaker is the softer job, while delivering
|
||||||
|
// 14.9s of hail against 2.4s and 44 mm/hr against 16.5. `offerBand` returns
|
||||||
|
// the ORDERED LIST instead, so the forecast decides which facts a night
|
||||||
|
// carries and a card cannot drop the next one somebody adds.
|
||||||
|
for (const nm of offerStormNames) {
|
||||||
|
const def = offerDefs[nm];
|
||||||
|
const st = stormStats(def);
|
||||||
|
const lines = offerBand(def, 0);
|
||||||
|
const keys = lines.map((l) => l.key);
|
||||||
|
const text = (k) => lines.find((l) => l.key === k)?.text ?? '';
|
||||||
|
|
||||||
|
assert(keys.includes('wind'), `${nm}: no wind line on the offer band`);
|
||||||
|
assert(keys.includes('rain'), `${nm}: no rain-rate line on the offer band`);
|
||||||
|
assert(keys.includes('change'), `${nm}: no change line on the offer band`);
|
||||||
|
assert(!keys.includes('confidence'),
|
||||||
|
`${nm}: tonight's band hedged its own confidence — lead 0 is exact`);
|
||||||
|
|
||||||
|
// HAIL, BOTH DIRECTIONS — the assert night 7 needed. Present iff it hails:
|
||||||
|
// a missing line recommends the trap, an invented one teaches that the
|
||||||
|
// absence of a hail line means somebody checked.
|
||||||
|
assert(keys.includes('hail') === (st.hailSeconds > 0),
|
||||||
|
`${nm}: hails for ${st.hailSeconds.toFixed(1)}s but the band's hail line is `
|
||||||
|
+ `${keys.includes('hail') ? 'present' : 'MISSING'}`);
|
||||||
|
if (st.hailSeconds > 0) {
|
||||||
|
assert(/fine pea|pea|marble|golf ball/.test(text('hail')),
|
||||||
|
`${nm}: hail line has no stone word — "${text('hail')}"`);
|
||||||
|
assert(/\ds of it/.test(text('hail')),
|
||||||
|
`${nm}: hail line does not say how long it hails — "${text('hail')}"`);
|
||||||
|
}
|
||||||
|
// The rate is the OTHER half of the soaker's trap, and the unit ponding
|
||||||
|
// reads — its absence is what made the two-field card lie.
|
||||||
|
assert(new RegExp(`${Math.round(st.rainPeakMmPerHour)} mm/hr`).test(text('rain')),
|
||||||
|
`${nm}: peaks at ${st.rainPeakMmPerHour.toFixed(1)} mm/hr, band says "${text('rain')}"`);
|
||||||
|
if (st.changeAt != null) {
|
||||||
|
assert(text('change').includes(`${Math.round(st.changeAt)}s`),
|
||||||
|
`${nm}: changes at ${st.changeAt}s, band says "${text('change')}"`);
|
||||||
|
} else {
|
||||||
|
assert(!/\d/.test(text('change')), `${nm}: never changes, band says "${text('change')}"`);
|
||||||
|
}
|
||||||
|
for (const l of lines) {
|
||||||
|
assert(!/NaN|Infinity|undefined/.test(l.text),
|
||||||
|
`${nm}: the ${l.key} line does not word its own numbers — "${l.text}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
t.test('GATE 1 (S17): the two buster variants are TELLABLE APART on a board card', () => {
|
||||||
|
// storm_03 and storm_03b are IDENTICAL in every number the band prints
|
||||||
|
// (sustained 13.0, gustPeak 21.4/21.2, rain 16.5 mm/hr, hail 2.4s at size
|
||||||
|
// 0.70) and differ ONLY in when the change lands, 30s against 18s. The
|
||||||
|
// board offers exactly that pair — the buster is the drawn alternative on
|
||||||
|
// nights 5 and 7. Drop the change line and the board offers two jobs whose
|
||||||
|
// weather it has just claimed to describe, described identically, and calls
|
||||||
|
// it a choice. That is why the band is a list and not two named fields.
|
||||||
|
const a = offerBand(storms.storm_03_southerly, 0).map((l) => l.text);
|
||||||
|
const b = offerBand(storms.storm_03b_earlybuster, 0).map((l) => l.text);
|
||||||
|
const differing = a.filter((line, ix) => line !== b[ix]);
|
||||||
|
assert(differing.length > 0,
|
||||||
|
'storm_03 and storm_03b word IDENTICAL offer bands — the board would offer two '
|
||||||
|
+ 'indistinguishable jobs and call it a choice');
|
||||||
|
assert(differing.some((l) => /change/.test(l)),
|
||||||
|
`the two busters differ on ${differing.join(' / ')} — expected the CHANGE TIME, since `
|
||||||
|
+ 'that is the only thing that differs in the storms');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── The PAIRING half of gate 1. `forecastHonest` above asks whether the
|
||||||
|
// forecast can describe a STORM; a band can be perfectly honest about a storm
|
||||||
|
// and still be an invitation to a night nobody can work. NIGHTS ∪ POOL again,
|
||||||
|
// this time carrying the SITE — the dimension the storm-side walk cannot see.
|
||||||
|
const offerPairings = [
|
||||||
|
...NIGHTS.map((_, ix) => {
|
||||||
|
const n = nightAt(ix);
|
||||||
|
return { id: `night ${ix + 1}`, storm: n.storm, site: n.site };
|
||||||
|
}),
|
||||||
|
...POOL.map((p) => ({ id: `pool:${p.id}`, storm: p.storm, site: p.site })),
|
||||||
|
];
|
||||||
|
|
||||||
|
t.test('GATE 1 (S17): every offerable PAIRING respects the measured yard/storm law', () => {
|
||||||
|
assert(offerPairings.length >= 12,
|
||||||
|
`only ${offerPairings.length} pairings found — the NIGHTS ∪ POOL walk is broken`);
|
||||||
|
const refused = pairingRefusals(offerPairings);
|
||||||
|
assert(refused.length === 0,
|
||||||
|
`the board would offer ${refused.length} pairing(s) refused by measurement:\n `
|
||||||
|
+ refused.map((r) => `${r.id}: ${r.why}`).join('\n '));
|
||||||
|
// Vacuity guard, on this exact input: without it the assert above passes on
|
||||||
|
// a law that returns [] unconditionally — the failure mode I shipped in S13
|
||||||
|
// and named. Move ONE night's yard and exactly one refusal must appear.
|
||||||
|
const moved = offerPairings.map((p) =>
|
||||||
|
(p.storm === 'storm_06_soaker' ? { ...p, site: 'backyard_01' } : p));
|
||||||
|
assert(pairingRefusals(moved).length >= 1,
|
||||||
|
'moving the soaker onto backyard_01 did not trip the law — the assert above is '
|
||||||
|
+ 'passing on a gate that cannot say no');
|
||||||
|
});
|
||||||
|
|
||||||
|
t.test('GATE 1 (S17): the pairing law refuses what it was measured to refuse, and nothing else', () => {
|
||||||
|
// MINE, with the S16 receipt in the message: the soaker over backyard_01
|
||||||
|
// has no win in it (hail cover capped at ~31%, both fabrics pond-tearing).
|
||||||
|
assert(pairingRefusal('storm_06_soaker', 'backyard_01') !== null,
|
||||||
|
'the soaker over the backyard is offerable — the ~31% hail-cover measurement has '
|
||||||
|
+ 'stopped being enforced');
|
||||||
|
assert(/31%/.test(pairingRefusal('storm_06_soaker', 'backyard_01')),
|
||||||
|
'the refusal does not carry the measurement that justifies it');
|
||||||
|
assert(pairingRefusal('storm_06_soaker', 'site_03_swing_lawn') !== null,
|
||||||
|
'the soaker over the swing lawn is offerable, and nobody has ever flown it');
|
||||||
|
assert(pairingRefusal('storm_06_soaker', 'site_02_corner_block') === null,
|
||||||
|
'the soaker over site_02 is REFUSED — that is the pairing it shipped for');
|
||||||
|
// A's two, enforced the same way and credited to A in the data.
|
||||||
|
assert(pairingRefusal('storm_02_wildnight', 'site_02_corner_block') !== null,
|
||||||
|
'the wild night is offerable off backyard_01, where its separation pin lives');
|
||||||
|
assert(pairingRefusal('storm_02b_icenight', 'site_03_swing_lawn') !== null,
|
||||||
|
'the ice night is offerable off backyard_01, where gardenBeyondSaving was measured');
|
||||||
|
assert(PAIRING_LAW.storm_02_wildnight.owner === 'A' && PAIRING_LAW.storm_06_soaker.owner === 'C',
|
||||||
|
'the law has lost track of who ruled which entry — the owner field is how a reader '
|
||||||
|
+ 'knows whose measurement to go and read');
|
||||||
|
// And it must NOT over-refuse: absence of a measurement is not a refusal.
|
||||||
|
assert(pairingRefusal('storm_03_southerly', 'site_03_swing_lawn') === null,
|
||||||
|
'the southerly over the swing lawn is refused — that is night 4, the shipped ladder');
|
||||||
|
assert(pairingRefusal('storm_99_unmeasured', 'anywhere') === null,
|
||||||
|
'a storm with no entry in the law was refused — inventing a refusal for a pairing '
|
||||||
|
+ 'nobody flew is the same offence in the other direction');
|
||||||
|
});
|
||||||
|
|
||||||
// ── GATE 1.2 (S17): THE EXPOSURE CROSS — A's exposureOf vs the failure
|
// ── GATE 1.2 (S17): THE EXPOSURE CROSS — A's exposureOf vs the failure
|
||||||
// route, the S14 pin pattern (two harnesses, one number, by construction).
|
// route, the S14 pin pattern (two harnesses, one number, by construction).
|
||||||
// Prep up front (Suite.test can't await); the tests skip honestly offline.
|
// Prep up front (Suite.test can't await); the tests skip honestly offline.
|
||||||
|
|||||||
@ -224,6 +224,186 @@ export function forecastHonest(def, name = def?.name ?? 'storm') {
|
|||||||
return { ok: errors.length === 0, errors };
|
return { ok: errors.length === 0, errors };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// THE OFFER BAND, and THE PAIRING HALF OF THE GATE (SPRINT17 gate 1)
|
||||||
|
//
|
||||||
|
// Landed ON TOP of forecastHonest above, by the other half of Lane C this
|
||||||
|
// sprint — two sessions converged on gate 1 and split it cleanly without
|
||||||
|
// meaning to. `forecastHonest` answers "can the forecast describe this STORM",
|
||||||
|
// which is the storm-side gate and stays exactly as it landed. These two add
|
||||||
|
// the parts it does not reach: WHAT THE CARD ACTUALLY PRINTS, and WHICH YARD
|
||||||
|
// the storm may be printed over. Nothing above this line was changed.
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* THE HONEST BAND FOR AN OFFER CARD — an ORDERED LIST of lines, not a bag of
|
||||||
|
* named fields, and the shape is the whole point.
|
||||||
|
*
|
||||||
|
* ⚠️ **WHY A LIST, when `forecastLines` already returns every string a card
|
||||||
|
* could want.** A card that reads named fields prints the fields its author
|
||||||
|
* remembered. A's board shipped `f.wind` and `f.stones` — a reasonable pair —
|
||||||
|
* and that pair MEASURABLY LIES ABOUT NIGHT 7:
|
||||||
|
*
|
||||||
|
* | night 7's two offers | wind line | stones line |
|
||||||
|
* |---|---|---|
|
||||||
|
* | the soaker (scripted) | gusts to ~55 km/h | fine pea stones |
|
||||||
|
* | the early buster (alt) | gusts to ~76 km/h | pea stones |
|
||||||
|
*
|
||||||
|
* On both printed lines the soaker is the SOFTER job. It is not: it delivers
|
||||||
|
* **14.9 s of hail against 2.4 s, and 44 mm/hr of rain against 16.5**. The
|
||||||
|
* soaker's whole design is that its wind reads gentle while its hail is the
|
||||||
|
* trap (C, S16 gate 3.1) — so a board printing only wind and stone size
|
||||||
|
* recommends the trap, in the storm's own voice, before a dollar is spent.
|
||||||
|
*
|
||||||
|
* The fix is not "also print rainRate". The fix is that the FORECAST decides
|
||||||
|
* which facts a night carries and hands over all of them, so the next fact
|
||||||
|
* added to a storm cannot be silently dropped by a card that predates it. Same
|
||||||
|
* construction, and the same reasoning, as A's `stormsToPreload(nights, pool)`
|
||||||
|
* split: an omission that is possible will eventually happen.
|
||||||
|
*
|
||||||
|
* ⚠️ **It never renders MORE than the truth either.** A hail line appears if and
|
||||||
|
* only if the storm hails — "hail: none" on a hail-free night teaches that the
|
||||||
|
* absence of a hail line means nobody checked.
|
||||||
|
*
|
||||||
|
* @param {object} def parsed storm JSON
|
||||||
|
* @param {number} [lead] 0..1 — the BOARD passes 0, and only 0: both offers are
|
||||||
|
* for TONIGHT. You are choosing which yard to stand in, not which night to
|
||||||
|
* work, and a hedged board would invent uncertainty the job sheet contradicts
|
||||||
|
* one card later.
|
||||||
|
* @returns {{key:string, text:string}[]} reading order: wind, hail, rain,
|
||||||
|
* change, confidence. `key` is for styling; `text` is the whole printed line.
|
||||||
|
*/
|
||||||
|
export function offerBand(def, lead = 0) {
|
||||||
|
const f = forecastLines(def, lead);
|
||||||
|
const ff = forecastFor(def, lead);
|
||||||
|
const t = f.truth;
|
||||||
|
const i = (v) => v.toFixed(0);
|
||||||
|
const lines = [{ key: 'wind', text: f.wind }];
|
||||||
|
|
||||||
|
// Hail, with its DURATION. `chance` at lead 0 is a duration threshold wearing
|
||||||
|
// a probability word (hailSeconds > 6 ⇒ 'likely'), which is honest enough on
|
||||||
|
// a job sheet and far too coarse on a board where two hail nights sit side by
|
||||||
|
// side: 'likely' covers both the soaker's 15 s and the ice night's 21 s, and
|
||||||
|
// 'possible' covers the buster's 2.4 s. The seconds are what a tradie buys
|
||||||
|
// steel against, so the seconds print.
|
||||||
|
if (f.stones) {
|
||||||
|
lines.push({
|
||||||
|
key: 'hail',
|
||||||
|
text: `hail ${f.hail.chance} · ${f.stones} · ${bandStr(ff.hail.seconds, i)}s of it`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// The rain RATE, always — the line A's card had no field for, and half of the
|
||||||
|
// soaker's trap. mm/hr is the unit the ponding model reads, so the card and
|
||||||
|
// the sim agree on what "heavy" means instead of each having an opinion.
|
||||||
|
lines.push({ key: 'rain', text: f.rainRate });
|
||||||
|
|
||||||
|
// The change, always — including "no change forecast", which is information,
|
||||||
|
// not filler. storm_03_southerly and storm_03b_earlybuster are IDENTICAL in
|
||||||
|
// every other number this band prints (sustained 13.0, gustPeak 21.4/21.2,
|
||||||
|
// rain 16.5 mm/hr, hail 2.4 s at size 0.70) and differ ONLY in when the change
|
||||||
|
// lands, 30 s against 18 s. The board offers exactly that pair — the buster is
|
||||||
|
// the drawn alternative on nights 5 and 7. Drop this line and the board offers
|
||||||
|
// two jobs whose weather it has just claimed to describe, described
|
||||||
|
// identically, and calls it a choice.
|
||||||
|
lines.push({
|
||||||
|
key: 'change',
|
||||||
|
text: t.changeAt == null ? 'no change forecast'
|
||||||
|
: (lead > 0 ? `southerly change ${bandStr(ff.changeAt, i)}s`
|
||||||
|
: `southerly change at ${i(t.changeAt)}s`),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (f.confidence) lines.push({ key: 'confidence', text: f.confidence });
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `band` under its own name — the module-scope helper, reused rather than retyped. */
|
||||||
|
function bandStr(b, fmt) { return band(b, fmt); }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ⚖️ **THE PAIRING LAW — which yards a storm may be OFFERED over.**
|
||||||
|
*
|
||||||
|
* `forecastHonest` above asks whether the forecast can describe a STORM. This
|
||||||
|
* asks the other half of SPRINT17 gate 1's rule, the half that is a fact about
|
||||||
|
* GEOMETRY × STORM rather than about wording: a band can be perfectly honest
|
||||||
|
* about a storm and still be an invitation to a night nobody can work.
|
||||||
|
*
|
||||||
|
* ⚠️ **These three were three prose comments before they were a rule.** A's POOL
|
||||||
|
* carries them as a comment listing what is "deliberately NOT here" — and this
|
||||||
|
* repo's standing verdict on that arrangement is A's own, on CONSTRAINT_KIND:
|
||||||
|
* an unenforced enum is decoration, and the carport shipped typed as a post for
|
||||||
|
* a sprint behind a JSDoc comment saying it couldn't. A comment cannot fail when
|
||||||
|
* somebody adds the sixth pool entry. Promoted to data, checked, red in the suite.
|
||||||
|
*
|
||||||
|
* **A — two of these three are YOUR reasons and are recorded as yours.** I have
|
||||||
|
* not re-measured a separation pin or a beyond-saving flag; I have moved your
|
||||||
|
* stated grounds into a structure that goes red. Reword freely — the mechanism
|
||||||
|
* is what I am defending, not the prose.
|
||||||
|
*
|
||||||
|
* An absent key means UNRESTRICTED, deliberately: this table records refusals
|
||||||
|
* somebody PAID FOR, and inventing one for a pairing nobody flew is the same
|
||||||
|
* offence in the other direction.
|
||||||
|
*/
|
||||||
|
export const PAIRING_LAW = Object.freeze({
|
||||||
|
storm_06_soaker: {
|
||||||
|
sites: ['site_02_corner_block'],
|
||||||
|
owner: 'C',
|
||||||
|
why: 'MEASURED (C, S16 gate 3.1, both shipped yards flown through gardenfly before the '
|
||||||
|
+ 'storm shipped): backyard_01\'s buyable geometry caps hail cover over the bed at '
|
||||||
|
+ '~31%, and its full-cover quads pond-tear at 3.3–3.7 kN in this mild wind on BOTH '
|
||||||
|
+ 'fabrics — best membrane line 37.5 against cloth 17.3, a fabric bet with no win in '
|
||||||
|
+ 'it. The soaker\'s forecast reads "fine pea stones · rain to 44 mm/hr" over there '
|
||||||
|
+ 'exactly as it does over site_02, and over there that band is an invitation to a '
|
||||||
|
+ 'night nobody can work. The swing lawn is not measured under it at all, which is '
|
||||||
|
+ 'the same refusal for a cheaper reason.',
|
||||||
|
},
|
||||||
|
storm_02_wildnight: {
|
||||||
|
sites: ['backyard_01'],
|
||||||
|
owner: 'A',
|
||||||
|
why: 'A (S17 gate 1, POOL): its separation is PINNED to backyard_01\'s site data. Fly it '
|
||||||
|
+ 'elsewhere and the pin describes a night nobody plays.',
|
||||||
|
},
|
||||||
|
storm_02b_icenight: {
|
||||||
|
sites: ['backyard_01'],
|
||||||
|
owner: 'A',
|
||||||
|
why: 'A (S17 gate 1, POOL), on A\'s S13 measurement: `gardenBeyondSaving` is a fact about '
|
||||||
|
+ 'THAT BED under that ice, not a property of the storm. Offered over another yard the '
|
||||||
|
+ 'night would carry the excuse without the evidence.',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* May this storm be offered over this yard?
|
||||||
|
* @returns {string|null} the refusal in English, or null when the pairing is offerable
|
||||||
|
*/
|
||||||
|
export function pairingRefusal(stormKey, siteKey) {
|
||||||
|
const law = PAIRING_LAW[stormKey];
|
||||||
|
if (!law || !law.sites) return null;
|
||||||
|
if (law.sites.includes(siteKey)) return null;
|
||||||
|
return `${stormKey} over ${siteKey}: not an offerable pairing — ${law.why} `
|
||||||
|
+ `(offerable: ${law.sites.join(', ')}; ruled by ${law.owner})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every pairing refusal in a list of candidate nights, as data.
|
||||||
|
*
|
||||||
|
* ⚠️ Deliberately NOT a filter. Silently dropping a bad entry leaves a morning
|
||||||
|
* with one offer and no explanation — a runtime surprise wearing a fix's
|
||||||
|
* clothes. The caller (c.test today; a `board.js` load-time assert the day A
|
||||||
|
* wants one) decides how loud to be, and gets the measurement in the message.
|
||||||
|
*
|
||||||
|
* @param {Array<{id?:string, storm:string, site:string}>} pairings
|
||||||
|
* @returns {{id:string, why:string}[]} empty = every pairing is offerable
|
||||||
|
*/
|
||||||
|
export function pairingRefusals(pairings = []) {
|
||||||
|
const out = [];
|
||||||
|
for (const p of pairings) {
|
||||||
|
const why = pairingRefusal(p.storm, p.site);
|
||||||
|
if (why) out.push({ id: p.id ?? `${p.storm}×${p.site}`, why });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
// Resolved against this module, not the server root: server.py serves the repo
|
// Resolved against this module, not the server root: server.py serves the repo
|
||||||
// root (so the 2D prototype stays reachable), but the demo bench serves web/.
|
// root (so the 2D prototype stays reachable), but the demo bench serves web/.
|
||||||
// import.meta.url is right under both, and under whatever Lane A does next.
|
// import.meta.url is right under both, and under whatever Lane A does next.
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user