diff --git a/THREADS.md b/THREADS.md
index 4f0fff1..5e18c3e 100644
--- a/THREADS.md
+++ b/THREADS.md
@@ -9505,3 +9505,195 @@ anchors are your GLB), but the tooling is now waiting, not TODO.
**Deploy still owes one hand:** tools/deploy_hardyards.sh is permission-gated in the
integrator's session (ssh + docker to the games VPS). John runs it or green-lights it;
S18 must not ship before S17 is live, per the standing one-sprint rail.
+
+[A] 2026-07-25 — ⚖️ **GATE 0 LANDED: DECLINING A BOOKED NIGHT COSTS YOU — −½★, on the card
+ before the choice and on the invoice after it.** John's ruling wired, and the number ruled:
+ **`REP.STOOD_UP = -0.5`, THE EXACT MIRROR OF `REP.CLEAN`.** A stood-up client costs you
+ what a flawless night earns — one night of perfect work, cancelled by one phone call you
+ didn't make. Bounded above by `REP.COLLATERAL` (−1.0) on purpose: ranking a broken promise
+ above a wrecked carport would make the invoice argue with itself. ⚠️ UNMEASURED per house
+ rule, and it is the lever — if John's play says the price is wrong, move THAT constant, not
+ `CLEAN`, which the whole rep ladder is measured against.
+
+ **THE ASYMMETRY, and the bug my own first cut shipped for ten minutes.** The fiction is a
+ tradie's two sources of work: **the spine slot is your diary, the pool is your phone.**
+ `isBooked(kind, night) = kind === 'scripted' && !night.emergency`. My first cut derived it
+ from the night ALONE — and the probe came back with the pool yard marked BOOKED, so taking
+ the scripted job printed *"the Karalis place is expecting you"* over a client nobody had
+ promised anything. Recorded because the wrong version read entirely plausible. A callout you
+ turn down is a callout somebody else takes; you owe standing to a diary entry and nothing else.
+
+ **WHEN IT LANDS: AT DAWN, and that ordering is load-bearing, not taste.** Charging inside
+ `take()` drops standing between the board and the job sheet — and the fee is priced off
+ standing, so the offer card would advertise $57 and the sheet one click later would quote
+ $56. That is the quote-vs-settle lie moved one card earlier, the exact bug `calloutFee()`
+ was extracted to make impossible. So `take()` RECORDS (the `standUps` ledger) and `settle()`
+ CHARGES, in the same repline that already explains every other rep movement. Pinned:
+ `w.quote(def).base === ` the fee the card printed, after the take.
+
+ It is **ADDITIVE, not exclusive**: stand a client up and then have a clean night at the yard
+ you went to instead and you net to LEVEL (+0.5 −0.5). That is right, and it is the ruling's
+ whole texture — you did good work, for somebody else, on a night you were promised elsewhere.
+
+ **THE MEMORY (clause 3), one ledger, three surfaces.** `week.standUps` = append-only
+ `{night, client, addr, offerId, took}`; `week.remembers(client)` is the question the board
+ card and the job sheet each ask by name. Board: *"They remember night 5 — you took another
+ job."* Sheet: *"They have not forgotten night 5. You are here on their second-choice terms."*
+ `standUps` hands out a COPY (hud.js's charter: the face never takes a decision, and a getter
+ returning the live array is a card one `.push` away from inventing a grudge — pinned), and
+ `reset()` clears it, because a memory outliving its run is the uncleared-`taken` bug one
+ surface along.
+
+ **BOTH PAPERS (clauses 2 and 4).** Offer card: a `.cost` line directly under the two money
+ lines — *"the Hendersons are expecting you · −½★ standing"*, red-side because it is a debit,
+ half-star not float because that is how REP is ruled. Invoice: a new **OFF THE BOARD** block
+ off the settlement (`s.taken`, `s.stoodUp`, `s.standUpCost`), so night 5's invoice still
+ names night 5's broken promise when you re-read it on night 7.
+
+ **CLAUSE 5 — TWO CALLOUTS, NO BOOKED JOB READS AS FREE, and the free case falls out of the
+ filter returning nothing rather than out of an `if`.** No cost line on any card, and the
+ board note changes: *"Nobody has you booked tonight. Neither of these costs you anything to
+ turn down."* — because "no cost shown" and "no cost" have to be the same sentence on a
+ document. Pinned WITH ITS VACUITY GUARD: flip one `booked` flag on the fixture board and the
+ charge must appear, or the clean result is being produced by a function that cannot charge.
+
+ ⚠️ **This state is only REACHABLE once gate 1 exists** (an emergency arrives in the diary's
+ chair but nobody had you down), which is why `isBooked` already knows the word `emergency` on
+ the day gate 0 lands. It is not speculative plumbing: it is the one line that stops gate 1
+ having to re-open gate 0's ruling.
+
+[A] 2026-07-25 — 🚨 **THE SHAPES, PUBLISHED EARLY: THE EMERGENCY CALLOUT / THE DISPATCH.**
+ B, C, D and E build against these. Nothing below is landed yet except the gate-0 hook — this
+ is the seam contract, posted first, per the pattern that has cost this repo one code conflict
+ in two sprints. Anything I change after posting, I change HERE with a dated note.
+
+ **1 · `night.emergency` — the field that makes a night an emergency callout.** A night entry
+ (NIGHTS-shaped, so `normaliseNight` carries it and every downstream surface reads it through
+ the path it already had) with one extra block:
+
+ {
+ storm: 'storm_03_southerly', // an ordinary storm def, unchanged
+ site: 'site_02_corner_block', // a yard the player has ALREADY RIGGED (see §5)
+ client: 'the Vasilaros place', addr: '2 Bight Rd — the corner block',
+ brief: 'what the phone call said',
+ emergency: {
+ arriveAt: 30, // seconds into the storm you turn up (ROADMAP's own number)
+ dispatch: 'the call, verbatim', // there is no forecast and no quote — that IS the drama
+ rig: [ … ], // ⇦ B OWNS: the rig you didn't build
+ },
+ }
+
+ **2 · `emergency.rig` — B's half, the rig you didn't build.** Someone else's work, authored as
+ DATA, already under load. Per-corner:
+
+ { anchorId: 'q1', hw: 'carabiner', broken: false }
+ anchorId an anchor id in THIS yard's site JSON
+ hw a HARDWARE name from contracts.js — 'carabiner' | 'shackle' | 'rated shackle'
+ broken true ⇒ that corner is ALREADY GONE when you arrive
+
+ A's spine installs it through the session's own public moves at the boundary `setConstraints`
+ uses (`session.rig()` + `setHardware()`, then commit) — the same public-moves-only discipline
+ `resetRig()` follows, so the economy stays the single source of truth. **B rules the semantics
+ and the authored set:** hardware below what the night needs, at least one corner already gone,
+ the rating hints telling the truth about why. **B also rules whether the garden bonus applies
+ at all** — you had no prep phase to protect a garden with — and writes the why. I have authored
+ a MINIMAL first cut so the phase is verifiable end-to-end in my tree; it is FLAGGED for you and
+ revert-and-tell-me applies. Yours is canon the moment it lands.
+
+ ⚠️ **The pre-broken corner is the one piece I cannot do from public moves alone.** Breaking a
+ corner before the storm starts is your file, B. If a `session`/`rig`-side door for "this corner
+ arrives already gone" is cheaper on your side than mine, name it in THREADS and I will call it
+ feature-detected exactly as I call `setConstraints?.()`.
+
+ **3 · `EMERGENCIES` — where an emergency enters the game, and why NOT the POOL.** A new
+ board.js table, authored per night, 1-based: `[{ night: 5, …entry }]`. `alternativeFor()`
+ returns the emergency scheduled for that index if there is one, and the seeded pool draw
+ otherwise. Two reasons it is not a POOL entry, both load-bearing:
+ · **the draw.** `alternativeFor` indexes the eligible pool by seed, so a seventh POOL entry
+ re-rolls EVERY night's alternative — it would invalidate the existing board pins and D's
+ cold-play record for a night it isn't even on. This way every other night's draw is
+ byte-identical and `offers().length === 2` / `offers[1].kind === 'callout'` both stand.
+ · **the one-variable law.** An emergency is a SET PIECE and the law needs an AUTHOR to place
+ it: *"is this yard already rigged by night N"* is only decidable if N is authored. The RNG
+ cannot be asked to respect a law about ordering.
+ `kind` stays `'callout'` (its provenance — the phone; it is the most callout-y callout there
+ is) and the card reads `o.night.emergency` for its costume. One source of truth, existing pins
+ green.
+
+ **4 · THE PHASE — a night that starts in `storm`.** `game.setPhase('storm', { at })` gains one
+ optional argument: `phaseT` opens at `at` instead of 0. Everything downstream is untouched by
+ construction — `windTime()` already returns `phaseT` during a storm, so wind, sky, hail and the
+ storm's authored events are all mid-flight when you get out of the ute; `tick()` still ends the
+ storm at `phaseT >= STORM_LEN`, so an emergency night is `STORM_LEN − arriveAt` long (90 − 30 =
+ 60 s). **There is NO prep phase**: `rigging.setActive(false)` is already what a non-prep phase
+ does, and `enterCommits()` already requires `phase === 'prep'`, so ENTER is inert with no new
+ guard. The morning runs:
+
+ forecast → board → take()
+ ordinary: load yard → setBudget/setConstraints → JOB SHEET → [GO] → prep → [ENTER] → storm t=0
+ emergency: load yard → setBudget → adopt emergency.rig → DISPATCH → [ROLL] → storm t=arriveAt
+
+ ⚠️ **Verify the phase entry with STEPPED TIME, not the live pane** — the pane's ~12 fps clamps
+ the sim and an emergency night's whole premise is a clock that starts at 30. `SHADES.step(1/60)`
+ in a loop + `SHADES.render()` is the repo's own path.
+
+ **5 · THE ONE-VARIABLE LAW, ENFORCED IN CODE — not in a comment.** `oneVariableOffences()` in
+ board.js, walking the shipped data, returning offences with the measurement in each message
+ (C's `pairingRefusals` construction — it accuses, it does not silently filter):
+ · a NIGHTS emergency at index *i* must have its **site appear in NIGHTS at some j < i** — the
+ emergency callout debuts over a yard the player has already rigged.
+ · an `EMERGENCIES` entry for night *N* must have its site rigged by an EARLIER night.
+ · a POOL emergency must have its site in NIGHTS at all — a yard whose only route into the game
+ is under the new job type is exactly the accident the law exists to forbid.
+ `offersFor()` throws on an emergency offer that breaks it (`validateConstraint`'s precedent —
+ content bugs fail loud), and a.test walks the shipped data with a vacuity guard.
+ **E — this is the line that protects your glasshouse:** it CANNOT be the emergency's debut
+ stage this sprint, no matter who wires what, because it is not in NIGHTS. Your yard debuts as
+ an ordinary rig-for-tonight job and DESIGN.md's sentence (someone's sail flogging itself to
+ death over their glasshouse) belongs to a later week. That is the payoff, not the introduction.
+
+ **6 · THE SHIPPED EMERGENCY: NIGHT 5's MORNING, opposite the wildnight.** Ruled, and every
+ other slot rejected for a reason:
+ · site_02_corner_block × storm_03_southerly — the yard night 3 rigs, the storm night 2
+ survives. **The only new variable is the JOB TYPE.** (The pairing is the POOL's audited
+ `corner_block_southerly`: 19 holding lines / 7 marginal, cheapest hold $40. ⚠️ That audit
+ answers *"can a rig-for-tonight job win here"*; **whether the INHERITED rig is triageable is
+ a different question and it is B's to measure** — flagged, not assumed.)
+ · **not night 4** — the booked job that morning is the swing lawn's DEBUT. Two new things on
+ one board, and taking the emergency would kill the swing lawn's only slot in the campaign.
+ · **not night 6/7** — the memory needs somewhere to land. The Hendersons are on the board
+ nights 1, 2, 5 and 6; stand them up on night 5 and the grudge is on the ICENIGHT'S card the
+ next morning. Nights 6 and 7 leave clause 3 unreachable in the shipped week.
+ · and it is D's finding #1 answered exactly: *"the wildnight was declinable for a Tuesday
+ southerly at similar money."* It still is — at double rates now, and −½★, printed.
+
+ **7 · EMERGENCY RATES — ⚖️ RULED ×2.0, and it is canon rather than my taste.** DESIGN.md line
+ 29 and ROADMAP arc 2 both say *double rates*, in those words, so the multiplier is the number
+ the design has been carrying since line one. `calloutFee(def, rep, constraints, emergency)`
+ gains the term — ONE fee function still, so the offer card, the dispatch and the invoice cannot
+ disagree. The justification for a number that large: you arrive with no prep, no quote, no
+ chance to have bought good steel, on somebody else's hardware, with the yard's whole collateral
+ exposure already live. ⚠️ UNMEASURED BY PLAY, cut-first alongside `CONSTRAINT_PREMIUM`.
+
+ **8 · C's SEAM — the dispatch has NO FORECAST.** `emergency.dispatch` is the phone call (data,
+ mine). What I am NOT writing is the weather: **`midStormRead(def, t)` is yours** — an ORDERED
+ `{key, text}` list, the same construction as your `offerBand`, describing what the sky and the
+ grass tell you at `t = arriveAt` when no paper briefed you. The dispatch card MAPS it exactly
+ the way the offer card maps `offerBand`, so a fact you add cannot be dropped by the card
+ picking two fields. Called through a namespace import (`import * as weather` +
+ `weather.midStormRead?.()`), because a named import of a function you haven't exported yet is
+ a hard module error, not a graceful no-op — the `setConstraints?.()` pattern, adapted to
+ modules. Until it lands the card renders the phone call alone.
+
+ **9 · D's SEAM — the knife.** You own the verb and the input; I own the tally and the paper.
+ Emit **`cutAway` on `rig.events`** with `{ t, corners: string[], reason? }` — the same Emitter
+ `break`/`repair`/`pondDump` already ride, so main.js's night tally picks it up with no new
+ wiring. I carry it to `run.cutAways` → the settlement → a line on the invoice. Your HUD read
+ and your key are yours; if you want a different event name or payload, say so and I will move
+ my end.
+
+ **10 · WHAT DOES NOT CHANGE, and this is the acceptance criterion for the whole gate:** the
+ ledger. `settle()` takes the same `run`, `def` and `spent`; fee, garden bonus, refund,
+ collateral, clean, warranty, rep, grade, the end card and the warranty chain all run on an
+ emergency night through the code they already had. If any of them needed a branch on "was this
+ an emergency", the shape above is wrong and I want to know before you build against it.
diff --git a/web/world/js/board.js b/web/world/js/board.js
index 8e2efa2..f946821 100644
--- a/web/world/js/board.js
+++ b/web/world/js/board.js
@@ -265,6 +265,94 @@ export const POOL = [
},
];
+/**
+ * ⚖️ **SPRINT18 gate 0 — DECLINING A BOOKED NIGHT COSTS YOU. JOHN'S RULING,
+ * 2026-07-25: "it adds pressure."**
+ *
+ * D's cold play found the hole and named it from the chair: the board was a FREE
+ * OPTION. Twice in one week it offered a fee-maximizer a dodge around the
+ * campaign's own set-pieces — the wildnight for a Tuesday southerly at similar
+ * money, and the designed-loss icenight against a winnable constrained job at
+ * the SAME $68 — and saying no cost nothing. A player could skip the week's two
+ * hardest lessons and never know they existed.
+ *
+ * ── THE NUMBER, AND WHY IT IS THIS NUMBER ─────────────────────────────────
+ *
+ * Standing is the currency, because standing is the currency arc 1 built for
+ * exactly this: `week.js` §REP already prices your name, moves it at dawn with
+ * its reasons printed, and pays you less when it's low. A new "reliability"
+ * number beside it would be two scores for one idea, and the second one would
+ * be the one nobody read.
+ *
+ * The cost is **the mirror of `REP.CLEAN`** — a stood-up client costs you
+ * exactly what a perfect night earns. That is not an aesthetic choice, it is
+ * the sentence the week already says in numbers: *one night of flawless work,
+ * cancelled by one phone call you didn't make.* Any smaller and the dodge is
+ * still free with extra steps; any larger and it outweighs `REP.COLLATERAL`
+ * (−1.0), which would rank standing a client up above WRECKING THEIR PROPERTY,
+ * and the invoice would be arguing with itself.
+ *
+ * The number itself lives in `week.js` (`REP.STOOD_UP`) with the rest of the
+ * reputation ruling — this file must not import week.js (week imports board;
+ * the cycle is the reason board.js is pure data + pure functions). What lives
+ * HERE is the structural half: **who a take stands up**, which is a fact about
+ * the board and nothing else. `a.test` pins the mirror (`STOOD_UP === -CLEAN`)
+ * so the two halves cannot drift apart in silence.
+ *
+ * ⚠️ UNMEASURED — the house rule, same status as `CONSTRAINT_PREMIUM` above and
+ * `REP` itself. Nobody has played a week where declining costs. The lever is one
+ * constant in week.js; if John's next play says the price is wrong, move THAT.
+ * Do not fund it by touching `REP.CLEAN`, which the whole rep ladder is measured
+ * against.
+ *
+ * ── THE ASYMMETRY, AND IT IS THE POINT ────────────────────────────────────
+ *
+ * You owe standing to a client who is EXPECTING you, and nobody else. The
+ * fiction is a tradie's two sources of work: **the spine slot is your diary, the
+ * pool is your phone.**
+ *
+ * · **the booked night** (`kind:'scripted'`) — the diary. A client has you down
+ * for tonight; the ruling's own words are the "TONIGHT, AS BOOKED" offer.
+ * Taking anything else stands them up.
+ * · **a callout** (`kind:'callout'`) — the phone. Nobody has you down, so
+ * declining it costs a fee and not a name. ⚠️ THE FIRST CUT OF THIS GOT IT
+ * BACKWARDS and the probe caught it in one call: I derived `booked` from the
+ * night alone, and the pool yard came back booked — so taking the scripted
+ * job printed "the Karalis place is expecting you" over a client who had
+ * never been promised anything. A callout you turn down is a callout somebody
+ * else takes. Recorded because the wrong version READ plausible.
+ * · **an EMERGENCY callout in the diary slot (gate 1)** — the storm is already
+ * running and the phone rang at ten at night. It is not a booking even though
+ * it arrives in the booking's chair: nobody had you down, so nobody is stood
+ * up. A morning whose board is two callouts and no booked job is a LEGAL
+ * board state, declining is genuinely FREE there, and the card must SAY so —
+ * "no cost shown" and "no cost" have to be the same sentence on a document.
+ *
+ * So a booking is a DIARY ENTRY THAT IS NOT AN EMERGENCY, both halves checked,
+ * and the free case falls out of the filter returning nothing rather than out of
+ * an `if` somebody has to remember to write.
+ *
+ * @param {string} kind the offer's provenance — 'scripted' (the diary) or 'callout'
+ * @param {object} night the night entry
+ */
+export function isBooked(kind, night) {
+ return kind === 'scripted' && !night?.emergency;
+}
+
+/**
+ * Who taking `taking` stands up: every OTHER offer on this board that a client
+ * is expecting you at.
+ *
+ * Returns a LIST, not a boolean, for the reason board.js returns lists
+ * everywhere: today a morning has one booked job and at most one client can be
+ * stood up, and a `standsUpSomeone` boolean would have to be rewritten the first
+ * morning that offers three. The cost is `list.length × REP.STOOD_UP`, so the
+ * arithmetic scales with the board on its own.
+ */
+export function standUpsIfTaken(offers, taking) {
+ return (offers ?? []).filter((o) => o !== taking && o.booked);
+}
+
/**
* A deterministic draw. `rng` is contracts.js's mulberry32 — the same PRNG the
* storm system reproduces against, so there is exactly one seeded-randomness
@@ -371,10 +459,33 @@ export function offersFor({ scripted, weekSeed, nightIndex, pool = POOL, priced
id, kind, night,
constraints: night.constraints ?? [],
premium: premiumFor(night.constraints),
+ /**
+ * SPRINT18 gate 0 — is a client EXPECTING you tonight? See isBooked: the
+ * diary slot, minus emergencies — so gate 1's emergency reads as unbooked
+ * without this function learning what an emergency is.
+ */
+ booked: isBooked(kind, night),
};
return priced ? Object.assign(offer, priced(night)) : offer;
};
const offers = [mk(scripted, 'scripted', 'scripted')];
if (alt) offers.push(mk(alt, 'callout', `alt:${alt.id}`));
+ /**
+ * SPRINT18 gate 0 — WHAT EACH TAKE COSTS, ON THE OFFER ITSELF.
+ *
+ * Annotated here rather than computed by the card, for the reason the fee is:
+ * a price the card works out for itself is a price the invoice can disagree
+ * with. week.take() charges off this same field, so the number the player read
+ * before choosing IS the number that moves their standing — by construction,
+ * not by two surfaces agreeing.
+ *
+ * Summaries, not offer references: an offer that pointed at its sibling would
+ * be a cycle the moment anything tried to serialise a board.
+ */
+ for (const o of offers) {
+ o.standsUp = standUpsIfTaken(offers, o).map((x) => ({
+ id: x.id, client: x.night.client ?? null, addr: x.night.addr ?? null,
+ }));
+ }
return offers;
}
diff --git a/web/world/js/hud.js b/web/world/js/hud.js
index 3cf88ba..66af113 100644
--- a/web/world/js/hud.js
+++ b/web/world/js/hud.js
@@ -66,6 +66,20 @@ const docketNo = (night) => 1040 + night;
* the business is the constant, and this is the one number the business has.
*/
const repFmt = (r) => `★ ${Number(r).toFixed(1)}`;
+/**
+ * SPRINT18 gate 0 — a MOVEMENT in standing, in the letterhead's own units.
+ *
+ * Half-stars written as halves, because that is how the number is ruled
+ * (week.js §REP: half-steps, so every printed value stays exact). "−0.5★" is a
+ * float on a business document; "−½★" is a rating. The sign is always printed —
+ * a cost with no sign on it is a number the player has to guess the direction of.
+ */
+const starDelta = (d) => {
+ const a = Math.abs(Number(d) || 0);
+ const whole = Math.floor(a);
+ const half = a - whole >= 0.5;
+ return `${d < 0 ? '−' : '+'}${whole || !half ? whole : ''}${half ? '½' : ''}★`;
+};
// Load bar colours: green → amber → red. The amber band is deliberately wide;
// a corner at 60% in a lull is one gust from 100% and the player should feel
@@ -342,6 +356,20 @@ const CSS = `
#hud-card .offer .fee b { color:#ffd9a3; }
#hud-card .offer .risk b { color:#ff8f86; }
#hud-card .offer.constrained { border-left:3px solid #ffc24a; }
+/* ⚖️ SPRINT18 gate 0 — THE PRICE OF SAYING NO, on the card, before the choice.
+ Deliberately styled like the risk row and NOT like the constraint mark: a
+ constraint is something the client asks of you, this is something taking the
+ job costs you. Red-side, because it is a debit — and it sits directly under
+ the fee, so the two numbers a dodge is decided on are adjacent. The whole
+ point of the ruling is that this is never a surprise on the invoice. */
+#hud-card .offer .cost b { color:#ff8f86; }
+#hud-card .offer .cost span { color:#c99a94; }
+/* The memory. Grey, italic, quiet — a client who remembers is not a warning
+ label, it is a fact about the room you are walking into. */
+#hud-card .offer .remembers, #hud-card .remembers {
+ margin-top:7px; font-size:11px; font-style:italic; color:#8a7f74;
+}
+#hud-card .board-note.free { color:#8ba0ad; }
#hud-card .offer .says { margin-top:8px; padding-left:9px; border-left:2px solid #3f5561;
font-size:11px; font-style:italic; color:#c8b78a; line-height:1.5; }
#hud-card .offer .cons { font-size:10px; letter-spacing:.12em; text-transform:uppercase;
@@ -838,8 +866,29 @@ export function createHud(d) {
${o.exposure != null ? `
their property at risk${
o.exposureItems?.length ? ` — ${o.exposureItems.map((x) => x.what).join(', ')}` : ''
}$${o.exposure}
` : ''}
+ ${/* ⚖️ SPRINT18 gate 0, clause 2 — WHAT TAKING THIS COSTS, IN THE
+ CLIENT'S TERMS, BEFORE THE CHOICE. John's ruling is explicit that
+ this may not wait for the invoice: "a choice whose price is
+ invisible until the invoice is a trap, and this repo doesn't ship
+ traps without tells." board.js works out WHO is stood up and
+ week.js rules the number; the card's whole job is to say it out
+ loud, next to the fee that is tempting you.
+
+ When nothing on the board is booked (gate 1's two-callout
+ 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". */''}
+ ${o.standsUp?.length ? `${
+ o.standsUp.map((s) => s.client ?? 'a client').join(' and ')
+ } ${o.standsUp.length > 1 ? 'are' : 'is'} expecting you${
+ starDelta(REP.STOOD_UP * o.standsUp.length)} standing
` : ''}
${cons.length ? `⚖ ${cons.map((c) => c.label).join(' · ')}
` : ''}
${cons.map((c) => `“${c.says}”
`).join('')}
+ ${/* gate 0, clause 3 — and if you have done this to THEM before, it is
+ on their card. main.js injects week.remembers(client). */''}
+ ${o.remembers?.length ? `They remember night ${
+ o.remembers.map((s) => s.night).join(' and night ')
+ } — you took another job.
` : ''}
`;
};
@@ -860,7 +909,20 @@ export function createHud(d) {
Take one. The other goes to somebody else.
${offers.map(offerHtml).join('')}
in the bank$${wk.bank}
- Both are tonight. You cannot work two yards in one storm.
+ ${/* ⚖️ SPRINT18 gate 0, clause 5 — THE ASYMMETRY, SAID IN WORDS.
+ A morning with a booked job on it: turning it down costs your name,
+ and the offer that costs says so on its own card. A morning with
+ NOTHING booked (two callouts — legal, and what gate 1's emergency
+ makes reachable): declining is genuinely free, and the note SAYS
+ free rather than leaving the player to infer it from an absence.
+ "No cost shown" and "no cost" have to be the same sentence on a
+ document, or the quiet morning teaches the same lie the loud one
+ would. */''}
+ ${
+ offers.some((o) => o.booked)
+ ? 'Both are tonight. You cannot work two yards in one storm.'
+ : 'Nobody has you booked tonight. Neither of these costs you anything to turn down.'
+ }
Fees quoted on the forecast at time of callout. Weather is not a variation.
`;
card.classList.add('on');
@@ -936,6 +998,15 @@ showForecast({ key, def, site, tomorrowDef }, wk, onGo) {
const brief = job.brief
? `${job.brief}
` : '';
+ // ⚖️ SPRINT18 gate 0, clause 3 — you have stood THIS client up before, and
+ // the sheet says so under their brief. The board's card is where the memory
+ // changes a decision; this is where it changes the room you walk into.
+ // Off the same week ledger, so it cannot disagree with the card.
+ const remembers = (wk.remembers ?? []).length ? `
+ They have not forgotten night ${
+ wk.remembers.map((s) => s.night).join(' and night ')
+ }. You are here on their second-choice terms.
` : '';
+
// SPRINT17 gate 2 — THE CLIENT'S TERMS, on the sheet, in their words.
// Under the brief and above the weather on purpose: the constraint is
// part of what they're asking for, not a footnote to it, and the player
@@ -1028,6 +1099,7 @@ showForecast({ key, def, site, tomorrowDef }, wk, onGo) {
card.innerHTML = `
${letterhead}
${brief}
+ ${remembers}
${constraintBlock}
NIGHT ${wk.night} OF ${wk.nights}
${pips}
@@ -1253,6 +1325,31 @@ showForecast({ key, def, site, tomorrowDef }, wk, onGo) {
every decision on it. Off the settlement (week.js carries
s.constraints), so it stays true when you re-read night 3's
invoice on night 5's morning. */''}
+ ${/* ⚖️ SPRINT18 gate 0, clause 4 — THE INVOICE STATES WHICH JOB YOU TOOK
+ AND WHAT IT COST YOU TO TAKE IT. Both papers, same as gate 2's
+ constraints: the offer card priced the decision, this is the record
+ of having made it. Off the settlement (week.js carries `taken` and
+ `stoodUp`), so night 5's invoice still names night 5's broken promise
+ when you re-read it on night 7's morning.
+
+ The standing row prints even at zero cost — an off-the-board job on a
+ morning where nothing was booked SAYS so ("nobody was expecting you"),
+ because gate 0's asymmetry has to be legible on the paper as well as
+ on the card. This is the one place the free case is worth a line
+ rather than a silence: the invoice is the document that explains, and
+ a player who paid the price on Tuesday should be able to see why
+ Thursday was free. */''}
+ ${w?.taken ? `
+
OFF THE BOARD
+
+
you took this callout${w.client ?? 'no client'}
+ ${w.stoodUp?.length
+ ? w.stoodUp.map((s) => `
+
stood up ${s.client ?? 'a client'} — they were expecting you${
+ starDelta(REP.STOOD_UP)} standing
+
No invoice for that one. They will remember the phone call.
`).join('')
+ : '
nobody was expecting you — nothing to stand up—
'}
+
` : ''}
${w?.constraints?.length ? `
PAID UNDER THESE TERMS
diff --git a/web/world/js/main.js b/web/world/js/main.js
index eea404d..fc7ece0 100644
--- a/web/world/js/main.js
+++ b/web/world/js/main.js
@@ -1050,6 +1050,14 @@ export async function boot(opts = {}) {
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),
};
});
}
@@ -1117,6 +1125,11 @@ export async function boot(opts = {}) {
// (quote.rep), but the sheet renders a letterhead even on nights whose
// quote degrades away, so it rides the week block explicitly.
rep: week.rep,
+ // ⚖️ SPRINT18 gate 0 — and if you have stood THIS client up before, the
+ // sheet says so under their brief. The board's card is where the memory
+ // changes a decision; the sheet is where it changes the room you walk
+ // into. Same week ledger, read by name.
+ remembers: week.remembers(week.job?.client),
},
() => { stormKey = week.stormKey; game.setPhase('prep'); },
);
diff --git a/web/world/js/tests/a.test.js b/web/world/js/tests/a.test.js
index 8a907fd..869561c 100644
--- a/web/world/js/tests/a.test.js
+++ b/web/world/js/tests/a.test.js
@@ -25,7 +25,11 @@ import {
} 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).
-import { CONSTRAINT_PREMIUM, POOL, exposureOf, validateConstraint } from '../board.js';
+import {
+ CONSTRAINT_PREMIUM, POOL, exposureOf, validateConstraint,
+ // SPRINT18 gate 0 — the ruling's structural half: who a take stands up.
+ isBooked, standUpsIfTaken,
+} from '../board.js';
import { createHud } from '../hud.js';
import { PALETTE, boundsFaults, emptyTemplate, exportSiteJSON, placeEntry } from '../editor.js';
import { assert, assertClose, assertEq, assertLess, fixedLoop } from '../testkit.js';
@@ -698,6 +702,170 @@ export default async function run(t) {
}
});
+ // --- ⚖️ SPRINT18 gate 0: DECLINING A BOOKED NIGHT COSTS YOU ---------------
+ // John's ruling, 2026-07-25, on D's cold-play filing: "it adds pressure."
+ // The board was a free option — a fee-maximizer could dodge the wildnight AND
+ // the designed-loss icenight at zero cost and never meet the week's two
+ // hardest lessons. These pin the ruling's five clauses.
+
+ t.test('SPRINT18 gate 0: the DIARY is booked and the PHONE is not — the asymmetry, every night', () => {
+ const w = createWeek();
+ for (let i = 0; i < NIGHTS.length; i++) {
+ const [booked, callout] = w.offers();
+ assertEq(booked.booked, true, `night ${i + 1}: the scripted job is a booking — a client has you down`);
+ assertEq(callout.booked, false, `night ${i + 1}: a pool callout is the phone ringing, not a promise`);
+ // Taking the job you were booked for stands NOBODY up. This is the half
+ // that would silently invert if `booked` were ever derived from the night
+ // alone (it was, in the first cut, and the pool yard came back booked —
+ // taking the spine then printed "the Karalis place is expecting you" over
+ // a client nobody had promised anything).
+ assertEq(booked.standsUp.length, 0, `night ${i + 1}: keeping your word costs nothing`);
+ assertEq(callout.standsUp.length, 1, `night ${i + 1}: taking the callout stands the booked client up`);
+ assertEq(callout.standsUp[0].client, nightAt(i).client,
+ `night ${i + 1}: and it names WHO, in the client's own name, on the card`);
+ w.advance();
+ }
+ });
+
+ t.test('SPRINT18 gate 0: the number is the MIRROR of a clean night, and cannot outrank a wrecked carport', () => {
+ // ⚖️ A's ruling. The structural half lives in board.js and the number in
+ // week.js §REP (board.js must not import week.js — week imports board). This
+ // is the pin that stops the two halves drifting into a coincidence.
+ assertEq(REP.STOOD_UP, -REP.CLEAN,
+ 'a stood-up client must cost exactly what a flawless night earns — one night of perfect '
+ + 'work, cancelled by one phone call you didn\'t make');
+ assertLess(Math.abs(REP.STOOD_UP), Math.abs(REP.COLLATERAL),
+ 'standing a client up must not rank above WRECKING THEIR PROPERTY, or the invoice argues with itself');
+ assert(REP.STOOD_UP < 0, 'and it is a cost, not a reward');
+ // Half-steps, like every other rep constant — the letterhead prints exact
+ // numbers and a third of a star is a float on a business document.
+ assertEq(REP.STOOD_UP * 2, Math.round(REP.STOOD_UP * 2), 'the cost lands on a half-star');
+ });
+
+ t.test('SPRINT18 gate 0: the cost lands at DAWN, on the invoice, and does NOT move tonight\'s fee', () => {
+ if (!boardDefs.storm_01_gentle) return 'SKIPPED — no server for storm defs';
+ const w = createWeek();
+ const [booked, callout] = w.offers();
+ const def = boardDefs[callout.night.storm];
+ if (!def) return 'SKIPPED — no def for the drawn alternative';
+ // The fee as the OFFER CARD printed it, priced at this morning's standing.
+ const cardFee = calloutFee(def, w.rep, callout.night.constraints);
+ w.take(callout);
+ // ⚠️ THE SEAM THIS PIN EXISTS FOR. Charging the standing inside take() would
+ // drop rep between the board and the job sheet, and the fee is priced off
+ // rep — so the card would advertise one number and the sheet, one click
+ // later, would quote another. That is the quote-vs-settle lie moved one card
+ // earlier, which is the exact bug calloutFee() was extracted to make
+ // impossible. The cost lands at DAWN with every other rep movement.
+ assertEq(w.rep, REP.START, 'standing does not move at the board — the fee is quoted off it');
+ assertEq(w.quote(def).base, cardFee, 'the sheet quotes exactly what the card advertised');
+
+ const s = w.settle({ hp: 100, win: true, collateral: [], intactHardwareValue: 0, rigRecord: [] }, def, 0);
+ assertEq(s.fullFee, cardFee, 'and the invoice pays it');
+ // Clause 4: the invoice states which job you took and what it cost to take it.
+ assertEq(s.taken, callout.id, 'the invoice says the job came off the board');
+ assertEq(s.stoodUp.length, 1, 'and names the client it stood up');
+ assertEq(s.stoodUp[0].client, booked.night.client);
+ assertEq(s.standUpCost, REP.STOOD_UP, 'with the standing it cost, already multiplied');
+ assert(s.repReasons.some((r) => r.includes('stood up')),
+ `the repline must say WHY standing moved, got ${JSON.stringify(s.repReasons)}`);
+ // ADDITIVE, not exclusive: a clean night at the yard you went to instead
+ // nets to level. You did good work — for somebody else, on a night you were
+ // promised elsewhere.
+ assertEq(s.rep, REP.START + REP.CLEAN + REP.STOOD_UP,
+ 'a clean night on a stood-up promise nets to level, and that is the ruling working');
+ });
+
+ t.test('SPRINT18 gate 0: the stood-up client REMEMBERS — and one decision is charged once', () => {
+ const w = createWeek();
+ const [booked, callout] = w.offers();
+ assertEq(w.remembers(booked.night.client).length, 0, 'nobody remembers anything on night one');
+ w.take(callout);
+ const mem = w.remembers(booked.night.client);
+ assertEq(mem.length, 1, 'the client you left waiting remembers it');
+ assertEq(mem[0].night, 1, 'and remembers WHICH night — the card and the sheet print it');
+ assertEq(mem[0].took, callout.night.client, 'the ledger records who you went to instead');
+ // Taking twice on one morning is the SAME take (S17's commitment rule), so it
+ // must be ONE broken promise. A double charge for one decision would be the
+ // worst kind of bug: invisible, and in the direction of punishment.
+ w.take(callout);
+ w.take(booked);
+ assertEq(w.standUps.length, 1, 'one decision, one broken promise');
+ // And the memory is READ-ONLY to its readers — hud.js's charter is that the
+ // face never takes a decision, and a getter handing out the live array is a
+ // card one `.push` away from inventing a grudge.
+ w.standUps.push({ night: 99, client: 'a card that pushed' });
+ assertEq(w.standUps.length, 1, 'week.standUps hands out a copy');
+ // A new week is a clean slate — the clients forget when the run does.
+ w.reset();
+ assertEq(w.standUps.length, 0, 'reset clears the ledger; a memory outliving its run is a bug');
+ });
+
+ t.test('SPRINT18 gate 0: TWO CALLOUTS, NO BOOKED JOB — declining is FREE, and that is not vacuous', () => {
+ // Clause 5. A legal board state the moment gate 1's emergency exists: an
+ // emergency arrives in the diary's chair but nobody booked you, so nobody is
+ // stood up. Built here from the predicate rather than from a shipped night,
+ // because the point is that the FREE case falls out of the filter returning
+ // nothing — not out of an `if` somebody remembered to write.
+ assertEq(isBooked('scripted', { storm: 'storm_01_gentle' }), true,
+ 'an ordinary diary night is a booking');
+ assertEq(isBooked('scripted', { storm: 'storm_01_gentle', emergency: { arriveAt: 30 } }), false,
+ 'an EMERGENCY in the diary slot is not a booking — the phone rang, nobody had you down');
+ assertEq(isBooked('callout', { storm: 'storm_01_gentle' }), false, 'a callout is never a booking');
+
+ const free = [
+ { id: 'scripted', booked: false, night: { client: 'the Karalis place' } },
+ { id: 'alt:x', booked: false, night: { client: 'the Delaneys' } },
+ ];
+ assertEq(standUpsIfTaken(free, free[0]).length, 0, 'nothing booked ⇒ taking either costs nothing');
+ assertEq(standUpsIfTaken(free, free[1]).length, 0, 'in both directions');
+ // ⚠️ THE VACUITY GUARD, and it is the whole reason this test is worth its
+ // line: a `standUpsIfTaken` that always returned [] would pass every assert
+ // above. Flip ONE flag and the charge must appear.
+ const booked = [{ ...free[0], booked: true }, free[1]];
+ assertEq(standUpsIfTaken(booked, booked[1]).length, 1,
+ 'and the free result is produced by an empty board, not by a function that cannot charge');
+ assertEq(standUpsIfTaken(booked, booked[0]).length, 0, 'while taking the booking itself is still free');
+ });
+
+ t.test('SPRINT18 gate 0: the price is on the CARD before the choice, and the free morning SAYS free', () => {
+ if (typeof document === 'undefined') return 'SKIPPED — no DOM';
+ // "A choice whose price is invisible until the invoice is a trap, and this
+ // repo doesn't ship traps without tells." The ruling names the card
+ // explicitly, so the card is pinned in DOM — the S12 lesson: both of that
+ // sprint's card bugs were invisible to a suite that never rendered one.
+ const w = createWeek();
+ const offers = w.offers();
+ const hud = createHud({ scene: new THREE.Scene() });
+ try {
+ hud.showBoard(offers, { night: 1, nights: NIGHTS.length, bank: 80, log: [], rep: 3.0 }, () => {});
+ const cards = [...document.querySelectorAll('#hud-card .offer')];
+ assertEq(cards.length, 2, 'two offers rendered');
+ assert(!cards[0].querySelector('.cost'), 'the booked job costs nothing to take — no cost line on it');
+ const cost = cards[1].querySelector('.cost');
+ assert(cost, 'the CALLOUT card must print what taking it costs, before the choice');
+ const text = cost.textContent;
+ assert(text.includes(nightAt(0).client),
+ `in the client's own terms — expected "${nightAt(0).client}", got "${text}"`);
+ assert(text.includes('expecting you'), `and in plain words, got "${text}"`);
+ assert(text.includes('½★'), `as a half-star, not a float, got "${text}"`);
+ assert(text.includes('−'), `and signed as a debit, got "${text}"`);
+
+ // The free morning: nothing booked, and the note SAYS free rather than
+ // leaving a player to infer it from an absence.
+ const note = document.querySelector('#hud-card .board-note')?.textContent ?? '';
+ assert(note.includes('cannot work two yards'), `a booked morning reads as normal, got "${note}"`);
+ hud.hideCard();
+ const freeOffers = offers.map((o) => ({ ...o, booked: false, standsUp: [] }));
+ hud.showBoard(freeOffers, { night: 1, nights: NIGHTS.length, bank: 80, log: [], rep: 3.0 }, () => {});
+ const freeNote = document.querySelector('#hud-card .board-note')?.textContent ?? '';
+ assert(freeNote.includes('costs you anything to turn down'),
+ `a morning with nothing booked must SAY declining is free, got "${freeNote}"`);
+ assert(!document.querySelector('#hud-card .offer .cost'),
+ 'and no offer on it carries a cost line');
+ } finally { hud.dispose(); }
+ });
+
// 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).
diff --git a/web/world/js/week.js b/web/world/js/week.js
index 895f834..df431df 100644
--- a/web/world/js/week.js
+++ b/web/world/js/week.js
@@ -387,6 +387,31 @@ export const REP = {
WARRANTY: -0.5,
/** Per collateral event. Down harder: their property beats your hardware. */
COLLATERAL: -1.0,
+ /**
+ * ⚖️ **SPRINT18 gate 0 — PER CLIENT YOU STOOD UP. JOHN'S RULING: declining a
+ * booked night costs you. "It adds pressure."**
+ *
+ * THE MIRROR OF `CLEAN`, exactly, and the full reasoning is in board.js beside
+ * `isBooked` (the structural half lives there because board.js cannot import
+ * this file). The short form: a stood-up client costs you what a flawless
+ * night earns — one night of perfect work, cancelled by one phone call you
+ * didn't make. Bounded above by `COLLATERAL` on purpose; ranking a broken
+ * promise above a broken carport would make the invoice argue with itself.
+ *
+ * ⚠️ UNMEASURED, and it is the lever. If John's play says the price is wrong,
+ * move THIS number — not `CLEAN`, which the rest of the ladder is measured
+ * against. a.test pins `STOOD_UP === -CLEAN` so the mirror cannot rot into a
+ * coincidence.
+ *
+ * WHEN it lands: at DAWN, with every other rep movement, and that ordering is
+ * load-bearing. You stand a client up in the morning; the reckoning is on the
+ * night's paper. Charging it inside take() would drop your standing between the
+ * board and the job sheet, and the fee is priced off standing — so the offer
+ * card would advertise $57 and the sheet one click later would quote $56. That
+ * is the quote-vs-settle lie, moved one card earlier, which is the exact bug
+ * calloutFee() exists to make impossible.
+ */
+ STOOD_UP: -0.5,
/** Callout-fee multiplier at rep r. Exact at 2dp for every half-step. */
multiplier: (r) => 1 + 0.04 * (r - 3),
};
@@ -485,6 +510,10 @@ export function gustPeakOf(def) {
* last dawn's brokenAtDawn corners) {anchorId, hw, cost, night, site, addr, client}
* @property {number} warrantyTotal
* @property {number} feeMult rep multiplier the fee was priced at
+ * @property {object[]} stoodUp SPRINT18 gate 0 — clients this night's take left
+ * waiting {night, client, addr, offerId, took}; `standUpCost` is the standing
+ * it cost, already multiplied.
+ * @property {number} standUpCost
* @property {number} rep after tonight's movement (repBefore/repDelta/repReasons too)
* @property {number} warrantiesToDate cumulative items this week, for the end card
*/
@@ -524,6 +553,21 @@ export function createWeek(opts = {}) {
*/
const taken = new Map();
const jobAt = (i) => taken.get(i) ?? nightAt(i);
+ /**
+ * ⚖️ SPRINT18 gate 0 — **THE CLIENTS YOU STOOD UP.** John's ruling's third
+ * clause: *"the stood-up client remembers."*
+ *
+ * Append-only, in night order, one entry per client stood up:
+ * `{night, client, addr, offerId, took}` — `night` 1-based (it is printed),
+ * `offerId` the job you took instead, `took` the client you took instead.
+ *
+ * A LEDGER, not a flag, and that is the whole of "your work follows you": the
+ * board reads it to put the memory on that client's next offer card, the job
+ * sheet reads it if you go back to them, and settle() reads TONIGHT'S entries
+ * to move standing. One place, three surfaces, no surface deciding anything.
+ * @type {{night:number,client:string|null,addr:string|null,offerId:string|null,took:string|null}[]}
+ */
+ const standUps = [];
/** @type {Settlement[]} */
const log = [];
@@ -643,9 +687,20 @@ export function createWeek(opts = {}) {
},
/**
- * TAKE ONE. The other vanishes — no backlog, no decline cost (SPRINT18
- * decides whether saying no costs you; SPRINT17 deliberately does not
- * charge for a choice it hasn't designed the consequences of).
+ * TAKE ONE. The other vanishes — and ⚖️ **SPRINT18 gate 0: if the other one
+ * had a client expecting you, you have just stood them up.** John's ruling,
+ * landed here because this is the one door both offers go through.
+ *
+ * The charge itself is at DAWN (settle reads tonight's `standUps` entries and
+ * moves standing with the rest) — see REP.STOOD_UP for why that ordering is
+ * not a detail. What happens HERE is the RECORD: who you left waiting, on
+ * which night, for what.
+ *
+ * `offer.standsUp` comes off board.js, so the cost the player read on the
+ * card is the cost that gets recorded. A hand-made offer with no board
+ * context (a.test does this deliberately, and so does any future scripted
+ * install) stands nobody up, which is the honest answer: nothing declined
+ * anybody.
*
* Idempotent per night and only forward: taking twice on the same morning
* is the same take, and there is no untake. That's not a limitation, it's
@@ -669,6 +724,17 @@ export function createWeek(opts = {}) {
const night = normaliseNight(offer.night);
Object.defineProperty(night, '_offerId', { value: offer.id, enumerable: false });
taken.set(index, night);
+ // ⚖️ gate 0 — the promise you just broke, written down before anything
+ // else can happen. Inside the `if` so the idempotence above covers it
+ // too: taking twice on one morning must not stand the same client up
+ // twice, and a double charge for one decision would be the worst kind of
+ // bug — invisible, and in the direction of punishment.
+ for (const s of offer.standsUp ?? []) {
+ standUps.push({
+ night: index + 1, client: s.client, addr: s.addr,
+ offerId: s.id, took: night.client ?? null,
+ });
+ }
}
return taken.get(index);
},
@@ -676,6 +742,23 @@ export function createWeek(opts = {}) {
/** Tonight's taken offer id, or null if the player is on the spine. */
get takenOffer() { return taken.get(index)?._offerId ?? null; },
+ /**
+ * ⚖️ SPRINT18 gate 0 — the stand-up ledger, read-only.
+ *
+ * A copy, because this is the one piece of week state a CARD reads directly
+ * and a card that could push onto it would be a HUD taking a decision
+ * (hud.js's own charter, first paragraph).
+ */
+ get standUps() { return standUps.slice(); },
+ /**
+ * Has this client been stood up, and when? The memory, per client — the
+ * board's offer card and the job sheet both ask this by name.
+ * @param {string|null} client
+ */
+ remembers(client) {
+ return client ? standUps.filter((s) => s.client === client) : [];
+ },
+
/** SPRINT17 — the week's draw seed, for the board and for a.test's pins. */
get seed() { return seed; },
@@ -693,6 +776,10 @@ export function createWeek(opts = {}) {
// for the run), so clearing the takes is what makes "play it again"
// actually replayable rather than handing you last run's choices.
taken.clear();
+ // gate 0: and the clients forget. A new week that opened with last run's
+ // broken promises on the board would be the memory outliving the run that
+ // earned it — the same defect as an uncleared `taken`, one surface along.
+ standUps.length = 0;
return week;
},
@@ -794,6 +881,18 @@ export function createWeek(opts = {}) {
repDelta += REP.COLLATERAL * collateralEvents;
repReasons.push(`broke ${collateralEvents > 1 ? 'things' : 'something'} of theirs`);
}
+ // ⚖️ SPRINT18 gate 0 — THE PRICE OF SAYING NO, settled with everything else
+ // tonight's work earned or cost. take() recorded it this morning; the
+ // invoice states it tonight, in the same repline that already explains
+ // every other movement. Note it is ADDITIVE, not exclusive: standing a
+ // client up and then having a clean night at the yard you went to instead
+ // is +0.5 −0.5 = level, and that is exactly right — you did good work, for
+ // somebody else, on a night you were promised elsewhere.
+ const stoodUp = standUps.filter((s) => s.night === week.night);
+ if (stoodUp.length > 0) {
+ repDelta += REP.STOOD_UP * stoodUp.length;
+ repReasons.push(`stood up ${stoodUp.map((s) => s.client ?? 'a client').join(' · ')}`);
+ }
rep = Math.min(REP.MAX, Math.max(REP.MIN, rep + repDelta));
repDelta = rep - repBefore; // what actually moved, after the clamp
@@ -815,6 +914,13 @@ export function createWeek(opts = {}) {
constraints: job.constraints ?? [],
premium: premiumFor(job.constraints),
taken: taken.has(index) ? (taken.get(index)._offerId ?? true) : false,
+ // ⚖️ SPRINT18 gate 0, clause 4 — THE INVOICE STATES WHAT IT COST YOU TO
+ // TAKE THIS JOB. Rides the settlement, like `constraints` and for the
+ // identical reason: night 5's invoice must still name the client night 5
+ // stood up when you re-read it on night 7. `standUpCost` is pre-multiplied
+ // so no surface has to know the constant to print the number.
+ stoodUp,
+ standUpCost: REP.STOOD_UP * stoodUp.length,
// SPRINT16 — the ledger. The rig record IS the work history (the night
// computed all of it; the week just keeps it), the warranty items are
// tonight's sheet answered on the invoice, and the rep block is the