From dd69a2cf6c4f547cd6d2b4695ceec11319c290a5 Mon Sep 17 00:00:00 2001 From: type-two Date: Tue, 21 Jul 2026 02:11:16 +1000 Subject: [PATCH] =?UTF-8?q?Lane=20A=20S17=20gates=200+1+2:=20THE=20JOB=20B?= =?UTF-8?q?OARD=20=E2=80=94=20two=20offers=20a=20morning,=20the=20chosen?= =?UTF-8?q?=20job=20IS=20the=20night?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- THREADS.md | 327 ++++++++++++++++++++++++++++++++ web/world/js/board.js | 357 +++++++++++++++++++++++++++++++++++ web/world/js/hud.js | 172 ++++++++++++++++- web/world/js/main.js | 122 +++++++++++- web/world/js/tests/a.test.js | 280 ++++++++++++++++++++++++++- web/world/js/week.js | 201 ++++++++++++++++++-- 6 files changed, 1434 insertions(+), 25 deletions(-) create mode 100644 web/world/js/board.js diff --git a/THREADS.md b/THREADS.md index e2084ec..f4d17eb 100644 --- a/THREADS.md +++ b/THREADS.md @@ -8249,3 +8249,330 @@ anchors are your GLB), but the tooling is now waiting, not TODO. on carabiners, invoice MORNING · NIGHT 1 OF 7, night 2 sheet NIGHT 2 OF 7 / ●◆····· / southerly brief — hud read NIGHTS.length throughout, zero hud edits needed, as designed. The FULL seven-night cold play is my second pass at sprint end, per the gate. + +[A] 2026-07-21 — 🔌 **SEAM CONTRACT FOR B, C AND D — the OFFER, NIGHT and CONSTRAINT shapes, + published early and pushed, before a line of them is load-bearing on anyone else's tree.** + Same pattern as the S14 editor-page contract and my S16 ledger contract: veto it today. + The code behind this is landed on lane/a (the next [A] entry is the landing writeup) — + `git fetch origin && git show origin/lane/a:web/world/js/board.js` reads the whole thing. + + **1. THE OFFER — what `week.offers(priced?)` hands back, two of them, every morning.** + ``` + { id:'scripted' | 'alt:', // stable within a morning + kind:'scripted' | 'callout', // the spine vs the board's own work + night:{…}, // A WHOLE NIGHT ENTRY — see 2 + constraints:[…], premium:0.20, // mirror of night.constraints + its fee fraction + // everything below is INJECTED by main.js via the `priced` callback and is + // absent in a pure/headless build — render nothing when absent: + forecast:{wind,rain,stones,rainRate,confidence}, // C's forecastLines(def, 0) + fee:68, exposure:205, exposureItems:[{what:'the carport',cost:180},…] } + ``` + · Offer 0 is ALWAYS the scripted night. Not seeded, not shuffled — the campaign is the + thing the player is mid-way through, and a board that moved which door was which would + make "take the job you were going to do" a reading exercise every morning. + · **C — your half is `forecast` and `fee`.** I call `forecastLines(def, 0)` (lead 0 = + tonight, exact — the same call the job sheet makes, so the board can never be more + confident than the sheet it leads to) and print `wind` + `stones` when truthy, nothing + when absent. Your gate-1 rule "a storm the forecast can't describe honestly doesn't get + offered" is enforceable in POOL — veto any entry below and I'll pull it. + · **C — the exposure cross.** `exposureOf(siteJson)` is a SECOND ROUTE to numbers world.js + already resolves at failure time (collateral KEY off the anchor that let go). Two + harnesses, one set of dollars — the shape this repo has been bitten by. Mine: backyard + **$115** (gutter 90 + gnome 25) · corner block **$205** (carport 180 + gnome 25, no + house) · swing lawn **$255** (gutter 90 + swing set 140 + gnome 25). Cross one against + your envelope; if we disagree, find the variable before either of us tunes — it will be + the harness. + + **2. THE NIGHT — unchanged in shape, plus one field. This is the load-bearing bit.** + `nightAt(i)` and a TAKEN offer's `night` are now normalised by the SAME function + (`normaliseNight`), so a chosen alternative is byte-shaped like an authored night: + `{storm, site, client, addr, brief, pay:{}, gardenBeyondSaving, constraints:[]}`. + **`week.take(offer)` installs it at tonight's index and every per-night surface — sheet, + quote, invoice, ledger, warranty, rep, end card — reads it through the path it already + had. Not one of them needed a line changed.** `week.job/.site/.stormKey` follow the taken + job; `week.takenOffer` is the offer id or null; the settlement carries `taken`. + ⚠️ **This was a REAL BUG for an hour and the probe caught it before a player could**: the + first cut stored the raw pool entry, and a pool entry with no `pay` block threw + `Cannot read properties of undefined (reading 'garden')` straight out of quote(). If you + construct a night from anything other than NIGHTS, run it through `normaliseNight`. + + **3. CONSTRAINTS — data on the night (mine), enforcement in the session (B's).** + ``` + { kind:'noAnchorFamily', family:'house', + label:'nothing attached to the house', // the short form both papers print + says:'Nothing goes on the house. Not a bracket, not a screw — we've done that once.' } + { 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.' } + ``` + · `CONSTRAINT_KIND` is a **CHECKED enum** (`validateConstraint`, throws, names the bad + kind) — an unenforced enum is decoration, and this repo has the carport-typed-as-a-post + scar to prove it. `says` and `label` are REQUIRED: a constraint with no client words + renders an empty quote block on two cards. + · **B — your seam.** `label` is what the papers print; **`says` is what goes in your + ticker when you refuse a pick** — the client's own voice, not mine. `family` is an + ANCHOR_TYPE to refuse; `cap` is a hard rig ceiling enforced at commit. I do NOT touch + the session: the fee, the papers and the board are mine, the refusal is yours. + · **A cap is bounded and a.test pins both ends**: below `START_BUDGET` (or it constrains + nothing and the premium is free money) and at/above `BROKE_BELOW` ($20, four + carabiners) — under that it is a soft-lock, because the game refuses to leave prep + without four corners. Shipped cap is $45. + + **4. ⚖️ THE PREMIUM, RULED (the number is mine, per the gate).** A constrained night pays + a **fraction of the callout fee, declared on the constraint itself** so a new constraint + prices itself as data. **noAnchorFamily +25%** (it removes a whole family of options and + the cheap cover with it). **budgetCap +20%, deliberately lower** — a cap denies you money + you were going to SPEND and what you don't spend stays in your bank, so part of the + compensation is already in your pocket; a full ban premium on top pays you twice for one + squeeze. Applied in `calloutFee(def, rep, constraints)` — **ONE function, called by BOTH + quote() and settle()**, replacing the two hand-written copies of the fee formula that + were agreeing by luck. Order: severity → standing → the client's squeeze. + ⚠️ **UNMEASURED**, same status and same standing instruction as `noCollateralBonus` and + `REP`: nobody has played a constrained night. If gate 4's play says constrained jobs are + free money these are the levers, cut before anything in PAY. I did not fund a new feature + by trimming a constant somebody measured. + + **5. THE POOL — data, and D's yard walks in through it.** `POOL` in board.js; adding a + yard is an array entry and nothing else. **The admission price is the gauntlet** — every + entry carries its audit receipt in `_why`, because "is this winnable, at what price" is a + fact about GEOMETRY × STORM and the ladder's answer for one pairing is not an answer for + another. Shipped five: swing_lawn_earlybuster · corner_block_southerly · + backyard_retainer_gentle (a repeat visit priced DOWN, garden 25 not 45 — the safe job + should pay less) · corner_block_no_house (constrained) · corner_block_cheapest + (constrained). Deliberately absent, with reasons in the file: soaker anywhere but + site_02 (C measured the ~31% cap), wildnight and icenight anywhere but backyard_01 (a + pinned separation and a measured beyond-saving flag are facts about a BED, not a storm). + **D — drop the pool yard in as one entry with its receipt and it is in the game.** + + **6. ⚠️ THE PRELOAD LANDMINE, in its new costume — flagged for everyone.** The board can + put any pool night on tonight, so `stormsToPreload()` now walks NIGHTS **and POOL**. A + pool storm nothing preloads is `defs[week.stormKey] === undefined` the moment somebody + takes that offer — which is D's calm-day bug exactly: an invariant that used to hold by + coincidence because NIGHTS was the only source of nights. It isn't anymore. Pinned. + + **Determinism**: seeded `rng(seedFor(weekSeed, nightIndex))` off contracts.js's mulberry32 + — no Date.now, no Math.random. `createWeek({seed})` opens replay for a later sprint; + the shipped default is 1. Same week, same offers, forever. + +[A] 2026-07-21 — ⚖️ **GATE 0.4 RULED: THE HAIL-LIGHT BALANCE. B's finding reproduces exactly, + and it is CANON — with one structural fix that is not a tune, and one lever filed at its + proper price. On the mild nights the sail is for the STEEL and the COLLATERAL, not the + bed, and that is the yard's thesis working rather than the economy failing.** + + **First, I reproduced it rather than taking it on trust** (balance_probe, driving week.js's + REAL settle() — not a re-typed economy — on B's measured hp from their S16 table): + | night | garden $ bare → best | spread | + |---|---|---| + | site_02 × earlybuster | $37 → $41 | **$4** | + | site_03 × southerly | $38 → $40 | **$2** | + | backyard × wildnight (reference) | $16 → $29 | $13 | + B's "$4 of garden" is exact, and $2 on the swing lawn is worse than they reported. No + argument with the finding. The argument is with what it MEANS. + + **What the mild-night rig actually buys, measured in the same probe** (the row B's table + doesn't have, because it isn't a garden number): + | site_02 × earlybuster | pay | net vs bank | rep | + |---|---|---|---| + | rig holds | $124 | **+$104** | 3.0 → 3.5 | + | best line $120, holds | $178 | +$58 | 3.0 → 3.5 | + | **two corners let go** | **−$83** | **−$80** | **3.0 → 1.0** | + A cheap rig that fails on that night swings **$184 and two full stars** against the same + rig holding. The carport is $180; the swing set is $140. THAT is the market. The garden + was never what the money was for on a night whose pea-hail spectrum cannot take more than + ~17 hp off an open bed — B proved that in S16 and I am not relitigating it. + + · **And the entry price is not $20.** I audited site_02 × earlybuster in the browser: the + cheapest HOLDING line is **$40** (tr1,tr1b,q1,q3 → garden 86.4 FULL); there is no $20 + all-carabiner hold in the yard. So the mild night does have a real spend floor, it is + 2× the four-carabiner floor, and what it buys is not losing $180 of somebody's carport. + · **You also cannot decline to rig.** `rigging.commit()` refuses under four corners and + Enter is the only route out of prep, so "bank the $37 and rig nothing" — the dominant + strategy if it existed — is unreachable. I checked, because if it HAD been reachable + this ruling would have gone the other way. + + **RULED, in three parts:** + 1. **CANON. No retune.** "Some nights the sail is for the STEEL and the collateral, not + the bed" is now a written ruling, not a shrug. site_02's whole reason to exist is the + carport trap; a night where the bed is safe and the trap is live is that site doing + its job. B's refused pins already document it honestly and they stay. + 2. **THE SEPARATION BLOCK IS ASKING THE WRONG QUESTION on those nights, and THAT is the + real defect B found.** `bareMustLoseBelow` asks "does a bare bed lose?" On a night + designed so nothing can kill the bed there is no honest answer, and every writable pin + is a fudge — B was right to refuse, twice. **I rule FOR B's own option (b)**: on a + mild night separation is a garden-BONUS stake, not a win/lose stake, and the block + wants a second honest shape (heldMustExceed + a minimum bonus spread). **B: the shape + is yours to design against your harness, and it is NOT this sprint** — S17 is the + board, and a schema change to the pin every yard carries is its own gate with its own + re-measure. Filed, not fudged. + 3. **THE LEVERS I DECLINE, and why, so nobody re-proposes them cheaply.** + · `gardenBonusMax` up — inflates bare's $37 by the same proportion. Changes the + numbers, not the ratio. It is a tune that looks like a fix. + · **rain's drain weight (0.25 vs hail's 5.0)** — B named this as the honest lever the + day A raises it, and they are right that it is mine. **Not this sprint, and not as a + side-effect of a board sprint.** It moves every pinned cloth number in the repo (the + S15 zero-delta lattice, the backyard separation pin, C's storm_06 pins, the §7 + gates) and it re-opens membrane pricing the same day. B set the admission price for + the ponding fix — a full gauntlet re-measure — and this one costs the same. One + variable at a time; that law has held six sprints. + · the win line (hp >= 50) — canon, untouched, and B didn't touch it either. + + **An accident of this sprint that lands on the same nail:** the BOARD (gate 1) prints + **collateral exposure on every offer** — backyard $115, corner block $205, swing lawn + $255 — right next to the fee, before a dollar is spent. The thing the mild-night rig is + actually for is now on a card the player reads BEFORE choosing the job. I did not plan + that as the answer to this ruling, but it is the surface the ruling wanted, and it means + the canon is taught rather than merely true. + +[A] 2026-07-21 — ⚖️ **GATE 0.4b RULED: gradeFor's scraped/solvent boundary STAYS AT >=3 on + the seven-night shape — D's flag answered, with the arithmetic, and the answer is "no + change" for a stated reason rather than by neglect.** + D derived the clean bar (held >= savable = 6) and left me the taste call, correctly. The + seven-night shape makes >=3 fit BETTER than it did at six, which is why it stays: + · savable = 7 nights − the icenight's designed loss = **6**. So scraped is 3–5 held and + solvent is 0–2 held, i.e. **solvent now means four to six dead gardens**. + · week.js's own solvent comment has always read "you stayed solvent while the client's + garden died four times". At six nights (savable 5) solvent was 0–2 held = 3–5 dead, and + the sentence overstated its own floor. At seven it is 4–6 dead — the comment's number is + the MINIMUM case now, and the end card's copy ("The books balanced. The garden didn't." + / "You can be paid all week and still have nothing to point at.") reads true across the + whole band instead of across most of it. + · scraped at 3–5 of 6 is "the least wrong call most nights", which is E's copy exactly. + Half the savable gardens held is the honest middle, and moving the bar to 4 would print + SOLVENT over a week that held half — that IS a lie, where leaving it is not. + So: **>=3 stands, ruled on the arithmetic, not left alone by default.** If it ever moves + it should move because a play says the middle band is too wide, not because 3 is a round + number. a.test's gradeFor pins already walk every value 0..6 and would catch a change. + +[A] 2026-07-21 — 🗣 **GATE 0.3 — B, MY CONSULT ON THE SCORE-HEADER WORDING. You propose, I + advise; the card is yours and I am not landing anything in it.** + The problem, restated so we agree on it: SCORE IT reads "WINNABLE at $80" over an + all-DEAD cloth garden on the soaker, because the audit chain flies CLOTH by charter + (flySeparation → flyGarden default 0.30) and the soaker is the one night where that + default is the wrong bet. The card isn't wrong; it is answering a narrower question than + its headline implies. + · **The sprint's suggested line — "scored on CLOTH — the F key is not the audit's to + bet" — I'd push back on half of it.** "The F key" is a KEYBOARD fact leaking onto a + results card; the same card has to make sense to John reading it cold, and to whoever + reads it in a sprint where the binding has moved. The fabric is the noun that matters. + · **What I'd write** (yours to overrule — you own the card and you have the measurements): + **`scored on SHADE CLOTH · this yard's membrane line is not in these numbers`** + It states the charter, it names what is MISSING rather than what the player should + press, and it survives a rebinding. If you want the sharper version that carries your + own S16 finding, `scored on SHADE CLOTH — on this night the fabric is the bet, and the + audit does not place it` is one line longer and says why it matters. + · **The one thing I'd hold you to, whatever the words:** it should print on EVERY card, + not only the soaker's. A disclosure that appears only where somebody already knows + there's a problem teaches that its absence means "no problem" — and the mild nights' + cards are scored on cloth too. Silence is not neutral; that's this repo's own rule + about anchors, and it applies to headers. + · Not my lane, no code from me. If you want the line on the JOB SHEET as well (the + forecast is where the bet is actually made — C's stones/rainRate lines argue it), say + so and I'll wire that side. + +[A] 2026-07-21 — 🧾 **GATES 1 + 2 LANDED: THE JOB BOARD. Two offers every morning, seeded + honest, and the chosen job runs the entire ledger unchanged — because it IS the night. + Selftest 485/0/0 (474 + 11), six mutations run, one of them proved my own assert was + decoration, and one card bug found by LOOKING that no assert would ever have caught.** + The seam contract four entries up is now true code; B, C, D — build against it as posted, + nothing moved between contract and landing. + + **What landed:** + · **`board.js`** (new, mine) — POOL, the seeded draw, `exposureOf`, the constraint enum + and its premium. Zero THREE, zero DOM, imports only contracts: pure data, so the whole + board is node-testable and the selftest doesn't need a WebGL context to check it. + · **`week.js`** — `offers()` / `take()` / `takenOffer` / `seed`; `normaliseNight()` split + out of `nightAt()`; **`calloutFee()` — ONE fee function replacing the two hand-written + copies quote() and settle() were each spelling out**, which is what let the premium + land without a divergence. + · **`hud.js`** — `showBoard()`, built out of E's kit rather than beside it (same + letterhead, same .row/.cond rhythm, same terms line). Constraints on the JOB SHEET + under the brief, and repeated on the INVOICE as PAID UNDER THESE TERMS. + · **`main.js`** — the morning is board → pick → world load → job sheet. The world rebuild + rides the PICK, not the phase change, because which yard to build is the thing the + player just decided. + + **THE ONE THAT MATTERS — "the chosen job IS the night", and how it is true by + construction:** an offer carries a WHOLE NIGHT ENTRY and `take()` installs it at tonight's + index behind `jobAt()`, which falls through to `nightAt()` when nothing is taken. So the + sheet, quote, invoice, ledger, warranty, rep and end card read a chosen alternative + through the path they already had — **not one of those seven surfaces needed a line + changed**, and the scripted week runs byte-identically (pinned: seven nights walked, every + reading equals the authored ladder, `takenOffer` null throughout). + + **⚠️ TWO BUGS FOUND, BOTH BY THINGS THAT ARE NOT THE TEST SUITE:** + 1. **The board probe caught `take()` storing the RAW pool entry** — a pool entry with no + `pay` block threw `Cannot read properties of undefined (reading 'garden')` straight out + of quote(). i.e. a chosen job was NOT the same shape as a scripted one, which is the + single promise gate 1 makes. Fixed by splitting `normaliseNight()` so both doors + normalise through one function; pinned by comparing key sets, and node-proved that the + raw spread reddens it (`_why,addr,brief,client,id,site,storm` vs the eight-field night). + 2. **NIGHT 1 HAD NO BOARD, and only LOOKING found it.** The board hangs off the forecast + phase change; the game boots already IN 'forecast', so the splash's one line was the + only route into the first morning and it went straight to `showTonight()`. Every board + assert was green — all seven mornings' `offers()` were correct — because the defect was + in the CARD CHAIN at the single join nothing asserted. **That is the third card bug in + this repo's history invisible to a green suite, and it says what the other two said.** + `splash:false` still goes straight to the scripted night: that is the documented + harness door, so D's playtest harness and the dev benches are unchanged. + + **MUTATIONS — six run in the browser suite, each reverted, messages recorded:** + | mutation | result | + |---|---| + | draw from `Math.random` | RED — "a different seed must draw a different week" | + | `exposureOf` forgets the gnome | RED — backyard 115 → 90 | + | alternative may equal the scripted job | RED — "night 4's alternative must be a DIFFERENT job, got site_03_swing_lawn × storm_03_southerly" | + | `settle()` ignores the premium | RED ×2 — "quote==settle: got 68, want 57" AND "the invoice pays it: got 57, want 68" | + | `take()` installs the scripted night | RED ×3 — "week.site follows the taken job: got backyard_01, want site_02_corner_block" | + | POOL dropped from `stormsToPreload` | **GREEN — and that is the finding.** | + + **⚠️ THE MUTATION THAT DIDN'T GO RED, because this is the one worth reading.** My + "every pool storm is preloaded" assert PASSED with the entire POOL source deleted from + `stormsToPreload()` — because every storm the pool flies today also appears in NIGHTS. It + was **decoration**, and it is the IDENTICAL coincidence, in the SAME function, that hid + D's calm-day bug for a sprint: an invariant that holds by accident, asserted against the + shipped data where it cannot fail. Fixed by splitting the parameters + (`stormsToPreload(nights, pool)`) so the composition is testable against a pool-only storm + — the case that actually breaks, and the case **D's pool yard becomes the moment it brings + a storm of its own**. Re-run with the split: RED, correctly. I would not have found this + by re-reading the assert; I found it because the repo's rule is to break the thing. + + **The pool, and its admission price.** Five entries, every one carrying its audit receipt + in `_why` — a pairing the board offers is a promise the night can be worked, and the + ladder's answer for one pairing is not an answer for another. Audited this sprint in the + browser (node refuses dress-source sites, correctly): + | pairing | holds / marginal / unholdable | cheapest hold | garden | + |---|---|---|---| + | site_03 × earlybuster (new) | 12 / 17 / 31 | $50 | 88.6 FULL | + | site_02 × southerly (new) | 19 / 7 / 38 | $40 | 87.6 FULL | + | site_02 × earlybuster (night 3's, re-flown as the control) | — | $40 | 86.4 FULL | + · **Both constraints were checked for soft-lock, not assumed.** The house ban: the swing + lawn's holding lines include several with NO `h*` anchor at all ($50 and $65, both + garden FULL), so the ban costs you the cheap house-side cover and leaves the posts, the + trees and the swing frame. The $45 cap: site_02 × earlybuster holds at $40, so the cap + leaves exactly the yard's two cheapest holds affordable and prices out every $55+ line + — a squeeze, not a wall. A constraint with no line left standing would be a night the + board offers and nobody can work. + · Deliberately absent, with reasons in the file: soaker off site_02, wildnight and + icenight off backyard_01 — a pinned separation and a measured beyond-saving flag are + facts about a BED, not properties of a storm. + + **Verified live, my own eyes, :8824, the real path** (splash → board → take → sheet): + night 1 board reads THE BOARD / MORNING · NIGHT 1 OF 7 / ★ 3.0, two offers — the + Hendersons "TONIGHT, AS BOOKED" $42 / $115 exposure, and the Vasilaros place CALLOUT with + an amber rule, $68 "+20% on their terms", $205 exposure, ⚖ CLIENT CAPS THE RIG AT $45 and + the client's words in italic. Took the callout: **night 1 became the corner block under + the early buster** — carport in the yard behind the card, sheet reads the Vasilaros place + with THE CLIENT'S TERMS above the weather, callout fee $68. Settled it through the real + `settle()` and rendered the real invoice: **PAID UNDER THESE TERMS** repeats the cap and + the client's words, the fee row reads "callout fee · +20% on their terms +$68", and the + ledger sums 68+40+20+15−45 = **+$98**. (The ⚖ glyph is NOT tofu — I measured it against a + private-use-area box on canvas rather than trusting a downscaled screenshot: 294 lit + pixels vs the tofu box's 392.) + + **Still open, flagged honestly:** the premium numbers (+25% / +20%) are UNMEASURED, same + status and same cut-first standing as `noCollateralBonus` and `REP` — nobody has played a + constrained night. Gate 4's play is what settles them. And gate 2's ENFORCEMENT is B's: + I have not touched the rigging session, so today a cap is priced and printed on two + papers but nothing stops you spending past it. **B — that's your half and the seam is + posted.** diff --git a/web/world/js/board.js b/web/world/js/board.js new file mode 100644 index 0000000..78661c2 --- /dev/null +++ b/web/world/js/board.js @@ -0,0 +1,357 @@ +/** + * 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; +} diff --git a/web/world/js/hud.js b/web/world/js/hud.js index c6d173a..a99754e 100644 --- a/web/world/js/hud.js +++ b/web/world/js/hud.js @@ -305,6 +305,44 @@ const CSS = ` } /* ---- end of E's kit ----------------------------------------------------- */ +/* --- SPRINT17 gate 1: THE JOB BOARD ------------------------------------ + A new card in the paper chain, and it has to meet the invoice's standard — + so it is built out of the kit rather than beside it: same letterhead, same + .row/.cond rhythm in the offer body, same terms line at the foot. + + The one NEW idea it needs is that an offer is a THING YOU PICK, so each one + is a button (the .storm affordance the old storm-select card used, widened + into a panel). Two offers, stacked, tonight's scripted job first — see + board.js offersFor() for why that order is fixed and not seeded. + + The constrained offer gets a visible mark, because gate 2's whole point is + that taking a constrained job is a CHOICE: an amber left border and the + client's own words in italic. A player must not be able to take one without + having been told, and "it was in the fee" is not being told. */ +#hud-card .offer { display:block; width:100%; text-align:left; margin:9px 0; + padding:13px 15px; background:#121c23; border:1px solid #2f3f4a; border-radius:7px; + color:#dde5ea; cursor:pointer; font:inherit; + transition:border-color .15s, background .15s; } +#hud-card .offer:hover { border-color:#7ee0ff; background:#16242c; } +#hud-card .offer .who { font-weight:700; color:#fff; letter-spacing:.04em; } +#hud-card .offer .addr { font-size:11px; color:#6d818e; margin-bottom:7px; } +#hud-card .offer .tag { float:right; font-size:10px; letter-spacing:.14em; + color:#55707f; text-transform:uppercase; } +#hud-card .offer .line { display:flex; justify-content:space-between; gap:14px; + font-size:12px; color:#8ba0ad; padding:2px 0; } +#hud-card .offer .line b { color:#dde5ea; font-weight:700; } +/* The two numbers a tradie decides on. The fee is the reason to say yes and + the exposure is the reason to say no, so they are weighted against each + other rather than both being grey: money in, money at risk. */ +#hud-card .offer .fee b { color:#ffd9a3; } +#hud-card .offer .risk b { color:#ff8f86; } +#hud-card .offer.constrained { border-left:3px solid #ffc24a; } +#hud-card .offer .says { margin-top:8px; padding-left:9px; border-left:2px solid #3f5561; + font-size:11px; font-style:italic; color:#c8b78a; line-height:1.5; } +#hud-card .offer .cons { font-size:10px; letter-spacing:.12em; text-transform:uppercase; + color:#ffc24a; margin-top:7px; } +#hud-card .board-note { margin-top:12px; font-size:11px; color:#6d8494; } + /* Tomorrow is a rumour, and it should look like one next to tonight's numbers. (Mine, not E's — the kit predates the tomorrow line and doesn't restyle it.) */ #hud-card .tomorrow { display:flex; gap:10px; align-items:baseline; margin-top:9px; @@ -734,6 +772,99 @@ export function createHud(d) { // --- cards ------------------------------------------------------------ + /** + * SPRINT17 gate 1 — THE JOB BOARD. The morning, before the job sheet. + * + * ROADMAP arc 2's opening move: *"mornings offer N callouts, you take one. + * Pay, risk, client and site visible before you commit."* Two offers, + * tonight's scripted night first, and taking either one makes it the night. + * + * **What an offer card shows, and why it is exactly this list** — SPRINT17 + * gate 1.2 asks for "what a tradie would demand before saying yes", which + * is not a feature list, it's a job interview: + * · WHO and WHERE — the client and the address, off the same night data + * the letterhead bills. A job you can't name is a job you don't take. + * · WHAT THE WEATHER IS DOING — C's honest forecast band at lead 0, the + * same call the job sheet makes, so the board cannot be more confident + * than the sheet it leads to. + * · THE FEE — what you're paid, premium included, priced through the + * SAME calloutFee() the sheet quotes and the invoice pays. + * · THE RISK — collateral exposure: what of theirs your failure can + * bill you for. This is the number that makes the choice a choice; a + * board that showed only the fee would be advertising, not quoting. + * · THE CLIENT'S CONSTRAINTS, in the client's own words, marked — so + * taking a constrained job is a decision and never a surprise. + * + * The pricing is injected (main.js owns the storm defs and site JSON); + * anything absent simply doesn't render, which is the same degradation rule + * the job sheet's forecast lines follow. The card works with nothing but + * client/site/brief, and that is what lets it be tested without a server. + * + * @param {object[]} offers board.js offersFor(), already priced + * @param {object} wk {night, nights, bank, log, rep} + * @param {(offer:object) => void} onTake + */ + showBoard(offers, wk, onTake) { + const pips = Array.from({ length: wk.nights }, (_, i) => { + const done = i < wk.night - 1; + const now = i === wk.night - 1; + const held = done && wk.log?.[i]?.won; + return `${ + now ? '◆' : done ? (held ? '●' : '○') : '·'}`; + }).join(''); + + const offerHtml = (o, i) => { + const n = o.night; + const cons = o.constraints ?? []; + const f = o.forecast; + return ` + `; + }; + + card.innerHTML = `
+
+
+
${BUSINESS.mark}
+
${BUSINESS.trade}
+
+
+
THE BOARD
+
MORNING · NIGHT ${wk.night} OF ${wk.nights}
+ ${wk.rep != null ? `
standing ${repFmt(wk.rep)}
` : ''} +
+
+

TWO JOBS ON OFFER

+
${pips}
+

Take one. The other goes to somebody else.

+ ${offers.map(offerHtml).join('')} +
in the bank$${wk.bank}
+
Both are tonight. You cannot work two yards in one storm.
+
Fees quoted on the forecast at time of callout. Weather is not a variation.
+
`; + card.classList.add('on'); + hud.setVisible(false); + for (const el of card.querySelectorAll('.offer')) { + el.addEventListener('click', () => { + hud.hideCard(); + onTake(offers[Number(el.dataset.offer)]); + }); + } + }, + /** * The forecast. Its job is to sell the dread and, incidentally, to be the * difficulty select for free — three authored storms already bracket the @@ -797,6 +928,23 @@ showForecast({ key, def, site, tomorrowDef }, wk, onGo) { const brief = job.brief ? `
${job.brief}
` : ''; + // SPRINT17 gate 2 — THE CLIENT'S TERMS, on the sheet, in their words. + // Under the brief and above the weather on purpose: the constraint is + // part of what they're asking for, not a footnote to it, and the player + // has to have read it BEFORE the schedule tells them what it pays. The + // invoice repeats this block verbatim off the settlement (a constraint + // the invoice forgot would be the paper losing the terms it was paid + // under, which is gate 2's specific requirement). + const cons = job.constraints ?? []; + const constraintBlock = cons.length ? ` +
THE CLIENT'S TERMS
+
+ ${cons.map((c) => ` +
${c.label}${ + c.kind === 'budgetCap' ? `$${c.cap} cap` : 'refused'}
+
“${c.says}”
`).join('')} +
` : ''; + // BUDGET Y — and this is the actual feature, not the flavour. The fee has // existed since Sprint 8 and the player has never seen it until the money // was already spent: you decided what to rig without knowing what the job @@ -872,6 +1020,7 @@ showForecast({ key, def, site, tomorrowDef }, wk, onGo) { card.innerHTML = `
${letterhead} ${brief} + ${constraintBlock}

NIGHT ${wk.night} OF ${wk.nights}

${pips}

${(f.name ?? key).replace(/_/g, ' ').toUpperCase()}${night ? ' · NIGHT' : ''}

@@ -1033,7 +1182,13 @@ showForecast({ key, def, site, tomorrowDef }, wk, onGo) { const invFeeTag = w && w.feeMult && w.feeMult !== 1 ? ` · standing ×${w.feeMult.toFixed(2)}` : ''; const money = w ? `
-
${w.won ? 'callout fee' : `callout fee — night lost (${Math.round(100 * w.fee / Math.max(1, w.fullFee))}%)`}${invFeeTag}+$${w.fee}
+
${w.won ? 'callout fee' : `callout fee — night lost (${Math.round(100 * w.fee / Math.max(1, w.fullFee))}%)`}${invFeeTag}${ + // SPRINT17 gate 2 — the fee row names the premium it was paid at. + // The constraint moved this number, so the row that shows the + // number is where it has to be said; a premium that only appeared + // in the total would be money with no reason printed next to it. + w.premium ? ` · +${Math.round(w.premium * 100)}% on their terms` : '' + }+$${w.fee}
garden bonus — ${r.hp.toFixed(0)}% of the bed${ // SPRINT14 — B's pool item, the invoice half. On a beyond-saving // night this row is always small, and next to "callout fee — night @@ -1083,6 +1238,21 @@ showForecast({ key, def, site, tomorrowDef }, wk, onGo) {

MORNING${w ? ` · NIGHT ${w.night} OF ${w.nights ?? NIGHT_COUNT}` : ''}

${r.subtitle}

${rows} + ${/* SPRINT17 gate 2 — THE INVOICE REPEATS THE CONSTRAINT IT WAS PAID + UNDER. The spec's own requirement, and it earns its place: the + invoice is the record, and a record of a constrained job that + doesn't say it was constrained has lost the one term that shaped + every decision on it. Off the settlement (week.js carries + s.constraints), so it stays true when you re-read night 3's + invoice on night 5's morning. */''} + ${w?.constraints?.length ? ` +
PAID UNDER THESE TERMS
+
+ ${w.constraints.map((c) => ` +
${c.label}${ + c.kind === 'budgetCap' ? `$${c.cap} cap` : 'refused'}
+
“${c.says}”
`).join('')} +
` : ''} ${money}
${r.verdict}
${/* SPRINT16 — the verdict speaks rep: the movement AND its reason, so diff --git a/web/world/js/main.js b/web/world/js/main.js index c04319a..36829bb 100644 --- a/web/world/js/main.js +++ b/web/world/js/main.js @@ -17,7 +17,7 @@ import * as THREE from '../vendor/three.module.js'; import { FIXED_DT, PHASES, STORM_LEN, HARDWARE, SPARE_COST, Emitter } from './contracts.js'; import { createWorld, loadSite } from './world.js'; import { createCameraRig, spawnYawFor } from './camera.js'; -import { loadStorm, createWind } from './weather.js'; +import { loadStorm, createWind, forecastLines } from './weather.js'; import { SailRig, createSailView } from './sail.js'; import { createPlayer } from './player.js'; import { Interact, wireYardActions } from './interact.js'; @@ -25,7 +25,8 @@ import { createDebris } from './debris.js'; import { createSkyFx } from './skyfx.js'; import { createRiggingUI, fabricNoteFor } from './rigging.js'; import { createHud } from './hud.js'; -import { createWeek, NIGHTS, nightAt } from './week.js'; +import { calloutFee, createWeek, NIGHTS, nightAt } from './week.js'; +import { POOL, exposureOf } from './board.js'; import { createGarden } from './garden.js'; /** The calm day the forecast and prep phases run under. */ @@ -52,10 +53,32 @@ export const CALM_STORM = 'storm_01_gentle'; * there — night one is gentle today, so the bug hides. That assert would be * decoration, which is the exact failure mode this repo keeps naming. * - * @param {string[]} [nights] storm keys, defaults to the shipped ladder + * ⚠️ **SPRINT17 — THE POOL JOINS THE LADDER HERE, and this is the same landmine + * in a new costume.** The board can put ANY pool night on tonight, so a pool + * storm that isn't preloaded is `defs[week.stormKey] === undefined` the moment + * a player takes that offer — which is D's calm-day bug exactly: an invariant + * ("every storm the game can run is loaded") that used to hold by coincidence, + * because NIGHTS was the only source of nights. It isn't anymore. Both sources + * are named here, deduped, and a.test pins that a pool-only storm survives the + * list. + * + * ⚠️ **TWO PARAMETERS, NOT ONE CONCATENATED DEFAULT — and this assert had to be + * rescued from being decoration, by mutation-checking rather than by reading + * it.** The first cut took a single `nights` list defaulting to NIGHTS+POOL, + * and a.test asserted "every pool storm is preloaded". That assert passed with + * the POOL source deleted outright, because every storm the pool flies today + * also appears in NIGHTS — the same coincidence, in the same function, that hid + * the calm-day bug for a sprint. Split, the composition is testable against a + * pool storm that is NOT in the ladder — the case that would actually break. + * + * @param {string[]} [nights] storm keys from the ladder + * @param {string[]} [pool] storm keys the BOARD can put on tonight */ -export function stormsToPreload(nights = NIGHTS.map((_, i) => nightAt(i).storm)) { - return [...new Set([CALM_STORM, ...nights])]; +export function stormsToPreload( + nights = NIGHTS.map((_, i) => nightAt(i).storm), + pool = POOL.map((p) => p.storm), +) { + return [...new Set([CALM_STORM, ...nights, ...pool])]; } const STORMS = stormsToPreload(); @@ -979,6 +1002,72 @@ export async function boot(opts = {}) { * anything. Lane B's file, flagged in THREADS — main.js therefore tracks * `spentThisNight` itself off the bank rather than trusting `.spent`. */ + /** + * SPRINT17 gate 1 — THE MORNING: the board, then the job sheet. + * + * Site JSON is needed for the exposure number on an offer whose yard we have + * NOT loaded (and must not load — loading it would rebuild the world behind + * the card for a job the player might not take). `loadSite` is a small JSON + * fetch with no GLBs, cached here so a week of boards costs three requests. + * + * The cache is keyed on site name and never invalidated on purpose: a site + * JSON cannot change inside a run, and a stale-cache bug on a number the + * player makes money decisions from would be worth more than the three + * requests it saves. + * @type {Map} + */ + const siteJson = new Map(); + async function siteJsonFor(name) { + if (!siteJson.has(name)) siteJson.set(name, await loadSite(name)); + return siteJson.get(name); + } + + /** + * Price every offer on this morning's board. + * + * The fee comes from `week.quote()` on a WEEK VIEW OF THAT NIGHT, not from a + * formula retyped here — so the number on the offer card is the number the + * job sheet quotes and the invoice pays, by construction rather than by two + * developers agreeing. (The board showing $68 and the sheet then saying $57 + * would be the quote-vs-settle lie moved one card earlier, which is exactly + * the class of bug the ledger's derivation was built to make impossible.) + */ + async function pricedOffers() { + for (const o of week.offers()) await siteJsonFor(o.night.site); + return week.offers((night) => { + const def = defs[night.storm]; + const exp = exposureOf(siteJson.get(night.site)); + return { + // C's honest lines at lead 0 — tonight, exact. The same call the job + // sheet makes, so the board can never be more confident than the sheet. + forecast: def ? forecastLines(def, 0) : null, + fee: def ? calloutFee(def, week.rep, night.constraints) : null, + exposure: exp.total, + exposureItems: exp.items, + }; + }); + } + + /** + * The morning, in order: the board offers two jobs, the player takes one, and + * ONLY THEN does the world load — because which yard to build is the thing + * they just decided. Everything downstream (`showTonight`) is untouched: it + * reads `week.site` / `week.job` / `week.quote()` exactly as it did when the + * week was a script, which is the whole point of installing the taken job as + * the night rather than bolting a parallel concept beside it. + */ + function showMorning() { + return pricedOffers().then((offers) => new Promise((resolve) => { + hud.showBoard(offers, { + night: week.night, nights: week.nights, bank: week.bank, + log: week.log, rep: week.rep, + }, (offer) => { + week.take(offer); + showTonight().then(resolve); + }); + })); + } + async function showTonight() { // SPRINT10: tonight's yard. loadSiteInto no-ops when the site is unchanged // (the four backyard nights) and rebuilds + re-points everything holding the @@ -1073,7 +1162,11 @@ export async function boot(opts = {}) { // on the new world/player, not the old one mid-swap. This is the only // phase transition that can rebuild the scene, so it's the only one that // has to sequence like this. - showTonight().then(() => { + // SPRINT17: the board comes first, and the world rebuild rides the PICK + // rather than the phase change — the yard to build is not known until + // the player has chosen a job. The resets still land after the rebuild + // (same .then sequencing, one card earlier in the chain). + showMorning().then(() => { garden.reset(); resetRig(); player.sim.carrying = null; @@ -1176,13 +1269,28 @@ export async function boot(opts = {}) { // `opts.splash === false` skips it — the selftest, the dev benches and D's // playtest harness all boot straight into a night, and none of them should // have to click through a door. + // + // ⚠️ SPRINT17 — THE FIRST MORNING GETS A BOARD TOO, and for an hour it did + // not. The board hangs off the forecast PHASE CHANGE, and night one never + // has one: the game boots already IN 'forecast', so this line is the only + // route into the first morning. Going through showTonight() made night 1 the + // one night of the week with no choice on it. Found by LOOKING at the running + // game, not by a test — every board assert operates on week.offers() and all + // seven mornings were correct; the CARD CHAIN was wrong at the single join no + // assert was watching. (Both card bugs in this repo's history were invisible + // to a green suite. This is the third, and it says the same thing.) + // + // `splash:false` still goes straight to the scripted night, and that is the + // documented harness door rather than an oversight: a bench that asked for a + // night wants the night, not a card waiting to be clicked. D — your playtest + // harness is unchanged; the REAL path is the one with the board on it. hud.setAudioMuteAvailable(typeof sky?.setMute === 'function'); if (!canPlayHere()) { hud.showTouchNotice(); // no way through, on purpose: see hud.showTouchNotice } else if (opts.splash === false) { showTonight(); } else { - hud.showSplash(() => showTonight()); + hud.showSplash(() => showMorning()); } // --- resize ------------------------------------------------------------- diff --git a/web/world/js/tests/a.test.js b/web/world/js/tests/a.test.js index ae2e4d1..8a907fd 100644 --- a/web/world/js/tests/a.test.js +++ b/web/world/js/tests/a.test.js @@ -20,7 +20,12 @@ import { RiggingSession } from '../rigging.js'; import { createSkyFx } from '../skyfx.js'; import { SailRig, orderRing } from '../sail.js'; import { loadStorm, createWind } from '../weather.js'; -import { createWeek, NIGHTS, nightAt, gradeFor, BROKE_BELOW, PAY, REP } from '../week.js'; +import { + BROKE_BELOW, PAY, REP, calloutFee, createWeek, gradeFor, nightAt, NIGHTS, +} from '../week.js'; +// SPRINT17 gate 1/2 — the board and the constraint data are mine; these are +// the seam this suite pins (offer shape, pool, exposure, constraint enum). +import { CONSTRAINT_PREMIUM, POOL, exposureOf, validateConstraint } from '../board.js'; import { createHud } from '../hud.js'; import { PALETTE, boundsFaults, emptyTemplate, exportSiteJSON, placeEntry } from '../editor.js'; import { assert, assertClose, assertEq, assertLess, fixedLoop } from '../testkit.js'; @@ -291,11 +296,15 @@ export default async function run(t) { // ladder WITHOUT the gentle storm — the case that actually broke. Checked // against the shipped NIGHTS it would pass either way (night one is gentle // today), which is how the bug hid in the first place. + // SPRINT17: stormsToPreload takes the LADDER and the POOL separately now + // (see its docstring — the single-list version made the pool assert + // decoration). These pass an empty pool so this pin keeps testing exactly + // what it was written to test: the calm day's survival, ladder-only. assert(stormsToPreload().includes(CALM_STORM), 'the calm day is preloaded on the shipped ladder'); - assert(stormsToPreload(['storm_02_wildnight']).includes(CALM_STORM), + assert(stormsToPreload(['storm_02_wildnight'], []).includes(CALM_STORM), "...and on a ladder that doesn't contain it anywhere — the case that broke"); - assertEq(stormsToPreload(['storm_01_gentle']).length, 1, + assertEq(stormsToPreload(['storm_01_gentle'], []).length, 1, 'and it is still deduped, not loaded twice'); }); @@ -424,6 +433,271 @@ export default async function run(t) { 'the second-to-last night is not the final night — going broke there ends the run'); }); + // --- SPRINT17 gate 1: THE JOB BOARD ------------------------------------- + // Loaded up front — Suite.test() cannot await, and every board assert wants a + // real storm def to price a fee against. + const boardDefs = {}; + for (const k of ['storm_01_gentle', 'storm_03_southerly', 'storm_03b_earlybuster']) { + try { boardDefs[k] = await loadStorm(k); } catch { /* no server: asserts below degrade */ } + } + // The raw site JSON, for the exposure pins — deliberately NOT a built world: + // exposureOf() answers "what is priced in this yard" off data a board can read + // without building anything, which is the whole reason it can price an offer + // for a yard the player has not chosen yet. + const siteJsonForTest = {}; + for (const k of ['backyard_01', 'site_02_corner_block', 'site_03_swing_lawn']) { + try { siteJsonForTest[k] = await loadSite(k); } catch { /* degrades to SKIPPED */ } + } + + t.test('SPRINT17: every morning offers TWO jobs — tonight, plus one alternative', () => { + const w = createWeek(); + for (let i = 0; i < NIGHTS.length; i++) { + const offers = w.offers(); + assertEq(offers.length, 2, `night ${i + 1} offers two jobs`); + // The scripted spine SURVIVES, and it 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. + assertEq(offers[0].kind, 'scripted'); + assertEq(offers[0].night.storm, nightAt(i).storm, `night ${i + 1}'s scripted offer IS the ladder's night`); + assertEq(offers[0].night.site, nightAt(i).site); + assertEq(offers[1].kind, 'callout', 'the second is drawn from the pool'); + // An alternative that is the same yard under the same storm is not an + // alternative — it is the board lying about having offered a choice. + assert(!(offers[1].night.storm === offers[0].night.storm + && offers[1].night.site === offers[0].night.site), + `night ${i + 1}'s alternative must be a DIFFERENT job, got ${offers[1].night.site} × ${offers[1].night.storm}`); + w.advance(); + } + }); + + t.test('SPRINT17: the board is DETERMINISTIC — same week, same offers, always', () => { + // The repo's oldest rule (contracts.js §Determinism) and the selftest + // depends on it: a pool that drew from Date.now or Math.random is a suite + // that goes red on a Tuesday. Three independent weeks, walked in full. + const walk = (seed) => { + const w = createWeek(seed == null ? {} : { seed }); + const out = []; + for (let i = 0; i < NIGHTS.length; i++) { out.push(w.offers().map((o) => o.id).join('|')); w.advance(); } + return out.join(' ; '); + }; + const a = walk(), b = walk(), c = walk(); + assertEq(a, b, 'two fresh weeks must offer the same board'); + assertEq(b, c, '...and a third'); + // And the seed is REAL — a different week is a different board, or the + // "deterministic" pin above is passing on a constant and proving nothing. + // (This is the negative control for the assert above it: without it, an + // offersFor() that ignored the seed entirely and always returned POOL[0] + // would sail through the equality checks.) + const other = walk(7); + assert(other !== a, 'a different seed must draw a different week of offers'); + }); + + t.test('SPRINT17: TAKE ONE — the chosen job IS the night, all the way to the invoice', () => { + if (!boardDefs.storm_03_southerly) return 'SKIPPED — no server for storm defs'; + const w = createWeek(); + const offers = w.offers(); + const alt = offers[1]; + // Precondition: the alternative really is a different job from the spine, + // or this test proves nothing about taking one. + assert(alt.night.site !== nightAt(0).site || alt.night.storm !== nightAt(0).storm, + 'precondition: the alternative differs from night 1'); + + w.take(alt); + assertEq(w.site, alt.night.site, 'week.site follows the taken job'); + assertEq(w.stormKey, alt.night.storm, 'and so does the storm'); + assertEq(w.job.client, alt.night.client, 'and the client the letterhead bills'); + assertEq(w.takenOffer, alt.id, 'the week records WHICH offer was taken'); + + // THE ASSERT GATE 1 ASKS FOR IN SO MANY WORDS: "a chosen alternative that + // skips the ledger is the board lying." Every per-night surface must work + // UNCHANGED on a chosen night — so run the whole paper chain over one. + const def = boardDefs[alt.night.storm]; + const q = w.quote(def); + assert(q.base > 0, 'a taken job quotes a fee'); + assertEq(q.total, q.base + q.garden + q.clean - q.warrantyTotal, 'the quote still sums'); + const s = w.settle({ hp: 90, win: true, collateral: [], intactHardwareValue: 40, rigRecord: [] }, def, 40); + assertEq(s.client, alt.night.client, 'the invoice bills the client you chose'); + assertEq(s.site, alt.night.site, 'the ledger records the yard you chose'); + assertEq(s.taken, alt.id, 'and that it came off the board'); + assertEq(s.pay, s.fee + s.bonus + s.refund + s.clean - s.collateral - s.warrantyTotal, + 'the ledger adds up on a chosen night exactly as it does on a scripted one'); + assertEq(q.base, s.fullFee, 'quote==settle holds on a taken job'); + assert(s.rep != null && s.grade != null, 'rep and grade are computed for a chosen night'); + }); + + t.test('SPRINT17: a taken night is INDISTINGUISHABLE in shape from an authored one', () => { + // The bug this pins was real and the board probe found it before a player + // could: take() stored the raw pool entry, and a pool entry with no `pay` + // block threw "Cannot read properties of undefined (reading 'garden')" out + // of quote(). Both doors normalise through normaliseNight() now — this + // asserts the SHAPES match rather than trusting that they do. + const w = createWeek(); + const scripted = nightAt(0); + w.take({ id: 'alt:probe', night: { storm: 'storm_01_gentle', site: 'backyard_01' } }); + const takenKeys = Object.keys(w.job).sort().join(','); + assertEq(takenKeys, Object.keys(scripted).sort().join(','), + 'a taken night carries exactly the fields an authored night carries'); + // And the defaults really defaulted, rather than being present-but-undefined. + assertEq(typeof w.job.pay, 'object', 'pay defaults to a block, not undefined'); + assertEq(w.job.constraints.length, 0, 'constraints default to an empty array'); + assertEq(w.job.gardenBeyondSaving, false, 'a new night cannot inherit the icenight\'s excuse'); + }); + + t.test('SPRINT17: take is a COMMITMENT — the other offer vanishes and there is no untake', () => { + const w = createWeek(); + const [scripted, alt] = w.offers(); + w.take(alt); + assertEq(w.takenOffer, alt.id); + // Taking again on the same morning is the SAME take, not a re-pick. A + // player who could re-open the board after reading the job sheet would be + // shopping, not choosing — and the fee is already quoted against the job. + w.take(scripted); + assertEq(w.takenOffer, alt.id, 'the morning\'s choice is final'); + assertEq(w.site, alt.night.site, 'and the yard did not change under it'); + // A fresh morning is a fresh choice, though. + w.advance(); + assertEq(w.takenOffer, null, 'tomorrow is unchosen'); + }); + + t.test('SPRINT17: the SPINE runs untouched when nothing is taken — the campaign is not the board', () => { + // 474 asserts stand on the scripted week. The board must be a second door, + // not a replacement: take nothing and every per-night reading is the + // authored ladder, byte for byte. + const w = createWeek(); + for (let i = 0; i < NIGHTS.length; i++) { + w.offers(); // building a board must not MOVE anything + assertEq(w.stormKey, nightAt(i).storm, `night ${i + 1} storm untouched by the board`); + assertEq(w.site, nightAt(i).site, `night ${i + 1} site untouched`); + assertEq(w.job.client, nightAt(i).client, `night ${i + 1} client untouched`); + assertEq(w.takenOffer, null, 'and nothing is marked taken'); + w.advance(); + } + }); + + t.test('SPRINT17: every pool night is a JOB, and its storm is one the game preloads', () => { + for (const p of POOL) { + assert(p.id, 'a pool entry needs an id'); + assert(p.client, `${p.id} has a client`); + assert(p.addr, `${p.id} has an address — a letterhead bills somebody somewhere`); + assert(p.brief && p.brief.length > 20, `${p.id} has a brief worth reading`); + assert(p.site, `${p.id} names a yard`); + assert(p._why, `${p.id} carries its audit receipt — an unmeasured pairing is a night the board is guessing about`); + // ⚠️ D's calm-day landmine, in its SPRINT17 costume: the board can put any + // pool night on tonight, so a pool storm the loader never fetched is + // `defs[week.stormKey] === undefined` the moment somebody takes it. + assert(stormsToPreload().includes(p.storm), + `${p.id} flies ${p.storm}, which nothing preloads — taking this offer would boot into an undefined storm`); + } + // ⚠️ THE LINE ABOVE IS NOT ENOUGH, AND A MUTATION PROVED IT (A, S17): every + // storm the pool flies TODAY also appears in NIGHTS, so that assert passes + // with the whole POOL source deleted from stormsToPreload — decoration, and + // the identical coincidence that hid the calm-day bug for a sprint. THIS is + // the assert with teeth: a pool storm that is NOT in the ladder, which is + // the case that actually breaks, and the case D's pool yard will be the + // moment it brings a storm of its own. + assert(stormsToPreload(['storm_01_gentle'], ['storm_99_fixture']).includes('storm_99_fixture'), + 'a POOL-ONLY storm must be preloaded — the board can put it on tonight and nothing else would load it'); + assert(stormsToPreload(['storm_01_gentle'], []).includes(CALM_STORM), + 'the calm day still survives a ladder without it (S11 pin, still true)'); + // ...and it is still deduped, so the loader does not fetch a storm twice + // just because the pool and the ladder agree about it. + assertEq(stormsToPreload(['storm_01_gentle'], ['storm_01_gentle']).length, 1, + 'a storm in BOTH the ladder and the pool is loaded once'); + }); + + t.test('SPRINT17: collateral exposure is the yard\'s priced property, and it matches the site JSON', () => { + if (!siteJsonForTest.backyard_01) return 'SKIPPED — no server for site JSON'; + // Two routes to one set of dollars (board.js walks the site JSON up front; + // world.js resolves a collateral KEY at failure time), which is exactly the + // shape this repo has been bitten by. Pin the totals against the data. + const b = exposureOf(siteJsonForTest.backyard_01); + assertEq(b.total, 115, 'the backyard: gutter 90 + gnome 25'); + const c = exposureOf(siteJsonForTest.site_02_corner_block); + assertEq(c.total, 205, 'the corner block: carport 180 + gnome 25 — and no house'); + const s = exposureOf(siteJsonForTest.site_03_swing_lawn); + assertEq(s.total, 255, 'the swing lawn: gutter 90 + swing set 140 + gnome 25 — the worst in the game'); + // Every item priced must be a real number a player can actually be billed, + // and the total must BE the items — an exposure that didn't sum to its own + // list would be the invoice bug one card earlier. + for (const { what, cost } of [...b.items, ...c.items, ...s.items]) { + assert(what && typeof what === 'string', 'every exposure item names itself'); + assert(Number.isFinite(cost) && cost > 0, `${what} is priced`); + } + assertEq(s.total, s.items.reduce((n, i) => n + i.cost, 0), 'the total IS the items'); + }); + + // --- SPRINT17 gate 2: CLIENT CONSTRAINTS (A's data half) ----------------- + + t.test('SPRINT17: a constrained night pays a premium, on BOTH papers, from ONE formula', () => { + if (!boardDefs.storm_03b_earlybuster) return 'SKIPPED — no server for storm defs'; + const capped = POOL.find((p) => p.constraints?.some((c) => c.kind === 'budgetCap')); + assert(capped, 'the pool ships a budget-capped job'); + const def = boardDefs[capped.storm]; + + const plain = calloutFee(def, REP.START, []); + const withCap = calloutFee(def, REP.START, capped.constraints); + assert(withCap > plain, `a constraint must PAY — got ${withCap} vs ${plain}`); + assertEq(withCap, Math.round(plain * (1 + CONSTRAINT_PREMIUM.budgetCap)), + 'and by exactly the premium the constraint declares'); + + // The sheet and the invoice must read the same number. quote() and settle() + // route through the same calloutFee() — this is the pin that keeps them + // there when somebody adds a fourth term. + const w = createWeek(); + w.take({ id: `alt:${capped.id}`, night: capped }); + const q = w.quote(def); + const s = w.settle({ hp: 90, win: true, collateral: [], intactHardwareValue: 0, rigRecord: [] }, def, 45); + assertEq(q.base, withCap, 'the job sheet quotes the premium'); + assertEq(s.fullFee, withCap, 'and the invoice pays it'); + assertEq(q.premium, s.premium, 'both papers state the same premium'); + // The invoice repeats the TERMS it was paid under — gate 2's requirement. + assertEq(s.constraints.length, capped.constraints.length, + 'the invoice repeats the constraint it was paid under'); + assertEq(s.constraints[0].label, capped.constraints[0].label); + }); + + t.test('SPRINT17: the constraint enum is CHECKED, so a bad one cannot ship', () => { + // An unenforced enum is decoration — this repo's standing rule, learned + // when the carport shipped typed as a 'post' behind a JSDoc that said it + // couldn't. Every shipped constraint validates... + for (const p of POOL) { + for (const c of p.constraints ?? []) validateConstraint(c, p.id); + } + // ...and a bogus one throws, with the kind named in the message. + let threw = ''; + try { validateConstraint({ kind: 'pay_me_double', says: 'give me money', label: 'nope' }, 'fixture'); } + catch (err) { threw = err.message; } + assert(threw.includes('pay_me_double'), `a bad kind must fail loud and name itself, got: ${threw || '(no throw)'}`); + // The fields the papers PRINT are required too — a constraint with no + // client words would render an empty quote block on two cards. + let threw2 = ''; + try { validateConstraint({ kind: 'budgetCap', cap: 45, label: 'capped' }, 'fixture'); } + catch (err) { threw2 = err.message; } + assert(threw2.includes('says'), `a constraint with no client words must fail, got: ${threw2 || '(no throw)'}`); + // ...and a budgetCap with no number is not a cap. + let threw3 = ''; + try { validateConstraint({ kind: 'budgetCap', says: 'keep it cheap please', label: 'capped' }, 'fixture'); } + catch (err) { threw3 = err.message; } + assert(threw3.includes('cap'), `a capless budgetCap must fail, got: ${threw3 || '(no throw)'}`); + }); + + t.test('SPRINT17: a budget cap is BELOW the start budget and ABOVE the four-carabiner floor', () => { + // The ruling, pinned: the client caps your SPEND, not your wallet. A cap at + // or above START_BUDGET is not a constraint (it changes nothing and the + // premium is free money); a cap under BROKE_BELOW is a soft-lock, because + // four carabiners is the cheapest legal rig and the game refuses to leave + // prep without four corners. + for (const p of POOL) { + for (const c of p.constraints ?? []) { + if (c.kind !== 'budgetCap') continue; + assertLess(c.cap, START_BUDGET, `${p.id}: a cap at or above the start budget constrains nothing`); + assert(c.cap >= BROKE_BELOW, + `${p.id}: cap $${c.cap} is under the $${BROKE_BELOW} four-carabiner floor — that is a soft-lock, not a squeeze`); + } + } + }); + // Gate 2 acceptance: both sites load from data, and the corner block is not // the backyard with the furniture moved. Built up front (Suite.test() can't // await — the guard I added last sprint enforces it, and just caught me). diff --git a/web/world/js/week.js b/web/world/js/week.js index 6ebccd8..895f834 100644 --- a/web/world/js/week.js +++ b/web/world/js/week.js @@ -15,6 +15,7 @@ */ import { START_BUDGET, HARDWARE } from './contracts.js'; +import { offersFor, premiumFor } from './board.js'; /** * The ladder. Escalating, and each rung introduces one new idea rather than just @@ -187,19 +188,28 @@ export const NIGHTS = [ ]; /** - * A night entry, every shape → a full JOB. + * A night entry, every shape → a full JOB. **The one normaliser**, SPRINT17. * * The plain-string form still resolves (SPRINT10's promise), and now so does a * night with no client: an unbriefed job is a job with no letterhead, not a * crash. `pay` is per-job so the schedule is data — see PAY for the defaults and * for why the clean bonus is the one number here that is NOT yet measured. * - * @param {number} i - * @returns {{storm:string, site:string, client:string|null, brief:string|null, pay:object}} + * ⚠️ **SPRINT17 — WHY THIS IS A SEPARATE FUNCTION FROM `nightAt`, and it is not + * tidiness.** The board can install a POOL entry as tonight's night, and a pool + * entry is hand-authored data with exactly the same optional fields a NIGHTS + * entry has. The first cut of `week.take()` stored `{...offer.night}` raw, and + * the board probe found it in one call: a pool entry with no `pay` block threw + * `Cannot read properties of undefined (reading 'garden')` out of quote() — + * i.e. a chosen job was NOT the same shape as a scripted one, which is the + * single promise gate 1 makes. Both doors normalise HERE now, so "a taken night + * is indistinguishable from an authored night" is a fact about the code rather + * than a thing two call sites both have to remember. + * + * @param {object|string} entry a NIGHTS entry, a pool entry, or a bare storm key */ -export function nightAt(i) { - const n = NIGHTS[i]; - const base = typeof n === 'string' ? { storm: n, site: 'backyard_01' } : (n ?? {}); +export function normaliseNight(entry) { + const base = typeof entry === 'string' ? { storm: entry, site: 'backyard_01' } : (entry ?? {}); return { storm: base.storm, site: base.site ?? 'backyard_01', @@ -210,9 +220,53 @@ export function nightAt(i) { // False by default on purpose: a night is presumed savable unless its data // says otherwise, so a new night can never inherit the icenight's excuse. gardenBeyondSaving: base.gardenBeyondSaving ?? false, + /** + * SPRINT17 gate 2 — CLIENT CONSTRAINTS, as data on the night. + * + * Empty by default, and an EMPTY ARRAY rather than null on purpose: every + * consumer (the job sheet, the invoice, the offer card, B's session) wants + * to iterate this, and a null that four surfaces each have to remember to + * guard is three surfaces away from a crash. The scripted spine ships + * unconstrained — constraints arrive on the board's callouts — so this + * field is `[]` on all seven nights and nothing about the campaign moves. + */ + constraints: base.constraints ?? [], }; } +/** + * Tonight's authored night, by index into the ladder. + * @param {number} i + * @returns {{storm:string, site:string, client:string|null, brief:string|null, pay:object}} + */ +export function nightAt(i) { + return normaliseNight(NIGHTS[i]); +} + +/** + * The callout fee, and the ONE place it is computed. SPRINT17. + * + * Extracted because gate 2 adds a third term (the constraint premium) to a + * formula that quote() and settle() were each spelling out for themselves. Two + * copies of a two-term formula is a latent divergence; two copies of a + * three-term formula is the quote-vs-settle lie with a longer fuse — and the + * sheet promising a premium the invoice doesn't pay is exactly the failure the + * warranty derivation was built to make impossible. So: one function, both + * callers, rounded ONCE at the end. + * + * Order matters and is fixed here: severity → standing → the client's squeeze. + * The premium is a fraction of the fee you'd otherwise be paid, so it compounds + * on the rep multiplier rather than on the raw fee — a ★5 tradie is paid more + * for accepting the same ban, which is the right way round. + * + * @param {object} def tonight's storm def + * @param {number} rep standing AS OF THIS MORNING + * @param {object[]} [constraints] tonight's client constraints + */ +export function calloutFee(def, rep, constraints = []) { + return Math.round(PAY.feeFor(gustPeakOf(def)) * REP.multiplier(rep) * (1 + premiumFor(constraints))); +} + /** * The economy, in one place because it is the thing most likely to need tuning * once somebody actually plays five nights in a row. @@ -363,6 +417,17 @@ export const REP = { */ export const BROKE_BELOW = HARDWARE[0].cost * 4; +/** + * SPRINT17 — the default week seed. + * + * A CONSTANT, not a clock. Every board in the shipped game is drawn from this + * and the night index, so the campaign has one fixed sequence of offers that + * the selftest can pin and John can play twice and recognise. `createWeek({seed})` + * is the door for a different week — which is where replay comes from in a + * later sprint, and it costs nothing to leave open now. + */ +export const DEFAULT_WEEK_SEED = 1; + /** * How the week is remembered, by gardens actually held. * @@ -434,6 +499,31 @@ export function createWeek(opts = {}) { let bank = startBank; let done = false; let rep = REP.START; // SPRINT16 — the number on the letterhead + /** + * SPRINT17 gate 1 — the week's SEED. Every morning's board is drawn from + * this and the night index, and from nothing else: no Date.now, no + * Math.random (contracts.js §Determinism, and the selftest depends on it — + * an offer pool that reads the clock is a suite that goes red on a Tuesday). + * Same seed, same week, same two offers, forever. + */ + const seed = opts.seed ?? DEFAULT_WEEK_SEED; + /** + * SPRINT17 gate 1 — TAKEN JOBS, keyed by night index. + * + * This is the whole mechanism behind "the chosen job IS the night", and it is + * an OVERRIDE rather than a replacement on purpose. `jobAt()` falls through to + * the authored `nightAt()` whenever nothing was taken, so: + * · the scripted week runs byte-identically to the pre-board game (474 + * asserts stand on that, and none of them know this map exists); + * · a taken alternative is a night entry of exactly the same SHAPE, so + * every per-night surface downstream — sheet, quote, invoice, ledger, + * warranty, rep, end card — reads it through the path it already had. + * Nothing consults "was this scripted?" to decide how to do its job, which + * is the property that makes the board impossible to half-wire. + * @type {Map} + */ + const taken = new Map(); + const jobAt = (i) => taken.get(i) ?? nightAt(i); /** @type {Settlement[]} */ const log = []; @@ -463,11 +553,11 @@ export function createWeek(opts = {}) { /** 1-based, for humans. "Night 3 of 5". */ get night() { return index + 1; }, get nights() { return NIGHTS.length; }, - get stormKey() { return nightAt(index).storm; }, + get stormKey() { return jobAt(index).storm; }, /** SPRINT10: which yard tonight is on. main.js loads it. */ - get site() { return nightAt(index).site; }, + get site() { return jobAt(index).site; }, /** SPRINT11: tonight's job — client, brief, pay schedule. The job sheet reads this. */ - get job() { return nightAt(index); }, + get job() { return jobAt(index); }, /** * What tonight pays, BEFORE you rig it — the "budget Y" half of DESIGN.md's @@ -482,7 +572,7 @@ export function createWeek(opts = {}) { * @param {object} def tonight's storm def */ quote(def) { - const j = nightAt(index); + const j = jobAt(index); // SPRINT16: the fee is priced on your standing, and the deduction for // last dawn's broken corners is on the quote — AT QUOTE TIME, so settle // pays exactly what the sheet said. Both read the same sources settle @@ -492,13 +582,27 @@ export function createWeek(opts = {}) { const warranty = warrantyItems(); const warrantyTotal = warranty.reduce((s, i) => s + i.cost, 0); return { - base: Math.round(PAY.feeFor(gustPeakOf(def)) * feeMult), + // SPRINT17 gate 2 — ONE fee function, shared with settle(), so the + // constraint premium cannot reach the sheet without reaching the + // invoice. (Before this it was two hand-written copies of the same + // formula agreeing by luck; adding a third term to both by hand is how + // that luck runs out.) + base: calloutFee(def, rep, j.constraints), garden: j.pay.garden ?? PAY.gardenBonusMax, clean: j.pay.clean ?? PAY.noCollateralBonus, feeMult, rep, warranty, warrantyTotal, + /** + * SPRINT17 gate 2 — the constraints the job is quoted UNDER, on the + * sheet, and repeated verbatim on the invoice they were paid under + * (hud.js prints both off this same array). `premium` is what they + * added to the fee, so the sheet can show the client's squeeze and the + * client's compensation as the two facts they are. + */ + constraints: j.constraints ?? [], + premium: premiumFor(j.constraints), /** * SPRINT14 (B's pool item, offered S13 and accepted): the job sheet has * to say when the garden bonus is money the night cannot pay. @@ -522,6 +626,59 @@ export function createWeek(opts = {}) { get total() { return this.base + this.garden + this.clean - this.warrantyTotal; }, }; }, + /** + * SPRINT17 gate 1 — THIS MORNING'S BOARD. Two offers: tonight's scripted + * night, and one alternative drawn deterministically from the pool. + * + * @param {(night:object) => object} [priced] optional per-offer pricing / + * forecast / exposure, injected by main.js because it owns the storm defs + * and the site JSON. board.js stays pure; the money is computed HERE, off + * the same rep and the same calloutFee() the sheet and invoice use, so an + * offer cannot advertise a fee the job sheet then contradicts. + */ + offers(priced = null) { + return offersFor({ + scripted: nightAt(index), weekSeed: seed, nightIndex: index, priced, + }); + }, + + /** + * TAKE ONE. The other vanishes — no backlog, no decline cost (SPRINT18 + * decides whether saying no costs you; SPRINT17 deliberately does not + * charge for a choice it hasn't designed the consequences of). + * + * Idempotent per night and only forward: taking twice on the same morning + * is the same take, and there is no untake. That's not a limitation, it's + * the point — the board is a commitment, and a player who could re-open it + * after reading the job sheet would be shopping, not choosing. + * + * @param {object} offer one of `offers()` + * @returns {object} the night now installed for tonight + */ + take(offer) { + if (!offer?.night) throw new Error('week.take: needs an offer with a .night (board.js offersFor)'); + if (!taken.has(index)) { + // Stamped with the offer id so the settlement — and therefore the + // invoice and the end card — can say the job came off the board and + // WHICH job it was. Non-enumerable so it can never leak into a night's + // data shape and make a taken night structurally different from a + // scripted one: `jobAt()` must hand back something indistinguishable. + // normaliseNight, NOT a spread — see its docstring. A pool entry is + // hand-authored data with the same optional fields a NIGHTS entry has, + // and a raw one crashes quote() on its missing `pay` block. + const night = normaliseNight(offer.night); + Object.defineProperty(night, '_offerId', { value: offer.id, enumerable: false }); + taken.set(index, night); + } + return taken.get(index); + }, + + /** Tonight's taken offer id, or null if the player is on the spine. */ + get takenOffer() { return taken.get(index)?._offerId ?? null; }, + + /** SPRINT17 — the week's draw seed, for the board and for a.test's pins. */ + get seed() { return seed; }, + get bank() { return bank; }, /** SPRINT16 — the letterhead's number. Stars, 0..5, half-steps. */ get rep() { return rep; }, @@ -532,6 +689,10 @@ export function createWeek(opts = {}) { reset() { index = 0; bank = startBank; done = false; log.length = 0; rep = REP.START; + // SPRINT17: a restarted week re-offers the same board (the seed is fixed + // for the run), so clearing the takes is what makes "play it again" + // actually replayable rather than handing you last run's choices. + taken.clear(); return week; }, @@ -550,12 +711,15 @@ export function createWeek(opts = {}) { // function, after the money is computed, so quote and settle agree by // ordering. Round after the multiplier, exactly as quote() does. const feeMult = REP.multiplier(rep); - const fullFee = Math.round(PAY.feeFor(gustPeakOf(def)) * feeMult); + // SPRINT17 gate 2: the SAME calloutFee() the quote printed, constraints + // and all — quote==settle by construction, extended to the premium the + // same way it was extended to the warranty deduction. + const fullFee = calloutFee(def, rep, jobAt(index).constraints); const fee = run.win ? fullFee : Math.round(fullFee * PAY.lostNightShare); // Per-job override, PAY's default otherwise — and settle MUST read the same // source the quote does, or the job sheet promises a number the invoice // doesn't pay. That's not a rounding difference, it's a lie on paper. - const gardenMax = nightAt(index).pay.garden ?? PAY.gardenBonusMax; + const gardenMax = jobAt(index).pay.garden ?? PAY.gardenBonusMax; const bonus = Math.round((Math.max(0, run.hp) / 100) * gardenMax); // Only hardware still on an unbroken corner comes home, and at half. @@ -566,7 +730,7 @@ export function createWeek(opts = {}) { // The clean bonus. Per-job data first, PAY's default otherwise — so a job // that wants to say "there's a lot here to break" can, without code. - const job = nightAt(index); + const job = jobAt(index); const cleanMax = job.pay.clean ?? PAY.noCollateralBonus; const clean = collateral === 0 ? cleanMax : 0; @@ -642,6 +806,15 @@ export function createWeek(opts = {}) { gardenBeyondSaving: job.gardenBeyondSaving ?? false, gardenMax, client: job.client, addr: job.addr, site: job.site, + // SPRINT17 — the invoice repeats the constraint it was PAID UNDER + // (gate 2's requirement, and the reason this rides the settlement + // rather than being looked up: the invoice for night 3 must state + // night 3's terms even when you're reading it on night 5's morning). + // `taken` says the job came off the board — the ledger records that + // you CHOSE this, which is the whole of arc 2 in one boolean. + constraints: job.constraints ?? [], + premium: premiumFor(job.constraints), + taken: taken.has(index) ? (taken.get(index)._offerId ?? true) : false, // SPRINT16 — the ledger. The rig record IS the work history (the night // computed all of it; the week just keeps it), the warranty items are // tonight's sheet answered on the invoice, and the rep block is the