Gate 0 rulings (written, with reasoning, in THREADS): - 0.4 hail-light balance: CANON. B's finding reproduces exactly ($4/$2 garden spread), but the mild-night rig market buys the STEEL and the collateral, not the bed — a cheap rig that fails on site_02 swings $184 and two stars against the same rig holding, and the cheapest HOLDING line is $40, not the $20 floor. Ruled for B's option (b): the separation block asks the wrong question on those nights; the new shape is B's and is NOT this sprint. Rain drain-weight lever declined at its proper price (full gauntlet re-measure, own gate). - 0.4b gradeFor scraped/solvent >=3 STANDS on the seven-night shape, ruled on the arithmetic (savable 6 → solvent is 4-6 dead, which is what the copy says). - 0.3 score-header wording consulted for B (no code from me). Gate 1 — the board: board.js (pure data, node-testable), week.offers()/take(), seeded from week+night via contracts' mulberry32 — no Date.now, no Math.random. An offer carries a whole NIGHT ENTRY and take() installs it, so sheet, quote, invoice, ledger, warranty, rep and end card all read a chosen job through the path they already had — seven surfaces, zero changed lines. Scripted spine runs byte-identically when nothing is taken. Gate 2 (data half) — constraints as night data, checked enum, premium ruled (+25% anchor ban / +20% budget cap, both UNMEASURED and flagged cut-first), stated on the offer, the job sheet and the invoice it was paid under. ONE calloutFee() now serves quote() and settle(). Enforcement is B's. Two bugs found off the suite: take() stored raw pool entries (crashed quote() on a missing pay block) → normaliseNight() split so both doors share it; and NIGHT 1 HAD NO BOARD — the splash bypassed it — found by looking, not testing. Pool pairings audited in the browser before shipping; both constraints checked for soft-lock against real holding lines. Mutation-checked ×6. One did NOT go red: "every pool storm is preloaded" passed with the POOL source deleted, because every pool storm is also in NIGHTS — the same coincidence that hid the calm-day bug. stormsToPreload(nights, pool) split so it can fail. Selftest 485/0/0 (474 + 11).
358 lines
20 KiB
JavaScript
358 lines
20 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.',
|
||
},
|
||
];
|
||
|
||
/**
|
||
* 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;
|
||
}
|