HardYards/web/world/js/board.js
type-two 82a52aa014 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>
2026-07-25 19:30:19 +10:00

730 lines
40 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* SHADES / HARD YARDS — THE JOB BOARD. Lane A owns this file.
*
* SPRINT17 gate 1, opening ROADMAP arc 2: *"mornings offer N callouts, you take
* one. Pay, risk, client and site visible before you commit — income becomes a
* choice."* Until now the week was a script: you were handed tonight and you
* rigged it. From here the week is a script you can step off.
*
* Zero imports beyond contracts + week, and no THREE — same charter as week.js,
* and for the same reason: an offer is pure data, so the board is testable at
* fixed cost in node with no browser and no WebGL context. The only thing that
* touches the screen is hud.js reading these objects.
*
* ── THE THREE RULES THIS FILE EXISTS TO KEEP ──────────────────────────────
*
* 1. **THE SCRIPTED SPINE SURVIVES.** Every morning offers tonight's authored
* night. It is both the campaign and the test fixture, and 474 asserts stand
* on it — so the board ADDS a second door, it never replaces the first. Take
* nothing and the week runs exactly as it ran before this file existed.
*
* 2. **DETERMINISTIC, FROM THE WEEK AND THE INDEX.** No Date.now, no
* Math.random — the repo's oldest rule (contracts.js §Determinism), and the
* selftest depends on it: an offer pool that draws from the clock is a suite
* that goes red on a Tuesday. Same week seed, same night, same two offers,
* forever. Seeded through `rng()` from contracts.js rather than a hash I'd
* have to justify — the house PRNG is already the thing every other seeded
* system in this repo reproduces against.
*
* 3. **THE CHOSEN JOB *IS* THE NIGHT — by construction, not by discipline.**
* An offer carries a WHOLE NIGHT ENTRY (`offer.night`), the same shape
* NIGHTS holds, and `week.take()` installs it at tonight's index. So the job
* sheet, the quote, the invoice, the ledger, warranty, rep and the end card
* all read a chosen alternative through the SAME `nightAt()`-shaped path
* they read a scripted night through — not one of them can tell the
* difference, and none of them needed a line changed. A board that bolted a
* parallel "callout" concept alongside the night would have had to teach
* seven surfaces about it, and one of them would have been missed. (a.test
* pins this from the paperwork end: a chosen alternative that skipped the
* ledger is the board lying.)
*/
import { rng } from './contracts.js';
/**
* What a client is allowed to demand. CHECKED, not documented — this repo's
* standing rule is that an unenforced enum is decoration (the carport shipped
* typed as a 'post' for a whole sprint behind a JSDoc comment that said it
* couldn't). `validateConstraint` below fails loud, and a.test flies a bogus
* kind through it to prove the check can fail.
*
* SPRINT17 gate 2 ships two. Both are DATA here and ENFORCEMENT in Lane B's
* rigging session — the seam agreed in THREADS:
*
* · `noAnchorFamily` — "nothing attached to the house". `family` names an
* ANCHOR_TYPE the session must refuse, with `says` in the ticker.
* · `budgetCap` — "cheapest option and I'll sue". `cap` is a hard ceiling on
* the RIG, below START_BUDGET. The client caps your spend, not your wallet:
* your bank is untouched, and what you don't spend you keep.
*/
export const CONSTRAINT_KIND = Object.freeze(['noAnchorFamily', 'budgetCap']);
/**
* A constrained night pays a PREMIUM. ⚖️ RULED (A, SPRINT17 gate 2) —
* the number, and why it is this number:
*
* The premium is a FRACTION OF THE CALLOUT FEE, declared on the constraint
* itself rather than fixed in code, so a new constraint prices itself as data
* (the `pay` override pattern this file's neighbour already uses). It scales
* with the fee, which means a nasty night's constraint is worth more than a
* gentle one's — correct, because the same ban costs you more when the weather
* is actually trying.
*
* · **noAnchorFamily: +25%.** It removes a whole family of options — on
* backyard_01 the house side is three of thirteen anchors AND the cheap
* cover over the bed. You are being paid to solve a smaller puzzle with the
* same storm in it.
* · **budgetCap: +20%, deliberately the lower of the two.** A cap denies you
* money you were going to SPEND, and what you don't spend stays in the bank
* — so part of your compensation is already in your pocket and paying a
* full house-ban premium on top would be paying you twice for one squeeze.
*
* ⚠️ **UNMEASURED — same status, and the same standing instruction, as
* `noCollateralBonus` and `REP` in week.js.** Nobody has played a constrained
* night yet; these are sized by argument, not by a playtest. They are per-
* constraint data, cutting them costs nothing but a number on a card, and if
* gate 4's play says constrained jobs are free money, THESE are the levers —
* cut before anything in PAY, which carries measured evidence and long reasons.
* I did not fund a new feature by quietly trimming a constant somebody measured.
*/
export const CONSTRAINT_PREMIUM = Object.freeze({ noAnchorFamily: 0.25, budgetCap: 0.20 });
/** Fails loud on a malformed constraint — content bugs are not runtime surprises. */
export function validateConstraint(c, where = '?') {
const bad = [];
if (!c || typeof c !== 'object') bad.push('not an object');
else {
if (!CONSTRAINT_KIND.includes(c.kind)) {
bad.push(`kind ${JSON.stringify(c.kind)} is not one of ${CONSTRAINT_KIND.join('|')}`);
}
if (!c.says || String(c.says).length < 8) bad.push('needs `says` — the CLIENT\'S words, for the ticker and both papers');
if (!c.label) bad.push('needs `label` — the short form the offer card and the job sheet print');
if (c.kind === 'noAnchorFamily' && !c.family) bad.push('noAnchorFamily needs `family`');
if (c.kind === 'budgetCap' && !Number.isFinite(c.cap)) bad.push('budgetCap needs a numeric `cap`');
}
if (bad.length) throw new Error(`board: constraint on ${where} is invalid:\n ${bad.join('\n ')}`);
return c;
}
/**
* The premium multiplier a night's constraints add to its callout fee.
* Pure, and the ONLY place the premium is computed — week.js's quote() and
* settle() both route through calloutFee(), so the sheet and the invoice
* cannot price a constrained night differently. Same construction that keeps
* warranty honest.
*/
export function premiumFor(constraints) {
return (constraints ?? []).reduce(
(s, c) => s + (c.premium ?? CONSTRAINT_PREMIUM[c.kind] ?? 0), 0);
}
/**
* THE ALTERNATIVE POOL — the board's own work, and it is DATA.
*
* SPRINT17 gate 1.5 sizes this honestly: "the two unused yard/storm pairings
* the one-variable law allows, plus repeat visits at different pay". Adding a
* yard to the board is an entry in this array and nothing else — which is the
* requirement D's pool yard (gate 3) lands against: their yard enters the game
* by being CHOSEN, the first one whose only route in is the board.
*
* ⚠️ **THE ADMISSION PRICE FOR A POOL ENTRY, and it is not negotiable:** a
* pairing here has been through the gauntlet. The board is allowed to offer a
* night the campaign never scripted; it is NOT allowed to offer a night nobody
* measured, because "is this site winnable, at what price" is a fact about
* GEOMETRY × STORM and the ladder's answer for one pairing is not an answer for
* another. Every entry below carries its audit receipt in `_why`. A new entry
* without one is a night the board is guessing about.
*
* What is deliberately NOT here, and why (the pairings that look free and
* aren't):
* · **soaker anywhere but site_02** — C MEASURED the pairing: the backyard's
* buyable geometry caps hail cover over the bed at ~31%, so the fabric bet
* has no win in it over there. An offer is a promise that the night can be
* worked.
* · **wildnight anywhere but backyard_01** — its separation is PINNED to that
* yard's site data. Fly it elsewhere and the pin describes a night nobody
* plays.
* · **icenight anywhere but backyard_01** — `gardenBeyondSaving` is a measured
* fact about that bed under that ice (A, S13), not a property of the storm.
* Offering it over another yard would carry the excuse without the evidence.
*/
export const POOL = [
{
id: 'swing_lawn_earlybuster',
storm: 'storm_03b_earlybuster', site: 'site_03_swing_lawn',
client: 'the Delaneys', addr: '31 Ferndale Ave — the swing lawn',
brief: 'Ruby\'s mum again — they got your number off the last job. Same lawn, except this '
+ 'change comes through early, before you\'ve got the second corner up. She wants the '
+ 'bed covered and she does not want the swing set touched more than it has to be.',
_why: 'AUDITED (A, S17 gate 1, audit.html, venturi (-6.22,-4.77) axis 1.571 gain 1.4 in the '
+ 'header): 12 holding lines / 17 marginal / 31 unholdable; cheapest hold $50 '
+ '(t2,t2b,p1,s1_f2 → garden 88.6 FULL), best flown 89.0 FULL. Winnable well inside '
+ 'the $80 start budget. The swing lawn under the EARLY change is the one-variable '
+ 'step off night 4 — same yard, the storm moved. $255 of exposure, the most in the '
+ 'game (gutter 90 + swing set 140 + gnome 25), which is what the offer card prints.',
},
{
id: 'corner_block_southerly',
storm: 'storm_03_southerly', site: 'site_02_corner_block',
client: 'the Vasilaros place', addr: '2 Bight Rd — the corner block',
brief: 'The corner block, and this time it\'s the plain Tuesday southerly — no early change, '
+ 'no surprises in the timing. They still reckon there\'s plenty to tie off to, which '
+ 'was true the last time somebody said it and cost a carport.',
_why: 'AUDITED (A, S17 gate 1, audit.html): 19 holding lines / 7 marginal / 38 unholdable; '
+ 'cheapest hold $40 (tr1,tr1b,q1,q3 → garden 87.6 FULL), best flown 90.4 FULL. '
+ 'site_02 under the storm night 2 already taught — one variable off night 3: the yard '
+ 'is the same, the change is not early. The carport is still the trap and still bills '
+ '$180; the brief re-sells it in the client\'s voice exactly as night 3 does, because '
+ 'the trap did not get easier.',
},
{
// A REPEAT VISIT AT DIFFERENT PAY — SPRINT17 gate 1.5's third kind, and the
// cheapest honest variety in the game: the same yard, the same storm the
// tutorial flies, offered as a small job. `pay.garden` is the override
// week.js has always read (settle() and quote() both take it from the same
// place), so a smaller bed's worth of money needs no code.
id: 'backyard_retainer_gentle',
storm: 'storm_01_gentle', site: 'backyard_01',
client: 'the Hendersons', addr: '14 Kurrajong St — the backyard',
brief: 'A retainer top-up, and they\'re honest about it: nothing in the forecast can hurt '
+ 'anything. Half the bed is already picked, so there\'s less riding on it — they '
+ 'just want the sail checked and stood up properly while somebody\'s there.',
pay: { garden: 25 },
_why: 'The gentle storm over the yard it was authored for — the one pairing in the game '
+ 'that needs no audit, because nothing in it can hurt the bed (week.js night 1). '
+ 'Priced DOWN (garden 25 vs 45): a night with no teeth should not pay like one that '
+ 'has them, and the board\'s whole point is that the safe job pays less.',
},
{
// ⚖️ A CONSTRAINED JOB — SPRINT17 gate 2's data half, on the board so that
// taking it is a CHOICE (the spec's word). Both papers repeat it and B's
// session enforces it; the premium is priced in board.js's ruling above.
id: 'corner_block_no_house',
storm: 'storm_03_southerly', site: 'site_03_swing_lawn',
client: 'the Delaneys', addr: '31 Ferndale Ave — the swing lawn',
brief: 'They\'ve had a bracket pull out of the weatherboard before and they are still '
+ 'cross about it. Tuesday\'s southerly, the same lawn — and the house is off limits, '
+ 'which they will tell you twice. They pay over the odds for the inconvenience.',
constraints: [{
kind: 'noAnchorFamily', family: 'house',
label: 'nothing attached to the house',
says: 'Nothing goes on the house. Not a bracket, not a screw — we\'ve done that once.',
}],
_why: 'Same audited pairing as night 4 (southerly × swing lawn, D\'s S16 authoring and '
+ 'B\'s S16 table), MINUS the house family. ⚖ THE BAN IS A PUZZLE, NOT A SOFT-LOCK, '
+ 'AND THAT WAS MEASURED, NOT ASSUMED (A, S17): the swing-lawn audit\'s holding lines '
+ 'include several with NO h* anchor at all — t2,t2b,p1,s1_f2 holds at $50 and '
+ 't1b,t1c,p1,s1_f1 at $65, both garden FULL. A constraint that left no line standing '
+ 'would be a night the board offers and nobody can work; this one costs you the cheap '
+ 'house-side cover and leaves the posts, the trees and the swing frame. B enforces at '
+ 'pick time with the client\'s words in the ticker.',
},
{
id: 'corner_block_cheapest',
storm: 'storm_03b_earlybuster', site: 'site_02_corner_block',
client: 'the Vasilaros place', addr: '2 Bight Rd — the corner block',
brief: 'Short notice and a short temper. They want it covered, they want it cheap, and '
+ 'they made a point of telling you what the last mob charged. Read the number on '
+ 'the sheet before you agree to it — it is not your budget, it is theirs.',
constraints: [{
kind: 'budgetCap', cap: 45,
label: 'client caps the rig at $45',
says: 'Cheapest option that does the job. Anything over forty-five and I\'ll be '
+ 'querying the invoice.',
}],
_why: 'Night 3\'s audited pairing under a $45 ceiling. ⚖ THE CAP IS A SQUEEZE, NOT A '
+ 'SOFT-LOCK, MEASURED (A, S17): site_02 × earlybuster has holding lines at $40 '
+ '(tr1,tr1b,q1,q3 → garden 86.4 FULL; tr1,tr1b,q2,q4 → 84.5 FULL), so $45 leaves '
+ 'exactly two of the yard\'s cheapest holds affordable and prices out every $55+ '
+ 'line the audit rates better. That is the intended shape: the cap does not stop you '
+ 'working, it stops you buying your way out of choosing. The carport at $180 is the '
+ 'tension — the rig this cap pushes you toward is the one with the least margin over it.',
},
{
// SPRINT17 gate 3.2 [D] — THE POOL YARD. The first yard whose ONLY route
// into the game is being chosen: it is in no NIGHTS slot, so declining it
// forever is a complete campaign. That is the arc-2 promise made literal,
// and it is why the entry lives here and not in week.js.
id: 'pool_yard_southerly',
storm: 'storm_03_southerly', site: 'site_04_pool_yard',
client: 'the Karalis place', addr: '5 Clearwater Ct — the pool yard',
brief: 'New pool, new fence, certified in March — and the certifier is back Thursday, so '
+ 'nothing touches that fence. Not a rope, not a clamp. The old sail post from before '
+ 'the pool went in is still standing, they say, and they say it like a selling point. '
+ 'It has been breathing pool air for years. Tuesday\'s southerly, the bed\'s by the water.',
_why: 'AUTHORED COLD + AUDITED (D, S17 gate 3.2 — SCORE IT card, funnel ON, confirmed by '
+ 'audit.html over the shipped file): 13 quads in band, 6 clean, 1 marginal; cheapest '
+ 'hold $40 (h1,t1b,p2,p3 → garden 92.7 FULL, 58% cover, no rust in it). The trap is '
+ 'priced, not padded: every 92%-cover line routes through the corroded post c1 (0.55, '
+ '$45 collateral, IN the venturi) — clean at $65 rated on t1,p1,p2,c1, UNHOLDABLE on '
+ 'the two shapes that pull the eye harder. Southerly = the storm nights 2 and 4 teach; '
+ 'the yard is the only new variable (one-variable law). Separation REFUSED with '
+ 'receipts in the site file (bare 83.7 FULL — mild-night canon per A\'s 0.4 ruling); '
+ 'exposure $160 (gutter 90 + corroded post 45 + gnome 25), the fence\'s fourteen '
+ 'posts deliberately absent from that number because none of them can be tied to.',
},
];
/**
* ⚖️ **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);
}
/**
* 🚨 **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
* story in this repo rather than two.
*
* The seed mixes the WEEK and the NIGHT INDEX (SPRINT17 gate 1.1's words), with
* odd multipliers so that week+1 and night+1 don't collide onto the same draw —
* `seed*1 + index*1` would offer week 2 night 1 and week 1 night 2 the same job,
* which looks like a bug to a player and is one.
*/
export function seedFor(weekSeed, nightIndex) {
return ((weekSeed | 0) * 0x9E37 + (nightIndex | 0) * 0x85EB + 0x27D4) >>> 0;
}
/**
* Is this pool entry a sane alternative to tonight's scripted job?
*
* The one rule: an alternative must be a DIFFERENT JOB. Offering the same
* yard under the same storm as "the other option" is the board lying about
* having offered a choice — and it is exactly what a naive index-into-the-pool
* produces on the nights whose pairing is in the pool (nights 3 and 4 both are).
*/
export function eligibleAlternatives(scripted, pool = POOL) {
return pool.filter((p) => !(p.storm === scripted.storm && p.site === scripted.site));
}
/**
* Tonight's alternative — one entry, drawn deterministically.
*
* @param {object} scripted tonight's authored night (nightAt(index))
* @param {number} weekSeed
* @param {number} nightIndex
*/
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));
return eligible[Math.floor(r() * eligible.length)] ?? null;
}
/**
* COLLATERAL EXPOSURE — the risk surface the offer card is required to show.
*
* SPRINT17 gate 1.2: the offer shows "the risk surface (collateral exposure at
* minimum)". This is that number: every priced piece of the client's property
* in that yard which YOUR failure can bill you for. Not a score, not a
* prediction — an exposure, the way a tradie reads a site before quoting it.
*
* ⚠️ **THE SECOND-HARNESS SEAM, DECLARED (C's gate 1.2 job).** world.js prices
* collateral at FAILURE time by resolving a collateral KEY off the anchor that
* let go; this walks the SITE JSON up front and sums what is priced there. Two
* routes to one set of dollars, which is precisely the shape this repo has been
* bitten by (`collateralFor` resolved the carport for five sprints only because
* site_02 happens to id its structure "carport"). They must agree per item or
* one of them is wrong: a.test pins the totals against the shipped sites, and
* C is crossing it against their envelope on at least one offer. If they ever
* disagree, find the variable before tuning either — it will be the harness.
*
* Reads the same fields world.js's collateralFor prefers (`spec.collateralValue`
* first, the GLB extra as fallback and proposal), so the two cannot drift on
* price even though they differ on question.
*/
export function exposureOf(site) {
const items = [];
const push = (what, cost) => {
if (Number.isFinite(cost) && cost > 0) items.push({ what, cost });
};
if (site?.house) push(site.house.collateralLabel ?? 'the house', site.house.collateralValue);
for (const s of site?.structures ?? []) push(s.collateralLabel ?? s.id, s.collateralValue);
// Named props, explicitly — there is deliberately no generic `props: []` in a
// site (docs/MANUAL.md: every prop is a named top-level key, wired on
// purpose), so this list grows by hand when a priced prop is added. That is
// the intended friction: a prop that quietly joined an exposure total without
// anyone deciding it should is the "free failure" bug wearing a new hat.
if (site?.gnome) push(site.gnome.collateralLabel ?? 'garden gnome', site.gnome.collateralValue);
const total = items.reduce((s, i) => s + i.cost, 0);
return { total, items };
}
/**
* BUILD THE MORNING'S BOARD — two offers, tonight's scripted night first.
*
* Order is not cosmetic and is not seeded: the scripted job is ALWAYS offer 0.
* The campaign is the thing the player is in the middle of, and a board that
* shuffled which door was which would make "take the job you were going to do"
* a reading exercise every morning.
*
* @param {object} o
* @param {object} o.scripted tonight's authored night
* @param {number} o.weekSeed
* @param {number} o.nightIndex 0-based
* @param {(night:object) => object} [o.priced] per-offer money/forecast/exposure,
* injected by the caller because it needs storm defs and site JSON that this
* pure module deliberately does not import. Shape is documented on the offer
* typedef in THREADS' seam contract; anything it returns is merged onto the
* 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, 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 ?? [],
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;
}