/** * 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; }