Eyes on the cards found what the suite structurally cannot:
· the board advertised $57 while the dispatch quoted $114 one click apart — the
quote-vs-settle lie walking back in through an unreachable closure inside
boot(). pricedOffers is now the exported pure priceOffer(), rigRecordFor's
precedent, and the bug has its own pin.
· the emergency wore the same grey "callout" tag as the Tuesday alternative
beside it — the tell rule broken on the surface the tell rule is for.
· the invoice argued with itself: the ledger said CB1 "is not on you" and the
verdict blamed CB1. verdictFor now reads `yours`, and the ruling is extracted
as answerableFor() so the RULING is pinnable, not just its consequences.
· the shop lied by $20 — the session charged me for the cowboy's carabiners.
· "the Hendersons IS expecting you", live, on the ruling's headline line.
Gate 5 (A's half): collateralKeyClashes() turns D's second-rust-post landmine
into a tripwire carrying both harnesses' numbers. It reports and does not throw
(D and E are authoring in parallel), and it deliberately does NOT make
exposureOf dedupe — the card is the honest side, the failure route is what needs
per-instance keys, and matching a bug converts a loud disagreement into a quiet
lie.
⚠️ RETRACTED IN THREADS: my $180 balance measurement is contaminated. C found
SailRig.step accepts `t` and never reads it, so setPhase('storm',{at:30}) moves
the sky and not the cloth (4.12× understated load, 93× light rain). B must not
rule the garden bonus against my $38. The TELL survives — CB1 still blows at
t≈33 — the ECONOMY does not.
Selftest 539/0/0 — 522 + 17, the exact sum. 19 mutations, all red with their own
words, controls green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
793 lines
44 KiB
JavaScript
793 lines
44 KiB
JavaScript
/**
|
||
* 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 };
|
||
}
|
||
|
||
/**
|
||
* ⚠️ **SPRINT18 gate 5 (A's half) — THE SECOND-RUST-POST LANDMINE, DISARMED AS A
|
||
* TRIPWIRE.** D's filing, S17 gate 3.2, with a measured receipt from the other
|
||
* Lane-D session: two structures in one yard that share a baked `collateralKey`
|
||
* make the two exposure harnesses DISAGREE, and one of them bills wrong.
|
||
*
|
||
* THE MEASUREMENT, D's, on a real two-corroded-post yard:
|
||
* · `exposureOf()` (this file, the OFFER CARD) read **$115** — per STRUCTURE.
|
||
* · `collateralExposure` (B/C, the FAILURE route) read **$45** — per KEY, deduped,
|
||
* because `collateralFor`/`wreckStructure` match the FIRST structure carrying a
|
||
* key and C's failure envelope dedupes keys.
|
||
* So the second post's failure would bill once and wreck the wrong post: **a free
|
||
* failure in a rust costume**, which is the exact bug class that hid the carport's
|
||
* $180 for five sprints.
|
||
*
|
||
* ⚖️ **WHY THIS REPORTS AND DOES NOT THROW, and why it does not "fix" `exposureOf`.**
|
||
* Two deliberate refusals:
|
||
* 1. **No throw.** D and E are authoring yards in parallel right now. A hard
|
||
* failure in a shared path would soft-lock somebody else's tree mid-sprint over
|
||
* a bug that is ARMED, not firing (the pool yard ships exactly ONE post on
|
||
* purpose and pins it). It accuses instead — C's `pairingRefusals`
|
||
* construction, and their reasoning applies unchanged.
|
||
* 2. **I did not make `exposureOf` dedupe by key.** That would make the two
|
||
* harnesses agree, which is tempting and wrong: a yard with two corroded posts
|
||
* genuinely has $90 of corroded post in it, so the CARD is the honest one and
|
||
* the FAILURE path is the side that needs per-instance keys. Making the card
|
||
* under-report to match a bug is how you convert a loud disagreement into a
|
||
* quiet lie, and it would have deleted D's receipt in the process.
|
||
*
|
||
* **THE REAL FIX, for the joint E+A pass:** per-instance collateral keys — the
|
||
* anchor naming the STRUCTURE it will wreck, not the KIND of thing it is. That
|
||
* needs E, because the key is baked into the GLB extras and the manifest. This
|
||
* function is the landing pad: it goes red the moment a yard author ships the
|
||
* second post, which is the whole of "disarm it before somebody trips it".
|
||
*
|
||
* @param {object} site a site JSON
|
||
* @returns {string[]} one accusation per clashing key, with its measurement
|
||
*/
|
||
export function collateralKeyClashes(site) {
|
||
/** @type {Map<string, object[]>} */
|
||
const byKey = new Map();
|
||
for (const s of site?.structures ?? []) {
|
||
// No key ⇒ world.js's collateralFor resolves it by `id`, which is unique by
|
||
// construction (the editor generates s1, s2, …) — so an unkeyed structure
|
||
// cannot clash and must not be reported as if it could.
|
||
if (!s.collateralKey) continue;
|
||
if (!byKey.has(s.collateralKey)) byKey.set(s.collateralKey, []);
|
||
byKey.get(s.collateralKey).push(s);
|
||
}
|
||
const out = [];
|
||
for (const [key, group] of byKey) {
|
||
if (group.length < 2) continue;
|
||
const perStructure = group.reduce((n, s) => n + (s.collateralValue ?? 0), 0);
|
||
const perKey = group[0].collateralValue ?? 0;
|
||
out.push(`${site?.name ?? site?.id ?? 'this yard'}: ${group.length} structures share the `
|
||
+ `collateral key '${key}' (${group.map((s) => s.id).join(', ')}). The offer card would read `
|
||
+ `$${perStructure} (per structure) and the failure route $${perKey} (per key, deduped) — `
|
||
+ `so ${group.length - 1} of them would fail FREE and wreck '${group[0].id}' instead. `
|
||
+ 'Per-instance collateral keys are needed first (SPRINT18 gate 5, E+A).');
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* 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;
|
||
}
|