John's ruling, 2026-07-25 ("it adds pressure"), wired end to end.
REP.STOOD_UP = -0.5, the exact mirror of REP.CLEAN: a stood-up client costs
you what a flawless night earns. Bounded above by REP.COLLATERAL so a broken
promise cannot outrank a wrecked carport. UNMEASURED per house rule; it is the
lever, not CLEAN.
isBooked(kind, night) — the diary is booked, the phone is not, and an emergency
in the diary's chair is not a booking. The first cut derived it from the night
alone and the pool yard came back booked; the probe caught it in one call.
take() records the broken promise, settle() charges it at dawn — charging inside
take() drops standing between the board and the sheet, and the fee is priced off
standing. That is the quote-vs-settle lie moved one card earlier.
Three surfaces off one ledger: the offer card prints the cost before the choice
in the client's own terms, the job sheet says they have not forgotten, the
invoice carries an OFF THE BOARD block. Two callouts and no booked job reads as
free, in words, with a vacuity guard behind it.
Selftest 528/0/0 — 522 + 6, the exact sum.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
492 lines
28 KiB
JavaScript
492 lines
28 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);
|
||
}
|
||
|
||
/**
|
||
* 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) {
|
||
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 }) {
|
||
const alt = alternativeFor(scripted, weekSeed, nightIndex, pool);
|
||
const mk = (night, kind, id) => {
|
||
for (const c of night.constraints ?? []) validateConstraint(c, 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;
|
||
}
|