Lane A S17 gates 0+1+2: THE JOB BOARD — two offers a morning, the chosen job IS the night

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).
This commit is contained in:
type-two 2026-07-21 02:11:16 +10:00
parent 75d5f9a4bf
commit dd69a2cf6c
6 changed files with 1434 additions and 25 deletions

View File

@ -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 / ●◆····· / 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. 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. 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:<poolId>', // 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 35 held and
solvent is 02 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 02 held = 35 dead, and
the sentence overstated its own floor. At seven it is 46 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 35 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+1545 = **+$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.**

357
web/world/js/board.js Normal file
View File

@ -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;
}

View File

@ -305,6 +305,44 @@ const CSS = `
} }
/* ---- end of E's kit ----------------------------------------------------- */ /* ---- 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. /* 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.) */ (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; #hud-card .tomorrow { display:flex; gap:10px; align-items:baseline; margin-top:9px;
@ -734,6 +772,99 @@ export function createHud(d) {
// --- cards ------------------------------------------------------------ // --- 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 `<span class="pip ${now ? 'now' : done ? (held ? 'held' : 'lost') : ''}">${
now ? '◆' : done ? (held ? '●' : '○') : '·'}</span>`;
}).join('');
const offerHtml = (o, i) => {
const n = o.night;
const cons = o.constraints ?? [];
const f = o.forecast;
return `
<button class="offer${cons.length ? ' constrained' : ''}" data-offer="${i}">
<span class="tag">${o.kind === 'scripted' ? 'tonight, as booked' : 'callout'}</span>
<div class="who">${n.client ?? 'no client'}</div>
<div class="addr">${n.addr ?? ''}</div>
${f?.wind ? `<div class="line"><span>${f.wind}</span></div>` : ''}
${f?.stones ? `<div class="line"><span>${f.stones}</span></div>` : ''}
${o.fee != null ? `<div class="line fee"><span>the job pays${
o.premium ? ` · +${Math.round(o.premium * 100)}% on their terms` : ''
}</span><b>$${o.fee}</b></div>` : ''}
${o.exposure != null ? `<div class="line risk"><span>their property at risk${
o.exposureItems?.length ? `${o.exposureItems.map((x) => x.what).join(', ')}` : ''
}</span><b>$${o.exposure}</b></div>` : ''}
${cons.length ? `<div class="cons">⚖ ${cons.map((c) => c.label).join(' · ')}</div>` : ''}
${cons.map((c) => `<div class="says">“${c.says}”</div>`).join('')}
</button>`;
};
card.innerHTML = `<div class="card jobcard">
<div class="letterhead">
<div>
<div class="mark">${BUSINESS.mark}</div>
<div class="trade">${BUSINESS.trade}</div>
</div>
<div class="docket">
<div class="kind">THE BOARD</div>
<div class="no">MORNING · NIGHT ${wk.night} OF ${wk.nights}</div>
${wk.rep != null ? `<div class="rep">standing ${repFmt(wk.rep)}</div>` : ''}
</div>
</div>
<h1>TWO JOBS ON OFFER</h1>
<div class="pips">${pips}</div>
<h2>Take one. The other goes to somebody else.</h2>
${offers.map(offerHtml).join('')}
<div class="row" style="margin-top:12px"><span>in the bank</span><b>$${wk.bank}</b></div>
<div class="board-note">Both are tonight. You cannot work two yards in one storm.</div>
<div class="terms">Fees quoted on the forecast at time of callout. Weather is not a variation.</div>
</div>`;
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 * The forecast. Its job is to sell the dread and, incidentally, to be the
* difficulty select for free three authored storms already bracket 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 const brief = job.brief
? `<div class="brief">${job.brief}</div>` : ''; ? `<div class="brief">${job.brief}</div>` : '';
// 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 ? `
<div class="sect">THE CLIENT'S TERMS</div>
<div class="sched">
${cons.map((c) => `
<div class="row"><span>${c.label}</span><b>${
c.kind === 'budgetCap' ? `$${c.cap} cap` : 'refused'}</b></div>
<div class="cond">${c.says}</div>`).join('')}
</div>` : '';
// BUDGET Y — and this is the actual feature, not the flavour. The fee has // 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 // 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 // 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 = `<div class="card jobcard"> card.innerHTML = `<div class="card jobcard">
${letterhead} ${letterhead}
${brief} ${brief}
${constraintBlock}
<h1>NIGHT ${wk.night} OF ${wk.nights}</h1> <h1>NIGHT ${wk.night} OF ${wk.nights}</h1>
<div class="pips">${pips}</div> <div class="pips">${pips}</div>
<h2>${(f.name ?? key).replace(/_/g, ' ').toUpperCase()}${night ? ' · NIGHT' : ''}</h2> <h2>${(f.name ?? key).replace(/_/g, ' ').toUpperCase()}${night ? ' · NIGHT' : ''}</h2>
@ -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 invFeeTag = w && w.feeMult && w.feeMult !== 1 ? ` · standing ×${w.feeMult.toFixed(2)}` : '';
const money = w ? ` const money = w ? `
<div class="ledger"> <div class="ledger">
<div class="row"><span>${w.won ? 'callout fee' : `callout fee — night lost (${Math.round(100 * w.fee / Math.max(1, w.fullFee))}%)`}${invFeeTag}</span><b>+$${w.fee}</b></div> <div class="row"><span>${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` : ''
}</span><b>+$${w.fee}</b></div>
<div class="row"><span>garden bonus ${r.hp.toFixed(0)}% of the bed${ <div class="row"><span>garden bonus ${r.hp.toFixed(0)}% of the bed${
// SPRINT14 — B's pool item, the invoice half. On a beyond-saving // SPRINT14 — B's pool item, the invoice half. On a beyond-saving
// night this row is always small, and next to "callout fee — night // night this row is always small, and next to "callout fee — night
@ -1083,6 +1238,21 @@ showForecast({ key, def, site, tomorrowDef }, wk, onGo) {
<h1>MORNING${w ? ` · NIGHT ${w.night} OF ${w.nights ?? NIGHT_COUNT}` : ''}</h1> <h1>MORNING${w ? ` · NIGHT ${w.night} OF ${w.nights ?? NIGHT_COUNT}` : ''}</h1>
<h2>${r.subtitle}</h2> <h2>${r.subtitle}</h2>
${rows} ${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 ? `
<div class="sect">PAID UNDER THESE TERMS</div>
<div class="sched">
${w.constraints.map((c) => `
<div class="row"><span>${c.label}</span><b>${
c.kind === 'budgetCap' ? `$${c.cap} cap` : 'refused'}</b></div>
<div class="cond">${c.says}</div>`).join('')}
</div>` : ''}
${money} ${money}
<div class="verdict ${r.win ? 'win' : 'lose'}">${r.verdict}</div> <div class="verdict ${r.win ? 'win' : 'lose'}">${r.verdict}</div>
${/* SPRINT16 the verdict speaks rep: the movement AND its reason, so ${/* SPRINT16 the verdict speaks rep: the movement AND its reason, so

View File

@ -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 { FIXED_DT, PHASES, STORM_LEN, HARDWARE, SPARE_COST, Emitter } from './contracts.js';
import { createWorld, loadSite } from './world.js'; import { createWorld, loadSite } from './world.js';
import { createCameraRig, spawnYawFor } from './camera.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 { SailRig, createSailView } from './sail.js';
import { createPlayer } from './player.js'; import { createPlayer } from './player.js';
import { Interact, wireYardActions } from './interact.js'; import { Interact, wireYardActions } from './interact.js';
@ -25,7 +25,8 @@ import { createDebris } from './debris.js';
import { createSkyFx } from './skyfx.js'; import { createSkyFx } from './skyfx.js';
import { createRiggingUI, fabricNoteFor } from './rigging.js'; import { createRiggingUI, fabricNoteFor } from './rigging.js';
import { createHud } from './hud.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'; import { createGarden } from './garden.js';
/** The calm day the forecast and prep phases run under. */ /** 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 * 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. * 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)) { export function stormsToPreload(
return [...new Set([CALM_STORM, ...nights])]; nights = NIGHTS.map((_, i) => nightAt(i).storm),
pool = POOL.map((p) => p.storm),
) {
return [...new Set([CALM_STORM, ...nights, ...pool])];
} }
const STORMS = stormsToPreload(); const STORMS = stormsToPreload();
@ -979,6 +1002,72 @@ export async function boot(opts = {}) {
* anything. Lane B's file, flagged in THREADS main.js therefore tracks * anything. Lane B's file, flagged in THREADS main.js therefore tracks
* `spentThisNight` itself off the bank rather than trusting `.spent`. * `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<string, object>}
*/
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() { async function showTonight() {
// SPRINT10: tonight's yard. loadSiteInto no-ops when the site is unchanged // SPRINT10: tonight's yard. loadSiteInto no-ops when the site is unchanged
// (the four backyard nights) and rebuilds + re-points everything holding the // (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 // 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 // phase transition that can rebuild the scene, so it's the only one that
// has to sequence like this. // 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(); garden.reset();
resetRig(); resetRig();
player.sim.carrying = null; 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 // `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 // playtest harness all boot straight into a night, and none of them should
// have to click through a door. // 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'); hud.setAudioMuteAvailable(typeof sky?.setMute === 'function');
if (!canPlayHere()) { if (!canPlayHere()) {
hud.showTouchNotice(); // no way through, on purpose: see hud.showTouchNotice hud.showTouchNotice(); // no way through, on purpose: see hud.showTouchNotice
} else if (opts.splash === false) { } else if (opts.splash === false) {
showTonight(); showTonight();
} else { } else {
hud.showSplash(() => showTonight()); hud.showSplash(() => showMorning());
} }
// --- resize ------------------------------------------------------------- // --- resize -------------------------------------------------------------

View File

@ -20,7 +20,12 @@ import { RiggingSession } from '../rigging.js';
import { createSkyFx } from '../skyfx.js'; import { createSkyFx } from '../skyfx.js';
import { SailRig, orderRing } from '../sail.js'; import { SailRig, orderRing } from '../sail.js';
import { loadStorm, createWind } from '../weather.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 { createHud } from '../hud.js';
import { PALETTE, boundsFaults, emptyTemplate, exportSiteJSON, placeEntry } from '../editor.js'; import { PALETTE, boundsFaults, emptyTemplate, exportSiteJSON, placeEntry } from '../editor.js';
import { assert, assertClose, assertEq, assertLess, fixedLoop } from '../testkit.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 // ladder WITHOUT the gentle storm — the case that actually broke. Checked
// against the shipped NIGHTS it would pass either way (night one is gentle // against the shipped NIGHTS it would pass either way (night one is gentle
// today), which is how the bug hid in the first place. // 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), assert(stormsToPreload().includes(CALM_STORM),
'the calm day is preloaded on the shipped ladder'); '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"); "...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'); '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'); '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 // 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 // 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). // await — the guard I added last sprint enforces it, and just caught me).

View File

@ -15,6 +15,7 @@
*/ */
import { START_BUDGET, HARDWARE } from './contracts.js'; 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 * 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 * 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 * 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 * 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. * for why the clean bonus is the one number here that is NOT yet measured.
* *
* @param {number} i * **SPRINT17 WHY THIS IS A SEPARATE FUNCTION FROM `nightAt`, and it is not
* @returns {{storm:string, site:string, client:string|null, brief:string|null, pay:object}} * 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) { export function normaliseNight(entry) {
const n = NIGHTS[i]; const base = typeof entry === 'string' ? { storm: entry, site: 'backyard_01' } : (entry ?? {});
const base = typeof n === 'string' ? { storm: n, site: 'backyard_01' } : (n ?? {});
return { return {
storm: base.storm, storm: base.storm,
site: base.site ?? 'backyard_01', 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 // 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. // says otherwise, so a new night can never inherit the icenight's excuse.
gardenBeyondSaving: base.gardenBeyondSaving ?? false, 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 * The economy, in one place because it is the thing most likely to need tuning
* once somebody actually plays five nights in a row. * once somebody actually plays five nights in a row.
@ -363,6 +417,17 @@ export const REP = {
*/ */
export const BROKE_BELOW = HARDWARE[0].cost * 4; 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. * How the week is remembered, by gardens actually held.
* *
@ -434,6 +499,31 @@ export function createWeek(opts = {}) {
let bank = startBank; let bank = startBank;
let done = false; let done = false;
let rep = REP.START; // SPRINT16 — the number on the letterhead 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<number, object>}
*/
const taken = new Map();
const jobAt = (i) => taken.get(i) ?? nightAt(i);
/** @type {Settlement[]} */ /** @type {Settlement[]} */
const log = []; const log = [];
@ -463,11 +553,11 @@ export function createWeek(opts = {}) {
/** 1-based, for humans. "Night 3 of 5". */ /** 1-based, for humans. "Night 3 of 5". */
get night() { return index + 1; }, get night() { return index + 1; },
get nights() { return NIGHTS.length; }, 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. */ /** 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. */ /** 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 * 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 * @param {object} def tonight's storm def
*/ */
quote(def) { quote(def) {
const j = nightAt(index); const j = jobAt(index);
// SPRINT16: the fee is priced on your standing, and the deduction for // 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 // 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 // pays exactly what the sheet said. Both read the same sources settle
@ -492,13 +582,27 @@ export function createWeek(opts = {}) {
const warranty = warrantyItems(); const warranty = warrantyItems();
const warrantyTotal = warranty.reduce((s, i) => s + i.cost, 0); const warrantyTotal = warranty.reduce((s, i) => s + i.cost, 0);
return { 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, garden: j.pay.garden ?? PAY.gardenBonusMax,
clean: j.pay.clean ?? PAY.noCollateralBonus, clean: j.pay.clean ?? PAY.noCollateralBonus,
feeMult, feeMult,
rep, rep,
warranty, warranty,
warrantyTotal, 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 * 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. * 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; }, 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; }, get bank() { return bank; },
/** SPRINT16 — the letterhead's number. Stars, 0..5, half-steps. */ /** SPRINT16 — the letterhead's number. Stars, 0..5, half-steps. */
get rep() { return rep; }, get rep() { return rep; },
@ -532,6 +689,10 @@ export function createWeek(opts = {}) {
reset() { reset() {
index = 0; bank = startBank; done = false; log.length = 0; index = 0; bank = startBank; done = false; log.length = 0;
rep = REP.START; 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; return week;
}, },
@ -550,12 +711,15 @@ export function createWeek(opts = {}) {
// function, after the money is computed, so quote and settle agree by // function, after the money is computed, so quote and settle agree by
// ordering. Round after the multiplier, exactly as quote() does. // ordering. Round after the multiplier, exactly as quote() does.
const feeMult = REP.multiplier(rep); 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); const fee = run.win ? fullFee : Math.round(fullFee * PAY.lostNightShare);
// Per-job override, PAY's default otherwise — and settle MUST read the same // 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 // 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. // 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); const bonus = Math.round((Math.max(0, run.hp) / 100) * gardenMax);
// Only hardware still on an unbroken corner comes home, and at half. // 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 // 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. // 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 cleanMax = job.pay.clean ?? PAY.noCollateralBonus;
const clean = collateral === 0 ? cleanMax : 0; const clean = collateral === 0 ? cleanMax : 0;
@ -642,6 +806,15 @@ export function createWeek(opts = {}) {
gardenBeyondSaving: job.gardenBeyondSaving ?? false, gardenBeyondSaving: job.gardenBeyondSaving ?? false,
gardenMax, gardenMax,
client: job.client, addr: job.addr, site: job.site, 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 // 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 // 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 // tonight's sheet answered on the invoice, and the rep block is the