Lane A S18: 👁️ played the callout — five bugs 535 green asserts could not see, all fixed and pinned; gate 5's A half

Eyes on the cards found what the suite structurally cannot:
· the board advertised $57 while the dispatch quoted $114 one click apart — the
  quote-vs-settle lie walking back in through an unreachable closure inside
  boot(). pricedOffers is now the exported pure priceOffer(), rigRecordFor's
  precedent, and the bug has its own pin.
· the emergency wore the same grey "callout" tag as the Tuesday alternative
  beside it — the tell rule broken on the surface the tell rule is for.
· the invoice argued with itself: the ledger said CB1 "is not on you" and the
  verdict blamed CB1. verdictFor now reads `yours`, and the ruling is extracted
  as answerableFor() so the RULING is pinnable, not just its consequences.
· the shop lied by $20 — the session charged me for the cowboy's carabiners.
· "the Hendersons IS expecting you", live, on the ruling's headline line.

Gate 5 (A's half): collateralKeyClashes() turns D's second-rust-post landmine
into a tripwire carrying both harnesses' numbers. It reports and does not throw
(D and E are authoring in parallel), and it deliberately does NOT make
exposureOf dedupe — the card is the honest side, the failure route is what needs
per-instance keys, and matching a bug converts a loud disagreement into a quiet
lie.

⚠️ RETRACTED IN THREADS: my $180 balance measurement is contaminated. C found
SailRig.step accepts `t` and never reads it, so setPhase('storm',{at:30}) moves
the sky and not the cloth (4.12× understated load, 93× light rain). B must not
rule the garden bonus against my $38. The TELL survives — CB1 still blows at
t≈33 — the ECONOMY does not.

Selftest 539/0/0 — 522 + 17, the exact sum. 19 mutations, all red with their own
words, controls green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-25 19:53:47 +10:00
parent 82a52aa014
commit 397d9435bc
5 changed files with 524 additions and 38 deletions

View File

@ -9787,3 +9787,162 @@ anchors are your GLB), but the tooling is now waiting, not TODO.
over an unrigged yard each produce their own accusation). Your yard debuts as an ordinary
rig-for-tonight job; DESIGN.md's sentence belongs to a later week. Flag me when it is placeable
and gate 5 is next on my list.
[A] 2026-07-25 — 👁️ **I PLAYED THE EMERGENCY, AND LOOKING AT THE CARDS FOUND FIVE BUGS 535 GREEN
ASSERTS COULD NOT SEE.** All five fixed, all five pinned, two of them by EXTRACTING the thing
that was wrong so the ruling itself became assertable. Recording them together because they
are one lesson: *every one of them lived on a surface, and the suite has no eyes.*
Real path throughout, on my own tab at :8824: splash → board → click the emergency → DISPATCH
→ ROLL → storm at t=30, fast-forwarded with `SHADES.step(1/60)` in a loop (the repo's own
path) → invoice. One disclosed harness move: `week.advance()` ×4 to reach night 5's morning,
which does not EARN nights 14 — the same class of seek as `boot({site, bank})`, and the bank
reads the honest $80 of night one throughout.
**BUG 1 — THE BOARD ADVERTISED $57 AND THE DISPATCH QUOTED $114, one click apart.** The
emergency term never reached the offer card: `pricedOffers` was an inline closure inside
`boot()`, behind a WebGL context where no assert can reach it, and it simply had not been given
the fourth argument. **This is the quote-vs-settle lie that `calloutFee()` was extracted to
make impossible, and it walked straight back in through the one surface no test could see.** So
`priceOffer(night, {def, site, rep, remembers})` is now exported and pure — the `rigRecordFor`
precedent, verbatim, for exactly the reason that function's own note gives. Pinned both ways
(an ordinary night prices as it always did; the emergency prices at ×2; and what the card
prints equals what `week.quote()` quotes).
**BUG 2 — THE EMERGENCY WORE THE SAME GREY "callout" TAG AS THE TUESDAY ALTERNATIVE BESIDE
IT.** Double rates, no prep, no quote, somebody else's rig — the biggest new thing in the game
— and the board said nothing until one click later. That is DESIGN.md's tell rule broken on the
exact surface the tell rule exists for. It now has its own tag (`right now · emergency`), its
own red left border against the constraint's amber (different KINDS of fact: amber is a client
asking something of you, red is weather already happening to somebody), one line naming what is
different (`storm already running · no prep · no quote · you arrive 30s in`), and the doubled
number carries `· 2× emergency rate` beside it, because money with no stated cause is what
every ledger comment in this repo is about.
**BUG 3 — THE INVOICE ARGUED WITH ITSELF ABOUT THE SAME CORNER.** The ledger said *"CB1 —
carabiner, already on the ground when you arrived · not billed · That corner is what the
callout was for. It is not on you"* and four lines below it the verdict said *"the carabiner at
CB1 went first and took 1 more with it."* `verdictFor` was being handed `lost` instead of
`yours`. Fixed — and this is what made me extract **`answerableFor(corners, emergency)`**, so
the ruling is a pure exported function with its own pin rather than four call sites each
remembering it. The corner COUNT still reports every corner down; the verdict speaks only of
work you are answerable for. Two different facts, both honest.
**BUG 4 — THE SHOP LIED BY $20.** The session charged me for the cowboy's four carabiners:
budget read $60 against a bank of $80. The ledger was fine (`spentThisNight` is forced to 0)
but `run.budgetLeft` and every refusal B prices off `budget` read that number. Now pre-banked
by `week.bank + theirCost + SPARE_COST`, computed FROM THE RIG DATA so B can author rated
shackles into a cowboy's rig without the line silently going wrong, and the budget lands back
on exactly `week.bank`. You arrive having spent nothing, because you did.
**BUG 5 — "the Hendersons IS expecting you"**, live, on the one line John's ruling says must be
readable before the choice. I had agreed the verb with the NUMBER OF CLIENTS STOOD UP, which is
the wrong thing to agree with: every client in this game is a collective noun whose number no
code here can infer, and the repo's own voice already treats them as plural (night 7's brief:
*"The Vasilaros place again — THEY asked for you by name"*). No agreement logic at all now;
"are", always, which is also the spec's own example wording.
── **THE NIGHT AS PLAYED, and the number B and John need** ─────────────────────────────
Night 5's board, live: **the wildnight, booked — $73, $115 at risk, golf-ball hail 11 s, 80
mm/hr.** Beside it: **the emergency — $114 at 2× rate, $205 at risk (the carport), "storm
already running · no prep · no quote · you arrive 30s in", and "the Hendersons are expecting
you · −½★ standing".** That is a genuinely hard morning and the dodge finally has a printed
price. The DISPATCH then lists the rig you didn't build corner by corner — CB1 carabiner **ON
THE GROUND**, TR1/TR1B/Q3 carabiners — names the rate, and refuses to total anything.
The storm entered at `phaseT` 30 with `windTime` 30, the right storm, the right yard, four
corners up and one spare in the ute. **CB1 blew at t≈33, through the REAL overload path** — a
$5 carabiner on the 0.22 carport beam, ~264 N effective against a southerly — which is the
night's premise arriving on its own without anybody poking a flag. Q3 followed later. Garden
84%, 2/4 corners standing, verdict "THE GARDEN MADE IT. The sail didn't."
⚠️ **THE BALANCE MEASUREMENT, AND I AM REPORTING IT RATHER THAN QUIETLY RETUNING CANON.**
That night banked **+$180 and took the wallet $80 → $260**, from four lines:
callout fee (2× rate) +$114 garden bonus — 84% of the bed +$38
clean site +$20 gear recovered (the spare only) +$8
spent on the rig $0 standing 1.0★
For scale, D's cold play measured the wildnight at **+$24** and the week's best ordinary night
at **+$104**. So the emergency is the biggest single night in the game by ~75%, it required no
prep skill, and its only price is half a star. **A fee-maximizer takes it every time.** Three
things are true about that and they belong to three different people:
· **the ×2 is CANON** (DESIGN.md line 29, ROADMAP arc 2, in those words) and I am not re-ruling
a number the document has carried since line one to fix my own feature's balance. Flagged
UNMEASURED-BY-PLAY, cut-first, and per-job `pay.emergencyRate` is the next move if it must
move — not a trim of `feeFor`, which carries measured evidence.
· **the $38 GARDEN BONUS IS B's RULING, and here is its number.** SPRINT18 gate 1.2 gives B
*"whether the garden bonus applies at all"* — you had no prep phase to protect a garden with.
$38 of $180 is 21% of the night, so B: this is the single largest lever on emergency balance
and you now have the measurement to rule it with.
· **the $20 CLEAN BONUS is a question I am RAISING AND NOT RULING.** It paid because the
client's property survived, which is what the bonus is defined to mean. But "you left their
place the way you found it" over a yard you arrived at mid-collapse is at least arguable, and
`noCollateralBonus` lives in PAY with measured neighbours — cutting it unilaterally to pay
for my own feature's balance is precisely the move this repo keeps catching. For gate 6.
**AND THE ONE THING THAT WENT EXACTLY RIGHT, unprompted:** collateral billed **$0**. The
carport — $180, the trap the client has been selling since night 3 — was NOT charged, because
the corner holding it arrived on the ground. The invoice split the blame in its own words
without anybody at the keyboard reminding it to. That is the ruling working, and it is the
difference between a job you were called out to and a job you failed.
[A] 2026-07-25 — ⚠️ **RETRACTION, SAME DAY: MY $180 BALANCE MEASUREMENT ABOVE IS CONTAMINATED AND
MUST NOT BE ACTED ON. C FOUND THE VARIABLE AND IT IS NOT IN MY FILE.** `SailRig.step(dt, wind,
t)` accepts `t` and never reads it — sail.js passes its OWN fixed-step clock, which `attach()`
zeroes — so **`setPhase('storm', {at: 30})` moves the sky, the garden, the debris and the
player, and does not move the CLOTH.** C measured it on my own shipped emergency: at the moment
you get out of the ute the sky blows **9.074 m/s** and the fabric feels **4.468** (a **4.12×
understatement** of load, which goes with v²), and the sky rains **5.000 mm/hr** while the pond
fills at **0.054** — 93× light. The one object in the yard that can FAIL is the one object that
never heard about `arriveAt`. The fix is one line and it is B's; C has relayed it.
**So every number in my play-through entry above is soft in the same direction: the callout
read EASY for a false reason.** Garden 84%, two corners standing, garden bonus $38, +$180 —
all of that was earned against a sail feeling a quarter of the load and none of the rain.
Concretely, and please read this before touching a constant:
· **B — do NOT rule the garden bonus against my $38.** That was my one deliverable to your
gate-1.2 ruling and it is void. Re-measure after your clock fix; the shape of the question is
unchanged (no prep phase, no garden to have protected) but the size of it is unknown.
· **Nobody tune `emergencyRate`, triage pay, or the callout's difficulty against the current
feel.** ×2.0 stays where DESIGN.md put it. My "biggest night in the game by ~75%" claim is
withdrawn as a measurement; it survives only as a QUESTION for gate 6.
· **Integrator — re-check the callout's feel after B's clock fix merges**, and treat the
economy paragraph in my play-through as un-run. The mutation receipts and the bug fixes in
that entry all stand; only the balance numbers are contaminated.
**WHAT SURVIVES THE CONTAMINATION, and it is worth saying because it is the night's premise:**
**CB1 still blew at t≈33.** A $5 carabiner on the 0.22 carport beam is so far under the night
that it fails even at 0.24× load — so the emergency's opening beat (you walk up the drive and
the carport corner is going) is real, not an artefact, and D's knife has a genuine target
waiting for it. The TELL holds; the ECONOMY does not. Those are separable and only one of them
is broken.
**MY §4 STANDS, and C pinned the property it rests on rather than taking my word for it:** the
wind field is closed-form and ORDER-INDEPENDENT — they read t=30 cold, after walking 0→30, and
after scrambling 90/0/45/12/88/30/1/5, byte-identical on every storm across five observables.
So `phaseT = at` really is sufficient for everything that takes `t` as an argument, which was
the load-bearing claim in the published shape. The gap was never the phase machine; it was one
function accepting a parameter it ignored. **SPRINT13's bug arriving through a new door**, in
C's words, and the reason a seam gets published early is so somebody else can measure it.
**C's DEBRIS FINDINGS, noted as the phase's owner (nothing for me to fix).** `clear()` rewound
the leaf accumulators but not the PRNG, so night 2 of the shipped week has never flown night
1's crates; and an entry at t=45 had every later crate wearing the skipped crate's
height/spin/phase. Both closed structurally in C's tree, and **my `arriveAt: 30` was green only
by COINCIDENCE — the earliest debris event is t=38.** What it changes for the callout, and it
is a design fact rather than a bug: a t=0 run has 6 leaves crossing the grass at t=30, and a
cold arrival has **none for ~7 s**. That moving grass is the gust front's tell, so a cold
arrival is quieter than the same instant reached by living through it — worth a look, once the
cloth clock is honest, at whether the dispatch's "what you can see from the drive" should be
doing that work instead.
**AND C's HALF OF MY §8 LANDED ON THE EXACT PATH I PUBLISHED**, which is the seam pattern
paying for itself twice in one day: `midStormRead(def, t)` called through
`weather.midStormRead?.()` under a namespace import — verified by them on that call path, not
assumed — and `dispatchHonest(def, t)` extending the honesty gate over emergency storms.
Their walk reads `board.EMERGENCIES` through a namespace import so c.test does not hard-fail
on a table that only exists in my tree, and **`corner_block_emergency` passes both arms**
(dispatchHonest OK, pairing legal). Nothing of mine has to move for their half to go live; the
dispatch card already maps the ordered list.

View File

@ -653,6 +653,69 @@ export function exposureOf(site) {
return { total, items };
}
/**
* **SPRINT18 gate 5 (A's half) THE SECOND-RUST-POST LANDMINE, DISARMED AS A
* TRIPWIRE.** D's filing, S17 gate 3.2, with a measured receipt from the other
* Lane-D session: two structures in one yard that share a baked `collateralKey`
* make the two exposure harnesses DISAGREE, and one of them bills wrong.
*
* THE MEASUREMENT, D's, on a real two-corroded-post yard:
* · `exposureOf()` (this file, the OFFER CARD) read **$115** per STRUCTURE.
* · `collateralExposure` (B/C, the FAILURE route) read **$45** per KEY, deduped,
* because `collateralFor`/`wreckStructure` match the FIRST structure carrying a
* key and C's failure envelope dedupes keys.
* So the second post's failure would bill once and wreck the wrong post: **a free
* failure in a rust costume**, which is the exact bug class that hid the carport's
* $180 for five sprints.
*
* **WHY THIS REPORTS AND DOES NOT THROW, and why it does not "fix" `exposureOf`.**
* Two deliberate refusals:
* 1. **No throw.** D and E are authoring yards in parallel right now. A hard
* failure in a shared path would soft-lock somebody else's tree mid-sprint over
* a bug that is ARMED, not firing (the pool yard ships exactly ONE post on
* purpose and pins it). It accuses instead C's `pairingRefusals`
* construction, and their reasoning applies unchanged.
* 2. **I did not make `exposureOf` dedupe by key.** That would make the two
* harnesses agree, which is tempting and wrong: a yard with two corroded posts
* genuinely has $90 of corroded post in it, so the CARD is the honest one and
* the FAILURE path is the side that needs per-instance keys. Making the card
* under-report to match a bug is how you convert a loud disagreement into a
* quiet lie, and it would have deleted D's receipt in the process.
*
* **THE REAL FIX, for the joint E+A pass:** per-instance collateral keys the
* anchor naming the STRUCTURE it will wreck, not the KIND of thing it is. That
* needs E, because the key is baked into the GLB extras and the manifest. This
* function is the landing pad: it goes red the moment a yard author ships the
* second post, which is the whole of "disarm it before somebody trips it".
*
* @param {object} site a site JSON
* @returns {string[]} one accusation per clashing key, with its measurement
*/
export function collateralKeyClashes(site) {
/** @type {Map<string, object[]>} */
const byKey = new Map();
for (const s of site?.structures ?? []) {
// No key ⇒ world.js's collateralFor resolves it by `id`, which is unique by
// construction (the editor generates s1, s2, …) — so an unkeyed structure
// cannot clash and must not be reported as if it could.
if (!s.collateralKey) continue;
if (!byKey.has(s.collateralKey)) byKey.set(s.collateralKey, []);
byKey.get(s.collateralKey).push(s);
}
const out = [];
for (const [key, group] of byKey) {
if (group.length < 2) continue;
const perStructure = group.reduce((n, s) => n + (s.collateralValue ?? 0), 0);
const perKey = group[0].collateralValue ?? 0;
out.push(`${site?.name ?? site?.id ?? 'this yard'}: ${group.length} structures share the `
+ `collateral key '${key}' (${group.map((s) => s.id).join(', ')}). The offer card would read `
+ `$${perStructure} (per structure) and the failure route $${perKey} (per key, deduped) — `
+ `so ${group.length - 1} of them would fail FREE and wreck '${group[0].id}' instead. `
+ 'Per-instance collateral keys are needed first (SPRINT18 gate 5, E+A).');
}
return out;
}
/**
* BUILD THE MORNING'S BOARD — two offers, tonight's scripted night first.
*

View File

@ -19,7 +19,7 @@ import { forecastLines, leadFor } from './weather.js';
// SPRINT16 — the end card compares the final rep against neutral. A constant,
// not state: the HUD still only READS. week.js has no DOM and no THREE, so
// this import costs nothing it didn't already cost main.js.
import { NIGHTS, REP } from './week.js';
import { NIGHTS, PAY, REP } from './week.js';
// SPRINT16 gate 4 (Lane D, flagged for A): the week is data — NIGHTS.length —
// and these surfaces used to hardcode "five". A six-night week whose splash,
@ -370,6 +370,11 @@ const CSS = `
margin-top:7px; font-size:11px; font-style:italic; color:#8a7f74;
}
#hud-card .board-note.free { color:#8ba0ad; }
/* 🚨 SPRINT18 gate 1 an emergency on the board. Red-side left border against the
constraint's amber, because they are different KINDS of fact: amber is a client
asking something of you, red is weather already happening to somebody. */
#hud-card .offer.emergency { border-left:3px solid #ff8f86; }
#hud-card .offer .line.siren span { color:#ff8f86; letter-spacing:.04em; }
/* --- 🚨 SPRINT18 gate 1: THE DISPATCH ------------------------------------
The paper chain's fourth document, and the design brief for it is one line:
@ -889,13 +894,29 @@ export function createHud(d) {
// 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 `
<button class="offer${cons.length ? ' constrained' : ''}" data-offer="${i}">
<span class="tag">${o.kind === 'scripted' ? 'tonight, as booked' : 'callout'}</span>
<button class="offer${cons.length ? ' constrained' : ''}${n.emergency ? ' emergency' : ''}" data-offer="${i}">
${/* 🚨 SPRINT18 gate 1 AN EMERGENCY MUST NOT LOOK LIKE AN ORDINARY
CALLOUT, and my first cut let it. On the live board it wore the same
grey "callout" tag as the Tuesday alternative beside it: double
rates, no prep, no quote, somebody else's rig the biggest new
thing in the game and the card said nothing. A player would find
out one click later, which is the tell rule broken on the exact
surface the tell rule exists for.
So: its own tag, its own left border, and one line naming what is
different about it, above the money. */''}
<span class="tag">${
n.emergency ? 'right now · emergency' : o.kind === 'scripted' ? 'tonight, as booked' : 'callout'
}</span>
<div class="who">${n.client ?? 'no client'}</div>
<div class="addr">${n.addr ?? ''}</div>
${n.emergency ? `<div class="line siren"><span>storm already running · no prep · no quote · you arrive ${
n.emergency.arriveAt}s in</span></div>` : ''}
${(o.band ?? []).map((l) => `<div class="line wx ${l.key}"><span>${l.text}</span></div>`).join('')}
${o.fee != null ? `<div class="line fee"><span>the job pays${
o.premium ? ` · +${Math.round(o.premium * 100)}% on their terms` : ''
}${/* the doubled number gets its reason printed beside it money with no
stated cause is the thing every ledger comment in this repo is about */''}${
n.emergency ? ` · ${PAY.emergencyRate}× emergency rate` : ''
}</span><b>$${o.fee}</b></div>` : ''}
${o.exposure != null ? `<div class="line risk"><span>their property at risk${
o.exposureItems?.length ? `${o.exposureItems.map((x) => x.what).join(', ')}` : ''
@ -912,9 +933,19 @@ export function createHud(d) {
morning) `standsUp` is empty and this renders NOTHING the free
case reads as free because there is no line, not because a line
says "$0". */''}
${/* "ARE", ALWAYS, and it is not a typo I am defending. My first cut
agreed the verb with the NUMBER OF CLIENTS STOOD UP and rendered
"the Hendersons IS expecting you" on the live board bad English
on the one line John's ruling says must be readable before the
choice. The count is the wrong thing to agree with: every client in
this game is a collective noun (a household, a place), whose number
no code here can infer, and the repo's own voice already treats them
as plural night 7's brief says "The Vasilaros place again THEY
asked for you by name". The spec's own example wording is "the
Hendersons are expecting you". So: no agreement logic at all. */''}
${o.standsUp?.length ? `<div class="line cost"><span>${
o.standsUp.map((s) => s.client ?? 'a client').join(' and ')
} ${o.standsUp.length > 1 ? 'are' : 'is'} expecting you</span><b>${
} are expecting you</span><b>${
starDelta(REP.STOOD_UP * o.standsUp.length)} standing</b></div>` : ''}
${cons.length ? `<div class="cons">⚖ ${cons.map((c) => c.label).join(' · ')}</div>` : ''}
${cons.map((c) => `<div class="says">“${c.says}”</div>`).join('')}

View File

@ -357,6 +357,94 @@ export function rigRecordFor(corners, tally = new Map(), theirs = new Set()) {
});
}
/**
* WHAT AN OFFER CARD PRINTS pure, exported, and it did not use to be either.
*
* **EXTRACTED BECAUSE IT SHIPPED A LIVE BUG THE SUITE COULD NOT SEE.** This was
* an inline closure inside `boot()`, unreachable by any assert, and on night 5's
* real board it advertised the emergency callout at **$57 while the dispatch one
* click later quoted $114** it had simply never been given the emergency term.
* That is exactly the quote-vs-settle lie this file's own comment says the board
* must not be able to tell ("the board can never be more confident than the
* sheet"), and it was invisible to 535 asserts because the money lived behind a
* WebGL context. Eyes on the card found it; being a function is what stops the
* next one.
*
* The rule it now keeps by construction: **every number on an offer card comes
* from the same function the job sheet and the invoice use.** `calloutFee` for the
* money, C's `forecastLines`/`offerBand` for the weather, `exposureOf` for the
* risk nothing retyped here.
*
* @param {object} night a normalised night (week.js normaliseNight)
* @param {object} ctx
* @param {object} [ctx.def] the storm def; absent the weather lines degrade away
* @param {object} [ctx.site] the site JSON, for the exposure walk
* @param {number} ctx.rep standing as of this morning the fee is priced on it
* @param {object[]} [ctx.remembers] this client's grudges (week.remembers)
*/
export function priceOffer(night, { def = null, site = null, rep = 3, remembers = [] } = {}) {
const exp = exposureOf(site);
return {
// 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.
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 6x the hail — the board recommending its own trap.
band: def ? offerBand(def, 0) : null,
// 🚨 SPRINT18 gate 1 — the fourth argument IS the bug above, fixed: an
// emergency pays double ON THE CARD, because the card is where the player
// decides whether double is worth it.
fee: def ? calloutFee(def, rep, night.constraints, !!night.emergency) : null,
exposure: exp.total,
exposureItems: exp.items,
remembers,
};
}
/**
* 🚨 **SPRINT18 gate 1 WHO IS ANSWERABLE FOR WHICH CORNER.** The ruling that
* makes an emergency invoice honest, as a pure function so that the RULING is
* pinnable and not merely its consequences.
*
* Exported for the `rigRecordFor` reason, stated in that function's own note:
* scoreRun lives inside `boot()` behind a WebGL context where no assert can reach
* it, and this is exactly the kind of rule that would otherwise only ever be
* "tested" by a green suite that never ran it. It was worth extracting twice over,
* because rendering the first real emergency invoice found TWO bugs downstream of
* this split the verdict blaming an inherited corner, and the gnome billable off
* corners the player never chose.
*
* **A corner that was already down when you arrived is the JOB, not your failure.**
* It bills nothing, wrecks nothing, books no warranty and costs no standing it is
* the REASON there is a callout. Without the split, an emergency night charges you
* $180 for the carport the cowboy was already losing, bills his $5 carabiner, docks
* your standing for his corner, and books you a free warranty callout tomorrow to
* go and fix his work: four surfaces, all agreeing, all wrong.
*
* Two lists out, deliberately, because they answer two different questions:
* · `lost` every corner down. REPORTING (the HUD's corners-intact count). It
* must stay honest: reading 4/4 over a sail with three corners on the ground
* would be its own lie.
* · `yours` the ones you are ANSWERABLE for. Billing, collateral, the subtitle
* and the verdict read this.
*
* @param {Array} corners rig.corners at dawn
* @param {object|null} emergency the night's `emergency` block, if it is one
*/
export function answerableFor(corners, emergency = null) {
const theirs = new Set((emergency?.rig ?? []).filter((c) => c.broken).map((c) => c.anchorId));
const lost = (corners ?? []).filter((c) => c.broken);
return {
theirs,
lost,
yours: lost.filter((c) => !theirs.has(c.anchorId)),
inherited: lost.filter((c) => theirs.has(c.anchorId))
.map((c) => ({ anchorId: c.anchorId, hw: c.hw?.name ?? '?' })),
};
}
// ---------------------------------------------------------------------------
// Phase machine
// ---------------------------------------------------------------------------
@ -908,12 +996,7 @@ export async function boot(opts = {}) {
* subtitle read this.
*/
const em = week.job.emergency;
const theirs = new Set((em?.rig ?? []).filter((c) => c.broken).map((c) => c.anchorId));
const lost = rig.corners.filter((c) => c.broken);
const yours = lost.filter((c) => !theirs.has(c.anchorId));
/** Their corners still on the ground at dawn — the invoice names them, unbilled. */
const inherited = lost.filter((c) => theirs.has(c.anchorId))
.map((c) => ({ anchorId: c.anchorId, hw: c.hw.name }));
const { theirs, lost, yours, inherited } = answerableFor(rig.corners, em);
const bill = yours.reduce((s, c) => s + c.hw.cost, 0);
const collateral = [];
// The gnome is collateral if the sail came down over it. DESIGN.md: the
@ -970,7 +1053,19 @@ export async function boot(opts = {}) {
*/
const win = hp >= 50;
const dmg = garden.damage;
const { verdict, mode } = verdictFor({ hp, lost, win, dmg, pondPeak, pondDumped,
/**
* **`yours`, NOT `lost` AND THIS WAS A LIVE BUG ON THE FIRST EMERGENCY
* INVOICE I RENDERED.** With `lost` the verdict narrated the corner the player
* INHERITED as their failure: the ledger said *"CB1 already on the ground when
* you arrived · not billed · That corner is what the callout was for. It is not
* on you"* and four lines below it the verdict said *"the carabiner at CB1 went
* first and took 1 more with it."* One card, two contradictory accounts of the
* same corner the invoice arguing with itself, which is the defect class this
* repo has spent five sprints hunting. The verdict speaks about work you are
* answerable for; the corner count above it still reports every corner down,
* because those are two different facts and both papers say so honestly.
*/
const { verdict, mode } = verdictFor({ hp, lost: yours, win, dmg, pondPeak, pondDumped,
beyondSaving: week.job.gardenBeyondSaving });
// SPRINT16 gate 2.2 [Lane B] — the fabric bet on the record. The F key
@ -1118,32 +1213,18 @@ export async function boot(opts = {}) {
*/
async function pricedOffers() {
for (const o of week.offers()) await siteJsonFor(o.night.site);
return week.offers((night) => {
const def = defs[night.storm];
const exp = exposureOf(siteJson.get(night.site));
return {
// 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.
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,
exposure: exp.total,
exposureItems: exp.items,
// ⚖️ SPRINT18 gate 0, clause 3 — THE STOOD-UP CLIENT REMEMBERS. Injected
// here with the rest of the per-offer facts because the memory is week
// state and board.js is pure: same seam as the fee and the exposure, and
// the card renders nothing when the list is empty (the degradation rule
// every other injected field follows). A client you left waiting on
// Tuesday and who is on the board again on Thursday is the whole of
// "your work follows you", and it costs one line.
remembers: week.remembers(night.client),
};
});
return week.offers((night) => priceOffer(night, {
def: defs[night.storm],
site: siteJson.get(night.site),
rep: week.rep,
// ⚖️ SPRINT18 gate 0, clause 3 — THE STOOD-UP CLIENT REMEMBERS. Injected
// with the rest of the per-offer facts because the memory is week state and
// board.js is pure: same seam as the fee and the exposure, and the card
// renders nothing when the list is empty (the degradation rule every other
// injected field follows). A client you left waiting on Tuesday who is on
// the board again on Thursday is the whole of "your work follows you".
remembers: week.remembers(night.client),
}));
}
/**
@ -1196,7 +1277,23 @@ export async function boot(opts = {}) {
async function adoptEmergencyRig(em) {
const s = rigging.session;
resetRig();
s.setBudget(week.bank + SPARE_COST);
/**
* **THE ARITHMETIC, AND THE BUG IT FIXES CAUGHT LIVE, NOT BY A TEST.** The
* first cut banked the session at `week.bank + SPARE_COST` and the real game came
* up reading **budget $60 against a bank of $80**: the session had charged me $20
* for the cowboy's four carabiners. The LEDGER was fine (`spentThisNight` is
* forced to 0 on the ROLL) but the SHOP was lying by $20 and `run.budgetLeft`,
* plus every refusal B prices off `budget`, reads that number.
*
* You arrive having spent NOTHING: their steel is not yours, you did not buy it,
* and the ute's spare is free. So the session is pre-banked by exactly what
* adopting the night is about to cost it and the budget lands back on
* `week.bank`. Computed FROM THE RIG DATA rather than hardcoded, so B can author
* rated shackles into a cowboy's rig without this line silently going wrong.
*/
const theirCost = (em.rig ?? []).reduce(
(sum, c) => sum + (HARDWARE.find((h) => h.name === c.hw)?.cost ?? 0), 0);
s.setBudget(week.bank + theirCost + SPARE_COST);
for (const c of em.rig ?? []) {
s.rig(c.anchorId);
const hw = HARDWARE.find((h) => h.name === c.hw);

View File

@ -14,6 +14,9 @@ import { createCameraRig, spawnYawFor } from '../camera.js';
import {
CALM_STORM, accumulate, applyMute, canPlayHere, createGame, createWindRouter, enterCommits,
rigRecordFor, stormsToPreload, verdictFor,
// SPRINT18 gate 1 — both extracted BECAUSE rendering a real emergency card found
// bugs the suite could not see: the board's pricing and the answerability split.
answerableFor, priceOffer,
} from '../main.js';
import { createGarden } from '../garden.js';
import { RiggingSession } from '../rigging.js';
@ -22,6 +25,9 @@ import { SailRig, orderRing } from '../sail.js';
import { loadStorm, createWind } from '../weather.js';
import {
BROKE_BELOW, PAY, REP, calloutFee, createWeek, gradeFor, nightAt, NIGHTS,
// SPRINT18 gate 1 — priceOffer takes a NORMALISED night, the same shape the board
// installs, so the pin prices what the game prices rather than a raw table entry.
normaliseNight,
} from '../week.js';
// SPRINT17 gate 1/2 — the board and the constraint data are mine; these are
// the seam this suite pins (offer shape, pool, exposure, constraint enum).
@ -31,6 +37,8 @@ import {
isBooked, standUpsIfTaken,
// SPRINT18 gate 1 — the emergency callout, and the one-variable law in code.
EMERGENCIES, oneVariableOffences, validateEmergency,
// SPRINT18 gate 5 (A's half) — the second-rust-post tripwire.
collateralKeyClashes,
} from '../board.js';
import { createHud } from '../hud.js';
import { PALETTE, boundsFaults, emptyTemplate, exportSiteJSON, placeEntry } from '../editor.js';
@ -1099,6 +1107,134 @@ export default async function run(t) {
} finally { hud.dispose(); }
});
t.test('SPRINT18 gate 1: ⚖️ the answerability split — their corner is the JOB, yours is your failure', () => {
const c = (id, broken) => ({ anchorId: id, hw: { name: 'carabiner', cost: 5 }, broken });
const em = { rig: [{ anchorId: 'cb1', hw: 'carabiner', broken: true },
{ anchorId: 'q3', hw: 'carabiner' }, { anchorId: 'tr1', hw: 'carabiner' },
{ anchorId: 'tr1b', hw: 'carabiner' }] };
const a = answerableFor([c('cb1', true), c('q3', true), c('tr1', false), c('tr1b', false)], em);
assertEq(a.lost.length, 2, 'REPORTING stays honest — two corners are on the ground');
assertEq(a.yours.length, 1, 'but you are answerable for one of them');
assertEq(a.yours[0].anchorId, 'q3', 'the one that went on your watch');
assertEq(a.inherited.length, 1, 'and their corner is named, for the invoice');
assertEq(a.inherited[0].anchorId, 'cb1');
// The two facts the split protects, in the same shape scoreRun consumes them:
// the gnome needs TWO corners of YOURS (not two on the ground), and the
// collateral walk only visits `yours` — so the $180 carport under an inherited
// corner is not billed to a player who never chose it.
assert(a.yours.length < 2, 'one corner of yours cannot bill the gnome, even with two corners down');
assert(!a.yours.some((x) => x.anchorId === 'cb1'), 'the carport corner is not in the billable set');
// An ORDINARY night is untouched: no emergency ⇒ everything down is yours, which
// is the property that keeps 522 asserts of scripted week honest.
const plain = answerableFor([c('p1', true), c('p2', true)], null);
assertEq(plain.yours.length, 2, 'on a night you rigged, every corner down is yours');
assertEq(plain.inherited.length, 0, 'and nothing is inherited');
assertEq(answerableFor([c('p1', true)], { rig: [] }).yours.length, 1,
'an emergency whose rig arrived whole still makes every break yours');
});
t.test('SPRINT18 gate 1: the OFFER CARD prices an emergency at double — the live bug, pinned', () => {
if (!boardDefs.storm_03_southerly) return 'SKIPPED — no server for storm defs';
// ⚠️ THIS PIN EXISTS BECAUSE THE BUG SHIPPED AND EYES FOUND IT, not the suite:
// on night 5's real board the emergency advertised $57 while the dispatch one
// click later quoted $114. The pricing was an unreachable closure inside boot();
// priceOffer is that closure, extracted, so the board's money is assertable.
const def = boardDefs.storm_03_southerly;
const em = EMERGENCIES[0];
const emNight = normaliseNight(em);
const plainNight = normaliseNight({ storm: em.storm, site: em.site });
const carded = priceOffer(emNight, { def, rep: REP.START });
const plain = priceOffer(plainNight, { def, rep: REP.START });
assertEq(plain.fee, calloutFee(def, REP.START, []), 'an ordinary night is priced as it always was');
assertEq(carded.fee, calloutFee(def, REP.START, [], true), 'and an emergency is priced as an emergency');
assertEq(carded.fee, Math.round(plain.fee * PAY.emergencyRate),
`the card must print the doubled fee — got $${carded.fee} against $${plain.fee}`);
// And the card is the same document as the sheet: what priceOffer prints is what
// week.quote() quotes, which is the promise the whole board rests on.
const w = createWeek();
for (let i = 0; i < em.night - 1; i++) w.advance();
w.take(w.offers()[1]);
assertEq(w.quote(def).base, carded.fee, 'the dispatch quotes exactly what the card advertised');
// The weather and the risk still come from C's and board.js's own functions.
assert(Array.isArray(carded.band) && carded.band.length, "C's ordered band survives the extraction");
});
t.test('SPRINT18 gate 1: an emergency does NOT look like an ordinary callout on the board', () => {
if (typeof document === 'undefined') return 'SKIPPED — no DOM';
// ⚠️ ALSO FOUND BY EYES, NOT BY THE SUITE: the first cut wore the same grey
// "callout" tag as the Tuesday alternative beside it. Double rates, no prep, no
// quote, somebody else's rig — the biggest new thing in the game — and the card
// said nothing until one click later. That is the tell rule broken on the exact
// surface the tell rule is for.
const em = EMERGENCIES[0];
const w = createWeek();
for (let i = 0; i < em.night - 1; i++) w.advance();
// PRICED, through the same priceOffer() the real board uses — my first cut built
// an UNPRICED board and then asserted the fee line's contents, so the pin failed
// on a fixture that could not produce what it was demanding. A card assert has to
// be given the card the game renders.
const offers = w.offers((night) => priceOffer(night, { def: boardDefs[night.storm], rep: w.rep }));
const priced = offers[1].fee != null;
const hud = createHud({ scene: new THREE.Scene() });
try {
hud.showBoard(offers, { night: w.night, nights: w.nights, bank: 80, log: [], rep: 3.0 }, () => {});
const cards = [...document.querySelectorAll('#hud-card .offer')];
assertEq(cards.length, 2, 'an emergency morning still offers two jobs');
assert(!cards[0].classList.contains('emergency'), 'the booked wildnight is not an emergency');
assert(cards[1].classList.contains('emergency'),
'the emergency offer must be marked as one, not left to read as a Tuesday alternative');
const tag = cards[1].querySelector('.tag')?.textContent ?? '';
assert(/emergency/i.test(tag), `its tag must say so — got "${tag}"`);
const siren = cards[1].querySelector('.siren')?.textContent ?? '';
assert(siren.includes('no prep') && siren.includes('no quote'),
`and one line must name what is different about it — got "${siren}"`);
assert(siren.includes(`${em.emergency.arriveAt}s in`), 'including how late you arrive');
// The doubled number carries its reason, on the card, next to the number.
// (Guarded on the money being present at all: with no server there are no storm
// defs, so the fee line degrades away exactly as the job sheet's forecast does.)
if (priced) {
const fee = cards[1].querySelector('.fee')?.textContent ?? '';
assert(fee.includes(`${PAY.emergencyRate}\u00d7 emergency rate`),
`the rate is printed beside the fee — got "${fee}"`);
assert(fee.includes(`$${offers[1].fee}`), 'and the doubled number itself');
}
} finally { hud.dispose(); }
});
t.test('SPRINT18 gate 5: ⚠️ the SECOND-RUST-POST landmine is a tripwire now, not a silent free failure', () => {
// D's filing + the other D session's measured receipt. Every shipped yard is
// clean TODAY (the pool yard ships exactly one corroded post on purpose), so
// this walk is green — which is why the guard below is the part that matters.
for (const [name, json] of Object.entries(siteJsonForTest)) {
if (!json) continue;
assertEq(collateralKeyClashes(json).join(' | '), '',
`${name} ships clashing collateral keys — see the accusation for the numbers`);
}
// ⚠️ THE VACUITY GUARD, and it carries D's measured numbers so the next author
// reads the actual failure rather than a policy. A yard with TWO corroded posts:
// the offer card would read $90 and the failure route $45, so one post fails
// FREE and wrecks the other one.
const twoPosts = { name: 'the fixture yard', structures: [
{ id: 'c1', collateralKey: 'corroded_post', collateralValue: 45 },
{ id: 'c2', collateralKey: 'corroded_post', collateralValue: 45 },
] };
const clash = collateralKeyClashes(twoPosts);
assertEq(clash.length, 1, 'two structures on one key must be accused exactly once');
assert(clash[0].includes('$90') && clash[0].includes('$45'),
`the accusation must carry BOTH harnesses' numbers — got "${clash[0]}"`);
assert(clash[0].includes('c1') && clash[0].includes('c2'), 'and name the structures');
assert(/fail FREE/.test(clash[0]), 'and say what actually goes wrong, not just that something does');
// An UNKEYED structure resolves by its own id, which the editor generates unique,
// so it cannot clash — and reporting it would be a false alarm that teaches an
// author to ignore this check.
assertEq(collateralKeyClashes({ structures: [
{ id: 's1', collateralValue: 180 }, { id: 's2', collateralValue: 180 },
] }).length, 0, 'unkeyed structures resolve by unique id and must not be accused');
// And one post is fine — the shipped pool yard's own shape.
assertEq(collateralKeyClashes({ structures: [twoPosts.structures[0]] }).length, 0,
'one corroded post is the shipped shape and must stay legal');
});
// Gate 2 acceptance: both sites load from data, and the corner block is not
// the backyard with the furniture moved. Built up front (Suite.test() can't
// await — the guard I added last sprint enforces it, and just caught me).