/** * 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.', }, ]; /** * 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), }; return priced ? Object.assign(offer, priced(night)) : offer; }; const offers = [mk(scripted, 'scripted', 'scripted')]; if (alt) offers.push(mk(alt, 'callout', `alt:${alt.id}`)); return offers; }