Lane A S18 gate 1 spine: 🚨 THE EMERGENCY CALLOUT — a night that starts in storm, a DISPATCH, double rates, the ledger unchanged

setPhase('storm', {at}) is the whole engine change: windTime() already returns
phaseT during a storm, tick() already ends at STORM_LEN, so an emergency night
is 60s long inside weather that is already working. No new phase.

EMERGENCIES, scheduled per night, not a POOL entry — a seventh pool entry would
re-roll every night's draw, and the one-variable law needs an author to place a
set-piece. Diffed the whole week against HEAD: exactly one line moved, and what
it replaced was corner_block_southerly — the same yard and the same storm as an
ordinary job. The job type is literally the only variable.

⚖️ Rulings: emergency rate ×2.0 (DESIGN.md line 29, canon, UNMEASURED-by-play);
a corner that ARRIVED broken bills nothing, wrecks nothing, books no warranty
and costs no standing (`theirs` on the rig record — without it the game charges
you for a cowboy's carabiner and books you a free callout to fix his work); the
inherited steel does not come home; the ute carries one free spare, or triage is
a verb with no object.

THE ONE-VARIABLE LAW IS NOW CODE: oneVariableOffences() accuses, offersFor()
throws, three-way vacuity guard. E's glasshouse cannot be the emergency's debut
stage no matter who wires what.

The DISPATCH is defined by its refusals: no schedule, no total, no AMOUNT DUE —
nobody costed this job. It prints the call, C's mid-storm read (feature-detected
through a namespace import), the rig that is already up with the down corners
marked ON THE GROUND, and the rate. Pinned in DOM, refusals included.

Seams live and waiting: B's breakCorner door, C's midStormRead, D's cutAway
event (tallied, carried to the settlement, printed on the invoice).

Selftest 535/0/0 — 528 + 7, the exact sum.

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

View File

@ -9697,3 +9697,93 @@ anchors are your GLB), but the tooling is now waiting, not TODO.
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.
[A] 2026-07-25 — 🚨 **GATE 1 SPINE LANDED, AND THREE AMENDMENTS TO THE SHAPES I PUBLISHED THIS
MORNING** (I said I would amend here rather than quietly; these are those notes). The
emergency callout runs end to end: a night that starts in storm, a DISPATCH instead of a job
sheet, double rates, and the whole existing ledger unchanged.
**AMENDMENT 1 — ⚖️ THE RULING THAT MAKES AN EMERGENCY INVOICE HONEST: A CORNER THAT WAS
ALREADY DOWN WHEN YOU ARRIVED IS THE JOB, NOT YOUR FAILURE.** I did not have this when I
published §2 this morning, and building it found it. Without the ruling, the shape of an
emergency night is grotesque *and would have shipped looking correct*: you turn up at
somebody else's collapsing rig and the invoice charges you $180 for the carport the cowboy
was already losing, bills you for his $5 carabiner, docks your standing for his corner, and
then books you a FREE WARRANTY CALLOUT TOMORROW to go and fix his work. Four separate
surfaces, all agreeing, all wrong.
So: **`rigRecordFor(corners, tally, theirs)`** now stamps `theirs: true` on the arrived-broken
corners, and the ledger reads it in four places — `bill`, the collateral walk, `warrantyItems()`
and the rep block. The corner is still HONESTLY RECORDED as down (the record does not lie, and
the HUD's "corners intact" count still reads it); what changes is who is answerable. Two lists
in scoreRun, deliberately: `lost` for REPORTING, `yours` for BILLING. B, C, D — this is the
only change to a shape you may already be building against, and the field name is `theirs`.
**⚠️ B, THE ONE DOOR I STILL NEED FROM YOU.** `broken: true` cannot be honoured from public
moves: sail.js's own comment says a break must also hand the node its mass back (`invMass`),
dump the pond and emit, or the corner stays *welded in mid-air* instead of flying. There is no
public `breakCorner`. I call **`rig.breakCorner?.(anchorId, { reason: 'arrived' })`**
feature-detected — the `setConstraints?.()` pattern — and **the night does not depend on it**:
the authored corner is a $5 carabiner on the carport beam (~264 N effective under a southerly),
so it fails within seconds through the REAL overload path, with the real event and the real
invMass release, whether or not the door exists. Emergent beats poked. Yours to open or to
rename.
**AMENDMENT 2 — ⚖️ THE UTE CARRIES ONE SPARE, FREE.** Without it, triage is a verb with no
object: you arrive at a broken corner unable to do the only thing the job is for. You had no
prep phase so you bought nothing, but a tradie's ute has a shackle rolling around in it.
Implemented in VISIBLE ARITHMETIC through public moves rather than a private door — bank the
session at `week.bank + SPARE_COST`, buy the spare, and the budget lands back on exactly
`week.bank`, so `spentThisNight` reads $0, which is the truth. (B: a `grantSpare()` would say
it better than three lines of mine — name it and I will move.)
**AMENDMENT 3 — ⚖️ THE INTACT HARDWARE DOES NOT COME HOME.** On an emergency, the steel on the
unbroken corners is the cowboy's, in the client's yard, and you leave it there; only your own
unused spare is refunded. This is also the check that stops `emergencyRate: 2.0` being free
money — without it a double-rate callout ALSO hands you half of somebody else's four rated
shackles at dawn, and the best-paying strategy in the game becomes turning up to other
people's disasters and salvaging them.
**THE RECEIPT I DID NOT EXPECT, and it is a good one.** I diffed the whole week's board draws
against HEAD to prove the scheduled emergency changed nothing it shouldn't. **Exactly one line
moved** — night 5's alternative — and what it *replaced* was `corner_block_southerly`: the
SAME yard and the SAME storm, offered as an ordinary rig-for-tonight job. So on the one night
the new job type debuts, the board's own alternative was already site_02 × southerly, and the
emergency changes literally nothing about that morning except the JOB TYPE. The one-variable
law is not being obeyed by argument here; it is obeyed by arithmetic.
**THE DISPATCH, as built.** Wears E's letterhead (one outfit, four papers now: JOB / INV /
THE BOARD / **CALL**) and is defined by what it REFUSES: no schedule, no garden bonus, no clean
bonus, no total, no AMOUNT DUE — nobody costed this job, and a card printing a total under a
job nobody quoted would be inventing the one document the fiction says does not exist. What it
does print: the phone call as the brief · C's mid-storm read (or one honest italic line saying
nobody briefed you) · **the rig that is already up, corner by corner, with the ones on the
ground marked "ON THE GROUND" in words** — DESIGN.md's tell rule, because a trap is allowed to
be brutal and is not allowed to be a surprise · the RATE and the fee it applies to, because
double rates are the reason you get in the ute. Pinned in DOM, including the refusals (a
refusal is only checkable by rendering it — the S12 lesson).
**STILL ONE MONEY FUNCTION.** `calloutFee(def, rep, constraints, emergency)` gained a fourth
term rather than growing a rival, so the offer card, the dispatch and the invoice cannot
disagree about a night that pays double. `quote()` gained an `emergency` block and the dispatch
is a VIEW of it — a second `dispatch()` computing its own fee is the quote-vs-settle divergence
with a new name.
**D — YOUR SEAM IS LIVE AND WAITING.** `rig.events.emit('cutAway', {t, corners, reason?})` is
already subscribed: I tally it, clear it at storm start, carry it to `run.cutAways` → the
settlement → **a line on the invoice** ("sail cut loose at CB1 — 34s in, deliberate · *You
spent the sail to keep the steel. That is the trade the knife is for.*"). Emit nothing and every
surface renders nothing. **And the shipped emergency was authored FOR your knife:** the cowboy
tied the sail to the CARPORT BEAM — the worst steel in the game, $180 of collateral, the anchor
the client has been selling as "plenty to tie off to" since night 3 — and that corner is failing
while you walk up the drive. Release, cut, or ride it out, with $180 on the line and one spare
in the ute. DESIGN.md line 165 over the one piece of collateral this game has already taught the
player to fear.
**E — the one-variable law now protects your glasshouse in code, not in a comment.**
`oneVariableOffences()` refuses any emergency over a yard no scripted night rigs, so the
glasshouse CANNOT be the emergency's debut stage this sprint no matter who wires what. Pinned
with a three-way vacuity guard (a NIGHTS emergency, an EMERGENCIES entry, and a POOL emergency
over an unrigged yard each produce their own accusation). Your yard debuts as an ordinary
rig-for-tonight job; DESIGN.md's sentence belongs to a later week. Flag me when it is placeable
and gate 5 is next on my list.

View File

@ -353,6 +353,215 @@ export function standUpsIfTaken(offers, taking) {
return (offers ?? []).filter((o) => o !== taking && o.booked);
}
/**
* 🚨 **SPRINT18 gate 1 THE EMERGENCY CALLOUT. DESIGN.md line 29, finally in
* the game:** *"Emergency callout mid-storm, right now, double rates. The
* chaos levels. Someone's sail is flogging itself to death over their
* glasshouse."* ROADMAP arc 2 sizes it exactly: *"the storm phase already does
* everything this needs except spawn you at t=30 with a rig you didn't build."*
*
* WHY THIS IS ITS OWN TABLE AND NOT A POOL ENTRY
*
* Both reasons are load-bearing and neither is tidiness:
*
* 1. **THE DRAW.** `alternativeFor` indexes the ELIGIBLE POOL by seed. A seventh
* pool entry therefore re-rolls every night's alternative it would change
* the board on six nights it isn't even on, invalidating the shipped board
* pins and D's seven-night cold-play record for nothing. Scheduled per night,
* every other morning's draw is byte-identical and `offers().length === 2`
* still holds.
* 2. **THE ONE-VARIABLE LAW.** An emergency is a SET PIECE, and the law needs an
* AUTHOR to place it: *"has the player already rigged this yard by night N"*
* is only a decidable question if N is authored. You cannot ask an RNG to
* respect a law about ordering. `oneVariableOffences` below is that law in
* code rather than in a comment, which SPRINT18's own spec demands of me
* ("A's ordering must make that impossible to get wrong by accident").
*
* `kind` stays `'callout'` its provenance is the phone, and an emergency is
* the most callout-y callout there is. The costume comes off `night.emergency`,
* so there is one source of truth and every existing pin stays green.
*
* THE SLOT: NIGHT 5's MORNING, OPPOSITE THE WILDNIGHT. RULED, with every
* other slot rejected for a stated reason
*
* · **site_02 × storm_03_southerly** the yard night 3 rigs, the storm night 2
* survives. **The only new variable is the JOB TYPE**, which is the law
* satisfied literally rather than argued around.
* · **not night 4** that morning's booked job 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 whole campaign.
* · **not nights 6 or 7** the MEMORY needs somewhere to land. The Hendersons
* are on the board nights 1, 2, 5 and 6, so standing them up on night 5 puts
* the grudge on the icenight's card the very next morning. From 6 or 7,
* gate 0 clause 3 is unreachable in the shipped week a mechanic nobody
* would ever see.
* · and it answers D's cold-play finding #1 in its own words: *"the wildnight
* was declinable for a Tuesday southerly at similar money."* It still is
* at double rates now, and ½, both printed before you choose.
*/
export const EMERGENCIES = [
{
/** 1-based, the MORNING it arrives. Read by emergencyFor(index). */
night: 5,
id: 'corner_block_emergency',
storm: 'storm_03_southerly', site: 'site_02_corner_block',
client: 'the Vasilaros place', addr: '2 Bight Rd — the corner block',
brief: 'Nobody quoted this one. You are being asked how fast you can be there.',
emergency: {
/**
* Seconds into the storm you turn up ROADMAP's own number. STORM_LEN is
* 90, so an emergency night is 60 seconds long and every one of them is
* inside weather that is already working.
*/
arriveAt: 30,
/**
* THE PHONE CALL. This is the dispatch's whole brief, and it is deliberately
* not a forecast and not a quote: *there was no time* is the drama, and a
* client who reads you a wind speed at nine at night is a client who is not
* panicking. What the WEATHER is doing when you arrive is C's `midStormRead`,
* because nobody briefed you you have to look at the sky yourself.
*/
dispatch: 'Ten past nine and the phone goes. The corner block — they got a second sail put '
+ 'up over the carport side by a mob off a card in the letterbox, and it is coming '
+ 'apart in the dark. She is not asking what it costs. She is asking how fast you '
+ 'can be there.',
/**
* **B OWNS THIS: THE RIG YOU DIDN'T BUILD.** Per-corner
* `{anchorId, hw, broken?}` see THREADS [A] 2026-07-25 §2 for the shape
* and the seam. This is a MINIMAL FIRST CUT so the phase is verifiable end
* to end in my tree; it is flagged for B and revert-and-tell-me applies.
*
* What it says as authored, and why these four anchors: **the cowboy took
* the bait the player was warned about.** `cb1` is the carport beam the
* worst steel in the game (ratingHint under 0.35, a.test pins it below the
* fascia), $180 of collateral under it, and the client has been selling it
* as "plenty to tie off to" since night 3. A $5 carabiner on a 0.22 beam is
* ~264 N effective against a southerly, so **this corner is failing while
* you walk up the drive** emergently, through the real overload path,
* with the real event and the real invMass release, not through a poked
* flag. That is the night: **release, cut, or ride it out**, with $180 on
* the line and a knife in the ute. DESIGN.md line 165's signature decision,
* over the one piece of collateral in this game that a player has already
* been taught to fear.
*/
rig: [
{ anchorId: 'cb1', hw: 'carabiner', broken: true },
{ anchorId: 'tr1', hw: 'carabiner' },
{ anchorId: 'tr1b', hw: 'carabiner' },
{ anchorId: 'q3', hw: 'carabiner' },
],
},
_why: 'THE PAIRING IS THE POOL\'S OWN AUDITED corner_block_southerly (A, S17 gate 1, '
+ 'audit.html): 19 holding lines / 7 marginal / 38 unholdable, cheapest hold $40 '
+ '(tr1,tr1b,q1,q3 → garden 87.6 FULL). ⚠️ THAT AUDIT ANSWERS A DIFFERENT QUESTION and '
+ 'the difference is flagged, not smoothed over: it measures whether a rig-for-tonight '
+ 'job can win here. Whether the INHERITED rig is TRIAGEABLE — what a knife, a spare and '
+ 'sixty seconds can do to somebody else\'s carabiners — is B\'s to measure, and the '
+ 'admission price for a board entry (this file\'s own rule) is not paid until they have. '
+ 'One-variable: the yard is night 3\'s, the storm is night 2\'s, the JOB TYPE is new.',
},
];
/**
* Tonight's scheduled emergency, or null. 0-based index in, 1-based `night` out
* the table is authored in the numbers a human counts in, because a table of
* set-pieces is read by people far more often than by code.
*/
export function emergencyFor(nightIndex, table = EMERGENCIES) {
return (table ?? []).find((e) => e.night === (nightIndex | 0) + 1) ?? null;
}
/**
* Fails loud on a malformed emergency `validateConstraint`'s precedent, and the
* same reasoning: a content bug must be a crash at the board, not a runtime
* surprise sixty seconds into a storm the player cannot leave.
*/
export function validateEmergency(night, where = '?') {
const e = night?.emergency;
if (!e) return night;
const bad = [];
if (!Number.isFinite(e.arriveAt) || e.arriveAt <= 0) {
bad.push('needs a positive numeric `arriveAt` — the storm clock you turn up on');
}
if (!e.dispatch || String(e.dispatch).length < 20) {
bad.push('needs `dispatch` — the phone call. There is no forecast and no quote; this is the whole brief');
}
if (!Array.isArray(e.rig) || e.rig.length !== 4) {
bad.push(`needs a 4-corner \`rig\` (the rig you didn't build), got ${e.rig?.length ?? 'none'}`);
}
for (const c of e.rig ?? []) {
if (!c?.anchorId) bad.push('a rig corner with no `anchorId`');
if (!c?.hw) bad.push(`rig corner ${c?.anchorId ?? '?'} has no \`hw\` — someone tied it to something`);
}
if (bad.length) throw new Error(`board: emergency on ${where} is invalid:\n ${bad.join('\n ')}`);
return night;
}
/**
* **THE ONE-VARIABLE LAW, IN CODE.** ROADMAP principle 5, and SPRINT18 is the
* first sprint that could break it by accident: a new JOB TYPE (the emergency
* callout) and a new YARD (E's glasshouse) ship in the same sprint, and DESIGN.md
* pairs them in one sentence. **They may not debut on the same night.** That
* sentence is the payoff, not the introduction.
*
* Constructed as C's `pairingRefusals` is, and for their stated reason: it
* **ACCUSES, it does not silently filter.** Dropping a bad entry would leave a
* morning with one offer and no explanation a runtime surprise wearing a fix's
* clothes. Every offence carries its measurement in the message.
*
* The three rules, and what each one actually catches:
* 1. **a NIGHTS emergency at index i needs its site in NIGHTS at some j < i**
* the emergency debuts over a yard the player has already rigged.
* 2. **an EMERGENCIES entry for night N needs its site rigged by an earlier
* night** same rule, the other table. This is the one the shipped data
* lives under.
* 3. **a POOL emergency needs its site in NIGHTS at all** a yard whose ONLY
* route into the game is under the new job type is precisely the accident.
* This is the rule that protects E: the glasshouse is not in NIGHTS, so it
* CANNOT be an emergency's stage this sprint, no matter who wires what.
*
* @param {object} o
* @param {object[]} o.nights the ladder (week.js NIGHTS)
* @param {object[]} [o.pool]
* @param {object[]} [o.emergencies]
*/
export function oneVariableOffences({ nights, pool = POOL, emergencies = EMERGENCIES }) {
const offences = [];
const siteOf = (n) => (typeof n === 'string' ? 'backyard_01' : n?.site ?? 'backyard_01');
const ladder = (nights ?? []).map(siteOf);
/** The first night index that rigs this yard, or -1. */
const firstRig = (site) => ladder.indexOf(site);
(nights ?? []).forEach((n, i) => {
if (typeof n === 'string' || !n?.emergency) return;
const j = firstRig(siteOf(n));
if (!(j >= 0 && j < i)) {
offences.push(`NIGHTS[${i}] (night ${i + 1}) is an EMERGENCY over ${siteOf(n)}, a yard `
+ `${j < 0 ? 'no night rigs' : `first rigged on night ${j + 1}`} — the new JOB TYPE and `
+ 'the yard would debut together. The emergency callout debuts over a yard the player has already rigged.');
}
});
for (const e of emergencies ?? []) {
const i = (e.night | 0) - 1;
const j = firstRig(siteOf(e));
if (!(j >= 0 && j < i)) {
offences.push(`EMERGENCIES '${e.id ?? '?'}' arrives on night ${e.night} over ${siteOf(e)}, a yard `
+ `${j < 0 ? 'no scripted night rigs' : `first rigged on night ${j + 1}`} — a player taking it `
+ 'would meet a new JOB TYPE and a new YARD on the same night.');
}
}
for (const p of pool ?? []) {
if (!p?.emergency) continue;
if (firstRig(siteOf(p)) < 0) {
offences.push(`POOL '${p.id ?? '?'}' is an EMERGENCY over ${siteOf(p)}, which is in no NIGHTS `
+ 'slot at all — that yard\'s only route into the game would be under the new job type.');
}
}
return offences;
}
/**
* A deterministic draw. `rng` is contracts.js's mulberry32 the same PRNG the
* storm system reproduces against, so there is exactly one seeded-randomness
@ -386,7 +595,19 @@ export function eligibleAlternatives(scripted, pool = POOL) {
* @param {number} weekSeed
* @param {number} nightIndex
*/
export function alternativeFor(scripted, weekSeed, nightIndex, pool = POOL) {
export function alternativeFor(scripted, weekSeed, nightIndex, pool = POOL, emergencies = EMERGENCIES) {
/**
* 🚨 SPRINT18 gate 1 A SCHEDULED EMERGENCY DISPLACES THE DRAW, and it is
* checked BEFORE the rng is even constructed. Deliberate: an emergency is an
* authored set-piece, so the night it lands on is not the seed's business, and
* a board that rolled dice about whether the phone rings would make the
* one-variable law unenforceable (you cannot ask an RNG to respect an ordering
* rule). Every OTHER night's draw is byte-identical to before this existed
* `rng` is constructed fresh from seedFor(week, night) with no cross-night
* state, so skipping the call here cannot shift tomorrow's.
*/
const emergency = emergencyFor(nightIndex, emergencies);
if (emergency) return emergency;
const eligible = eligibleAlternatives(scripted, pool);
if (!eligible.length) return null;
const r = rng(seedFor(weekSeed, nightIndex));
@ -451,10 +672,27 @@ export function exposureOf(site) {
* offer. Absent the board still builds (client, site, brief, constraints),
* which is what keeps this testable with no server.
*/
export function offersFor({ scripted, weekSeed, nightIndex, pool = POOL, priced = null }) {
export function offersFor({ scripted, weekSeed, nightIndex, pool = POOL, priced = null, nights = null }) {
const alt = alternativeFor(scripted, weekSeed, nightIndex, pool);
/**
* SPRINT18 gate 1 THE ONE-VARIABLE LAW, ENFORCED AT THE DOOR. `nights` is
* threaded in by week.js's `offers()` (which owns NIGHTS; this file must not
* import it), so the law runs in the REAL GAME every morning and not only in
* the suite. Omit it and the board still builds that is what keeps board.js
* testable with no ladder but the shipped path always supplies it, and
* a.test pins that it does. THROW, not filter: a board that quietly dropped an
* offer would leave a morning with one door and no explanation.
*/
if (nights) {
const offences = oneVariableOffences({ nights, pool, emergencies: EMERGENCIES });
if (offences.length) {
throw new Error(`board: THE ONE-VARIABLE LAW IS BROKEN (ROADMAP principle 5) — a new job `
+ `type and a new yard may not debut together:\n ${offences.join('\n ')}`);
}
}
const mk = (night, kind, id) => {
for (const c of night.constraints ?? []) validateConstraint(c, id);
validateEmergency(night, id);
const offer = {
id, kind, night,
constraints: night.constraints ?? [],

View File

@ -370,6 +370,40 @@ const CSS = `
margin-top:7px; font-size:11px; font-style:italic; color:#8a7f74;
}
#hud-card .board-note.free { color:#8ba0ad; }
/* --- 🚨 SPRINT18 gate 1: THE DISPATCH ------------------------------------
The paper chain's fourth document, and the design brief for it is one line:
IT IS NOT A JOB SHEET. A job sheet is a quote a letterhead, a schedule,
three amounts each carrying its condition. A dispatch is a phone call written
down at ten at night. So it wears the kit's letterhead (one outfit, four
papers) and then deliberately does NOT have the sched block: no garden bonus,
no clean bonus, no total, because nobody costed this job. The one number on it
is the RATE, because double rates are the reason you get in the ute.
Red, not amber. The board's constraint mark is amber (a client asking
something of you); this is the only card in the game that opens while weather
is already happening to somebody. */
#hud-card .dispatch .docket .kind { color:#ff8f86; }
#hud-card .dispatch h1 { color:#ffd9a3; }
#hud-card .dispatch .siren { margin:2px 0 12px; font-size:11px; letter-spacing:.18em;
text-transform:uppercase; color:#ff8f86; }
/* The call itself, in the client's voice, and given the room a brief gets
it IS the brief. There is no other one. */
#hud-card .dispatch .call { margin:10px 0 4px; padding:11px 13px; border-left:3px solid #ff8f86;
background:#1a1417; font-size:13px; line-height:1.6; color:#e6d8d5; }
/* C's mid-storm read: what the sky and the grass tell you, since no paper does.
Same rhythm as the offer card's weather band, one shade colder nobody
briefed this, you are reading it off the yard. */
#hud-card .dispatch .read { display:flex; justify-content:space-between; gap:14px;
font-size:12px; color:#8ba0ad; padding:2px 0; }
#hud-card .dispatch .read.hail span { color:#cbd8e0; }
#hud-card .dispatch .noread { font-size:11px; font-style:italic; color:#6d818e; margin:8px 0 2px; }
/* The inherited rig, listed. A corner that arrives on the ground is named
before you go, not discovered the tell rule (DESIGN.md: traps have tells). */
#hud-card .dispatch .theirs { font-size:12px; color:#8ba0ad; padding:2px 0;
display:flex; justify-content:space-between; gap:14px; }
#hud-card .dispatch .theirs b { color:#dde5ea; font-weight:700; }
#hud-card .dispatch .theirs.down b { color:#ff8f86; }
#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;
@ -935,6 +969,92 @@ export function createHud(d) {
}
},
/**
* 🚨 SPRINT18 gate 1 **THE DISPATCH.** The paper an emergency callout gets
* INSTEAD of a job sheet, and the "instead" is the whole design: SPRINT18's
* spec says *"a dispatch instead of a job sheet (there was no time to quote
* that's the drama)"*, so what makes this card right is mostly what it refuses
* to print.
*
* WHAT IT SHOWS, and why exactly this:
* · **THE CALL** `emergency.dispatch`, in the client's voice. It is the
* brief, because there is no other one.
* · **WHAT THE SKY IS DOING** C's `midStormRead(def, arriveAt)`, MAPPED as
* an ordered list exactly as the offer card maps `offerBand`, so a fact C
* adds cannot be dropped by this card picking two fields. Absent one
* honest italic line saying nobody briefed you, which is the truth and not
* a placeholder.
* · **THE RIG YOU DIDN'T BUILD** every corner named with its hardware, and
* the ones already on the ground marked. DESIGN.md's tell rule: the trap is
* allowed to be brutal, it is not allowed to be a surprise. A player who
* reads "carabiner — ON THE GROUND" against the carport has been told.
* · **THE RATE** the fee and the multiplier it came from. The one number.
*
* WHAT IT REFUSES TO SHOW: the garden bonus, the clean bonus, and a total.
* `quote.emergency` exists so this card can be a VIEW of the same money object
* the invoice settles against (see week.js) while printing none of the rows
* nobody quoted.
*
* @param {object} d {job, quote, def, site, read, rig}
* @param {object} wk {night, nights, bank, rep}
* @param {() => void} onRoll
*/
showDispatch({ job, quote, read, site }, wk, onRoll) {
const em = job?.emergency ?? {};
const q = quote ?? {};
const rigRows = (em.rig ?? []).map((c) => `
<div class="theirs${c.broken ? ' down' : ''}"><span>${c.anchorId?.toUpperCase()}</span><b>${
c.hw ?? '?'}${c.broken ? ' — ON THE GROUND' : ''}</b></div>`).join('');
card.innerHTML = `<div class="card jobcard dispatch">
<div class="letterhead">
<div>
<div class="mark">${BUSINESS.mark}</div>
<div class="trade">${BUSINESS.trade}</div>
</div>
<div class="docket">
<div class="kind">DISPATCH</div>
<div class="no">CALL ${docketNo(wk.night)} · ${ABN}</div>
${wk.rep != null ? `<div class="rep">standing ${repFmt(wk.rep)}</div>` : ''}
</div>
</div>
<div class="billto">
<div class="who">${job?.client ?? 'no client'}</div>
${job?.addr ? `<div class="addr">${job.addr}</div>` : ''}
</div>
<h1>RIGHT NOW</h1>
<div class="siren">no quote · no prep · you arrive ${em.arriveAt ?? 0}s into it</div>
${site ? `<div class="stat" style="color:#7ee0ff">${site.name}${site.blurb ? `${site.blurb}` : ''}</div>` : ''}
${em.dispatch ? `<div class="call">${em.dispatch}</div>` : ''}
${/* C's seam. An ordered list, mapped — never two hand-picked fields. */''}
<div class="sect">WHAT YOU CAN SEE FROM THE DRIVE</div>
${read?.length
? read.map((l) => `<div class="read ${l.key}"><span>${l.text}</span></div>`).join('')
: '<div class="noread">Nobody briefed you. You will find out what this storm is doing by standing in it.</div>'}
${rigRows ? `<div class="sect">THE RIG THAT IS ALREADY UP</div>${rigRows}` : ''}
<div class="sect">WHAT IT PAYS</div>
<div class="sched">
<div class="row"><span>emergency callout${
q.emergency?.rate ? `${q.emergency.rate}× the going rate` : ''
}${q.feeMult && q.feeMult !== 1 ? ` · standing ×${q.feeMult.toFixed(2)}` : ''}</span><b>$${q.base ?? 0}</b></div>
<div class="cond">Nobody costed this job. What the garden is worth, and what you owe for
anything that breaks, is on the invoice in the morning.</div>
</div>
${(q.warranty ?? []).length ? `
<div class="sect">STILL OWED FROM LAST NIGHT</div>
<div class="sched">
${q.warranty.map((it) => `
<div class="row"><span>warranty ${it.anchorId.toUpperCase()}, ${it.hw}, no charge to the client</span><b>$${it.cost}</b></div>`).join('')}
</div>` : ''}
<div class="row" style="margin-top:12px"><span>in the bank</span><b>$${wk.bank}</b></div>
<button class="go">ROLL</button>
<div class="terms">Emergency attendance. Rates as advised by telephone. No pre-attendance inspection was possible.</div>
</div>`;
card.classList.add('on');
hud.setVisible(false);
card.querySelector('.go').addEventListener('click', () => { hud.hideCard(); onRoll(); });
},
/**
* The forecast. Its job is to sell the dread and, incidentally, to be the
* difficulty select for free three authored storms already bracket the
@ -1350,6 +1470,31 @@ showForecast({ key, def, site, tomorrowDef }, wk, onGo) {
<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>` : ''}
${/* 🚨 SPRINT18 gate 1 THE EMERGENCY'S PAPERWORK. Three things the invoice
for a night you didn't rig has to say, and every one of them exists
because the alternative is the paper lying:
· IT WAS A CALLOUT, at what rate. The fee row already carries the
money; this says why it was double.
· WHOSE CORNERS WERE ALREADY DOWN named, and explicitly NOT BILLED.
This is the split-the-blame line, the same job the icenight's
invoice does ("the shackle at P2 is the part that's on you"). Without
it a player reads a $180 carport against a corner they never chose.
· THE KNIFE (D's verb, principle 2 a verb with no line on the
paperwork is a verb the game does not admit to). Silent until D's
input lands, which is the degradation rule, not a stub. */''}
${w?.emergency ? `
<div class="sect">EMERGENCY ATTENDANCE</div>
<div class="sched">
<div class="row"><span>called out mid-storm arrived ${w.emergency.arriveAt}s in, no prep, no quote</span><b>${w.emergency.rate}× rate</b></div>
${(w.inherited ?? []).map((c) => `
<div class="row"><span>${c.anchorId.toUpperCase()} ${c.hw}, already on the ground when you arrived</span><b>not billed</b></div>
<div class="cond">That corner is what the callout was for. It is not on you.</div>`).join('')}
${(w.cutAways ?? []).map((c) => `
<div class="row"><span>sail cut loose${c.corners?.length ? ` at ${c.corners.map((x) => String(x).toUpperCase()).join(', ')}` : ''} ${
Math.round(c.t)}s in, deliberate</span><b>your call</b></div>
<div class="cond">You spent the sail to keep the steel. That is the trade the knife is for.</div>`).join('')}
</div>` : ''}
${w?.constraints?.length ? `
<div class="sect">PAID UNDER THESE TERMS</div>
<div class="sched">

View File

@ -18,6 +18,13 @@ import { FIXED_DT, PHASES, STORM_LEN, HARDWARE, SPARE_COST, Emitter } from './co
import { createWorld, loadSite } from './world.js';
import { createCameraRig, spawnYawFor } from './camera.js';
import { loadStorm, createWind, forecastLines, offerBand } from './weather.js';
// SPRINT18 gate 1 — a NAMESPACE import beside the named ones, for exactly one
// reason: C's `midStormRead` (the dispatch's mid-storm read) lands at
// integration, and a named import of a function that does not exist yet is a
// hard module error rather than a graceful no-op. `weather.midStormRead?.()` is
// the `setConstraints?.()` seam pattern, adapted to ES modules. Delete this
// import the day the named one can carry it.
import * as weather from './weather.js';
import { SailRig, createSailView } from './sail.js';
import { createPlayer } from './player.js';
import { Interact, wireYardActions } from './interact.js';
@ -322,10 +329,18 @@ export function verdictFor({ hp, lost, win, dmg, pondPeak, pondDumped, beyondSav
* partition semantics are exactly the kind of rule that would otherwise only
* ever be "tested" by a green suite that never ran it.
*
* 🚨 SPRINT18 gate 1 `theirs` marks a corner that ARRIVED BROKEN on an emergency
* callout, and it is not a cosmetic flag. Without it the ledger books TOMORROW'S
* WARRANTY on a cowboy's carabiner: you would drive back, unpaid, to redo work
* you never did, and your standing would drop for a corner that was on the ground
* before you got out of the ute. week.js's `warrantyItems()` and the rep block
* both skip it. See the ruling in scoreRun.
*
* @param {Array} corners rig.corners at dawn
* @param {Map<string,{broke:number,repaired:number}>} [tally] the night's break/repair events per anchor
* @param {Set<string>} [theirs] anchor ids that arrived broken (emergency callouts)
*/
export function rigRecordFor(corners, tally = new Map()) {
export function rigRecordFor(corners, tally = new Map(), theirs = new Set()) {
return corners.map((c) => {
const tal = tally.get(c.anchorId);
const broke = (tal?.broke ?? 0) > 0 || !!c.broken;
@ -337,6 +352,7 @@ export function rigRecordFor(corners, tally = new Map()) {
broke,
repaired: broke && !c.broken,
brokenAtDawn: !!c.broken,
theirs: theirs.has(c.anchorId),
};
});
}
@ -361,13 +377,33 @@ export function createGame() {
on(type, fn) { return emitter.on(type, fn); },
/** @param {'forecast'|'prep'|'storm'|'aftermath'} next */
setPhase(next) {
/**
* @param {'forecast'|'prep'|'storm'|'aftermath'} next
* @param {{at?:number}} [opts] 🚨 SPRINT18 gate 1 THE EMERGENCY CALLOUT'S
* ONE LINE OF ENGINE WORK. `at` opens the phase clock at that many seconds
* instead of 0, which is the entire mechanism behind *"a night that starts
* in storm"*: ROADMAP sized this gate as *"the storm phase already does
* everything this needs except spawn you at t=30 with a rig you didn't
* build"*, and this is the t=30 half.
*
* Everything downstream is untouched BY CONSTRUCTION rather than by care,
* which is why it is one argument here and not a new phase:
* · `windTime()` already returns `phaseT` during a storm, so the wind, the
* sky, the hail and the storm's own authored events are all mid-flight
* the moment you get out of the ute you do not arrive at a calm yard
* that then starts blowing.
* · `tick()` already ends the storm at `phaseT >= STORM_LEN`, so an
* emergency night is `STORM_LEN at` long and no clock needed telling.
* · `phaseChange` still fires with the same payload, so every listener
* (sky rebuild, rigging deactivation, pond/tally resets) runs as usual.
*/
setPhase(next, { at = 0 } = {}) {
if (!PHASES.includes(next)) throw new Error(`unknown phase '${next}'`);
if (next === phase) return;
if (!(at >= 0) || !Number.isFinite(at)) throw new Error(`setPhase('${next}', {at}) — at must be a finite number ≥ 0, got ${at}`);
const from = phase;
phase = next;
phaseT = 0;
phaseT = at;
emitter.emit('phaseChange', { from, to: next });
},
@ -849,12 +885,41 @@ export async function boot(opts = {}) {
/** What the storm cost, assembled at the moment it ends. */
function scoreRun() {
/**
* 🚨 **SPRINT18 gate 1 THE RULING THAT MAKES AN EMERGENCY INVOICE HONEST:
* A CORNER THAT WAS ALREADY DOWN WHEN YOU ARRIVED IS THE JOB, NOT YOUR
* FAILURE.** It bills you nothing, wrecks nothing, books no warranty and costs
* no standing it is the REASON there is a callout.
*
* Without this, the shape of an emergency night is grotesque and would have
* shipped looking correct: you turn up at somebody else's collapsing rig and
* the invoice charges you $180 for the carport the cowboy was already losing,
* bills you for his $5 carabiner, docks your standing for his corner, and then
* books you a free warranty callout tomorrow to fix his work. The paper has to
* split the blame, and this repo already knows how to say it the icenight's
* invoice does exactly this ("The shackle at P2 letting go is the part that's
* on you").
*
* Two lists, deliberately, because they answer two different questions:
* · `lost` every corner down at dawn. This is REPORTING (the HUD's
* "corners intact" count), and it must stay honest: reading 4/4 over a
* sail with three corners on the ground would be its own lie.
* · `yours` the ones you are ANSWERABLE for. Billing, collateral and the
* subtitle read this.
*/
const em = week.job.emergency;
const theirs = new Set((em?.rig ?? []).filter((c) => c.broken).map((c) => c.anchorId));
const lost = rig.corners.filter((c) => c.broken);
const bill = lost.reduce((s, c) => s + c.hw.cost, 0);
const yours = lost.filter((c) => !theirs.has(c.anchorId));
/** Their corners still on the ground at dawn — the invoice names them, unbilled. */
const inherited = lost.filter((c) => theirs.has(c.anchorId))
.map((c) => ({ anchorId: c.anchorId, hw: c.hw.name }));
const bill = yours.reduce((s, c) => s + c.hw.cost, 0);
const collateral = [];
// The gnome is collateral if the sail came down over it. DESIGN.md: the
// worst debris in any storm is your own failed work.
if (lost.length >= 2) collateral.push({ what: 'garden gnome', cost: world.gnome.collateralValue });
// worst debris in any storm is your own failed work. `yours`, not `lost`:
// two corners on the ground when you ARRIVED is not the sail you dropped.
if (yours.length >= 2) collateral.push({ what: 'garden gnome', cost: world.gnome.collateralValue });
/**
* SPRINT11 the carport trap finally BILLS (§gate 1).
@ -875,7 +940,7 @@ export async function boot(opts = {}) {
* gone, not $360. It bills once and the wreck swaps once.
*/
const taken = new Set();
for (const c of lost) {
for (const c of yours) {
const key = world.anchor(c.anchorId)?.collateral;
const priced = key ? world.collateralFor(key) : null;
if (!priced || taken.has(key)) continue;
@ -931,9 +996,13 @@ export async function boot(opts = {}) {
collateral,
budgetLeft: Math.max(0, s.budget - bill - collateral.reduce((a, c) => a + c.cost, 0)),
win,
subtitle: lost.length
? `${lost.map((c) => `${c.hw.name} at ${c.anchorId.toUpperCase()}`).join(', ')} let go.`
: 'Every corner held.',
subtitle: yours.length
? `${yours.map((c) => `${c.hw.name} at ${c.anchorId.toUpperCase()}`).join(', ')} let go.`
: inherited.length
// An emergency you held: nothing of yours went, and the corner that was
// already down stays named rather than quietly counted as a hold.
? `Nothing of yours let go. ${inherited.map((c) => c.anchorId.toUpperCase()).join(', ')} was on the ground when you got there.`
: 'Every corner held.',
/** Decision 13's headline: how much of the bed's damage the cloth stopped. */
hailBlocked: dmg.hail + dmg.rain > 0
? `${Math.round(dmg.hail)} HP to hail, ${Math.round(dmg.rain)} to rain`
@ -944,16 +1013,31 @@ export async function boot(opts = {}) {
* that rode out a gale isn't new any more. Broken gear is worth nothing,
* which is the whole of `bill`.
*/
intactHardwareValue:
rig.corners.filter((c) => !c.broken).reduce((sum, c) => sum + c.hw.cost, 0)
+ (rigging.session.spares ?? 0) * SPARE_COST,
/**
* 🚨 SPRINT18 gate 1 RULED: **ON AN EMERGENCY, THE INTACT HARDWARE IS
* NOT YOURS TO UNCLIP.** It is the cowboy's steel in the client's yard, and
* you leave it there. Only the spare out of your own ute comes home.
*
* This is also the check that keeps `emergencyRate: 2.0` from being free
* money: without it, a double-rate callout would ALSO hand you half of
* somebody else's four rated shackles at dawn, and the biggest earner in the
* game would be turning up to other people's disasters and salvaging them.
*/
intactHardwareValue: em
? (rigging.session.spares ?? 0) * SPARE_COST
: rig.corners.filter((c) => !c.broken).reduce((sum, c) => sum + c.hw.cost, 0)
+ (rigging.session.spares ?? 0) * SPARE_COST,
pondPeak,
pondDumped,
verdict,
/** Which failure mode the verdict is speaking from. Asserted in a.test.js. */
verdictMode: mode,
/** SPRINT16 gate 1 — the work history. See rigRecordFor above. */
rigRecord: rigRecordFor(rig.corners, nightTally),
rigRecord: rigRecordFor(rig.corners, nightTally, theirs),
/** SPRINT18 gate 1 — their corners, still down, named on the invoice and unbilled. */
inherited,
/** SPRINT18 gate 1 — D's knife. Tallied off rig.events; see the cutAway note. */
cutAways: cutAwayLog.slice(),
};
}
@ -1082,6 +1166,56 @@ export async function boot(opts = {}) {
}));
}
/**
* 🚨 SPRINT18 gate 1 **THE RIG YOU DIDN'T BUILD.** Someone else's work,
* installed from `night.emergency.rig` (B's data) before the storm opens.
*
* Through the session's own PUBLIC MOVES, exactly as `resetRig()` is, and for
* the same reason: the economy stays the single source of truth and nothing
* here reaches into B's fields. Two things worth stating out loud:
*
* **1. THE UTE CARRIES ONE SPARE. RULED (A).** You had no prep phase, so you
* bought nothing but a tradie's ute has a shackle rolling around in it, and
* without one, triage is a verb with no object: you would arrive at a broken
* corner unable to do the only thing the job is for. It is FREE, and the way it
* is made free is visible arithmetic rather than a private door: bank the
* session at `week.bank + SPARE_COST`, buy the spare, and the session's budget
* lands back on exactly `week.bank`. `spentThisNight` therefore reads $0, which
* is the truth there was no shop. (B: if you would rather expose a
* `grantSpare()`, say so in THREADS and I will move to it.)
*
* **2. THE PRE-BROKEN CORNER IS B'S DOOR TO OPEN.** `broken: true` needs more
* than a flag sail.js's own comment says so: a break must also hand the node
* its mass back (`invMass`), dump the pond and emit, or the corner stays welded
* in mid-air instead of flying. There is no public `breakCorner`, so this calls
* one feature-detected (`setConstraints?.()`'s pattern) and the night does not
* depend on it: the authored corner is a $5 carabiner on the carport beam, which
* is ~264 N effective under a southerly, so it fails within seconds THROUGH THE
* REAL OVERLOAD PATH whether or not the door exists. Emergent beats poked.
*/
async function adoptEmergencyRig(em) {
const s = rigging.session;
resetRig();
s.setBudget(week.bank + SPARE_COST);
for (const c of em.rig ?? []) {
s.rig(c.anchorId);
const hw = HARDWARE.find((h) => h.name === c.hw);
if (hw) s.setHardware(c.anchorId, hw);
}
s.setSpares(1);
if (!rigging.commit()) {
// Loud, not silent: an emergency whose rig would not commit is a night with
// no sail in it, and the player would be standing in a storm wondering what
// they were called out for.
throw new Error(`main: the emergency rig would not commit — ${em.rig?.length ?? 0} corners `
+ `on ${week.site}. Check every anchorId exists in that yard's site JSON.`);
}
// Corners that ARRIVED gone. Feature-detected; paper-only until B's door lands.
for (const c of em.rig ?? []) {
if (c.broken) rig.breakCorner?.(c.anchorId, { reason: 'arrived' });
}
}
async function showTonight() {
// SPRINT10: tonight's yard. loadSiteInto no-ops when the site is unchanged
// (the four backyard nights) and rebuilds + re-points everything holding the
@ -1109,6 +1243,56 @@ export async function boot(opts = {}) {
// doesn't, this line is the first suspect.
rigging.session.setConstraints?.(week.job?.constraints ?? []);
spentThisNight = 0;
/**
* 🚨 SPRINT18 gate 1 **THE FORK: A DISPATCH INSTEAD OF A JOB SHEET.** The one
* branch this whole gate needed in the phase flow, and it is deliberately the
* last thing in the morning rather than a new phase: everything above this line
* (the yard, the bank, the client's terms) is identical for both job types,
* because an emergency IS a job it just isn't a quote.
*
* Two orderings in here are load-bearing:
* · `stormKey` is set BEFORE the phase change, exactly as the ordinary path
* does on its GO button, because `phaseChange` points the wind router at
* `winds[stormKey]`. Set it after and you would stand in a calm yard while
* the card said gale.
* · **the rig is adopted on the BUTTON, not before the card.** The forecast
* phase's own handler runs `showMorning().then(() => { garden.reset();
* resetRig(); })`, and `resetRig()` would strip an emergency rig installed
* any earlier a night with no sail in it, and the player standing in a
* storm wondering what they were called out for. It is also better fiction:
* you roll, and THEN you are there.
*/
const em = week.job.emergency;
if (em) {
stormKey = week.stormKey;
const emDef = defs[week.stormKey];
hud.showDispatch(
{
job: week.job,
quote: week.quote(emDef),
site: siteMeta[week.site],
/**
* C's seam (THREADS [A] 2026-07-25 §8): what the sky and the grass tell
* you when no paper briefed you. Called through the NAMESPACE import on
* purpose a named import of a function lane/c has not exported yet is a
* hard module error, not a graceful no-op, so `weather.midStormRead?.()`
* is the `setConstraints?.()` pattern adapted to ES modules. Until it
* lands the card prints one honest italic line saying nobody briefed you.
*/
read: emDef ? (weather.midStormRead?.(emDef, em.arriveAt) ?? null) : null,
},
{ night: week.night, nights: week.nights, bank: week.bank, rep: week.rep },
() => {
adoptEmergencyRig(em).then(() => {
spentThisNight = 0; // there was no shop; the ute's spare is free.
game.setPhase('storm', { at: em.arriveAt });
});
},
);
return;
}
// SPRINT11 — the job sheet reads three new things off the week: tonight's
// JOB (client + brief), the QUOTE (what it pays, before you rig it, which is
// the half of DESIGN.md's brief the game never showed), and TOMORROW's storm
@ -1166,6 +1350,31 @@ export async function boot(opts = {}) {
rig.events.on('break', (e) => { tallyFor(e.anchorId).broke++; });
rig.events.on('repair', (e) => { tallyFor(e.anchorId).repaired++; });
/**
* 🚨 SPRINT18 gate 1 **D'S KNIFE, THE TALLY AND THE PAPER HALF.** DESIGN.md
* line 165: *"knife (cut a sail loose — lose the sail, save the anchors)"*, and
* the signature decision under load: **release, cut, or ride it out.**
*
* The seam agreed in THREADS [A] 2026-07-25 §9: **D owns the verb, the input and
* the HUD read; I own the tally, the settlement field and the line on the
* paperwork.** D emits `cutAway` on `rig.events` with `{t, corners, reason?}`
* the same Emitter `break`/`repair`/`pondDump` already ride, so this needed no
* new wiring on either side. Subscribed once (the Emitter survives a re-rig) and
* cleared at storm start, exactly like nightTally above: a cut on the calm day
* is not the night's story.
*
* Principle 2, no exceptions a verb with no line on the paperwork is a verb the
* game does not admit to. Until D's input lands this log stays empty and every
* surface renders nothing, which is the degradation rule rather than a stub.
* @type {{t:number, corners:string[], reason:string|null}[]}
*/
const cutAwayLog = [];
rig.events.on('cutAway', (e) => {
const corners = Array.isArray(e?.corners) ? [...e.corners] : (e?.anchorId ? [e.anchorId] : []);
cutAwayLog.push({ t: e?.t ?? game.phaseT, corners, reason: e?.reason ?? null });
pushEvent(`sail cut loose${corners.length ? ` at ${corners.map((c) => String(c).toUpperCase()).join(', ')}` : ''}`);
});
// --- phases -------------------------------------------------------------
game.on('phaseChange', ({ to }) => {
// Prep and forecast happen on the calm day; the storm you picked only
@ -1186,7 +1395,7 @@ export async function boot(opts = {}) {
if (paused) { paused = false; hud.setPaused(false); }
if (muted) setMuted(true);
if (to === 'storm') { pondPeak = 0; pondDumped = 0; nightTally.clear(); }
if (to === 'storm') { pondPeak = 0; pondDumped = 0; nightTally.clear(); cutAwayLog.length = 0; }
if (to === 'forecast') {
hud.setDawn(false);

View File

@ -29,6 +29,8 @@ import {
CONSTRAINT_PREMIUM, POOL, exposureOf, validateConstraint,
// SPRINT18 gate 0 — the ruling's structural half: who a take stands up.
isBooked, standUpsIfTaken,
// SPRINT18 gate 1 — the emergency callout, and the one-variable law in code.
EMERGENCIES, oneVariableOffences, validateEmergency,
} from '../board.js';
import { createHud } from '../hud.js';
import { PALETTE, boundsFaults, emptyTemplate, exportSiteJSON, placeEntry } from '../editor.js';
@ -774,6 +776,23 @@ export default async function run(t) {
// 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');
// ⚠️ A GAP IN MY OWN PIN, FOUND BY PROBING IT (A, S18 mutation M7). Deleting
// the `repDelta += REP.STOOD_UP` line and nothing else left the REASON still
// printing on the invoice while the NUMBER never moved — "stood up the
// Hendersons" over standing that didn't budge, which is the paper lying in
// the most persuasive way available to it. The asserts above caught it, but
// only through the net-to-level arithmetic. This is the DIFFERENTIAL that
// says it directly: the same night, kept, must leave you strictly better off.
const kept = createWeek();
const keptOffers = kept.offers();
kept.take(keptOffers[0]); // took the booking — nobody stood up
const sKept = kept.settle({ hp: 100, win: true, collateral: [], intactHardwareValue: 0, rigRecord: [] },
boardDefs[keptOffers[0].night.storm] ?? def, 0);
assertEq(sKept.stoodUp.length, 0, 'precondition: keeping your word stands nobody up');
assertLess(s.rep, sKept.rep, 'a broken promise must leave you BELOW the same night kept');
assertEq(Number((sKept.rep - s.rep).toFixed(2)), Math.abs(REP.STOOD_UP),
'and below it by exactly the ruled amount — a reason with no movement behind it is the paper lying');
});
t.test('SPRINT18 gate 0: the stood-up client REMEMBERS — and one decision is charged once', () => {
@ -866,6 +885,220 @@ export default async function run(t) {
} finally { hud.dispose(); }
});
// --- 🚨 SPRINT18 gate 1: THE EMERGENCY CALLOUT (A's spine) ----------------
t.test('SPRINT18 gate 1: a night that STARTS in storm — setPhase({at}) is the whole engine change', () => {
const g = createGame();
// ROADMAP sized this gate as "the storm phase already does everything this
// needs except spawn you at t=30 with a rig you didn't build". This is the
// t=30 half, and the pin is that NOTHING ELSE had to move.
g.setPhase('storm', { at: 30 });
assertEq(g.phase, 'storm', 'an emergency night opens in the storm, not in prep');
assertEq(g.phaseT, 30, 'and the storm clock opens where the storm already is');
// The storm is STORM_LEN long TOTAL, not STORM_LEN long from your arrival —
// you get what is left of somebody else's night. 90 30 = 60.
g.tick(STORM_LEN - 30 - 1);
assertEq(g.phase, 'storm', 'still in it a second before the end');
g.tick(2);
assertEq(g.phase, 'aftermath', `an emergency night is STORM_LEN at long, not STORM_LEN`);
// The ordinary path is untouched — `at` defaults to 0, so every existing
// phase change opens on zero exactly as it always did.
const g2 = createGame();
g2.setPhase('prep'); g2.setPhase('storm');
assertEq(g2.phaseT, 0, 'an ordinary storm still starts at t=0');
// And a nonsense clock is a loud content bug, not a silent NaN storm.
let threw = '';
try { createGame().setPhase('storm', { at: NaN }); } catch (err) { threw = err.message; }
assert(threw.includes('at must be'), `a NaN arrival must fail loud, got: ${threw || '(no throw)'}`);
});
t.test('SPRINT18 gate 1: the emergency is on the board, priced at DOUBLE, and stands nobody up', () => {
if (!boardDefs.storm_03_southerly) return 'SKIPPED — no server for storm defs';
const em = EMERGENCIES[0];
assert(em, 'the sprint ships one emergency');
const w = createWeek();
for (let i = 0; i < em.night - 1; i++) w.advance();
const offers = w.offers();
assertEq(offers.length, 2, 'an emergency morning still offers two jobs');
assertEq(offers[1].kind, 'callout', 'its provenance is the phone — the most callout-y callout there is');
assertEq(offers[1].night.storm, em.storm, 'and it IS the scheduled emergency, not a seeded draw');
assertEq(offers[1].night.site, em.site);
assert(offers[1].night.emergency, 'the costume comes off night.emergency — one source of truth');
// ⚖️ DOUBLE RATES: DESIGN.md line 29 and ROADMAP arc 2, in those words.
const def = boardDefs[em.storm];
const plain = calloutFee(def, REP.START, []);
const emergency = calloutFee(def, REP.START, [], true);
assertEq(emergency, Math.round(plain * PAY.emergencyRate),
'an emergency pays the ruled multiple of the ordinary fee, through the SAME calloutFee()');
assertEq(PAY.emergencyRate, 2.0, 'and the multiple is the canon 2.0 — "double rates", DESIGN.md line 29');
// gate 0 × gate 1, the join: taking it stands up the booked client (it is a
// callout), and the emergency itself is NOT a booking anybody can be stood
// up over. Both halves, on one board.
assertEq(offers[0].booked, true, 'the wildnight is still a booking');
assertEq(offers[1].booked, false, 'nobody booked you for an emergency — the phone rang');
assertEq(offers[1].standsUp.length, 1, 'so taking it stands the booked client up');
assertEq(offers[1].standsUp[0].client, nightAt(em.night - 1).client);
});
t.test('SPRINT18 gate 1: there is NO QUOTE on an emergency, and the ledger runs UNCHANGED anyway', () => {
if (!boardDefs.storm_03_southerly) return 'SKIPPED — no server for storm defs';
const em = EMERGENCIES[0];
const def = boardDefs[em.storm];
const w = createWeek();
for (let i = 0; i < em.night - 1; i++) w.advance();
w.take(w.offers()[1]);
assert(w.job.emergency, 'the taken emergency IS the night — normaliseNight carried it');
assertEq(w.job.emergency.arriveAt, em.emergency.arriveAt, 'with its arrival clock intact');
const q = w.quote(def);
assert(q.emergency, 'the quote carries the emergency block the dispatch renders');
assertEq(q.emergency.rate, PAY.emergencyRate);
assertEq(q.base, calloutFee(def, REP.START, [], true), 'the dispatch prints the doubled fee');
// ⚠️ THE ACCEPTANCE CRITERION FOR THE WHOLE GATE (THREADS [A] §10): the ledger
// runs on an emergency night through the code it already had. If any of these
// needed a branch on "was this an emergency", the SHAPE is wrong.
const s = w.settle({ hp: 72, win: true, collateral: [], intactHardwareValue: 15,
rigRecord: [], inherited: [{ anchorId: 'cb1', hw: 'carabiner' }], cutAways: [] }, def, 0);
assertEq(q.base, s.fullFee, 'quote==settle holds on a night nobody quoted');
assertEq(s.pay, s.fee + s.bonus + s.refund + s.clean - s.collateral - s.warrantyTotal,
'the ledger adds up on an emergency exactly as it does on a scripted night');
assert(s.rep != null && s.grade != null && s.bankAfter != null, 'rep, grade and the bank all computed');
assertEq(s.emergency.rate, PAY.emergencyRate, 'and the invoice records that it was a callout, at what rate');
assertEq(s.inherited.length, 1, 'and names the corner that was already down');
});
t.test('SPRINT18 gate 1: a corner that ARRIVED broken bills nothing, wrecks nothing, books no warranty, costs no standing', () => {
// ⚖️ A's ruling, and the pin exists because the wrong version would have
// shipped looking correct: you turn up at somebody else's collapsing rig and
// the invoice charges you for the cowboy's carabiner, docks your standing for
// his corner, and books you a free warranty callout tomorrow to fix his work.
const theirs = new Set(['cb1']);
const corner = (id, broken) => ({
anchorId: id, hw: { name: 'carabiner', cost: 5, rating: 1200 },
anchor: { ratingHint: 0.22 }, broken,
});
const rec = rigRecordFor([corner('cb1', true), corner('q3', true), corner('tr1', false)],
new Map(), theirs);
assertEq(rec[0].theirs, true, 'the inherited corner is marked as theirs');
assertEq(rec[1].theirs, false, 'and yours is not');
assertEq(rec[0].brokenAtDawn, true, 'it is still honestly recorded as down — the record does not lie');
// The WARRANTY half: last dawn's inherited corner books nothing tonight.
const def = { baseCurve: [[0, 10], [10, 20]], gusts: {} };
const w = createWeek();
w.settle({ hp: 80, win: true, collateral: [], intactHardwareValue: 0, rigRecord: rec }, def, 0);
const q = w.quote(def);
assertEq(q.warranty.length, 1,
`only YOUR corner books a warranty — got ${JSON.stringify(q.warranty.map((i) => i.anchorId))}`);
assertEq(q.warranty[0].anchorId, 'q3', 'and it is the one you were answerable for');
// The STANDING half: two corners down at dawn, one of them theirs, so the rep
// hit is for ONE. Read off the settlement that produced the record above.
const s = w.log[0];
assertEq(s.repReasons.some((r) => r.includes('1 corner down at dawn')), true,
`standing moves for one corner, not two — got ${JSON.stringify(s.repReasons)}`);
assertEq(s.rep, REP.START + REP.WARRANTY, 'exactly one warranty step, not two');
});
t.test('SPRINT18 gate 1: ⚠️ THE ONE-VARIABLE LAW IS ENFORCED IN CODE, and it accuses out loud', () => {
// SPRINT18's spec: "A's ordering must make that impossible to get wrong by
// accident." A comment cannot do that. This walks the SHIPPED data...
assertEq(oneVariableOffences({ nights: NIGHTS }).length, 0,
`the shipped week breaks the law: ${oneVariableOffences({ nights: NIGHTS }).join(' | ')}`);
// ...and the shipped emergency's yard really is one an earlier night rigs,
// which is the fact the law is protecting rather than a restatement of it.
const em = EMERGENCIES[0];
const firstRig = NIGHTS.findIndex((n) => (n.site ?? 'backyard_01') === em.site);
assert(firstRig >= 0 && firstRig < em.night - 1,
`the emergency arrives night ${em.night} over ${em.site}, first rigged night ${firstRig + 1}`);
// ⚠️ THE VACUITY GUARD — three ways to break the law, three accusations. A
// law that cannot say no is decoration, and this repo has shipped one before.
const glass = { storm: 'storm_03_southerly', site: 'site_99_glasshouse', client: 'x', addr: 'y' };
const asEmergency = { ...glass, emergency: { arriveAt: 30, dispatch: 'x'.repeat(30), rig: [] } };
const o1 = oneVariableOffences({ nights: [...NIGHTS, asEmergency] });
assert(o1.some((m) => m.includes('site_99_glasshouse') && m.includes('debut')),
`a NIGHTS emergency over an unrigged yard must be an offence, got ${JSON.stringify(o1)}`);
const o2 = oneVariableOffences({ nights: NIGHTS, emergencies: [{ ...asEmergency, night: 2, id: 'too_early' }] });
assert(o2.some((m) => m.includes('too_early')),
`an EMERGENCIES entry over an unrigged yard must be an offence, got ${JSON.stringify(o2)}`);
const o3 = oneVariableOffences({ nights: NIGHTS, pool: [{ ...asEmergency, id: 'pool_glass' }] });
assert(o3.some((m) => m.includes('pool_glass') && m.includes('no NIGHTS')),
`a POOL emergency over a yard in no NIGHTS slot must be an offence — this is the rule that `
+ `protects E's glasshouse from becoming the emergency's debut stage. Got ${JSON.stringify(o3)}`);
// And an emergency over a yard the ladder DOES rig early is legal, or the law
// would be refusing the thing it exists to permit.
assertEq(oneVariableOffences({ nights: NIGHTS,
emergencies: [{ ...EMERGENCIES[0], night: 7 }] }).length, 0,
'the law permits an emergency over an already-rigged yard');
});
t.test('SPRINT18 gate 1: a malformed emergency fails LOUD at the board, not 30s into a storm', () => {
// validateConstraint's precedent. A content bug must be a crash on the morning
// card, never a runtime surprise inside a storm the player cannot leave.
const ok = { storm: 'storm_01_gentle', site: 'backyard_01', emergency: EMERGENCIES[0].emergency };
validateEmergency(ok, 'fixture'); // the shipped shape passes
for (const p of POOL) validateEmergency(p, p.id);
for (const e of EMERGENCIES) validateEmergency(e, e.id);
const bad = (em, needle) => {
let msg = '';
try { validateEmergency({ ...ok, emergency: { ...ok.emergency, ...em } }, 'fixture'); }
catch (err) { msg = err.message; }
assert(msg.includes(needle), `expected "${needle}" in the failure, got: ${msg || '(no throw)'}`);
};
bad({ arriveAt: 0 }, 'arriveAt'); // arriving at t=0 is a prep night wearing a siren
bad({ dispatch: 'too short' }, 'dispatch'); // no forecast, no quote — the call IS the brief
bad({ rig: [] }, 'rig'); // an emergency with no rig is not a rig you didn't build
bad({ rig: [{ hw: 'carabiner' }, {}, {}, {}] }, 'anchorId');
// A night with NO emergency block passes untouched — every scripted night.
for (let i = 0; i < NIGHTS.length; i++) validateEmergency(nightAt(i), `night ${i + 1}`);
});
t.test('SPRINT18 gate 1: the DISPATCH is not a job sheet — no schedule, no total, and it says why', () => {
if (typeof document === 'undefined') return 'SKIPPED — no DOM';
// The S12 lesson: both of that sprint's card bugs were invisible to a suite
// that never rendered a card. This card's whole design is what it REFUSES to
// print, and a refusal is only checkable by rendering it.
const def = { baseCurve: [[0, 10]], gusts: { powBase: 5, powRamp: 5 } };
const em = EMERGENCIES[0];
const w = createWeek();
for (let i = 0; i < em.night - 1; i++) w.advance();
w.take(w.offers()[1]);
const hud = createHud({ scene: new THREE.Scene() });
try {
hud.showDispatch({ job: w.job, quote: w.quote(def), site: null, read: null },
{ night: w.night, nights: w.nights, bank: w.bank, rep: w.rep }, () => {});
const qs = (sel) => document.querySelector(`#hud-card ${sel}`);
assertEq(qs('.letterhead .docket .kind')?.textContent, 'DISPATCH', 'the fourth paper in the chain');
assert((qs('.letterhead .docket .no')?.textContent ?? '').includes('CALL '),
'a dispatch is a CALL, not a JOB or an INV — one outfit, four papers');
assert((qs('.siren')?.textContent ?? '').includes('no quote'),
'it says out loud that nobody quoted this');
assert(qs('.call'), 'the phone call is the brief, because there is no other one');
// The REFUSALS, which are the design.
const body = qs('.jobcard')?.textContent ?? '';
assert(!/AMOUNT DUE|garden bonus|clean site/i.test(body),
'a card for a job nobody costed must not print a schedule or a total');
assert(!qs('.row.due'), 'and it must not conclude — you find out on the invoice');
// The one number it DOES print, and the rate it came from.
assert(body.includes(`${PAY.emergencyRate}\u00d7 the going rate`),
`the rate is the reason you get in the ute — not found in the card`);
assert(body.includes(`$${w.quote(def).base}`), 'and the fee it applies to is on the card');
// DESIGN.md's tell rule: the inherited rig is NAMED before you go, and the
// corner already on the ground is marked. Brutal is allowed; surprising is not.
const down = [...document.querySelectorAll('#hud-card .theirs.down')];
assertEq(down.length, em.emergency.rig.filter((c) => c.broken).length,
'every corner that arrives on the ground is marked as such on the dispatch');
assert(down[0].textContent.includes('ON THE GROUND'), 'in words, not by colour alone');
// No read from C yet ⇒ one honest line, not an empty section.
assert((qs('.noread')?.textContent ?? '').includes('Nobody briefed you'),
'with no mid-storm read available the card is honest about it rather than blank');
} 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

@ -231,6 +231,24 @@ export function normaliseNight(entry) {
* field is `[]` on all seven nights and nothing about the campaign moves.
*/
constraints: base.constraints ?? [],
/**
* 🚨 SPRINT18 gate 1 THE EMERGENCY CALLOUT, as data on the night.
*
* `null` by default, and null rather than `{}` on purpose the opposite
* ruling to `constraints` above, for the opposite reason. Constraints are
* ITERATED by four surfaces, so an empty array saves three of them a guard.
* An emergency is BRANCHED ON (is tonight a dispatch or a job sheet?), and a
* truthy empty object is the worst possible answer to that question: every
* caller would read "yes" and then find no arriveAt and no rig. `null` makes
* the branch honest.
*
* The whole point of it living HERE is that it rides `normaliseNight`, so an
* emergency taken off the board is the same SHAPE as an authored night and
* every downstream surface sheet, quote, invoice, ledger, warranty, rep,
* end card reads it through the path it already had. That is the same
* promise gate 1 of SPRINT17 made about the board, extended to a job type.
*/
emergency: base.emergency ?? null,
};
}
@ -259,12 +277,19 @@ export function nightAt(i) {
* on the rep multiplier rather than on the raw fee a 5 tradie is paid more
* for accepting the same ban, which is the right way round.
*
* 🚨 SPRINT18 gate 1 the fourth term is EMERGENCY RATES, and it is deliberately
* a term on THIS function rather than a second fee function. The offer card, the
* dispatch and the invoice all price an emergency through here, so a night that
* pays double on the board cannot pay single on the paper.
*
* @param {object} def tonight's storm def
* @param {number} rep standing AS OF THIS MORNING
* @param {object[]} [constraints] tonight's client constraints
* @param {boolean} [emergency] mid-storm callout double rates (PAY.emergencyRate)
*/
export function calloutFee(def, rep, constraints = []) {
return Math.round(PAY.feeFor(gustPeakOf(def)) * REP.multiplier(rep) * (1 + premiumFor(constraints)));
export function calloutFee(def, rep, constraints = [], emergency = false) {
return Math.round(PAY.feeFor(gustPeakOf(def)) * REP.multiplier(rep)
* (1 + premiumFor(constraints)) * (emergency ? PAY.emergencyRate : 1));
}
/**
@ -341,6 +366,34 @@ export const PAY = {
* and cutting it costs nothing but a row on the invoice.
*/
noCollateralBonus: 20,
/**
* 🚨 **SPRINT18 gate 1 EMERGENCY RATES. RULED ×2.0, and this one is CANON
* rather than my taste.** DESIGN.md line 29 says *"mid-storm, right now, double
* rates"* and ROADMAP arc 2 says *"you arrive MID-STORM at double rates"* in
* those words, since line one of the design. I am not going to re-rule a number
* the document has been carrying for the whole project because it looks large
* next to `feeFor`.
*
* And it is not free money, which is the check I actually ran on it. What you
* give up for it:
* · **no prep.** No shop, no quote, no chance to have bought good steel.
* · **somebody else's hardware**, chosen by somebody cheaper than you, and NOT
* yours to unclip at dawn (see scoreRun's ruling in main.js — an emergency's
* intact hardware does not come home, so half the usual refund line is gone).
* · **the yard's whole collateral exposure already live** from the second you
* arrive, on corners you did not pick, with sixty seconds on the clock.
* A southerly pays $57 rig-for-tonight and $114 as a callout roughly one and
* a half good rigs, for a night where the rig is already wrong and the $180
* carport is the thing letting go.
*
* **UNMEASURED BY PLAY**, cut-first alongside `CONSTRAINT_PREMIUM` and REP,
* and it is the FIRST thing to look at if gate 6 says the emergency is the
* obviously-correct pick every time it appears. Per-job data would be the next
* move (a `pay.emergencyRate` override, exactly as `pay.garden` works), not a
* quiet trim of `feeFor`, which carries measured evidence and long reasons.
*/
emergencyRate: 2.0,
};
/**
@ -587,7 +640,13 @@ export function createWeek(opts = {}) {
function warrantyItems() {
const prev = log[log.length - 1];
if (!prev) return [];
return (prev.rig ?? []).filter((c) => c.brokenAtDawn).map((c) => ({
// 🚨 SPRINT18 gate 1 — `!c.theirs`: a corner that was already on the ground when
// you arrived at an emergency callout books NO warranty. Without this line the
// ledger sends you back, unpaid, to redo a cowboy's work — the game billing you
// for a failure that predates you, which is the exact opposite of what the
// warranty ledger was built to say. main.js's rigRecordFor sets the flag; the
// rep block below skips it for the same reason.
return (prev.rig ?? []).filter((c) => c.brokenAtDawn && !c.theirs).map((c) => ({
anchorId: c.anchorId, hw: c.hw, cost: c.hwCost,
night: prev.night, site: prev.site, addr: prev.addr, client: prev.client,
}));
@ -631,7 +690,7 @@ export function createWeek(opts = {}) {
// invoice. (Before this it was two hand-written copies of the same
// formula agreeing by luck; adding a third term to both by hand is how
// that luck runs out.)
base: calloutFee(def, rep, j.constraints),
base: calloutFee(def, rep, j.constraints, !!j.emergency),
garden: j.pay.garden ?? PAY.gardenBonusMax,
clean: j.pay.clean ?? PAY.noCollateralBonus,
feeMult,
@ -647,6 +706,23 @@ export function createWeek(opts = {}) {
*/
constraints: j.constraints ?? [],
premium: premiumFor(j.constraints),
/**
* 🚨 SPRINT18 gate 1 **THERE IS NO QUOTE ON AN EMERGENCY, and this block
* is how the paper says so.** The DISPATCH renders the RATE and the fee it
* applies to, because double rates are the reason you get in the ute and
* nothing else from this schedule. No garden bonus, no clean bonus, no
* total: nobody costed this job, and a card printing a total under a job
* nobody quoted would be inventing the one document the fiction says does
* not exist. You find out on the invoice. That IS the drama.
*
* It stays ONE money function all the same (`quote()` for both papers, off
* `calloutFee()`), because the alternative a second `dispatch()` that
* computes its own fee is the quote-vs-settle divergence with a new name.
* The dispatch is a VIEW of this object, never a rival to it.
*/
emergency: j.emergency
? { arriveAt: j.emergency.arriveAt, rate: PAY.emergencyRate, dispatch: j.emergency.dispatch }
: null,
/**
* SPRINT14 (B's pool item, offered S13 and accepted): the job sheet has
* to say when the garden bonus is money the night cannot pay.
@ -683,6 +759,11 @@ export function createWeek(opts = {}) {
offers(priced = null) {
return offersFor({
scripted: nightAt(index), weekSeed: seed, nightIndex: index, priced,
// SPRINT18 gate 1 — the ladder, so board.js can enforce the one-variable
// law at the door of the real game and not only in the suite. board.js
// cannot import this file (week imports board), so the law's input has to
// travel down rather than the law travelling up.
nights: NIGHTS,
});
},
@ -801,7 +882,7 @@ export function createWeek(opts = {}) {
// SPRINT17 gate 2: the SAME calloutFee() the quote printed, constraints
// and all — quote==settle by construction, extended to the premium the
// same way it was extended to the warranty deduction.
const fullFee = calloutFee(def, rep, jobAt(index).constraints);
const fullFee = calloutFee(def, rep, jobAt(index).constraints, !!jobAt(index).emergency);
const fee = run.win ? fullFee : Math.round(fullFee * PAY.lostNightShare);
// Per-job override, PAY's default otherwise — and settle MUST read the same
// source the quote does, or the job sheet promises a number the invoice
@ -856,8 +937,11 @@ export function createWeek(opts = {}) {
// cannot cost it BY CONSTRUCTION — the flag needs no exemption here, and
// a.test pins that from both directions.
const rigRec = run.rigRecord ?? [];
const brokeCount = rigRec.filter((c) => c.broke).length;
const dawnBroken = rigRec.filter((c) => c.brokenAtDawn).length;
// SPRINT18 gate 1 — their corners are not your record. A rig you inherited
// mid-storm cannot cost you a clean night for breaking before you got there,
// and cannot cost you standing for still being down at dawn.
const brokeCount = rigRec.filter((c) => c.broke && !c.theirs).length;
const dawnBroken = rigRec.filter((c) => c.brokenAtDawn && !c.theirs).length;
const collateralEvents = (run.collateral ?? []).length;
const repBefore = rep;
let repDelta = 0;
@ -928,6 +1012,13 @@ export function createWeek(opts = {}) {
// end card's rich-with-warranties reading without the HUD walking the
// log itself.
rig: rigRec,
// 🚨 SPRINT18 gate 1 — the emergency's own paperwork, carried on the
// settlement like `constraints` so the invoice stays true when re-read:
// whether tonight was a dispatch, what the rate was, whose corners were
// already down (named, unbilled), and whether you used the knife.
emergency: job.emergency ? { arriveAt: job.emergency.arriveAt, rate: PAY.emergencyRate } : null,
inherited: run.inherited ?? [],
cutAways: run.cutAways ?? [],
warranty, warrantyTotal, feeMult,
rep, repBefore, repDelta, repReasons,
warrantiesToDate: log.reduce((n, x) => n + (x.warranty?.length ?? 0), 0)