Lane A S18 gate 0: ⚖️ DECLINING A BOOKED NIGHT COSTS YOU — −½★, on the card and on the invoice

John's ruling, 2026-07-25 ("it adds pressure"), wired end to end.

REP.STOOD_UP = -0.5, the exact mirror of REP.CLEAN: a stood-up client costs
you what a flawless night earns. Bounded above by REP.COLLATERAL so a broken
promise cannot outrank a wrecked carport. UNMEASURED per house rule; it is the
lever, not CLEAN.

isBooked(kind, night) — the diary is booked, the phone is not, and an emergency
in the diary's chair is not a booking. The first cut derived it from the night
alone and the pool yard came back booked; the probe caught it in one call.

take() records the broken promise, settle() charges it at dawn — charging inside
take() drops standing between the board and the sheet, and the fee is priced off
standing. That is the quote-vs-settle lie moved one card earlier.

Three surfaces off one ledger: the offer card prints the cost before the choice
in the client's own terms, the job sheet says they have not forgotten, the
invoice carries an OFF THE BOARD block. Two callouts and no booked job reads as
free, in words, with a vacuity guard behind it.

Selftest 528/0/0 — 522 + 6, the exact sum.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-25 19:11:03 +10:00
parent e6d4c36daa
commit 27d9ebcc63
6 changed files with 692 additions and 5 deletions

View File

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

View File

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

View File

@ -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 ? `<div class="line risk"><span>their property at risk${
o.exposureItems?.length ? `${o.exposureItems.map((x) => x.what).join(', ')}` : ''
}</span><b>$${o.exposure}</b></div>` : ''}
${/* 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 ? `<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>${
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('')}
${/* 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 ? `<div class="remembers">They remember night ${
o.remembers.map((s) => s.night).join(' and night ')
} you took another job.</div>` : ''}
</button>`;
};
@ -860,7 +909,20 @@ export function createHud(d) {
<h2>Take one. The other goes to somebody else.</h2>
${offers.map(offerHtml).join('')}
<div class="row" style="margin-top:12px"><span>in the bank</span><b>$${wk.bank}</b></div>
<div class="board-note">Both are tonight. You cannot work two yards in one storm.</div>
${/* 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. */''}
<div class="board-note${offers.some((o) => o.booked) ? '' : ' free'}">${
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.'
}</div>
<div class="terms">Fees quoted on the forecast at time of callout. Weather is not a variation.</div>
</div>`;
card.classList.add('on');
@ -936,6 +998,15 @@ showForecast({ key, def, site, tomorrowDef }, wk, onGo) {
const brief = job.brief
? `<div class="brief">${job.brief}</div>` : '';
// ⚖️ 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 ? `
<div class="remembers">They have not forgotten night ${
wk.remembers.map((s) => s.night).join(' and night ')
}. You are here on their second-choice terms.</div>` : '';
// 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 = `<div class="card jobcard">
${letterhead}
${brief}
${remembers}
${constraintBlock}
<h1>NIGHT ${wk.night} OF ${wk.nights}</h1>
<div class="pips">${pips}</div>
@ -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 ? `
<div class="sect">OFF THE BOARD</div>
<div class="sched">
<div class="row"><span>you took this callout</span><b>${w.client ?? 'no client'}</b></div>
${w.stoodUp?.length
? w.stoodUp.map((s) => `
<div class="row bad"><span>stood up ${s.client ?? 'a client'} they were expecting you</span><b>${
starDelta(REP.STOOD_UP)} standing</b></div>
<div class="cond">No invoice for that one. They will remember the phone call.</div>`).join('')
: '<div class="row"><span>nobody was expecting you — nothing to stand up</span><b>—</b></div>'}
</div>` : ''}
${w?.constraints?.length ? `
<div class="sect">PAID UNDER THESE TERMS</div>
<div class="sched">

View File

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

View File

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

View File

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