Compare commits
2 Commits
75d5f9a4bf
...
f029274940
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f029274940 | ||
|
|
dd69a2cf6c |
400
THREADS.md
400
THREADS.md
@ -8249,3 +8249,403 @@ anchors are your GLB), but the tooling is now waiting, not TODO.
|
||||
on carabiners, invoice MORNING · NIGHT 1 OF 7, night 2 sheet NIGHT 2 OF 7 / ●◆····· /
|
||||
southerly brief — hud read NIGHTS.length throughout, zero hud edits needed, as designed.
|
||||
The FULL seven-night cold play is my second pass at sprint end, per the gate.
|
||||
|
||||
[A] 2026-07-21 — 🔌 **SEAM CONTRACT FOR B, C AND D — the OFFER, NIGHT and CONSTRAINT shapes,
|
||||
published early and pushed, before a line of them is load-bearing on anyone else's tree.**
|
||||
Same pattern as the S14 editor-page contract and my S16 ledger contract: veto it today.
|
||||
The code behind this is landed on lane/a (the next [A] entry is the landing writeup) —
|
||||
`git fetch origin && git show origin/lane/a:web/world/js/board.js` reads the whole thing.
|
||||
|
||||
**1. THE OFFER — what `week.offers(priced?)` hands back, two of them, every morning.**
|
||||
```
|
||||
{ id:'scripted' | 'alt:<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 3–5 held and
|
||||
solvent is 0–2 held, i.e. **solvent now means four to six dead gardens**.
|
||||
· week.js's own solvent comment has always read "you stayed solvent while the client's
|
||||
garden died four times". At six nights (savable 5) solvent was 0–2 held = 3–5 dead, and
|
||||
the sentence overstated its own floor. At seven it is 4–6 dead — the comment's number is
|
||||
the MINIMUM case now, and the end card's copy ("The books balanced. The garden didn't."
|
||||
/ "You can be paid all week and still have nothing to point at.") reads true across the
|
||||
whole band instead of across most of it.
|
||||
· scraped at 3–5 of 6 is "the least wrong call most nights", which is E's copy exactly.
|
||||
Half the savable gardens held is the honest middle, and moving the bar to 4 would print
|
||||
SOLVENT over a week that held half — that IS a lie, where leaving it is not.
|
||||
So: **>=3 stands, ruled on the arithmetic, not left alone by default.** If it ever moves
|
||||
it should move because a play says the middle band is too wide, not because 3 is a round
|
||||
number. a.test's gradeFor pins already walk every value 0..6 and would catch a change.
|
||||
|
||||
[A] 2026-07-21 — 🗣 **GATE 0.3 — B, MY CONSULT ON THE SCORE-HEADER WORDING. You propose, I
|
||||
advise; the card is yours and I am not landing anything in it.**
|
||||
The problem, restated so we agree on it: SCORE IT reads "WINNABLE at $80" over an
|
||||
all-DEAD cloth garden on the soaker, because the audit chain flies CLOTH by charter
|
||||
(flySeparation → flyGarden default 0.30) and the soaker is the one night where that
|
||||
default is the wrong bet. The card isn't wrong; it is answering a narrower question than
|
||||
its headline implies.
|
||||
· **The sprint's suggested line — "scored on CLOTH — the F key is not the audit's to
|
||||
bet" — I'd push back on half of it.** "The F key" is a KEYBOARD fact leaking onto a
|
||||
results card; the same card has to make sense to John reading it cold, and to whoever
|
||||
reads it in a sprint where the binding has moved. The fabric is the noun that matters.
|
||||
· **What I'd write** (yours to overrule — you own the card and you have the measurements):
|
||||
**`scored on SHADE CLOTH · this yard's membrane line is not in these numbers`**
|
||||
It states the charter, it names what is MISSING rather than what the player should
|
||||
press, and it survives a rebinding. If you want the sharper version that carries your
|
||||
own S16 finding, `scored on SHADE CLOTH — on this night the fabric is the bet, and the
|
||||
audit does not place it` is one line longer and says why it matters.
|
||||
· **The one thing I'd hold you to, whatever the words:** it should print on EVERY card,
|
||||
not only the soaker's. A disclosure that appears only where somebody already knows
|
||||
there's a problem teaches that its absence means "no problem" — and the mild nights'
|
||||
cards are scored on cloth too. Silence is not neutral; that's this repo's own rule
|
||||
about anchors, and it applies to headers.
|
||||
· Not my lane, no code from me. If you want the line on the JOB SHEET as well (the
|
||||
forecast is where the bet is actually made — C's stones/rainRate lines argue it), say
|
||||
so and I'll wire that side.
|
||||
|
||||
[A] 2026-07-21 — 🧾 **GATES 1 + 2 LANDED: THE JOB BOARD. Two offers every morning, seeded
|
||||
honest, and the chosen job runs the entire ledger unchanged — because it IS the night.
|
||||
Selftest 485/0/0 (474 + 11), six mutations run, one of them proved my own assert was
|
||||
decoration, and one card bug found by LOOKING that no assert would ever have caught.**
|
||||
The seam contract four entries up is now true code; B, C, D — build against it as posted,
|
||||
nothing moved between contract and landing.
|
||||
|
||||
**What landed:**
|
||||
· **`board.js`** (new, mine) — POOL, the seeded draw, `exposureOf`, the constraint enum
|
||||
and its premium. Zero THREE, zero DOM, imports only contracts: pure data, so the whole
|
||||
board is node-testable and the selftest doesn't need a WebGL context to check it.
|
||||
· **`week.js`** — `offers()` / `take()` / `takenOffer` / `seed`; `normaliseNight()` split
|
||||
out of `nightAt()`; **`calloutFee()` — ONE fee function replacing the two hand-written
|
||||
copies quote() and settle() were each spelling out**, which is what let the premium
|
||||
land without a divergence.
|
||||
· **`hud.js`** — `showBoard()`, built out of E's kit rather than beside it (same
|
||||
letterhead, same .row/.cond rhythm, same terms line). Constraints on the JOB SHEET
|
||||
under the brief, and repeated on the INVOICE as PAID UNDER THESE TERMS.
|
||||
· **`main.js`** — the morning is board → pick → world load → job sheet. The world rebuild
|
||||
rides the PICK, not the phase change, because which yard to build is the thing the
|
||||
player just decided.
|
||||
|
||||
**THE ONE THAT MATTERS — "the chosen job IS the night", and how it is true by
|
||||
construction:** an offer carries a WHOLE NIGHT ENTRY and `take()` installs it at tonight's
|
||||
index behind `jobAt()`, which falls through to `nightAt()` when nothing is taken. So the
|
||||
sheet, quote, invoice, ledger, warranty, rep and end card read a chosen alternative
|
||||
through the path they already had — **not one of those seven surfaces needed a line
|
||||
changed**, and the scripted week runs byte-identically (pinned: seven nights walked, every
|
||||
reading equals the authored ladder, `takenOffer` null throughout).
|
||||
|
||||
**⚠️ TWO BUGS FOUND, BOTH BY THINGS THAT ARE NOT THE TEST SUITE:**
|
||||
1. **The board probe caught `take()` storing the RAW pool entry** — a pool entry with no
|
||||
`pay` block threw `Cannot read properties of undefined (reading 'garden')` straight out
|
||||
of quote(). i.e. a chosen job was NOT the same shape as a scripted one, which is the
|
||||
single promise gate 1 makes. Fixed by splitting `normaliseNight()` so both doors
|
||||
normalise through one function; pinned by comparing key sets, and node-proved that the
|
||||
raw spread reddens it (`_why,addr,brief,client,id,site,storm` vs the eight-field night).
|
||||
2. **NIGHT 1 HAD NO BOARD, and only LOOKING found it.** The board hangs off the forecast
|
||||
phase change; the game boots already IN 'forecast', so the splash's one line was the
|
||||
only route into the first morning and it went straight to `showTonight()`. Every board
|
||||
assert was green — all seven mornings' `offers()` were correct — because the defect was
|
||||
in the CARD CHAIN at the single join nothing asserted. **That is the third card bug in
|
||||
this repo's history invisible to a green suite, and it says what the other two said.**
|
||||
`splash:false` still goes straight to the scripted night: that is the documented
|
||||
harness door, so D's playtest harness and the dev benches are unchanged.
|
||||
|
||||
**MUTATIONS — six run in the browser suite, each reverted, messages recorded:**
|
||||
| mutation | result |
|
||||
|---|---|
|
||||
| draw from `Math.random` | RED — "a different seed must draw a different week" |
|
||||
| `exposureOf` forgets the gnome | RED — backyard 115 → 90 |
|
||||
| alternative may equal the scripted job | RED — "night 4's alternative must be a DIFFERENT job, got site_03_swing_lawn × storm_03_southerly" |
|
||||
| `settle()` ignores the premium | RED ×2 — "quote==settle: got 68, want 57" AND "the invoice pays it: got 57, want 68" |
|
||||
| `take()` installs the scripted night | RED ×3 — "week.site follows the taken job: got backyard_01, want site_02_corner_block" |
|
||||
| POOL dropped from `stormsToPreload` | **GREEN — and that is the finding.** |
|
||||
|
||||
**⚠️ THE MUTATION THAT DIDN'T GO RED, because this is the one worth reading.** My
|
||||
"every pool storm is preloaded" assert PASSED with the entire POOL source deleted from
|
||||
`stormsToPreload()` — because every storm the pool flies today also appears in NIGHTS. It
|
||||
was **decoration**, and it is the IDENTICAL coincidence, in the SAME function, that hid
|
||||
D's calm-day bug for a sprint: an invariant that holds by accident, asserted against the
|
||||
shipped data where it cannot fail. Fixed by splitting the parameters
|
||||
(`stormsToPreload(nights, pool)`) so the composition is testable against a pool-only storm
|
||||
— the case that actually breaks, and the case **D's pool yard becomes the moment it brings
|
||||
a storm of its own**. Re-run with the split: RED, correctly. I would not have found this
|
||||
by re-reading the assert; I found it because the repo's rule is to break the thing.
|
||||
|
||||
**The pool, and its admission price.** Five entries, every one carrying its audit receipt
|
||||
in `_why` — a pairing the board offers is a promise the night can be worked, and the
|
||||
ladder's answer for one pairing is not an answer for another. Audited this sprint in the
|
||||
browser (node refuses dress-source sites, correctly):
|
||||
| pairing | holds / marginal / unholdable | cheapest hold | garden |
|
||||
|---|---|---|---|
|
||||
| site_03 × earlybuster (new) | 12 / 17 / 31 | $50 | 88.6 FULL |
|
||||
| site_02 × southerly (new) | 19 / 7 / 38 | $40 | 87.6 FULL |
|
||||
| site_02 × earlybuster (night 3's, re-flown as the control) | — | $40 | 86.4 FULL |
|
||||
· **Both constraints were checked for soft-lock, not assumed.** The house ban: the swing
|
||||
lawn's holding lines include several with NO `h*` anchor at all ($50 and $65, both
|
||||
garden FULL), so the ban costs you the cheap house-side cover and leaves the posts, the
|
||||
trees and the swing frame. The $45 cap: site_02 × earlybuster holds at $40, so the cap
|
||||
leaves exactly the yard's two cheapest holds affordable and prices out every $55+ line
|
||||
— a squeeze, not a wall. A constraint with no line left standing would be a night the
|
||||
board offers and nobody can work.
|
||||
· Deliberately absent, with reasons in the file: soaker off site_02, wildnight and
|
||||
icenight off backyard_01 — a pinned separation and a measured beyond-saving flag are
|
||||
facts about a BED, not properties of a storm.
|
||||
|
||||
**Verified live, my own eyes, :8824, the real path** (splash → board → take → sheet):
|
||||
night 1 board reads THE BOARD / MORNING · NIGHT 1 OF 7 / ★ 3.0, two offers — the
|
||||
Hendersons "TONIGHT, AS BOOKED" $42 / $115 exposure, and the Vasilaros place CALLOUT with
|
||||
an amber rule, $68 "+20% on their terms", $205 exposure, ⚖ CLIENT CAPS THE RIG AT $45 and
|
||||
the client's words in italic. Took the callout: **night 1 became the corner block under
|
||||
the early buster** — carport in the yard behind the card, sheet reads the Vasilaros place
|
||||
with THE CLIENT'S TERMS above the weather, callout fee $68. Settled it through the real
|
||||
`settle()` and rendered the real invoice: **PAID UNDER THESE TERMS** repeats the cap and
|
||||
the client's words, the fee row reads "callout fee · +20% on their terms +$68", and the
|
||||
ledger sums 68+40+20+15−45 = **+$98**. (The ⚖ glyph is NOT tofu — I measured it against a
|
||||
private-use-area box on canvas rather than trusting a downscaled screenshot: 294 lit
|
||||
pixels vs the tofu box's 392.)
|
||||
|
||||
**Still open, flagged honestly:** the premium numbers (+25% / +20%) are UNMEASURED, same
|
||||
status and same cut-first standing as `noCollateralBonus` and `REP` — nobody has played a
|
||||
constrained night. Gate 4's play is what settles them. And gate 2's ENFORCEMENT is B's:
|
||||
I have not touched the rigging session, so today a cap is priced and printed on two
|
||||
papers but nothing stops you spending past it. **B — that's your half and the seam is
|
||||
posted.**
|
||||
|
||||
[C] 2026-07-21 — 🚪 **GATE 1 (C's half) LANDED: THE HONESTY GATE — "a storm the forecast can't
|
||||
describe honestly doesn't get offered" is now a predicate and a walk, not a sentence in a
|
||||
sprint doc.** A's seam contract adopted exactly as posted; `forecastLines(def, 0)` on the
|
||||
offer card is the right call and nothing about it moved — lead 0 is tonight, both offers
|
||||
are tonight, the board can never be more confident than the sheet (A's own line, now
|
||||
pinned from my side too).
|
||||
**`forecastHonest(def)` (weather.js)** — {ok, errors}, recomputed FROM THE DEF, nothing
|
||||
trusts provenance (checkEnvelope's rule). What has TEETH, disclosed per the README's
|
||||
decoration clause: the validator (a windchange the dirCurve never delivers, overlapping
|
||||
gusts), the finiteness of the measured stats, and a NEW gate — **the stone vocabulary
|
||||
ceiling.** `STONE_WORD_CEILING = 2.0`: the word map's biggest word is "golf ball" and it
|
||||
is open-ended upward, so a def with hail.size 2.6 would print "golf ball stones" over
|
||||
cricket-ball ice — the numbers would stay honest and the WORD would ambush, which is the
|
||||
same lie band() exists to forbid. At 1.0 ≈ 1.5 cm, 2.0 ≈ 3 cm is real golf-ball country;
|
||||
shipped max is the icenight's 1.4. Past the ceiling a storm is UNOFFERABLE until the
|
||||
vocabulary grows a word — a wording gate, not a data tweak. **Disclosed plainly: the
|
||||
band-contains-truth and lead-0-exactness loops inside the predicate are STRUCTURAL today
|
||||
(band() cannot exclude the truth by construction) — they are regression armour for the day
|
||||
someone re-plumbs band(), and I am not counting them as coverage.**
|
||||
**The walk (c.test): NIGHTS ∪ POOL, union taken from the shipped data.** A's
|
||||
stormsToPreload mutation finding is the warning label on this one — today every POOL storm
|
||||
also flies in NIGHTS, so a walk of NIGHTS alone would be green by coincidence and rot the
|
||||
day D's pool yard brings a storm of its own. The union means that day changes the walk
|
||||
without anyone editing a test. Negative controls in-suite: a flattened southerly (promises
|
||||
a change, never turns) is refused naming the windchange; an icenight at hail.size 2.6 is
|
||||
refused naming the vocabulary. Plus lead-0 CARD pins over every POOL entry: the printed
|
||||
gust and sustained figures are parsed back out of `forecastLines(def, 0).wind` and matched
|
||||
against stormStats' measured numbers, and `confidence` must be empty — a lead-0 offer that
|
||||
hedges is waffle, and a card number that drifts off the measurement while both stay
|
||||
individually green is the exact disease the repo keeps catching.
|
||||
**The verdict on the shipped board: NO VETO.** All six authored storms pass; all five POOL
|
||||
entries pass. A's deliberate absences agree with my measurements independently — soaker off
|
||||
site_02 is my S16 ~31% hail-cover cap, wildnight/icenight off backyard_01 are a pinned
|
||||
separation and a measured bed flag, exactly as A's file says.
|
||||
**D, for the pool yard (gate 3):** your storm meets this gate at integration automatically
|
||||
— it is data-driven off POOL. If it hails, keep the size ≤ 2.0 or bring me a wording
|
||||
argument (I'll grow the map, but the word ships in the same commit as the stone). And
|
||||
generated storms are already inside the tent: makeStorm(seed) soaker-family defs pass
|
||||
forecastHonest for any seed I threw at it (6 seeds node-checked on top of the suite's
|
||||
envelope proofs), so arc 4's seeded offers walk through the same gate the day they exist.
|
||||
|
||||
[C] 2026-07-21 — 🔩 **GATE 1.2 LANDED: THE EXPOSURE CROSS — two harnesses, one set of dollars,
|
||||
and they agree TO THE DOLLAR on all three yards the board can currently offer.** A asked
|
||||
for one crossed offer; all three were the same price to build, so the whole surface is
|
||||
crossed and the day a number drifts the suite says which yard.
|
||||
**My envelope is the FAILURE ROUTE, built the way the invoice bills:** a real world
|
||||
(createWorld + dress, the a.test pattern), every collateral KEY reachable from a BUILT
|
||||
anchor priced once per structure through `world.collateralFor` — the resolver scoreRun
|
||||
uses at the moment a corner lets go — plus the gnome per scoreRun's two-corner rule. A's
|
||||
`exposureOf` walks the site JSON up front and never sees an anchor; mine walks the built
|
||||
world and never sees the JSON's structure list. Two routes, one number, by construction
|
||||
(the S14 pin pattern, third outing).
|
||||
| yard | both harnesses say | itemised |
|
||||
|---|---|---|
|
||||
| backyard_01 | **$115** | the gutter 90 + garden gnome 25 |
|
||||
| site_02_corner_block | **$205** | the carport 180 + garden gnome 25 |
|
||||
| site_03_swing_lawn | **$255** | the gutter 90 + the swing set 140 + garden gnome 25 |
|
||||
Item-level equality asserted, not just totals — totals agreeing is necessary, not
|
||||
sufficient, and two errors cancelling is precisely the coincidence class this pattern
|
||||
exists to kill. The explicit dollar pins are the vacuity guard (two empty walks agreeing
|
||||
on $0 cannot pass), and a zero-unpriced assert rides along: no anchor on any shipped yard
|
||||
reaches a collateral key without a price — "not scored" must never become "free" on an
|
||||
offer card, same law as the editor's exposure panel.
|
||||
**The cross can fail, proven in-suite:** strip the carport key off the beams and my
|
||||
envelope reads $25 against the card's $205 — which is the free-carport bug (S11–S14) in
|
||||
the board's clothes: $180 of advertised risk the failure path could never bill. That is
|
||||
the drift this cross now catches structurally, on every run.
|
||||
**A: your three numbers are confirmed from the failure side. No variable to find.**
|
||||
Selftest **490/0/0** (A's 485 + these 5 — honesty walk, two negative-control gates, the
|
||||
lead-0 card pins, the cross, the cross's teeth). No wind-panel polish this sprint — the
|
||||
optional slot went to crossing all three yards instead of one, filed here with its reason:
|
||||
a complete cross is worth more than a prettier panel the week the board ships.
|
||||
|
||||
357
web/world/js/board.js
Normal file
357
web/world/js/board.js
Normal 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;
|
||||
}
|
||||
@ -305,6 +305,44 @@ const CSS = `
|
||||
}
|
||||
/* ---- end of E's kit ----------------------------------------------------- */
|
||||
|
||||
/* --- SPRINT17 gate 1: THE JOB BOARD ------------------------------------
|
||||
A new card in the paper chain, and it has to meet the invoice's standard —
|
||||
so it is built out of the kit rather than beside it: same letterhead, same
|
||||
.row/.cond rhythm in the offer body, same terms line at the foot.
|
||||
|
||||
The one NEW idea it needs is that an offer is a THING YOU PICK, so each one
|
||||
is a button (the .storm affordance the old storm-select card used, widened
|
||||
into a panel). Two offers, stacked, tonight's scripted job first — see
|
||||
board.js offersFor() for why that order is fixed and not seeded.
|
||||
|
||||
The constrained offer gets a visible mark, because gate 2's whole point is
|
||||
that taking a constrained job is a CHOICE: an amber left border and the
|
||||
client's own words in italic. A player must not be able to take one without
|
||||
having been told, and "it was in the fee" is not being told. */
|
||||
#hud-card .offer { display:block; width:100%; text-align:left; margin:9px 0;
|
||||
padding:13px 15px; background:#121c23; border:1px solid #2f3f4a; border-radius:7px;
|
||||
color:#dde5ea; cursor:pointer; font:inherit;
|
||||
transition:border-color .15s, background .15s; }
|
||||
#hud-card .offer:hover { border-color:#7ee0ff; background:#16242c; }
|
||||
#hud-card .offer .who { font-weight:700; color:#fff; letter-spacing:.04em; }
|
||||
#hud-card .offer .addr { font-size:11px; color:#6d818e; margin-bottom:7px; }
|
||||
#hud-card .offer .tag { float:right; font-size:10px; letter-spacing:.14em;
|
||||
color:#55707f; text-transform:uppercase; }
|
||||
#hud-card .offer .line { display:flex; justify-content:space-between; gap:14px;
|
||||
font-size:12px; color:#8ba0ad; padding:2px 0; }
|
||||
#hud-card .offer .line b { color:#dde5ea; font-weight:700; }
|
||||
/* The two numbers a tradie decides on. The fee is the reason to say yes and
|
||||
the exposure is the reason to say no, so they are weighted against each
|
||||
other rather than both being grey: money in, money at risk. */
|
||||
#hud-card .offer .fee b { color:#ffd9a3; }
|
||||
#hud-card .offer .risk b { color:#ff8f86; }
|
||||
#hud-card .offer.constrained { border-left:3px solid #ffc24a; }
|
||||
#hud-card .offer .says { margin-top:8px; padding-left:9px; border-left:2px solid #3f5561;
|
||||
font-size:11px; font-style:italic; color:#c8b78a; line-height:1.5; }
|
||||
#hud-card .offer .cons { font-size:10px; letter-spacing:.12em; text-transform:uppercase;
|
||||
color:#ffc24a; margin-top:7px; }
|
||||
#hud-card .board-note { margin-top:12px; font-size:11px; color:#6d8494; }
|
||||
|
||||
/* Tomorrow is a rumour, and it should look like one next to tonight's numbers.
|
||||
(Mine, not E's — the kit predates the tomorrow line and doesn't restyle it.) */
|
||||
#hud-card .tomorrow { display:flex; gap:10px; align-items:baseline; margin-top:9px;
|
||||
@ -734,6 +772,99 @@ export function createHud(d) {
|
||||
|
||||
// --- cards ------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* SPRINT17 gate 1 — THE JOB BOARD. The morning, before the job sheet.
|
||||
*
|
||||
* ROADMAP arc 2's opening move: *"mornings offer N callouts, you take one.
|
||||
* Pay, risk, client and site visible before you commit."* Two offers,
|
||||
* tonight's scripted night first, and taking either one makes it the night.
|
||||
*
|
||||
* **What an offer card shows, and why it is exactly this list** — SPRINT17
|
||||
* gate 1.2 asks for "what a tradie would demand before saying yes", which
|
||||
* is not a feature list, it's a job interview:
|
||||
* · WHO and WHERE — the client and the address, off the same night data
|
||||
* the letterhead bills. A job you can't name is a job you don't take.
|
||||
* · WHAT THE WEATHER IS DOING — C's honest forecast band at lead 0, the
|
||||
* same call the job sheet makes, so the board cannot be more confident
|
||||
* than the sheet it leads to.
|
||||
* · THE FEE — what you're paid, premium included, priced through the
|
||||
* SAME calloutFee() the sheet quotes and the invoice pays.
|
||||
* · THE RISK — collateral exposure: what of theirs your failure can
|
||||
* bill you for. This is the number that makes the choice a choice; a
|
||||
* board that showed only the fee would be advertising, not quoting.
|
||||
* · THE CLIENT'S CONSTRAINTS, in the client's own words, marked — so
|
||||
* taking a constrained job is a decision and never a surprise.
|
||||
*
|
||||
* The pricing is injected (main.js owns the storm defs and site JSON);
|
||||
* anything absent simply doesn't render, which is the same degradation rule
|
||||
* the job sheet's forecast lines follow. The card works with nothing but
|
||||
* client/site/brief, and that is what lets it be tested without a server.
|
||||
*
|
||||
* @param {object[]} offers board.js offersFor(), already priced
|
||||
* @param {object} wk {night, nights, bank, log, rep}
|
||||
* @param {(offer:object) => void} onTake
|
||||
*/
|
||||
showBoard(offers, wk, onTake) {
|
||||
const pips = Array.from({ length: wk.nights }, (_, i) => {
|
||||
const done = i < wk.night - 1;
|
||||
const now = i === wk.night - 1;
|
||||
const held = done && wk.log?.[i]?.won;
|
||||
return `<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
|
||||
* 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
|
||||
? `<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
|
||||
// existed since Sprint 8 and the player has never seen it until the money
|
||||
// was already spent: you decided what to rig without knowing what the job
|
||||
@ -872,6 +1020,7 @@ showForecast({ key, def, site, tomorrowDef }, wk, onGo) {
|
||||
card.innerHTML = `<div class="card jobcard">
|
||||
${letterhead}
|
||||
${brief}
|
||||
${constraintBlock}
|
||||
<h1>NIGHT ${wk.night} OF ${wk.nights}</h1>
|
||||
<div class="pips">${pips}</div>
|
||||
<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 money = w ? `
|
||||
<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${
|
||||
// SPRINT14 — B's pool item, the invoice half. On a beyond-saving
|
||||
// night this row is always small, and next to "callout fee — night
|
||||
@ -1083,6 +1238,21 @@ showForecast({ key, def, site, tomorrowDef }, wk, onGo) {
|
||||
<h1>MORNING${w ? ` · NIGHT ${w.night} OF ${w.nights ?? NIGHT_COUNT}` : ''}</h1>
|
||||
<h2>${r.subtitle}</h2>
|
||||
${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}
|
||||
<div class="verdict ${r.win ? 'win' : 'lose'}">${r.verdict}</div>
|
||||
${/* SPRINT16 — the verdict speaks rep: the movement AND its reason, so
|
||||
|
||||
@ -17,7 +17,7 @@ import * as THREE from '../vendor/three.module.js';
|
||||
import { FIXED_DT, PHASES, STORM_LEN, HARDWARE, SPARE_COST, Emitter } from './contracts.js';
|
||||
import { createWorld, loadSite } from './world.js';
|
||||
import { createCameraRig, spawnYawFor } from './camera.js';
|
||||
import { loadStorm, createWind } from './weather.js';
|
||||
import { loadStorm, createWind, forecastLines } from './weather.js';
|
||||
import { SailRig, createSailView } from './sail.js';
|
||||
import { createPlayer } from './player.js';
|
||||
import { Interact, wireYardActions } from './interact.js';
|
||||
@ -25,7 +25,8 @@ import { createDebris } from './debris.js';
|
||||
import { createSkyFx } from './skyfx.js';
|
||||
import { createRiggingUI, fabricNoteFor } from './rigging.js';
|
||||
import { createHud } from './hud.js';
|
||||
import { createWeek, NIGHTS, nightAt } from './week.js';
|
||||
import { calloutFee, createWeek, NIGHTS, nightAt } from './week.js';
|
||||
import { POOL, exposureOf } from './board.js';
|
||||
import { createGarden } from './garden.js';
|
||||
|
||||
/** The calm day the forecast and prep phases run under. */
|
||||
@ -52,10 +53,32 @@ export const CALM_STORM = 'storm_01_gentle';
|
||||
* there — night one is gentle today, so the bug hides. That assert would be
|
||||
* decoration, which is the exact failure mode this repo keeps naming.
|
||||
*
|
||||
* @param {string[]} [nights] storm keys, defaults to the shipped ladder
|
||||
* ⚠️ **SPRINT17 — THE POOL JOINS THE LADDER HERE, and this is the same landmine
|
||||
* in a new costume.** The board can put ANY pool night on tonight, so a pool
|
||||
* storm that isn't preloaded is `defs[week.stormKey] === undefined` the moment
|
||||
* a player takes that offer — which is D's calm-day bug exactly: an invariant
|
||||
* ("every storm the game can run is loaded") that used to hold by coincidence,
|
||||
* because NIGHTS was the only source of nights. It isn't anymore. Both sources
|
||||
* are named here, deduped, and a.test pins that a pool-only storm survives the
|
||||
* list.
|
||||
*
|
||||
* ⚠️ **TWO PARAMETERS, NOT ONE CONCATENATED DEFAULT — and this assert had to be
|
||||
* rescued from being decoration, by mutation-checking rather than by reading
|
||||
* it.** The first cut took a single `nights` list defaulting to NIGHTS+POOL,
|
||||
* and a.test asserted "every pool storm is preloaded". That assert passed with
|
||||
* the POOL source deleted outright, because every storm the pool flies today
|
||||
* also appears in NIGHTS — the same coincidence, in the same function, that hid
|
||||
* the calm-day bug for a sprint. Split, the composition is testable against a
|
||||
* pool storm that is NOT in the ladder — the case that would actually break.
|
||||
*
|
||||
* @param {string[]} [nights] storm keys from the ladder
|
||||
* @param {string[]} [pool] storm keys the BOARD can put on tonight
|
||||
*/
|
||||
export function stormsToPreload(nights = NIGHTS.map((_, i) => nightAt(i).storm)) {
|
||||
return [...new Set([CALM_STORM, ...nights])];
|
||||
export function stormsToPreload(
|
||||
nights = NIGHTS.map((_, i) => nightAt(i).storm),
|
||||
pool = POOL.map((p) => p.storm),
|
||||
) {
|
||||
return [...new Set([CALM_STORM, ...nights, ...pool])];
|
||||
}
|
||||
|
||||
const STORMS = stormsToPreload();
|
||||
@ -979,6 +1002,72 @@ export async function boot(opts = {}) {
|
||||
* anything. Lane B's file, flagged in THREADS — main.js therefore tracks
|
||||
* `spentThisNight` itself off the bank rather than trusting `.spent`.
|
||||
*/
|
||||
/**
|
||||
* SPRINT17 gate 1 — THE MORNING: the board, then the job sheet.
|
||||
*
|
||||
* Site JSON is needed for the exposure number on an offer whose yard we have
|
||||
* NOT loaded (and must not load — loading it would rebuild the world behind
|
||||
* the card for a job the player might not take). `loadSite` is a small JSON
|
||||
* fetch with no GLBs, cached here so a week of boards costs three requests.
|
||||
*
|
||||
* The cache is keyed on site name and never invalidated on purpose: a site
|
||||
* JSON cannot change inside a run, and a stale-cache bug on a number the
|
||||
* player makes money decisions from would be worth more than the three
|
||||
* requests it saves.
|
||||
* @type {Map<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() {
|
||||
// SPRINT10: tonight's yard. loadSiteInto no-ops when the site is unchanged
|
||||
// (the four backyard nights) and rebuilds + re-points everything holding the
|
||||
@ -1073,7 +1162,11 @@ export async function boot(opts = {}) {
|
||||
// on the new world/player, not the old one mid-swap. This is the only
|
||||
// phase transition that can rebuild the scene, so it's the only one that
|
||||
// has to sequence like this.
|
||||
showTonight().then(() => {
|
||||
// SPRINT17: the board comes first, and the world rebuild rides the PICK
|
||||
// rather than the phase change — the yard to build is not known until
|
||||
// the player has chosen a job. The resets still land after the rebuild
|
||||
// (same .then sequencing, one card earlier in the chain).
|
||||
showMorning().then(() => {
|
||||
garden.reset();
|
||||
resetRig();
|
||||
player.sim.carrying = null;
|
||||
@ -1176,13 +1269,28 @@ export async function boot(opts = {}) {
|
||||
// `opts.splash === false` skips it — the selftest, the dev benches and D's
|
||||
// playtest harness all boot straight into a night, and none of them should
|
||||
// have to click through a door.
|
||||
//
|
||||
// ⚠️ SPRINT17 — THE FIRST MORNING GETS A BOARD TOO, and for an hour it did
|
||||
// not. The board hangs off the forecast PHASE CHANGE, and night one never
|
||||
// has one: the game boots already IN 'forecast', so this line is the only
|
||||
// route into the first morning. Going through showTonight() made night 1 the
|
||||
// one night of the week with no choice on it. Found by LOOKING at the running
|
||||
// game, not by a test — every board assert operates on week.offers() and all
|
||||
// seven mornings were correct; the CARD CHAIN was wrong at the single join no
|
||||
// assert was watching. (Both card bugs in this repo's history were invisible
|
||||
// to a green suite. This is the third, and it says the same thing.)
|
||||
//
|
||||
// `splash:false` still goes straight to the scripted night, and that is the
|
||||
// documented harness door rather than an oversight: a bench that asked for a
|
||||
// night wants the night, not a card waiting to be clicked. D — your playtest
|
||||
// harness is unchanged; the REAL path is the one with the board on it.
|
||||
hud.setAudioMuteAvailable(typeof sky?.setMute === 'function');
|
||||
if (!canPlayHere()) {
|
||||
hud.showTouchNotice(); // no way through, on purpose: see hud.showTouchNotice
|
||||
} else if (opts.splash === false) {
|
||||
showTonight();
|
||||
} else {
|
||||
hud.showSplash(() => showTonight());
|
||||
hud.showSplash(() => showMorning());
|
||||
}
|
||||
|
||||
// --- resize -------------------------------------------------------------
|
||||
|
||||
@ -20,7 +20,12 @@ import { RiggingSession } from '../rigging.js';
|
||||
import { createSkyFx } from '../skyfx.js';
|
||||
import { SailRig, orderRing } from '../sail.js';
|
||||
import { loadStorm, createWind } from '../weather.js';
|
||||
import { createWeek, NIGHTS, nightAt, gradeFor, BROKE_BELOW, PAY, REP } from '../week.js';
|
||||
import {
|
||||
BROKE_BELOW, PAY, REP, calloutFee, createWeek, gradeFor, nightAt, NIGHTS,
|
||||
} from '../week.js';
|
||||
// SPRINT17 gate 1/2 — the board and the constraint data are mine; these are
|
||||
// the seam this suite pins (offer shape, pool, exposure, constraint enum).
|
||||
import { CONSTRAINT_PREMIUM, POOL, exposureOf, validateConstraint } from '../board.js';
|
||||
import { createHud } from '../hud.js';
|
||||
import { PALETTE, boundsFaults, emptyTemplate, exportSiteJSON, placeEntry } from '../editor.js';
|
||||
import { assert, assertClose, assertEq, assertLess, fixedLoop } from '../testkit.js';
|
||||
@ -291,11 +296,15 @@ export default async function run(t) {
|
||||
// ladder WITHOUT the gentle storm — the case that actually broke. Checked
|
||||
// against the shipped NIGHTS it would pass either way (night one is gentle
|
||||
// today), which is how the bug hid in the first place.
|
||||
// SPRINT17: stormsToPreload takes the LADDER and the POOL separately now
|
||||
// (see its docstring — the single-list version made the pool assert
|
||||
// decoration). These pass an empty pool so this pin keeps testing exactly
|
||||
// what it was written to test: the calm day's survival, ladder-only.
|
||||
assert(stormsToPreload().includes(CALM_STORM),
|
||||
'the calm day is preloaded on the shipped ladder');
|
||||
assert(stormsToPreload(['storm_02_wildnight']).includes(CALM_STORM),
|
||||
assert(stormsToPreload(['storm_02_wildnight'], []).includes(CALM_STORM),
|
||||
"...and on a ladder that doesn't contain it anywhere — the case that broke");
|
||||
assertEq(stormsToPreload(['storm_01_gentle']).length, 1,
|
||||
assertEq(stormsToPreload(['storm_01_gentle'], []).length, 1,
|
||||
'and it is still deduped, not loaded twice');
|
||||
});
|
||||
|
||||
@ -424,6 +433,271 @@ export default async function run(t) {
|
||||
'the second-to-last night is not the final night — going broke there ends the run');
|
||||
});
|
||||
|
||||
// --- SPRINT17 gate 1: THE JOB BOARD -------------------------------------
|
||||
// Loaded up front — Suite.test() cannot await, and every board assert wants a
|
||||
// real storm def to price a fee against.
|
||||
const boardDefs = {};
|
||||
for (const k of ['storm_01_gentle', 'storm_03_southerly', 'storm_03b_earlybuster']) {
|
||||
try { boardDefs[k] = await loadStorm(k); } catch { /* no server: asserts below degrade */ }
|
||||
}
|
||||
// The raw site JSON, for the exposure pins — deliberately NOT a built world:
|
||||
// exposureOf() answers "what is priced in this yard" off data a board can read
|
||||
// without building anything, which is the whole reason it can price an offer
|
||||
// for a yard the player has not chosen yet.
|
||||
const siteJsonForTest = {};
|
||||
for (const k of ['backyard_01', 'site_02_corner_block', 'site_03_swing_lawn']) {
|
||||
try { siteJsonForTest[k] = await loadSite(k); } catch { /* degrades to SKIPPED */ }
|
||||
}
|
||||
|
||||
t.test('SPRINT17: every morning offers TWO jobs — tonight, plus one alternative', () => {
|
||||
const w = createWeek();
|
||||
for (let i = 0; i < NIGHTS.length; i++) {
|
||||
const offers = w.offers();
|
||||
assertEq(offers.length, 2, `night ${i + 1} offers two jobs`);
|
||||
// The scripted spine SURVIVES, and it is always offer 0 — the campaign is
|
||||
// the thing the player is in the middle of, and a board that shuffled
|
||||
// which door was which would make "take the job you were going to do" a
|
||||
// reading exercise every morning.
|
||||
assertEq(offers[0].kind, 'scripted');
|
||||
assertEq(offers[0].night.storm, nightAt(i).storm, `night ${i + 1}'s scripted offer IS the ladder's night`);
|
||||
assertEq(offers[0].night.site, nightAt(i).site);
|
||||
assertEq(offers[1].kind, 'callout', 'the second is drawn from the pool');
|
||||
// An alternative that is the same yard under the same storm is not an
|
||||
// alternative — it is the board lying about having offered a choice.
|
||||
assert(!(offers[1].night.storm === offers[0].night.storm
|
||||
&& offers[1].night.site === offers[0].night.site),
|
||||
`night ${i + 1}'s alternative must be a DIFFERENT job, got ${offers[1].night.site} × ${offers[1].night.storm}`);
|
||||
w.advance();
|
||||
}
|
||||
});
|
||||
|
||||
t.test('SPRINT17: the board is DETERMINISTIC — same week, same offers, always', () => {
|
||||
// The repo's oldest rule (contracts.js §Determinism) and the selftest
|
||||
// depends on it: a pool that drew from Date.now or Math.random is a suite
|
||||
// that goes red on a Tuesday. Three independent weeks, walked in full.
|
||||
const walk = (seed) => {
|
||||
const w = createWeek(seed == null ? {} : { seed });
|
||||
const out = [];
|
||||
for (let i = 0; i < NIGHTS.length; i++) { out.push(w.offers().map((o) => o.id).join('|')); w.advance(); }
|
||||
return out.join(' ; ');
|
||||
};
|
||||
const a = walk(), b = walk(), c = walk();
|
||||
assertEq(a, b, 'two fresh weeks must offer the same board');
|
||||
assertEq(b, c, '...and a third');
|
||||
// And the seed is REAL — a different week is a different board, or the
|
||||
// "deterministic" pin above is passing on a constant and proving nothing.
|
||||
// (This is the negative control for the assert above it: without it, an
|
||||
// offersFor() that ignored the seed entirely and always returned POOL[0]
|
||||
// would sail through the equality checks.)
|
||||
const other = walk(7);
|
||||
assert(other !== a, 'a different seed must draw a different week of offers');
|
||||
});
|
||||
|
||||
t.test('SPRINT17: TAKE ONE — the chosen job IS the night, all the way to the invoice', () => {
|
||||
if (!boardDefs.storm_03_southerly) return 'SKIPPED — no server for storm defs';
|
||||
const w = createWeek();
|
||||
const offers = w.offers();
|
||||
const alt = offers[1];
|
||||
// Precondition: the alternative really is a different job from the spine,
|
||||
// or this test proves nothing about taking one.
|
||||
assert(alt.night.site !== nightAt(0).site || alt.night.storm !== nightAt(0).storm,
|
||||
'precondition: the alternative differs from night 1');
|
||||
|
||||
w.take(alt);
|
||||
assertEq(w.site, alt.night.site, 'week.site follows the taken job');
|
||||
assertEq(w.stormKey, alt.night.storm, 'and so does the storm');
|
||||
assertEq(w.job.client, alt.night.client, 'and the client the letterhead bills');
|
||||
assertEq(w.takenOffer, alt.id, 'the week records WHICH offer was taken');
|
||||
|
||||
// THE ASSERT GATE 1 ASKS FOR IN SO MANY WORDS: "a chosen alternative that
|
||||
// skips the ledger is the board lying." Every per-night surface must work
|
||||
// UNCHANGED on a chosen night — so run the whole paper chain over one.
|
||||
const def = boardDefs[alt.night.storm];
|
||||
const q = w.quote(def);
|
||||
assert(q.base > 0, 'a taken job quotes a fee');
|
||||
assertEq(q.total, q.base + q.garden + q.clean - q.warrantyTotal, 'the quote still sums');
|
||||
const s = w.settle({ hp: 90, win: true, collateral: [], intactHardwareValue: 40, rigRecord: [] }, def, 40);
|
||||
assertEq(s.client, alt.night.client, 'the invoice bills the client you chose');
|
||||
assertEq(s.site, alt.night.site, 'the ledger records the yard you chose');
|
||||
assertEq(s.taken, alt.id, 'and that it came off the board');
|
||||
assertEq(s.pay, s.fee + s.bonus + s.refund + s.clean - s.collateral - s.warrantyTotal,
|
||||
'the ledger adds up on a chosen night exactly as it does on a scripted one');
|
||||
assertEq(q.base, s.fullFee, 'quote==settle holds on a taken job');
|
||||
assert(s.rep != null && s.grade != null, 'rep and grade are computed for a chosen night');
|
||||
});
|
||||
|
||||
t.test('SPRINT17: a taken night is INDISTINGUISHABLE in shape from an authored one', () => {
|
||||
// The bug this pins was real and the board probe found it before a player
|
||||
// could: take() stored the raw pool entry, and a pool entry with no `pay`
|
||||
// block threw "Cannot read properties of undefined (reading 'garden')" out
|
||||
// of quote(). Both doors normalise through normaliseNight() now — this
|
||||
// asserts the SHAPES match rather than trusting that they do.
|
||||
const w = createWeek();
|
||||
const scripted = nightAt(0);
|
||||
w.take({ id: 'alt:probe', night: { storm: 'storm_01_gentle', site: 'backyard_01' } });
|
||||
const takenKeys = Object.keys(w.job).sort().join(',');
|
||||
assertEq(takenKeys, Object.keys(scripted).sort().join(','),
|
||||
'a taken night carries exactly the fields an authored night carries');
|
||||
// And the defaults really defaulted, rather than being present-but-undefined.
|
||||
assertEq(typeof w.job.pay, 'object', 'pay defaults to a block, not undefined');
|
||||
assertEq(w.job.constraints.length, 0, 'constraints default to an empty array');
|
||||
assertEq(w.job.gardenBeyondSaving, false, 'a new night cannot inherit the icenight\'s excuse');
|
||||
});
|
||||
|
||||
t.test('SPRINT17: take is a COMMITMENT — the other offer vanishes and there is no untake', () => {
|
||||
const w = createWeek();
|
||||
const [scripted, alt] = w.offers();
|
||||
w.take(alt);
|
||||
assertEq(w.takenOffer, alt.id);
|
||||
// Taking again on the same morning is the SAME take, not a re-pick. A
|
||||
// player who could re-open the board after reading the job sheet would be
|
||||
// shopping, not choosing — and the fee is already quoted against the job.
|
||||
w.take(scripted);
|
||||
assertEq(w.takenOffer, alt.id, 'the morning\'s choice is final');
|
||||
assertEq(w.site, alt.night.site, 'and the yard did not change under it');
|
||||
// A fresh morning is a fresh choice, though.
|
||||
w.advance();
|
||||
assertEq(w.takenOffer, null, 'tomorrow is unchosen');
|
||||
});
|
||||
|
||||
t.test('SPRINT17: the SPINE runs untouched when nothing is taken — the campaign is not the board', () => {
|
||||
// 474 asserts stand on the scripted week. The board must be a second door,
|
||||
// not a replacement: take nothing and every per-night reading is the
|
||||
// authored ladder, byte for byte.
|
||||
const w = createWeek();
|
||||
for (let i = 0; i < NIGHTS.length; i++) {
|
||||
w.offers(); // building a board must not MOVE anything
|
||||
assertEq(w.stormKey, nightAt(i).storm, `night ${i + 1} storm untouched by the board`);
|
||||
assertEq(w.site, nightAt(i).site, `night ${i + 1} site untouched`);
|
||||
assertEq(w.job.client, nightAt(i).client, `night ${i + 1} client untouched`);
|
||||
assertEq(w.takenOffer, null, 'and nothing is marked taken');
|
||||
w.advance();
|
||||
}
|
||||
});
|
||||
|
||||
t.test('SPRINT17: every pool night is a JOB, and its storm is one the game preloads', () => {
|
||||
for (const p of POOL) {
|
||||
assert(p.id, 'a pool entry needs an id');
|
||||
assert(p.client, `${p.id} has a client`);
|
||||
assert(p.addr, `${p.id} has an address — a letterhead bills somebody somewhere`);
|
||||
assert(p.brief && p.brief.length > 20, `${p.id} has a brief worth reading`);
|
||||
assert(p.site, `${p.id} names a yard`);
|
||||
assert(p._why, `${p.id} carries its audit receipt — an unmeasured pairing is a night the board is guessing about`);
|
||||
// ⚠️ D's calm-day landmine, in its SPRINT17 costume: the board can put any
|
||||
// pool night on tonight, so a pool storm the loader never fetched is
|
||||
// `defs[week.stormKey] === undefined` the moment somebody takes it.
|
||||
assert(stormsToPreload().includes(p.storm),
|
||||
`${p.id} flies ${p.storm}, which nothing preloads — taking this offer would boot into an undefined storm`);
|
||||
}
|
||||
// ⚠️ THE LINE ABOVE IS NOT ENOUGH, AND A MUTATION PROVED IT (A, S17): every
|
||||
// storm the pool flies TODAY also appears in NIGHTS, so that assert passes
|
||||
// with the whole POOL source deleted from stormsToPreload — decoration, and
|
||||
// the identical coincidence that hid the calm-day bug for a sprint. THIS is
|
||||
// the assert with teeth: a pool storm that is NOT in the ladder, which is
|
||||
// the case that actually breaks, and the case D's pool yard will be the
|
||||
// moment it brings a storm of its own.
|
||||
assert(stormsToPreload(['storm_01_gentle'], ['storm_99_fixture']).includes('storm_99_fixture'),
|
||||
'a POOL-ONLY storm must be preloaded — the board can put it on tonight and nothing else would load it');
|
||||
assert(stormsToPreload(['storm_01_gentle'], []).includes(CALM_STORM),
|
||||
'the calm day still survives a ladder without it (S11 pin, still true)');
|
||||
// ...and it is still deduped, so the loader does not fetch a storm twice
|
||||
// just because the pool and the ladder agree about it.
|
||||
assertEq(stormsToPreload(['storm_01_gentle'], ['storm_01_gentle']).length, 1,
|
||||
'a storm in BOTH the ladder and the pool is loaded once');
|
||||
});
|
||||
|
||||
t.test('SPRINT17: collateral exposure is the yard\'s priced property, and it matches the site JSON', () => {
|
||||
if (!siteJsonForTest.backyard_01) return 'SKIPPED — no server for site JSON';
|
||||
// Two routes to one set of dollars (board.js walks the site JSON up front;
|
||||
// world.js resolves a collateral KEY at failure time), which is exactly the
|
||||
// shape this repo has been bitten by. Pin the totals against the data.
|
||||
const b = exposureOf(siteJsonForTest.backyard_01);
|
||||
assertEq(b.total, 115, 'the backyard: gutter 90 + gnome 25');
|
||||
const c = exposureOf(siteJsonForTest.site_02_corner_block);
|
||||
assertEq(c.total, 205, 'the corner block: carport 180 + gnome 25 — and no house');
|
||||
const s = exposureOf(siteJsonForTest.site_03_swing_lawn);
|
||||
assertEq(s.total, 255, 'the swing lawn: gutter 90 + swing set 140 + gnome 25 — the worst in the game');
|
||||
// Every item priced must be a real number a player can actually be billed,
|
||||
// and the total must BE the items — an exposure that didn't sum to its own
|
||||
// list would be the invoice bug one card earlier.
|
||||
for (const { what, cost } of [...b.items, ...c.items, ...s.items]) {
|
||||
assert(what && typeof what === 'string', 'every exposure item names itself');
|
||||
assert(Number.isFinite(cost) && cost > 0, `${what} is priced`);
|
||||
}
|
||||
assertEq(s.total, s.items.reduce((n, i) => n + i.cost, 0), 'the total IS the items');
|
||||
});
|
||||
|
||||
// --- SPRINT17 gate 2: CLIENT CONSTRAINTS (A's data half) -----------------
|
||||
|
||||
t.test('SPRINT17: a constrained night pays a premium, on BOTH papers, from ONE formula', () => {
|
||||
if (!boardDefs.storm_03b_earlybuster) return 'SKIPPED — no server for storm defs';
|
||||
const capped = POOL.find((p) => p.constraints?.some((c) => c.kind === 'budgetCap'));
|
||||
assert(capped, 'the pool ships a budget-capped job');
|
||||
const def = boardDefs[capped.storm];
|
||||
|
||||
const plain = calloutFee(def, REP.START, []);
|
||||
const withCap = calloutFee(def, REP.START, capped.constraints);
|
||||
assert(withCap > plain, `a constraint must PAY — got ${withCap} vs ${plain}`);
|
||||
assertEq(withCap, Math.round(plain * (1 + CONSTRAINT_PREMIUM.budgetCap)),
|
||||
'and by exactly the premium the constraint declares');
|
||||
|
||||
// The sheet and the invoice must read the same number. quote() and settle()
|
||||
// route through the same calloutFee() — this is the pin that keeps them
|
||||
// there when somebody adds a fourth term.
|
||||
const w = createWeek();
|
||||
w.take({ id: `alt:${capped.id}`, night: capped });
|
||||
const q = w.quote(def);
|
||||
const s = w.settle({ hp: 90, win: true, collateral: [], intactHardwareValue: 0, rigRecord: [] }, def, 45);
|
||||
assertEq(q.base, withCap, 'the job sheet quotes the premium');
|
||||
assertEq(s.fullFee, withCap, 'and the invoice pays it');
|
||||
assertEq(q.premium, s.premium, 'both papers state the same premium');
|
||||
// The invoice repeats the TERMS it was paid under — gate 2's requirement.
|
||||
assertEq(s.constraints.length, capped.constraints.length,
|
||||
'the invoice repeats the constraint it was paid under');
|
||||
assertEq(s.constraints[0].label, capped.constraints[0].label);
|
||||
});
|
||||
|
||||
t.test('SPRINT17: the constraint enum is CHECKED, so a bad one cannot ship', () => {
|
||||
// An unenforced enum is decoration — this repo's standing rule, learned
|
||||
// when the carport shipped typed as a 'post' behind a JSDoc that said it
|
||||
// couldn't. Every shipped constraint validates...
|
||||
for (const p of POOL) {
|
||||
for (const c of p.constraints ?? []) validateConstraint(c, p.id);
|
||||
}
|
||||
// ...and a bogus one throws, with the kind named in the message.
|
||||
let threw = '';
|
||||
try { validateConstraint({ kind: 'pay_me_double', says: 'give me money', label: 'nope' }, 'fixture'); }
|
||||
catch (err) { threw = err.message; }
|
||||
assert(threw.includes('pay_me_double'), `a bad kind must fail loud and name itself, got: ${threw || '(no throw)'}`);
|
||||
// The fields the papers PRINT are required too — a constraint with no
|
||||
// client words would render an empty quote block on two cards.
|
||||
let threw2 = '';
|
||||
try { validateConstraint({ kind: 'budgetCap', cap: 45, label: 'capped' }, 'fixture'); }
|
||||
catch (err) { threw2 = err.message; }
|
||||
assert(threw2.includes('says'), `a constraint with no client words must fail, got: ${threw2 || '(no throw)'}`);
|
||||
// ...and a budgetCap with no number is not a cap.
|
||||
let threw3 = '';
|
||||
try { validateConstraint({ kind: 'budgetCap', says: 'keep it cheap please', label: 'capped' }, 'fixture'); }
|
||||
catch (err) { threw3 = err.message; }
|
||||
assert(threw3.includes('cap'), `a capless budgetCap must fail, got: ${threw3 || '(no throw)'}`);
|
||||
});
|
||||
|
||||
t.test('SPRINT17: a budget cap is BELOW the start budget and ABOVE the four-carabiner floor', () => {
|
||||
// The ruling, pinned: the client caps your SPEND, not your wallet. A cap at
|
||||
// or above START_BUDGET is not a constraint (it changes nothing and the
|
||||
// premium is free money); a cap under BROKE_BELOW is a soft-lock, because
|
||||
// four carabiners is the cheapest legal rig and the game refuses to leave
|
||||
// prep without four corners.
|
||||
for (const p of POOL) {
|
||||
for (const c of p.constraints ?? []) {
|
||||
if (c.kind !== 'budgetCap') continue;
|
||||
assertLess(c.cap, START_BUDGET, `${p.id}: a cap at or above the start budget constrains nothing`);
|
||||
assert(c.cap >= BROKE_BELOW,
|
||||
`${p.id}: cap $${c.cap} is under the $${BROKE_BELOW} four-carabiner floor — that is a soft-lock, not a squeeze`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Gate 2 acceptance: both sites load from data, and the corner block is not
|
||||
// the backyard with the furniture moved. Built up front (Suite.test() can't
|
||||
// await — the guard I added last sprint enforces it, and just caught me).
|
||||
|
||||
@ -14,10 +14,18 @@
|
||||
*/
|
||||
|
||||
import * as THREE from '../../vendor/three.module.js';
|
||||
import { assert, assertClose, fixedLoop } from '../testkit.js';
|
||||
import { assert, assertClose, assertEq, fixedLoop } from '../testkit.js';
|
||||
import { FIXED_DT, checkContract, DEBRIS_PIECE_FIELDS, createStubWind } from '../contracts.js';
|
||||
import { loadStorm, createWind, windForSite, forecastLines, forecastFor, leadFor, hailBlockFor } from '../weather.js';
|
||||
import {
|
||||
loadStorm, createWind, windForSite, forecastLines, forecastFor, leadFor, hailBlockFor,
|
||||
forecastHonest, stormStats, STONE_WORD_CEILING,
|
||||
} from '../weather.js';
|
||||
import { loadSite, createWorld } from '../world.js';
|
||||
// SPRINT17 gate 1 — the board's weather side. NIGHTS ∪ POOL is every storm an
|
||||
// offer card can print; both are data, so the honesty walk below tracks them
|
||||
// without a test edit the day D's pool yard lands.
|
||||
import { NIGHTS } from '../week.js';
|
||||
import { POOL, exposureOf } from '../board.js';
|
||||
// GATE 2.3 — the GAME's own wind wiring, IMPORTED rather than re-typed. A pin
|
||||
// that copied main.js's two lines would agree with a copy of the game forever,
|
||||
// including on the day the game itself changed. "Two harnesses, one number"
|
||||
@ -1284,4 +1292,179 @@ export default async function run(t) {
|
||||
`membrane on carabiners (${mem20.hp} hp, ${mem20.cornersLost} lost) should read WORSE than cloth `
|
||||
+ `(${cloth20.hp} hp, ${cloth20.cornersLost} lost) — the doubled load has stopped costing anything`);
|
||||
});
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
// SPRINT17 gate 1 — THE BOARD'S WEATHER SIDE (C).
|
||||
//
|
||||
// A's seam contract (THREADS 2026-07-21): the offer card prints my
|
||||
// forecastLines(def, 0), and "a storm the forecast can't describe honestly
|
||||
// doesn't get offered" is MY rule to enforce. Enforced here, over the union
|
||||
// of both night sources, so a new pool entry meets the gate at integration
|
||||
// without anyone remembering to ask.
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
// Every storm the board can put on a card, from BOTH sources, by
|
||||
// construction. A's stormsToPreload mutation finding is the warning label:
|
||||
// today every POOL storm also flies in NIGHTS, so a walk of NIGHTS alone
|
||||
// would be green by coincidence and rot the day D's pool yard brings a storm
|
||||
// of its own. The union is taken from the shipped data, so that day changes
|
||||
// this walk without anyone editing a test.
|
||||
const offerStormNames = [...new Set([
|
||||
...NIGHTS.map((n) => (typeof n === 'string' ? n : n.storm)),
|
||||
...POOL.map((p) => p.storm),
|
||||
])];
|
||||
const offerDefs = {};
|
||||
const offerLoadErrors = [];
|
||||
for (const nm of offerStormNames) {
|
||||
try { offerDefs[nm] = storms[nm] ?? await loadStorm(nm); }
|
||||
catch (err) { offerLoadErrors.push(`${nm}: ${err.message}`); }
|
||||
}
|
||||
|
||||
t.test('GATE 1 (S17): a storm the forecast cannot describe honestly is not offered', () => {
|
||||
assert(offerLoadErrors.length === 0,
|
||||
`offerable storms failed to load/validate:\n ${offerLoadErrors.join('\n ')}`);
|
||||
// Vacuity guard: the union walk must actually be walking the shipped week.
|
||||
assert(offerStormNames.length >= 6,
|
||||
`only ${offerStormNames.length} offerable storms found — the NIGHTS ∪ POOL walk is broken`);
|
||||
const failures = [];
|
||||
for (const nm of offerStormNames) {
|
||||
const h = forecastHonest(offerDefs[nm], nm);
|
||||
if (!h.ok) failures.push(`${nm}:\n ${h.errors.join('\n ')}`);
|
||||
}
|
||||
assert(failures.length === 0,
|
||||
'the board offers storms the forecast cannot describe honestly — pull them from POOL/NIGHTS '
|
||||
+ `or fix the def (A's contract: C vetoes, A pulls):\n ${failures.join('\n ')}`);
|
||||
});
|
||||
|
||||
t.test('GATE 1 (S17): the honesty gate can fail — a lying change and an unspeakable stone are refused', () => {
|
||||
// Negative controls through the SAME predicate the gate walk uses — the
|
||||
// composition a pool-only storm exercises the day one exists. (The band-
|
||||
// containment loops inside forecastHonest are structural — band() cannot
|
||||
// exclude the truth by construction — and are disclosed as regression
|
||||
// armour in its header, not counted as coverage. THESE two are the teeth.)
|
||||
const lying = structuredClone(storms.storm_03_southerly);
|
||||
lying.dirCurve = lying.dirCurve.map(([tt]) => [tt, lying.dirCurve[0][1]]); // promises a change, never turns
|
||||
const l = forecastHonest(lying, 'lying_southerly');
|
||||
assert(!l.ok, 'a windchange the dirCurve never delivers passed the honesty gate');
|
||||
assert(l.errors.some((e) => e.includes('windchange')),
|
||||
`the refusal should name the lie, got:\n ${l.errors.join('\n ')}`);
|
||||
|
||||
const unspeakable = structuredClone(storms.storm_02b_icenight);
|
||||
unspeakable.hail.size = STONE_WORD_CEILING + 0.6;
|
||||
const u = forecastHonest(unspeakable, 'cricket_ball_night');
|
||||
assert(!u.ok, `hail.size ${unspeakable.hail.size} passed — the vocabulary ceiling is decoration`);
|
||||
assert(u.errors.some((e) => e.includes('vocabulary') || e.includes('ceiling')),
|
||||
`the refusal should name the vocabulary, got:\n ${u.errors.join('\n ')}`);
|
||||
// and the shipped icenight sits under the ceiling with honest headroom
|
||||
assert(stormStats(storms.storm_02b_icenight).hailSize <= STONE_WORD_CEILING,
|
||||
'the shipped icenight is over the vocabulary ceiling — the gate would pull a shipped night');
|
||||
});
|
||||
|
||||
t.test('GATE 1 (S17): the offer card\'s lead-0 lines carry the measured truth', () => {
|
||||
// The board prints forecastLines(def, 0) (A's pricedOffers — "the same
|
||||
// call the job sheet makes"). Cross the card TEXT against stormStats'
|
||||
// measured numbers, so the wording cannot drift off the measurement while
|
||||
// both stay green alone. Walks the POOL: these are the cards a player
|
||||
// reads about a night they have never flown.
|
||||
for (const p of POOL) {
|
||||
const def = offerDefs[p.storm];
|
||||
const f = forecastLines(def, 0);
|
||||
assert(f.confidence === '',
|
||||
`${p.id}: a lead-0 offer hedges ("${f.confidence}") — tonight is exact and the card must not waffle`);
|
||||
const g = f.wind.match(/gusts to ~(\d+) km\/h/);
|
||||
assert(g, `${p.id}: the wind line lost its gust figure: "${f.wind}"`);
|
||||
assertEq(g[1], (stormStats(def).gustPeak * 3.6).toFixed(0),
|
||||
`${p.id}: the card's gust number is not the measured storm`);
|
||||
const su = f.wind.match(/^sustained to (\d+)/);
|
||||
assert(su, `${p.id}: the wind line lost its sustained figure: "${f.wind}"`);
|
||||
assertEq(su[1], stormStats(def).sustained.toFixed(0),
|
||||
`${p.id}: the card's sustained number is not the measured storm`);
|
||||
}
|
||||
});
|
||||
|
||||
// ── GATE 1.2 (S17): THE EXPOSURE CROSS — A's exposureOf vs the failure
|
||||
// route, the S14 pin pattern (two harnesses, one number, by construction).
|
||||
// Prep up front (Suite.test can't await); the tests skip honestly offline.
|
||||
const crossWorlds = {};
|
||||
for (const nm of ['site_02_corner_block', 'backyard_01', 'site_03_swing_lawn']) {
|
||||
try {
|
||||
const sj = await loadSite(nm);
|
||||
const w = createWorld(new THREE.Scene(), { wind: createStubWind({ calm: true }), site: sj });
|
||||
let dressed = true;
|
||||
try { await w.dress(); } catch { dressed = false; } // graybox anchors carry no GLB collateral keys
|
||||
crossWorlds[nm] = { sj, w, dressed };
|
||||
} catch (err) {
|
||||
crossWorlds[nm] = { error: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* MY envelope: what a failure can actually bill, gathered the way the game
|
||||
* bills it (main.js scoreRun) — every collateral KEY reachable from a BUILT
|
||||
* anchor, priced once per structure through world.collateralFor (the
|
||||
* resolver the invoice uses), plus the gnome (billed on a two-corner
|
||||
* collapse, priced off world.gnome). A's exposureOf walks the site JSON up
|
||||
* front and never sees an anchor; this walks the built world and never sees
|
||||
* the JSON's structure list. Two routes to one set of dollars — when they
|
||||
* disagree, find the variable before either is tuned; it will be the
|
||||
* harness (the S14 rule, verbatim).
|
||||
*/
|
||||
const failureEnvelope = (w) => {
|
||||
const keys = [...new Set(w.anchors.map((a) => a.collateral).filter(Boolean))];
|
||||
const items = [];
|
||||
const unpriced = [];
|
||||
for (const k of keys) {
|
||||
const p = w.collateralFor(k);
|
||||
if (p) items.push({ what: p.label, cost: p.cost });
|
||||
else unpriced.push(k);
|
||||
}
|
||||
if (Number.isFinite(w.gnome?.collateralValue)) {
|
||||
items.push({ what: 'garden gnome', cost: w.gnome.collateralValue });
|
||||
}
|
||||
return { items, unpriced, total: items.reduce((s, i) => s + i.cost, 0) };
|
||||
};
|
||||
|
||||
t.test('GATE 1.2 (S17): collateral exposure — two harnesses, one set of dollars', () => {
|
||||
// The explicit dollar pins are the vacuity guard: two empty walks agreeing
|
||||
// on $0 cannot pass here. 205 = carport 180 + gnome 25; 115 = gutter 90 +
|
||||
// gnome 25; 255 = gutter 90 + swing set 140 + gnome 25 (the numbers A
|
||||
// verified live on the board, and a.test pins per-structure). Every yard
|
||||
// the board can currently offer is crossed — the gate asked for one.
|
||||
const pins = { site_02_corner_block: 205, backyard_01: 115, site_03_swing_lawn: 255 };
|
||||
for (const [nm, want] of Object.entries(pins)) {
|
||||
const c = crossWorlds[nm];
|
||||
if (c?.error) return `SKIPPED — no server for ${nm}: ${c.error}`;
|
||||
if (!c.dressed) return `SKIPPED — ${nm} GLBs unavailable, graybox anchors carry no collateral keys`;
|
||||
const mine = failureEnvelope(c.w);
|
||||
const board = exposureOf(c.sj);
|
||||
assertEq(mine.unpriced.length, 0,
|
||||
`${nm}: anchors reach unpriced collateral (${mine.unpriced.join(', ')}) — exposure the card `
|
||||
+ 'cannot show, and "not scored" must never become "free"');
|
||||
assertEq(mine.total, board.total, `${nm}: the two exposure harnesses disagree — find the variable`);
|
||||
assertEq(mine.total, want, `${nm}: both harnesses agree on the WRONG number`);
|
||||
// Totals agreeing is necessary, not sufficient — item-level agreement is
|
||||
// what rules out two errors cancelling.
|
||||
const key = (xs) => xs.map((i) => `${i.what}=$${i.cost}`).sort().join(' · ');
|
||||
assertEq(key(mine.items), key(board.items),
|
||||
`${nm}: totals agree but the itemisation differs — the agreement is a coincidence`);
|
||||
}
|
||||
});
|
||||
|
||||
t.test('GATE 1.2 (S17): the cross has teeth — strip the beam\'s key and the harnesses disagree', () => {
|
||||
const c = crossWorlds.site_02_corner_block;
|
||||
if (!c || c.error || !c.dressed) return 'SKIPPED — no dressed corner block (see the cross above)';
|
||||
// The drift class this cross exists to catch: an anchor that loses its
|
||||
// collateral key is $180 the offer card advertises and the failure path
|
||||
// can never bill (the free-carport bug, S11–S14, wearing the board's
|
||||
// clothes). Mutate the LIVE world — this is its last use in the suite —
|
||||
// and the equality above must break, or it never could have.
|
||||
const stripped = c.w.anchors.filter((a) => a.collateral === 'carport');
|
||||
assert(stripped.length >= 2,
|
||||
`only ${stripped.length} anchor(s) carry the carport key — the trap has lost its reach`);
|
||||
for (const a of stripped) a.collateral = null;
|
||||
const mine = failureEnvelope(c.w);
|
||||
assertEq(mine.total, 25, `the stripped envelope should read gnome-only $25, got $${mine.total}`);
|
||||
assert(mine.total !== exposureOf(c.sj).total,
|
||||
'the harnesses still agree with the carport unreachable — the cross cannot fail');
|
||||
});
|
||||
}
|
||||
|
||||
@ -23,6 +23,30 @@ export {
|
||||
const kmh = (ms) => ms * 3.6;
|
||||
const band = (b, fmt) => (b.hi - b.lo < 0.05 ? fmt(b.lo) : `${fmt(b.lo)}–${fmt(b.hi)}`);
|
||||
|
||||
// SPRINT16 gate 3.3's word map, lifted to module scope in S17 so the honesty
|
||||
// gate below audits THE map the card prints from — a predicate checking a copy
|
||||
// of the vocabulary would agree with the copy forever, which is the exact drift
|
||||
// this repo keeps getting bitten by. Words map hail.size units (1.0 ≈ a 1.5 cm
|
||||
// stone): storm_03's 0.7 reads "pea" (its own comment's word), the soaker's
|
||||
// 0.45 "fine pea", the wild night's 1.3 and the ice night's 1.4 read "golf
|
||||
// balls" — the sizes cloth stops dead.
|
||||
const stoneWord = (v) => (v < 0.6 ? 'fine pea' : v <= 0.9 ? 'pea' : v <= 1.2 ? 'marble' : 'golf ball');
|
||||
|
||||
/**
|
||||
* Where the stone vocabulary stops being honest. "golf ball" is the map's
|
||||
* biggest word and it is open-ended upward — a storm whose stones measure 2.6
|
||||
* would still print "golf ball stones", underselling ice the way the numbers
|
||||
* are forbidden to. The numeric bands stay truthful at any size (band() can't
|
||||
* lie), but the card leads with the WORD, and a word that rules out what
|
||||
* actually falls is the same ambush as a band that does. 2.0 is where the line
|
||||
* sits: at 1.0 ≈ 1.5 cm, 2.0 ≈ 3 cm is real golf-ball territory, so the word
|
||||
* holds all the way up to it; past it we are in cricket-ball country the map
|
||||
* has no word for. Shipped max is the ice night's 1.4 — real headroom, hard
|
||||
* ceiling. A storm above this is unofferable until the vocabulary grows a word
|
||||
* for it, and that is a wording sprint, not a data tweak.
|
||||
*/
|
||||
export const STONE_WORD_CEILING = 2.0;
|
||||
|
||||
/**
|
||||
* How vague a night reads when it is `nightsOut` nights away — the week IS the
|
||||
* forecast horizon, so tonight is exact (0) and the far end of the week is as
|
||||
@ -88,11 +112,8 @@ export function forecastLines(def, lead = 0) {
|
||||
// them; this owns the wording, same split as every other line here). The
|
||||
// stone is the fabric bet's whole argument: pea hail rattles through a
|
||||
// 2 mm weave, anything bigger cannot — so the size word is what tells a
|
||||
// player which fabric tonight wants, before a dollar is spent. Words map
|
||||
// hail.size units (1.0 ≈ a 1.5 cm stone): storm_03's 0.7 reads "pea" (its
|
||||
// own comment's word), the soaker's 0.45 "fine pea", the wild night's 1.3
|
||||
// and the ice night's 1.4 read "golf balls" — the sizes cloth stops dead.
|
||||
const stoneWord = (v) => (v < 0.6 ? 'fine pea' : v <= 0.9 ? 'pea' : v <= 1.2 ? 'marble' : 'golf ball');
|
||||
// player which fabric tonight wants, before a dollar is spent. The word map
|
||||
// itself is module-scope now (S17) — the offer-honesty gate audits it.
|
||||
const sz = f.hail.size;
|
||||
const stones = f.hail.chance === 'none' ? '' : (
|
||||
stoneWord(sz.lo) === stoneWord(sz.hi)
|
||||
@ -117,6 +138,92 @@ export function forecastLines(def, lead = 0) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* SPRINT17 gate 1 — CAN THE FORECAST DESCRIBE THIS STORM HONESTLY?
|
||||
*
|
||||
* The board's rule, stated in A's seam contract and enforced here: *a storm the
|
||||
* forecast can't describe honestly doesn't get offered.* An offer card leads
|
||||
* with my lines — if those lines can't tell the truth about a def, the def has
|
||||
* no business on the board, however winnable the pairing is. c.test walks
|
||||
* NIGHTS ∪ POOL through this, so a new pool entry (D's yard, arc-4 seeds)
|
||||
* meets the gate at integration without anyone remembering to ask.
|
||||
*
|
||||
* Same contract shape as validateStorm: {ok, errors}, errors in English,
|
||||
* recomputed FROM THE DEF — nothing here trusts the caller's provenance, so a
|
||||
* hand-edited def fails on its own merits (the checkEnvelope rule).
|
||||
*
|
||||
* What has TEETH here, disclosed plainly (README: an assert that cannot fail
|
||||
* is decoration):
|
||||
* · validateStorm — structural lies (a windchange the dirCurve never
|
||||
* delivers, overlapping gusts, a flatlining tail) really do fail.
|
||||
* · the stone vocabulary ceiling — a def with stones past STONE_WORD_CEILING
|
||||
* really does fail (the icenight at hail.size 2.6 is the negative control).
|
||||
* · finiteness of the measured stats — a curve that produces NaN fails.
|
||||
* What is STRUCTURAL and rides along as regression armour, not as a live gate:
|
||||
* band-contains-truth and lead-0 exactness are guaranteed by forecastFor's
|
||||
* band() today (lo=min(v,·), hi=max(v,·), width→0 at lead 0) — those loops can
|
||||
* only fail on the day someone re-plumbs band(), which is precisely the day
|
||||
* they should. Disclosed here and in THREADS rather than dressed up as
|
||||
* coverage.
|
||||
*
|
||||
* @param {object} def parsed storm JSON
|
||||
* @param {string} [name]
|
||||
* @returns {{ok: boolean, errors: string[]}}
|
||||
*/
|
||||
export function forecastHonest(def, name = def?.name ?? 'storm') {
|
||||
const errors = [];
|
||||
const v = validateStorm(def, name);
|
||||
if (!v.ok) errors.push(...v.errors);
|
||||
if (!def || typeof def !== 'object' || !v.ok) return { ok: false, errors };
|
||||
|
||||
// The truth must be measurable before it can be told.
|
||||
const s = stormStats(def);
|
||||
for (const k of ['sustained', 'gustPeak', 'rainPeak', 'rainPeakMmPerHour',
|
||||
'hailPeak', 'hailSeconds', 'hailSize']) {
|
||||
if (!Number.isFinite(s[k])) {
|
||||
errors.push(`${name}: stormStats.${k} is not finite — no honest line can be printed from it`);
|
||||
}
|
||||
}
|
||||
|
||||
// The vocabulary must reach the stone. Numbers band honestly at any size;
|
||||
// the WORD is the card's opening argument and it must not undersell ice.
|
||||
if (s.hailSeconds > 0 && s.hailSize > STONE_WORD_CEILING) {
|
||||
errors.push(`${name}: hail.size ${s.hailSize} is past the stone vocabulary's ceiling `
|
||||
+ `(${STONE_WORD_CEILING}) — "${stoneWord(s.hailSize)}" would undersell what falls; `
|
||||
+ 'grow the word map before offering this storm');
|
||||
}
|
||||
|
||||
// Recompute the band promises from the def (structural today — see header).
|
||||
const contains = (b, val, what, lead) => {
|
||||
if (val == null || b == null) return;
|
||||
if (!(Number.isFinite(b.lo) && Number.isFinite(b.hi))) {
|
||||
errors.push(`${name}: ${what} band at lead ${lead} is not finite`);
|
||||
} else if (val < b.lo - 1e-9 || val > b.hi + 1e-9) {
|
||||
errors.push(`${name}: ${what} band [${b.lo}, ${b.hi}] at lead ${lead} rules out the truth ${val}`);
|
||||
}
|
||||
};
|
||||
for (const lead of [0, 0.5, 1]) {
|
||||
const f = forecastFor(def, lead);
|
||||
contains(f.sustained, s.sustained, 'sustained', lead);
|
||||
contains(f.gustPeak, s.gustPeak, 'gustPeak', lead);
|
||||
contains(f.rain, s.rainPeak, 'rain', lead);
|
||||
contains(f.rainMmPerHour, s.rainPeakMmPerHour, 'rainMmPerHour', lead);
|
||||
contains(f.hail.seconds, s.hailSeconds, 'hail.seconds', lead);
|
||||
contains(f.hail.size, s.hailSize, 'hail.size', lead);
|
||||
contains(f.changeAt, s.changeAt, 'changeAt', lead);
|
||||
if (lead === 0) {
|
||||
for (const [what, b] of [['sustained', f.sustained], ['gustPeak', f.gustPeak], ['rain', f.rain]]) {
|
||||
if (b.lo !== b.hi) errors.push(`${name}: lead-0 ${what} band [${b.lo}, ${b.hi}] hedges — tonight is exact`);
|
||||
}
|
||||
if (s.hailSeconds > 0 && f.hail.chance === 'none') {
|
||||
errors.push(`${name}: hails for ${s.hailSeconds.toFixed(1)}s but tonight's card says "none"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: errors.length === 0, errors };
|
||||
}
|
||||
|
||||
// Resolved against this module, not the server root: server.py serves the repo
|
||||
// root (so the 2D prototype stays reachable), but the demo bench serves web/.
|
||||
// import.meta.url is right under both, and under whatever Lane A does next.
|
||||
|
||||
@ -15,6 +15,7 @@
|
||||
*/
|
||||
|
||||
import { START_BUDGET, HARDWARE } from './contracts.js';
|
||||
import { offersFor, premiumFor } from './board.js';
|
||||
|
||||
/**
|
||||
* The ladder. Escalating, and each rung introduces one new idea rather than just
|
||||
@ -187,19 +188,28 @@ export const NIGHTS = [
|
||||
];
|
||||
|
||||
/**
|
||||
* A night entry, every shape → a full JOB.
|
||||
* A night entry, every shape → a full JOB. **The one normaliser**, SPRINT17.
|
||||
*
|
||||
* The plain-string form still resolves (SPRINT10's promise), and now so does a
|
||||
* night with no client: an unbriefed job is a job with no letterhead, not a
|
||||
* crash. `pay` is per-job so the schedule is data — see PAY for the defaults and
|
||||
* for why the clean bonus is the one number here that is NOT yet measured.
|
||||
*
|
||||
* @param {number} i
|
||||
* @returns {{storm:string, site:string, client:string|null, brief:string|null, pay:object}}
|
||||
* ⚠️ **SPRINT17 — WHY THIS IS A SEPARATE FUNCTION FROM `nightAt`, and it is not
|
||||
* tidiness.** The board can install a POOL entry as tonight's night, and a pool
|
||||
* entry is hand-authored data with exactly the same optional fields a NIGHTS
|
||||
* entry has. The first cut of `week.take()` stored `{...offer.night}` raw, and
|
||||
* the board probe found it in one call: a pool entry with no `pay` block threw
|
||||
* `Cannot read properties of undefined (reading 'garden')` out of quote() —
|
||||
* i.e. a chosen job was NOT the same shape as a scripted one, which is the
|
||||
* single promise gate 1 makes. Both doors normalise HERE now, so "a taken night
|
||||
* is indistinguishable from an authored night" is a fact about the code rather
|
||||
* than a thing two call sites both have to remember.
|
||||
*
|
||||
* @param {object|string} entry a NIGHTS entry, a pool entry, or a bare storm key
|
||||
*/
|
||||
export function nightAt(i) {
|
||||
const n = NIGHTS[i];
|
||||
const base = typeof n === 'string' ? { storm: n, site: 'backyard_01' } : (n ?? {});
|
||||
export function normaliseNight(entry) {
|
||||
const base = typeof entry === 'string' ? { storm: entry, site: 'backyard_01' } : (entry ?? {});
|
||||
return {
|
||||
storm: base.storm,
|
||||
site: base.site ?? 'backyard_01',
|
||||
@ -210,9 +220,53 @@ export function nightAt(i) {
|
||||
// False by default on purpose: a night is presumed savable unless its data
|
||||
// says otherwise, so a new night can never inherit the icenight's excuse.
|
||||
gardenBeyondSaving: base.gardenBeyondSaving ?? false,
|
||||
/**
|
||||
* SPRINT17 gate 2 — CLIENT CONSTRAINTS, as data on the night.
|
||||
*
|
||||
* Empty by default, and an EMPTY ARRAY rather than null on purpose: every
|
||||
* consumer (the job sheet, the invoice, the offer card, B's session) wants
|
||||
* to iterate this, and a null that four surfaces each have to remember to
|
||||
* guard is three surfaces away from a crash. The scripted spine ships
|
||||
* unconstrained — constraints arrive on the board's callouts — so this
|
||||
* field is `[]` on all seven nights and nothing about the campaign moves.
|
||||
*/
|
||||
constraints: base.constraints ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Tonight's authored night, by index into the ladder.
|
||||
* @param {number} i
|
||||
* @returns {{storm:string, site:string, client:string|null, brief:string|null, pay:object}}
|
||||
*/
|
||||
export function nightAt(i) {
|
||||
return normaliseNight(NIGHTS[i]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The callout fee, and the ONE place it is computed. SPRINT17.
|
||||
*
|
||||
* Extracted because gate 2 adds a third term (the constraint premium) to a
|
||||
* formula that quote() and settle() were each spelling out for themselves. Two
|
||||
* copies of a two-term formula is a latent divergence; two copies of a
|
||||
* three-term formula is the quote-vs-settle lie with a longer fuse — and the
|
||||
* sheet promising a premium the invoice doesn't pay is exactly the failure the
|
||||
* warranty derivation was built to make impossible. So: one function, both
|
||||
* callers, rounded ONCE at the end.
|
||||
*
|
||||
* Order matters and is fixed here: severity → standing → the client's squeeze.
|
||||
* The premium is a fraction of the fee you'd otherwise be paid, so it compounds
|
||||
* on the rep multiplier rather than on the raw fee — a ★5 tradie is paid more
|
||||
* for accepting the same ban, which is the right way round.
|
||||
*
|
||||
* @param {object} def tonight's storm def
|
||||
* @param {number} rep standing AS OF THIS MORNING
|
||||
* @param {object[]} [constraints] tonight's client constraints
|
||||
*/
|
||||
export function calloutFee(def, rep, constraints = []) {
|
||||
return Math.round(PAY.feeFor(gustPeakOf(def)) * REP.multiplier(rep) * (1 + premiumFor(constraints)));
|
||||
}
|
||||
|
||||
/**
|
||||
* The economy, in one place because it is the thing most likely to need tuning
|
||||
* once somebody actually plays five nights in a row.
|
||||
@ -363,6 +417,17 @@ export const REP = {
|
||||
*/
|
||||
export const BROKE_BELOW = HARDWARE[0].cost * 4;
|
||||
|
||||
/**
|
||||
* SPRINT17 — the default week seed.
|
||||
*
|
||||
* A CONSTANT, not a clock. Every board in the shipped game is drawn from this
|
||||
* and the night index, so the campaign has one fixed sequence of offers that
|
||||
* the selftest can pin and John can play twice and recognise. `createWeek({seed})`
|
||||
* is the door for a different week — which is where replay comes from in a
|
||||
* later sprint, and it costs nothing to leave open now.
|
||||
*/
|
||||
export const DEFAULT_WEEK_SEED = 1;
|
||||
|
||||
/**
|
||||
* How the week is remembered, by gardens actually held.
|
||||
*
|
||||
@ -434,6 +499,31 @@ export function createWeek(opts = {}) {
|
||||
let bank = startBank;
|
||||
let done = false;
|
||||
let rep = REP.START; // SPRINT16 — the number on the letterhead
|
||||
/**
|
||||
* SPRINT17 gate 1 — the week's SEED. Every morning's board is drawn from
|
||||
* this and the night index, and from nothing else: no Date.now, no
|
||||
* Math.random (contracts.js §Determinism, and the selftest depends on it —
|
||||
* an offer pool that reads the clock is a suite that goes red on a Tuesday).
|
||||
* Same seed, same week, same two offers, forever.
|
||||
*/
|
||||
const seed = opts.seed ?? DEFAULT_WEEK_SEED;
|
||||
/**
|
||||
* SPRINT17 gate 1 — TAKEN JOBS, keyed by night index.
|
||||
*
|
||||
* This is the whole mechanism behind "the chosen job IS the night", and it is
|
||||
* an OVERRIDE rather than a replacement on purpose. `jobAt()` falls through to
|
||||
* the authored `nightAt()` whenever nothing was taken, so:
|
||||
* · the scripted week runs byte-identically to the pre-board game (474
|
||||
* asserts stand on that, and none of them know this map exists);
|
||||
* · a taken alternative is a night entry of exactly the same SHAPE, so
|
||||
* every per-night surface downstream — sheet, quote, invoice, ledger,
|
||||
* warranty, rep, end card — reads it through the path it already had.
|
||||
* Nothing consults "was this scripted?" to decide how to do its job, which
|
||||
* is the property that makes the board impossible to half-wire.
|
||||
* @type {Map<number, object>}
|
||||
*/
|
||||
const taken = new Map();
|
||||
const jobAt = (i) => taken.get(i) ?? nightAt(i);
|
||||
/** @type {Settlement[]} */
|
||||
const log = [];
|
||||
|
||||
@ -463,11 +553,11 @@ export function createWeek(opts = {}) {
|
||||
/** 1-based, for humans. "Night 3 of 5". */
|
||||
get night() { return index + 1; },
|
||||
get nights() { return NIGHTS.length; },
|
||||
get stormKey() { return nightAt(index).storm; },
|
||||
get stormKey() { return jobAt(index).storm; },
|
||||
/** SPRINT10: which yard tonight is on. main.js loads it. */
|
||||
get site() { return nightAt(index).site; },
|
||||
get site() { return jobAt(index).site; },
|
||||
/** SPRINT11: tonight's job — client, brief, pay schedule. The job sheet reads this. */
|
||||
get job() { return nightAt(index); },
|
||||
get job() { return jobAt(index); },
|
||||
|
||||
/**
|
||||
* What tonight pays, BEFORE you rig it — the "budget Y" half of DESIGN.md's
|
||||
@ -482,7 +572,7 @@ export function createWeek(opts = {}) {
|
||||
* @param {object} def tonight's storm def
|
||||
*/
|
||||
quote(def) {
|
||||
const j = nightAt(index);
|
||||
const j = jobAt(index);
|
||||
// SPRINT16: the fee is priced on your standing, and the deduction for
|
||||
// last dawn's broken corners is on the quote — AT QUOTE TIME, so settle
|
||||
// pays exactly what the sheet said. Both read the same sources settle
|
||||
@ -492,13 +582,27 @@ export function createWeek(opts = {}) {
|
||||
const warranty = warrantyItems();
|
||||
const warrantyTotal = warranty.reduce((s, i) => s + i.cost, 0);
|
||||
return {
|
||||
base: Math.round(PAY.feeFor(gustPeakOf(def)) * feeMult),
|
||||
// SPRINT17 gate 2 — ONE fee function, shared with settle(), so the
|
||||
// constraint premium cannot reach the sheet without reaching the
|
||||
// invoice. (Before this it was two hand-written copies of the same
|
||||
// formula agreeing by luck; adding a third term to both by hand is how
|
||||
// that luck runs out.)
|
||||
base: calloutFee(def, rep, j.constraints),
|
||||
garden: j.pay.garden ?? PAY.gardenBonusMax,
|
||||
clean: j.pay.clean ?? PAY.noCollateralBonus,
|
||||
feeMult,
|
||||
rep,
|
||||
warranty,
|
||||
warrantyTotal,
|
||||
/**
|
||||
* SPRINT17 gate 2 — the constraints the job is quoted UNDER, on the
|
||||
* sheet, and repeated verbatim on the invoice they were paid under
|
||||
* (hud.js prints both off this same array). `premium` is what they
|
||||
* added to the fee, so the sheet can show the client's squeeze and the
|
||||
* client's compensation as the two facts they are.
|
||||
*/
|
||||
constraints: j.constraints ?? [],
|
||||
premium: premiumFor(j.constraints),
|
||||
/**
|
||||
* SPRINT14 (B's pool item, offered S13 and accepted): the job sheet has
|
||||
* to say when the garden bonus is money the night cannot pay.
|
||||
@ -522,6 +626,59 @@ export function createWeek(opts = {}) {
|
||||
get total() { return this.base + this.garden + this.clean - this.warrantyTotal; },
|
||||
};
|
||||
},
|
||||
/**
|
||||
* SPRINT17 gate 1 — THIS MORNING'S BOARD. Two offers: tonight's scripted
|
||||
* night, and one alternative drawn deterministically from the pool.
|
||||
*
|
||||
* @param {(night:object) => object} [priced] optional per-offer pricing /
|
||||
* forecast / exposure, injected by main.js because it owns the storm defs
|
||||
* and the site JSON. board.js stays pure; the money is computed HERE, off
|
||||
* the same rep and the same calloutFee() the sheet and invoice use, so an
|
||||
* offer cannot advertise a fee the job sheet then contradicts.
|
||||
*/
|
||||
offers(priced = null) {
|
||||
return offersFor({
|
||||
scripted: nightAt(index), weekSeed: seed, nightIndex: index, priced,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* TAKE ONE. The other vanishes — no backlog, no decline cost (SPRINT18
|
||||
* decides whether saying no costs you; SPRINT17 deliberately does not
|
||||
* charge for a choice it hasn't designed the consequences of).
|
||||
*
|
||||
* Idempotent per night and only forward: taking twice on the same morning
|
||||
* is the same take, and there is no untake. That's not a limitation, it's
|
||||
* the point — the board is a commitment, and a player who could re-open it
|
||||
* after reading the job sheet would be shopping, not choosing.
|
||||
*
|
||||
* @param {object} offer one of `offers()`
|
||||
* @returns {object} the night now installed for tonight
|
||||
*/
|
||||
take(offer) {
|
||||
if (!offer?.night) throw new Error('week.take: needs an offer with a .night (board.js offersFor)');
|
||||
if (!taken.has(index)) {
|
||||
// Stamped with the offer id so the settlement — and therefore the
|
||||
// invoice and the end card — can say the job came off the board and
|
||||
// WHICH job it was. Non-enumerable so it can never leak into a night's
|
||||
// data shape and make a taken night structurally different from a
|
||||
// scripted one: `jobAt()` must hand back something indistinguishable.
|
||||
// normaliseNight, NOT a spread — see its docstring. A pool entry is
|
||||
// hand-authored data with the same optional fields a NIGHTS entry has,
|
||||
// and a raw one crashes quote() on its missing `pay` block.
|
||||
const night = normaliseNight(offer.night);
|
||||
Object.defineProperty(night, '_offerId', { value: offer.id, enumerable: false });
|
||||
taken.set(index, night);
|
||||
}
|
||||
return taken.get(index);
|
||||
},
|
||||
|
||||
/** Tonight's taken offer id, or null if the player is on the spine. */
|
||||
get takenOffer() { return taken.get(index)?._offerId ?? null; },
|
||||
|
||||
/** SPRINT17 — the week's draw seed, for the board and for a.test's pins. */
|
||||
get seed() { return seed; },
|
||||
|
||||
get bank() { return bank; },
|
||||
/** SPRINT16 — the letterhead's number. Stars, 0..5, half-steps. */
|
||||
get rep() { return rep; },
|
||||
@ -532,6 +689,10 @@ export function createWeek(opts = {}) {
|
||||
reset() {
|
||||
index = 0; bank = startBank; done = false; log.length = 0;
|
||||
rep = REP.START;
|
||||
// SPRINT17: a restarted week re-offers the same board (the seed is fixed
|
||||
// for the run), so clearing the takes is what makes "play it again"
|
||||
// actually replayable rather than handing you last run's choices.
|
||||
taken.clear();
|
||||
return week;
|
||||
},
|
||||
|
||||
@ -550,12 +711,15 @@ export function createWeek(opts = {}) {
|
||||
// function, after the money is computed, so quote and settle agree by
|
||||
// ordering. Round after the multiplier, exactly as quote() does.
|
||||
const feeMult = REP.multiplier(rep);
|
||||
const fullFee = Math.round(PAY.feeFor(gustPeakOf(def)) * feeMult);
|
||||
// SPRINT17 gate 2: the SAME calloutFee() the quote printed, constraints
|
||||
// and all — quote==settle by construction, extended to the premium the
|
||||
// same way it was extended to the warranty deduction.
|
||||
const fullFee = calloutFee(def, rep, jobAt(index).constraints);
|
||||
const fee = run.win ? fullFee : Math.round(fullFee * PAY.lostNightShare);
|
||||
// Per-job override, PAY's default otherwise — and settle MUST read the same
|
||||
// source the quote does, or the job sheet promises a number the invoice
|
||||
// doesn't pay. That's not a rounding difference, it's a lie on paper.
|
||||
const gardenMax = nightAt(index).pay.garden ?? PAY.gardenBonusMax;
|
||||
const gardenMax = jobAt(index).pay.garden ?? PAY.gardenBonusMax;
|
||||
const bonus = Math.round((Math.max(0, run.hp) / 100) * gardenMax);
|
||||
|
||||
// Only hardware still on an unbroken corner comes home, and at half.
|
||||
@ -566,7 +730,7 @@ export function createWeek(opts = {}) {
|
||||
|
||||
// The clean bonus. Per-job data first, PAY's default otherwise — so a job
|
||||
// that wants to say "there's a lot here to break" can, without code.
|
||||
const job = nightAt(index);
|
||||
const job = jobAt(index);
|
||||
const cleanMax = job.pay.clean ?? PAY.noCollateralBonus;
|
||||
const clean = collateral === 0 ? cleanMax : 0;
|
||||
|
||||
@ -642,6 +806,15 @@ export function createWeek(opts = {}) {
|
||||
gardenBeyondSaving: job.gardenBeyondSaving ?? false,
|
||||
gardenMax,
|
||||
client: job.client, addr: job.addr, site: job.site,
|
||||
// SPRINT17 — the invoice repeats the constraint it was PAID UNDER
|
||||
// (gate 2's requirement, and the reason this rides the settlement
|
||||
// rather than being looked up: the invoice for night 3 must state
|
||||
// night 3's terms even when you're reading it on night 5's morning).
|
||||
// `taken` says the job came off the board — the ledger records that
|
||||
// you CHOSE this, which is the whole of arc 2 in one boolean.
|
||||
constraints: job.constraints ?? [],
|
||||
premium: premiumFor(job.constraints),
|
||||
taken: taken.has(index) ? (taken.get(index)._offerId ?? true) : false,
|
||||
// SPRINT16 — the ledger. The rig record IS the work history (the night
|
||||
// computed all of it; the week just keeps it), the warranty items are
|
||||
// tonight's sheet answered on the invoice, and the rep block is the
|
||||
|
||||
Loading…
Reference in New Issue
Block a user