Compare commits

...

21 Commits

Author SHA1 Message Date
type-two
a012225259 Sprint 17 integration [I] entry: the board live, seven nights, convergence recorded 2026-07-21 06:32:13 +10:00
type-two
9498dfc1b9 S17 integration: apply C's offerBand hunk — the board stops lying about night 7 (hail on every offer card) 2026-07-21 06:25:14 +10:00
type-two
8edf461286 Merge remote-tracking branch 'origin/lane/c'
# Conflicts:
#	THREADS.md
2026-07-21 06:21:56 +10:00
type-two
3717ef0d54 Merge remote-tracking branch 'origin/lane/b'
# Conflicts:
#	THREADS.md
2026-07-21 06:21:16 +10:00
type-two
5ae696040d Lane D S17: [D] THREADS — convergence declared, the other D session's pool yard + cold-play adopted
Two Lane D sessions ran gates 3.2 + 4 in parallel; found via push rejection.
Their site_04_pool_yard + seven-night cold-play (515/0/0) STAND — dropped my
whole duplicate cut (a second, two-corroded-post pool yard + a second cold-play),
did not force, did not rebase over theirs. Verified their tree green (515/0/0)
before adding a line.

Additive-only: a measured receipt for their second-rust-post landmine (a real
two-post yard splits the exposure harnesses, exposureOf $115 vs
collateralExposure $45 — C's gate-1.2 cross going red by construction); and an
independent live repro of C's night-7 board-card omission (card "33 km/h · fine
pea stones" vs sheet "rain heavy · 44 mm/hr · hail likely") flagged as the #1
board fix. Scratch merge judged the wider tree (main + newest b/c) at 517/0/0,
per-lane sum verified, nothing lost.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 06:14:11 +10:00
type-two
d8466355e8 Lane D S17: [D] THREADS — the seven-night cold play through the board, verdicts per night
Two alternatives taken (the pool yard N1, the $45 cap N4), the marquee, the
designed loss and the fabric bet on the spine. THE WEEK HELD 7/7 · 6/7 · $401
· star 0.0 · 5 warranty jobs. Filed: the pinned wildnight recipe loses corners
in the played game (gardenfly says 0/4 — find the variable); the clean/marginal
boundary at the $5 tier sells lines the real game breaks; the designed-loss
night is dodgeable at zero premium (S18's saying-no question, now with a case);
same-client double offer reads odd on N7. Final tree 515/0/0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 04:30:20 +10:00
type-two
4cdb2fead8 Lane B S17 gate 3: the corroded tier prices honestly in the card
The piece the 0.2/0.3/2 pass explicitly deferred. Pins E's corroded tier
through the card's own pricing surfaces, off E's real resolver (not a
hand-typed 0.55):

- STEEL half (rigging.selftest): factoryExtras('corroded_post') -> hint 0.55
  + collateral key; _effRating on a corroded corner is rating x 0.55, so the
  same load a carabiner holds on a sound post is over a carabiner on a
  corroded one. The rust costs a tier.
- COLLATERAL half (gardenfly.selftest, real dressed world): a node-less
  corroded placement -> collateralFor =  'the corroded post', summed by
  collateralExposure -- through the resolver, not the baked GLB value direct
  (the carport-to-null shortcut).

Cross-lane seam: the tier lives on lane/e, so both pins skip-with-disclosure
on bare lane/b (SKIPPED, visible in the report) and go live at integration.
Measured GREEN on a scratch merge of lane/a + lane/e (hint 0.55, collateral
). Browser 485/3/0, node sail 47/47 rigging 29/29.

Test-only + THREADS; adopted the landed 0.2/0.3/2 unchanged rather than
overwrite pushed work.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 03:59:53 +10:00
type-two
64fb25529f Lane D S17: [D] THREADS — gate 3.2 landing (the pool yard, receipts, the second-rust-post filing)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 03:53:46 +10:00
type-two
39701884e3 Lane D S17 gate 3.2: THE POOL YARD — authored cold, in the game only by being chosen
site_04_pool_yard: E's ring (fourteen honest noes) east, the bed poolside, ONE
corroded post in the venturi channel where the NE corner wants steel. Authored
in the editor (palette shapes, iterative SCORE IT x3, canonical export).
Gauntlet: WINNABLE at $80 — 13 quads, 6 clean, 1 marginal; cheapest hold $40
(h1,t1b,p2,p3, 92.7 FULL, no rust); every 92%-cover line routes through c1 —
clean $65 rated on t1,p1,p2,c1, UNHOLDABLE on the two shapes that pull the eye
harder. audit.html over the shipped file reproduces the card to the digit.
Separation REFUSED with receipts (bare 83.7 FULL — mild-night canon, A's 0.4).

board.js (A's granted edit): pool_yard_southerly POOL entry with the audit
receipt. d.test: +5 pins (chosen-never-scripted, ring/rust facts, judged-XOR,
wreck apron, exposure $160). gardenfly.selftest (B's file, flagged): judged
walk 3 -> 4 in the same commit that ships the yard.

Selftest 515/0/0 (510 + 5, the count moved by exactly what was added).
Mutations: 1 full-suite (finding stripped -> DOUBLE red: B's walk 'UNJUDGED'
+ my XOR pin, 513/2), 10 probed (entry deleted/re-paired/receipt stripped/
scripted-slot, gnome unpriced/rust zeroed/fence priced, apron x3) — every one
red with its own words, controls green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 03:53:20 +10:00
type-two
bb8451d12e Sprint 17 pt 1 integration: the board live (510/0/0), constraint seam verified at merge, [I] entry 2026-07-21 03:14:44 +10:00
type-two
3548b6edc1 Lane C S17 gate 1 (cont.): the OFFER BAND and the PAIRING law — night 7 stops lying
Landed ON TOP of f029274 with not one line of it changed. Two Lane C sessions
ran gate 1 in parallel; I found out by having my push rejected. forecastHonest
answers "can the forecast describe this STORM" and is better than the storm-side
half I had written, so mine is deleted. These are the two things it structurally
cannot reach — it takes a def, not a pairing, and it never touches the card.

weather.js is +180/-0. The only line of theirs I touch anywhere is widening one
c.test import from { NIGHTS } to { NIGHTS, nightAt }.

1. THE OFFER BAND, and the finding that motivates it. A's card prints f.wind
   and f.stones, and under that pair NIGHT 7 LIES:
     soaker (scripted)   gusts to ~55 km/h · fine pea stones
     early buster (alt)  gusts to ~76 km/h · pea stones
   On both printed lines the soaker is the softer job. It is not: 14.9s of hail
   against 2.4s, 44 mm/hr against 16.5. The soaker's whole design is that its
   wind reads gentle and its hail is the trap, so the board recommended the trap
   in the storm's own voice. offerBand(def, lead) returns an ORDERED LIST, so
   the forecast decides which facts a night carries and a card cannot drop the
   next one added — same construction as A's stormsToPreload(nights, pool).
   Verified in the real card on :8826.

   Second finding: storm_03 and storm_03b are identical in every number the band
   prints except the change time (30s vs 18s), and the board offers exactly that
   pair on nights 5 and 7. Without the change line it offers two indistinguish-
   able jobs and calls it a choice. Pinned.

2. PAIRING_LAW. A band can be perfectly honest about a storm and still be an
   invitation to a night nobody can work. A's three POOL exclusions were prose;
   promoted to checked data with the measurement in each message, credited by
   owner (the soaker's is mine with the S16 ~31% receipt; wildnight and icenight
   are recorded as A's reasons, not re-measured by me). Absent key = unrestricted
   deliberately — inventing a refusal for a pairing nobody flew is the same
   offence inverted, and that direction is pinned too. pairingRefusals is NOT a
   filter: silently dropping an entry is a runtime surprise wearing a fix's
   clothes. Walks NIGHTS u POOL carrying the SITE: 12 pairings, 0 refusals, with
   a vacuity guard in the same case.

Mutation-checked on THIS tree after the code changed address: hail line dropped
(RED, names the storm and the seconds), soaker restriction deleted (RED x2,
including the vacuity guard), change line dropped (RED, names the two busters).
All restored, 494/0/0.

Filed in THREADS, not landed: the hud/main hunk for A (leadFor precedent), and
a floor for stoneWord — hail.size 0 words itself "fine pea stones" because
stoneWord has no floor. Their STONE_WORD_CEILING fixes the top of that range;
nothing fixes the bottom. It belongs inside forecastHonest, which is theirs.

Selftest 494/0/0 (their 490 + 4).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 03:07:02 +10:00
type-two
44f019f31d Merge remote-tracking branch 'origin/lane/e'
# Conflicts:
#	THREADS.md
2026-07-21 03:06:26 +10:00
type-two
4cfb227935 Merge remote-tracking branch 'origin/lane/b'
# Conflicts:
#	THREADS.md
2026-07-21 03:06:16 +10:00
type-two
895539da7c Merge remote-tracking branch 'origin/lane/c'
# Conflicts:
#	THREADS.md
2026-07-21 03:05:43 +10:00
type-two
99345f40b1 Lane A S17: B's wiring line landed — setConstraints fed at the setBudget boundary, guarded until lane/b merges, seam closed 2026-07-21 03:04:18 +10:00
type-two
5befa54feb Lane B S17: [B] THREADS — gates 0.2/0.3/2 landed, mutation table, wiring line filed for A 2026-07-21 02:57:42 +10:00
type-two
1bcfcdad67 Lane B S17 gates 0.3 + 2: the card names its cloth, the session speaks for the client
GATE 0.3 — THE CHARTER, SAID OUT LOUD (D's soaker finding, A consulted on
wording and their every-card rule adopted). The audit always flew shade cloth
and never said so; on the soaker that silence read 'WINNABLE over an all-DEAD
garden — the night is broken'. Now: AUDIT.POROSITY is the one charter knob
(sweep.js + gardenfly.js both read it), scorecard.fabricCharter() names the
fabric and whether tonight's stones pass its weave (same hailBlockFor the sim
charges), and the SCORE IT header prints 'fabric · shade cloth (sweep
charter)' on EVERY card — plus, on leaky-storm nights, A's wording: the
fabric is the bet, and the audit does not place it.

GATE 2 — CLIENT CONSTRAINTS: A's shapes (seam contract, built against as
posted), this lane's teeth.
· rigging.js: validateNightConstraint (checked enum, both ends of the seam),
  setConstraints (survives reset like _startBudget; [] is the only clear).
  The house ban refuses the pick with the CLIENT'S WORDS as the ticker
  reason; the cap refuses at SPEND time on every path (rig / cycle / set /
  spares / fabric), exact at the boundary, refunds always free — and commit
  is belt-and-braces: a caller who walked around the doors gets a THROW.
  The cap covers the whole invoice, spares included — the client queries the
  invoice, not the hardware line.
· sweep.js: applyNightConstraints, ONE function both drivers share — bans
  shrink the candidate list (a card that recommends a forbidden line is the
  card lying), the cap re-prices affordable/clean without touching a load.
· scoreSite carries constraints/budget/constrainedOut; the card prints the
  client's terms next to funnel and fabric, and tells 'no geometry' from
  'client forbids all of it'.
· spent getter now measures against _startBudget (the re-banked session was
  measuring spend against the default bank).

Selftest 485/0/0 in the browser (474 + exactly the 11 added: 1 sail, 4
session, 2 charter, 4 audit-constraints). Wiring line for A filed in THREADS:
session.setConstraints(week.job?.constraints ?? []) at the night boundary.
2026-07-21 02:45:49 +10:00
type-two
aa0b36a54c Lane B S17 gate 0.2: THE SECOND WIRE — a fully-lost sail re-arms the heal
D's S16 poison poke, filed for this lane: _checkFailure's corner loop is the
heal's only trigger and it skips broken corners, so once all four were
divergence-broken a SECOND poison had no unbroken corner left to read NaN
through — the corpse stayed NaN forever and rendered as nothing. A lost sail
is still cloth in the yard; it must LOOK lost, not vanish.

The sentinel runs ONLY on a fully-broken rig (the corner wire covers every
other case): sum pos+prev — any NaN/±Inf makes the sum non-finite (Inf−Inf is
NaN), finite cloth cannot overflow it. Branch-free O(n) adds, no mutation, so
a finite lost sail is untouched byte-for-byte.

Test replays D's gait: cascade 4/4 → 5 s finite with ZERO heals (the negative
control — the sentinel must never mistake 'lost' for 'sick') → second poison
heals in one substep, finite a full second on. Node suite 47/47.
2026-07-21 02:45:28 +10:00
type-two
f029274940 Lane C S17 gate 1 + 1.2: the honesty gate on the board, and the exposure cross
- forecastHonest(def): "a storm the forecast can't describe honestly doesn't
  get offered" as a predicate, not a sentence — validator + measurable stats +
  the stone vocabulary ceiling (STONE_WORD_CEILING 2.0: past it, "golf ball"
  undersells the ice and the word ambushes where the band can't). The band-
  containment recomputes are disclosed as structural regression armour, not
  counted as coverage.
- c.test walks NIGHTS ∪ POOL from the shipped data (the union, per A's
  stormsToPreload finding — a pool-only storm is covered the day it exists).
  Negative controls: a lying windchange, a cricket-ball stone, both refused
  by name. Lead-0 card pins: the offer card's gust/sustained figures parsed
  back out and matched against stormStats; a lead-0 offer must not hedge.
- The exposure cross (the S14 pin pattern, third outing): my failure-route
  envelope (built world, anchor collateral keys through world.collateralFor,
  the gnome per scoreRun) vs A's exposureOf — item-level equality on ALL
  THREE yards: backyard 115, corner block 205, swing lawn 255. Zero unpriced
  keys. Teeth proven in-suite: strip the carport key and the cross reds.

Selftest 490/0/0 (A's 485 + 5), browser-verified; board verified live on
:8826 — both cards print the honest lead-0 lines and the crossed dollars.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 02:39:07 +10:00
type-two
bfc43c6da3 Lane A S17 second shift: separation-block fabric field RULED (B's S16 ask), E's corroded-post palette hunk applied verbatim, enum widening endorsed 2026-07-21 02:22:27 +10:00
type-two
dd69a2cf6c Lane A S17 gates 0+1+2: THE JOB BOARD — two offers a morning, the chosen job IS the night
Gate 0 rulings (written, with reasoning, in THREADS):
- 0.4 hail-light balance: CANON. B's finding reproduces exactly ($4/$2 garden
  spread), but the mild-night rig market buys the STEEL and the collateral, not
  the bed — a cheap rig that fails on site_02 swings $184 and two stars against
  the same rig holding, and the cheapest HOLDING line is $40, not the $20 floor.
  Ruled for B's option (b): the separation block asks the wrong question on
  those nights; the new shape is B's and is NOT this sprint. Rain drain-weight
  lever declined at its proper price (full gauntlet re-measure, own gate).
- 0.4b gradeFor scraped/solvent >=3 STANDS on the seven-night shape, ruled on
  the arithmetic (savable 6 → solvent is 4-6 dead, which is what the copy says).
- 0.3 score-header wording consulted for B (no code from me).

Gate 1 — the board: board.js (pure data, node-testable), week.offers()/take(),
seeded from week+night via contracts' mulberry32 — no Date.now, no Math.random.
An offer carries a whole NIGHT ENTRY and take() installs it, so sheet, quote,
invoice, ledger, warranty, rep and end card all read a chosen job through the
path they already had — seven surfaces, zero changed lines. Scripted spine runs
byte-identically when nothing is taken.

Gate 2 (data half) — constraints as night data, checked enum, premium ruled
(+25% anchor ban / +20% budget cap, both UNMEASURED and flagged cut-first),
stated on the offer, the job sheet and the invoice it was paid under. ONE
calloutFee() now serves quote() and settle(). Enforcement is B's.

Two bugs found off the suite: take() stored raw pool entries (crashed quote()
on a missing pay block) → normaliseNight() split so both doors share it; and
NIGHT 1 HAD NO BOARD — the splash bypassed it — found by looking, not testing.
Pool pairings audited in the browser before shipping; both constraints checked
for soft-lock against real holding lines.

Mutation-checked ×6. One did NOT go red: "every pool storm is preloaded" passed
with the POOL source deleted, because every pool storm is also in NIGHTS — the
same coincidence that hid the calm-day bug. stormsToPreload(nights, pool) split
so it can fail. Selftest 485/0/0 (474 + 11).
2026-07-21 02:11:16 +10:00
21 changed files with 3946 additions and 69 deletions

1058
THREADS.md

File diff suppressed because it is too large Load Diff

View File

@ -99,7 +99,7 @@ export function hardwareByName(name) {
* you want a different door.]
* @returns {{ hp, state, byHail, byRain, cornersLost, peaks, marginal, cost }}
*/
export function flyGarden({ anchors, bed, stormDef, siteDef = null, use = null, ids = null, hw = null, tension = 1.0, porosity = 0.30 }) {
export function flyGarden({ anchors, bed, stormDef, siteDef = null, use = null, ids = null, hw = null, tension = 1.0, porosity = AUDIT.POROSITY }) {
const wind = windForSite(stormDef, siteDef, anchors);
use?.(wind);
@ -107,8 +107,8 @@ export function flyGarden({ anchors, bed, stormDef, siteDef = null, use = null,
if (ids) {
const hwArr = Array.isArray(hw) ? hw : Array(4).fill(hw ?? hardwareByName('rated shackle'));
cost = hwArr.reduce((s, h) => s + (h.cost || 0), 0);
// shade cloth (porosity 0.30) unless the caller is measuring the fabric
// bet — same default as sweep.js, so nothing pre-SPRINT16 moves.
// shade cloth (AUDIT.POROSITY — the sweep charter, one knob since
// SPRINT17 gate 0.3) unless the caller is measuring the fabric bet.
rig = new SailRig({ anchors, gridN: 10, porosity }).attach(ids, hwArr, tension);
// D's skipped-attach trap: a rig without its shadow mesh scores as a bare
// bed and the number LOOKS plausible. Refuse to fly it.

View File

@ -33,6 +33,15 @@ import { createSkyFx } from '../../web/world/js/skyfx.js';
import { createGarden } from '../../web/world/js/garden.js';
import { FIXED_DT } from '../../web/world/js/contracts.js';
import { flyGarden, flySeparation, hardwareByName } from './gardenfly.js';
import { buildScoringWorld, collateralExposure } from './scorecard.js';
import { factoryExtras } from '../../web/world/js/ratings.js';
// SPRINT17 gate 3 [B] — CROSS-LANE SEAM. The corroded tier (rating_hint 0.55,
// the GLB, the gen entry) lives on lane/e. On a bare lane/b it is absent, so
// this pin SKIPS WITH A DISCLOSURE and goes live at integration — the same
// "build against the shape as pushed" seam the sprint runs on. Measured GREEN
// on the scratch merge (lane/a + lane/e): hint 0.55, collateral $45.
const CORRODED_TIER_LIVE = !!(factoryExtras('corroded_post'));
const assert = (cond, msg) => { if (!cond) throw new Error(msg); };
@ -119,7 +128,12 @@ export async function buildGardenflyTests() {
let sepStates = null, sepStatesErr = null;
try {
sepStates = [];
for (const name of ['backyard_01', 'site_02_corner_block', 'site_03_swing_lawn']) {
// SPRINT17 gate 3.2 [D]: site_04_pool_yard added to the judged walk in the
// same commit that ships it (B's file, flagged in THREADS, revert-and-tell-
// me standard) — a yard missing from this list is UNJUDGED silently, which
// is the exact silence the gate-2.3 rule forbids. It carries a refusal
// finding (bare 83.7 FULL under its southerly; receipts in the site file).
for (const name of ['backyard_01', 'site_02_corner_block', 'site_03_swing_lawn', 'site_04_pool_yard']) {
const sd = await loadSite(name);
sepStates.push({ name, pinned: !!sd.separation, finding: !!sd._separation_finding });
}
@ -151,7 +165,62 @@ export async function buildGardenflyTests() {
fabRun = { byDefault: flyFab(undefined), cloth: flyFab(0.30), membrane: flyFab(0) };
} catch (err) { fabErr = String((err && err.stack) || err); }
// SPRINT17 gate 3 support [B]: the corroded tier priced through the CARD'S
// OWN path, in a real dressed world. E landed the post; the 0.2/0.3/2 pass
// deferred gate 3; this pins that a corroded anchor prices honestly the
// moment it is placed. Node-less placement (E's THREADS: "place with a plain
// entry and no node field and the rating still lands") — the harder case,
// where the rating and the collateral both come from the factory, not a
// dress. buildScoringWorld only DRESSES (no sweep), so this is ~40 ms.
let corRun = null, corErr = null;
try {
if (!CORRODED_TIER_LIVE) throw { skip: true };
const corSite = await loadSite('backyard_01');
corSite.structures = [...(corSite.structures ?? []), {
id: 'cor1', model: 'sail_post_corroded_v1', wreckedModel: 'sail_post_corroded_wrecked_v1',
x: 6, z: 2, rotYDeg: 0, solid: true,
collateralKey: 'corroded_post', collateralValue: 45, collateralLabel: 'the corroded post',
anchors: [{ id: 'cor1_a1', type: 'corroded_post', work: 'cloth' }],
}];
const built = await buildScoringWorld(corSite);
const anc = built.anchors.find((a) => a.id === 'cor1_a1');
const col = {};
for (const a of built.anchors) {
if (!a.collateral) continue;
const p = built.world.collateralFor(a.collateral);
col[a.id] = p ? { key: a.collateral, cost: p.cost, label: p.label }
: { key: a.collateral, cost: null, label: a.collateral, unpriced: true };
}
corRun = { anchor: anc, priced: built.world.collateralFor('corroded_post'),
exposure: collateralExposure({ collateral: col }), dressed: built.dressed, dressError: built.dressError };
} catch (err) { if (err && err.skip) corErr = 'skip'; else corErr = String((err && err.stack) || err); }
return [
['gate 3: a corroded post prices honestly in the card — 0.55 hint, $45 collateral, dressed', () => {
if (!CORRODED_TIER_LIVE) return 'SKIPPED — corroded tier lands with lane/e; measured GREEN on '
+ 'the scratch merge (hint 0.55, collateral $45 through collateralFor)';
if (corErr) throw new Error(`corroded pricing died: ${corErr}`);
assert(corRun.dressed, `the corroded world did not dress (${corRun.dressError}) — GLB missing or broke`);
const a = corRun.anchor;
assert(a, 'the corroded anchor cor1_a1 was not created');
// The STEEL half, node-less: factoryExtras had to reach it, or the tier
// math prices this trap as the best steel in the game (adoptAnchor's
// `?? 1`, E's load-bearing _v1 warning).
assert(a.ratingHint === 0.55, `corroded anchor rated ${a.ratingHint}, not 0.55 — a node-less placement `
+ 'lost E\'s factory hint and the card would price it as sound steel');
assert(a.collateral === 'corroded_post', `corroded anchor collateral "${a.collateral}", not "corroded_post"`);
// The COLLATERAL half, through the card's OWN resolver and exposure sum —
// not the baked GLB value read directly (the carport-to-null shortcut).
assert(corRun.priced && corRun.priced.cost === 45,
`world.collateralFor('corroded_post') gave ${JSON.stringify(corRun.priced)}, expected $45`);
assert(corRun.priced.label === 'the corroded post', `collateral label "${corRun.priced.label}"`);
const line = corRun.exposure.priced.find((e) => e.key === 'corroded_post');
assert(line && line.cost === 45,
`collateralExposure has no $45 corroded line (${JSON.stringify(corRun.exposure.priced.map((e) => [e.key, e.cost]))})`);
assert(!corRun.exposure.unpriced.some((e) => e.key === 'corroded_post'),
'the corroded post landed in UNPRICED — the exposure total silently drops its $45');
return `corroded_post: hint ${a.ratingHint}, collateral $${corRun.priced.cost} "${corRun.priced.label}", in the exposure sum`;
}],
['gardenfly: the pinned separation line, flown in BOTH chains — the 12s-skew disagreement, pinned', () => {
// ⚠ THE STATE OF PLAY (2026-07-18, [B] THREADS, receipts in the entry):
// the pinned recipe HOLDS and rigging matters by a full state's width —
@ -204,7 +273,7 @@ export async function buildGardenflyTests() {
// but silence is not an option, and holding both means the finding went
// stale the day the pin landed and must be deleted (its own text says so).
if (sepStatesErr) throw new Error(`site read died: ${sepStatesErr}`);
assert(sepStates.length === 3, `expected 3 shipped yards, read ${sepStates.length}`);
assert(sepStates.length === 4, `expected 4 shipped yards, read ${sepStates.length}`);
for (const s of sepStates) {
assert(s.pinned || s.finding,
`${s.name} has neither a separation block nor a _separation_finding — the yard is UNJUDGED; ` +

View File

@ -53,9 +53,46 @@
import * as THREE from '../../web/world/vendor/three.module.js';
import { createWorld } from '../../web/world/js/world.js';
import { FABRIC } from '../../web/world/js/rigging.js';
import { hailBlockFor } from '../../web/world/js/weather.core.js';
import { AUDIT, auditSweepAsync, yieldToEventLoop } from './sweep.js';
import { flyGarden, flySeparation } from './gardenfly.js';
/**
* SPRINT17 gate 0.3 the score's fabric charter, SAID OUT LOUD. [B, with D's
* soaker finding as the brief and A consulted on the card wording.]
*
* Every audit number is flown on knitted shade cloth (AUDIT.POROSITY one
* knob, sweep.js and gardenfly.js both read it). Sound charter, but the card
* never NAMED it, and on the one night where fabric is the whole answer the
* silence lied by omission: the soaker's card said "✓ WINNABLE at $80" over a
* garden table where every cloth line reads DEAD, and a reader without the
* charter in their head concludes the night is broken. The fix is the same
* discipline as the funnel state: any front-end printing a score also prints
* what fabric it flew and on a storm whose stones pass the weave (C's 2 mm
* ruling, the same hailBlockFor the sim charges), it says the F key is not in
* the card.
*
* Pure data from pure inputs, browser-free, so the selftest can pin it against
* real storm defs without a flight.
*
* @param {object|null} stormDef parsed storm def (hail.size in storm units)
* @returns {{ fabric: {id,name,porosity}, leaks: boolean }}
* `fabric` the charter entry (matched out of rigging's FABRIC by the
* porosity actually flown, so a charter retune renames the card by itself);
* `leaks` true when tonight's stones pass this weave, i.e. the un-swept
* membrane would stop stones the swept cloth lets through.
*/
export function fabricCharter(stormDef) {
const f = FABRIC.find((x) => x.porosity === AUDIT.POROSITY)
?? { id: 'custom', name: `porosity ${AUDIT.POROSITY}`, porosity: AUDIT.POROSITY };
const hailSize = stormDef?.hail?.size ?? 0;
return {
fabric: { id: f.id, name: f.name, porosity: f.porosity },
leaks: hailSize > 0 && hailBlockFor(hailSize, AUDIT.POROSITY) < 1,
};
}
/** How many affordable lines get flown. Flights are seconds each; the card is
* on-demand and slow is fine, but an unbounded sweep on a yard with fifteen
* anchors is a hang, not a score. */
@ -129,7 +166,7 @@ export async function buildScoringWorld(site) {
* @returns {Promise<object>} pure data no DOM, no strings-as-verdicts
*/
export async function scoreSite({ site, stormDef, stormName = null, sepStormDef = null, flyCap = FLY_CAP, prebuilt = null,
onProgress = null, yieldEvery = 1 }) {
onProgress = null, yieldEvery = 1, constraints = [] }) {
const built = prebuilt ?? await buildScoringWorld(site);
const { world, anchors, bed, use, dressed, dressError } = built;
@ -164,8 +201,11 @@ export async function scoreSite({ site, stormDef, stormName = null, sepStormDef
: { key: a.collateral, cost: null, label: a.collateral, unpriced: true };
}
const { cands, rows, verdict, winners, marginalWinners } =
await auditSweepAsync({ anchors, bed, stormDef, siteDef: site, use, onProgress, yieldEvery });
// gate 2 (SPRINT17): a constrained NIGHT scores under its client's terms —
// bans shrink the candidate list before a single flight, the cap decides
// affordability. A card that recommends a forbidden line is the card lying.
const { cands, rows, verdict, winners, marginalWinners, budget, constrainedOut } =
await auditSweepAsync({ anchors, bed, stormDef, siteDef: site, use, onProgress, yieldEvery, constraints });
// Garden flights: the bare-bed control first, then every line the budget can
// buy. The flight list is known up front so the progress tick has an honest
@ -232,10 +272,19 @@ export async function scoreSite({ site, stormDef, stormName = null, sepStormDef
}
}
// The charter the whole card is denominated in (gate 0.3): reported next to
// the funnel state, computed from the storm actually scored — never assumed.
const charter = fabricCharter(stormDef);
return {
site: site.id ?? site.name ?? '(unnamed)',
storm: stormName, dressed, dressError,
venturi, funnelOn: venturi.length > 0,
fabric: charter.fabric, fabricLeaks: charter.leaks,
// gate 2: the terms this card was scored under, echoed so every front-end
// can print them — a constrained score that doesn't SAY so is the soaker
// silence again, one gate over. `budget` is what decided affordable/clean.
constraints, budget, constrainedOut,
anchorCount: anchors.length, bed,
cands: cands.length, rows, winners, marginalWinners, verdict,
flown, skipped, flyCap,

View File

@ -109,8 +109,10 @@ import { loadStorm, createWind, windForSite } from '../../web/world/js/weather.j
// correction, and the right call: a pin that retypes the thing it pins agrees
// with itself by construction.
import { createWindRouter } from '../../web/world/js/main.js';
import { buildScoringWorld } from './scorecard.js';
import { auditSweep, auditSweepAsync } from './sweep.js';
import { buildScoringWorld, fabricCharter } from './scorecard.js';
import { auditSweep, auditSweepAsync, AUDIT } from './sweep.js';
import { hailBlockFor } from '../../web/world/js/weather.core.js';
import { START_BUDGET } from '../../web/world/js/contracts.js';
const assert = (cond, msg) => { if (!cond) throw new Error(msg); };
@ -408,5 +410,137 @@ export async function buildScorecardTests() {
`gate 2.3: the cloned venturi reads gain ${v[0].gain} axis ${v[0].axis}, not gain 1.5 axis 2.1.`);
}]);
// ── SPRINT17 gate 0.3: the fabric charter is named, and named RIGHT ──────
// D's soaker finding: the card said WINNABLE over an all-DEAD cloth garden
// and nothing named the charter. These pin the disclosure's DATA against
// real storm defs — the soaker (fine stones, the one night the charter is
// the wrong bet) must read leaks:true, the icenight (1.4 fists of ice that
// no weave passes) must read leaks:false, and a dry storm must never warn.
// The wording is the card's; the truth of the flag is pinned here.
{
const soaker = await loadStorm('storm_06_soaker');
const icenight = await loadStorm('storm_02b_icenight');
tests.push(['gate 0.3: the charter names a real fabric, and it is the cloth the sweep flies', () => {
const c = fabricCharter(soaker);
assert(c.fabric.id === 'cloth' && c.fabric.porosity === AUDIT.POROSITY,
`gate 0.3: the charter resolved to '${c.fabric.id}' porosity ${c.fabric.porosity} — the card `
+ `would name a fabric the sweep does not fly (AUDIT.POROSITY ${AUDIT.POROSITY}). If the `
+ 'charter knob moved, rigging.js FABRIC must carry an entry at the new porosity or the '
+ 'card degrades to a raw number.');
assert(hailBlockFor(soaker.hail.size, AUDIT.POROSITY) < 1,
'gate 0.3 vacuity guard: the soaker\'s stones no longer pass the charter weave — the '
+ 'leaks pin below would be testing a night that cannot leak. Re-pick the fixture storm.');
}]);
tests.push(['gate 0.3: soaker leaks (fine stones pass the weave), icenight does not, dry night silent', () => {
assert(fabricCharter(soaker).leaks === true,
`gate 0.3: the soaker (hail.size ${soaker.hail.size}) reads leaks:false — the one card `
+ 'the disclosure exists for would not carry it. hailBlockFor and the charter porosity '
+ 'no longer agree with C\'s weave ruling.');
assert(fabricCharter(icenight).leaks === false,
`gate 0.3: the icenight (hail.size ${icenight.hail.size}) reads leaks:true — stones an `
+ 'order of magnitude over the weave aperture do not pass it, and a warning on every '
+ 'card teaches the player to ignore the one that matters.');
assert(fabricCharter({ hail: null }).leaks === false && fabricCharter(null).leaks === false,
'gate 0.3: a storm with no hail (or no storm at all) warns about a leak — the flag must '
+ 'mean "tonight\'s stones pass the weave", never "the weave has holes in principle".');
}]);
}
// ── SPRINT17 gate 2: the audit honours the night's constraints ───────────
// A card that recommends a forbidden line is the card lying — so the sweep
// itself refuses banned steel and prices against the client's cap, and these
// pin it on a fixture where both bites are provably non-vacuous. Shapes are
// A's seam contract verbatim; enforcement asserts for the SESSION live in
// rigging.selftest.js — this is the SCORE IT half of the same gate.
{
// YARD5 with the fifth anchor as HOUSE steel — same geometry, one family
// change, so every delta below is the constraint and nothing else.
const YARD5H = [
{ id: 'a1', type: 'post', pos: { x: -3, y: 3.9, z: -3 } },
{ id: 'a2', type: 'post', pos: { x: 3, y: 3.9, z: -3 } },
{ id: 'a3', type: 'post', pos: { x: 3, y: 3.9, z: 3 } },
{ id: 'a4', type: 'post', pos: { x: -3, y: 3.9, z: 3 } },
{ id: 'a5', type: 'house', pos: { x: 0, y: 3.9, z: 4 } },
].map((a) => ({ ...a, sway: () => a.pos }));
const HOUSE_BAN = { 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.' };
const sweepArgs = { anchors: YARD5H, bed: YARD5_BED, stormDef: YARD5_STORM, venturi: [] };
const baseH = auditSweep({ ...sweepArgs });
const emptyCon = auditSweep({ ...sweepArgs, constraints: [] });
const bannedH = auditSweep({ ...sweepArgs, constraints: [HOUSE_BAN] });
tests.push(['gate 2: constraints:[] is byte-identical to no constraints at all', () => {
assert(sweepJSON(emptyCon) === sweepJSON(baseH),
'gate 2: an empty constraint list changed the sweep — the unconstrained path is not '
+ 'the default path, and every historical number is quietly a different claim.');
assert(emptyCon.budget === START_BUDGET && emptyCon.constrainedOut === 0,
'gate 2: an unconstrained sweep reports a cap or a drop that did not happen');
}]);
tests.push(['gate 2: the house ban removes every line touching house steel — and provably removed something', () => {
assert(baseH.cands.some((c) => c.ids.includes('a5')),
'gate 2 vacuity guard: no unconstrained candidate touches a5 — the ban below would be '
+ 'measuring nothing. Fix the fixture, not the assert.');
assert(bannedH.constrainedOut > 0, 'gate 2: the ban dropped nothing on a yard with house candidates');
assert(bannedH.cands.length + bannedH.constrainedOut === baseH.cands.length,
'gate 2: dropped + kept != total — the filter is inventing or eating candidates');
for (const c of bannedH.cands) {
assert(!c.ids.includes('a5'),
`gate 2: candidate ${c.ids.join(',')} survived the house ban with a5 in it — `
+ 'the card would recommend a line the session refuses to rig, i.e. the card lying');
}
for (const r of [...bannedH.winners, ...bannedH.marginalWinners]) {
assert(!r.ids.includes('a5'), `gate 2: recommended line ${r.ids.join(',')} touches banned steel`);
}
}]);
tests.push(['gate 2: the cap re-prices affordability without touching a single load', () => {
// The cap is derived from the fixture's own price list so the test
// tracks the physics instead of hardcoding tonight's dollars — and so
// it can never go vacuous: when the yard's clean lines differ in price
// the cap sits at the cheapest (keeping it, excluding the rest); when
// they all cost the same (this symmetric fixture's actual shape, the
// first run of this test proved it) the cap sits $1 under, and must
// exclude every clean line.
assert(baseH.winners.length >= 1, 'gate 2 vacuity guard: no clean line to cap — re-fixture');
const prices = [...new Set(baseH.rows.filter((r) => r.cleanHw != null).map((r) => r.cleanHw))]
.sort((a, b) => a - b);
const capVal = prices.length >= 2 ? prices[0] : prices[0] - 1;
const over = baseH.rows.filter((r) => r.cleanHw != null && r.cleanHw > capVal);
assert(over.length >= 1, 'gate 2 vacuity guard: the derived cap excludes nothing — unreachable by construction, so the derivation regressed');
const capped = auditSweep({ ...sweepArgs, constraints: [{ kind: 'budgetCap', cap: capVal,
label: `client caps the rig at $${capVal}`, says: 'Cheapest option that does the job.' }] });
assert(capped.budget === capVal, `gate 2: cap $${capVal} not echoed (got ${capped.budget})`);
assert(capped.winners.length === baseH.winners.length - over.filter((o) => baseH.winners.some((w) => w.ids.join(',') === o.ids.join(','))).length,
`gate 2: ${capped.winners.length} winner(s) under the cap — the cap must remove exactly the clean lines it cannot buy`);
for (const r of capped.winners) {
assert(r.cleanHw <= capVal, `gate 2: winner ${r.ids.join(',')} at $${r.cleanHw} sold over a $${capVal} cap`);
}
for (const o of over) {
const twin = capped.rows.find((r) => r.ids.join(',') === o.ids.join(','));
assert(twin && !twin.clean,
`gate 2: ${o.ids.join(',')} ($${o.cleanHw} clean) still reads clean under a $${capVal} cap`);
assert(twin.cleanHw === o.cleanHw && twin.hw === o.hw,
`gate 2: the cap moved a PRICE on ${o.ids.join(',')} — the client caps the invoice, `
+ 'never the physics; loads and tiers must be byte-identical');
}
return `cap $${capVal} (${prices.length >= 2 ? 'cheapest of ' + prices.length + ' prices' : 'all clean lines priced $' + prices[0] + ', capped under'}) — ${over.length} line(s) priced out, loads untouched`;
}]);
tests.push(['gate 2: a ban that empties the yard says no-cover honestly, with the drop counted', () => {
const allBanned = auditSweep({ ...sweepArgs, constraints: [{ kind: 'noAnchorFamily', family: 'post',
label: 'nothing on the posts', says: 'Those posts are heritage-listed, believe it or not.' }] });
assert(allBanned.cands.length === 0 && allBanned.verdict.code === 'no-cover',
'gate 2: banning every rideable family did not empty the sweep');
assert(allBanned.constrainedOut === baseH.cands.length,
`gate 2: emptied yard reports ${allBanned.constrainedOut} dropped, expected ${baseH.cands.length}`
+ 'the card cannot tell "no geometry" from "client forbids all of it" without this number');
}]);
}
return tests;
}

View File

@ -22,6 +22,9 @@
import { SailRig, orderRing } from '../../web/world/js/sail.js';
import { windForSite } from '../../web/world/js/weather.js';
import { HARDWARE, START_BUDGET, FIXED_DT } from '../../web/world/js/contracts.js';
// gate 2 (SPRINT17): one validator for the constraint shapes, shared with the
// session that enforces them — audit and enforcement must not disagree.
import { validateNightConstraint } from '../../web/world/js/rigging.js';
/** Audit knobs, in one place so both front-ends and any future site agree. */
export const AUDIT = {
@ -37,6 +40,17 @@ export const AUDIT = {
* A line with a marginal corner is flagged, never sold as a clean PASS.
*/
MARGIN: 0.15,
/**
* SPRINT17 gate 0.3 the sweep's FABRIC CHARTER, in the knobs instead of
* hardcoded twice (here and gardenfly.js). Every sweep and every garden
* flight is knitted shade cloth (0.30), the fabric a competent player takes
* into a windy night; the F key never enters an audit. That was always true
* and never SAID D's soaker finding: the card read WINNABLE over an
* all-DEAD cloth garden and the silence read as "the night is broken".
* scorecard.js names the charter on every score now (fabricCharter);
* this constant is what it names.
*/
POROSITY: 0.30,
};
// SPRINT13: SETTLE_S / PRE_GUST_S / CALM_STORM are GONE, with the phantom
// settle they parameterised. In the real game the rig does not exist before
@ -90,11 +104,22 @@ export const tierFor = (peakN, ratingHint = 1) =>
* @param {function} [o.use] the yard's wind-proxy re-pointer (C's bench
* pattern) called with the sweep's wind so live
* tree-sway closures sample the storm being flown
* @returns {{ cands, rows, winners, marginalWinners, verdict:{ ok, code, best } }}
* @param {Array} [o.constraints] the night's client constraints (A's shapes;
* SPRINT17 gate 2) bans shrink the candidate
* list, a cap shrinks the budget, and the result
* says how much of each. A card that recommends a
* forbidden line is the card lying.
* @returns {{ cands, rows, winners, marginalWinners, verdict:{ ok, code, best },
* budget, constrainedOut }}
*/
export function auditSweep({ anchors, bed, stormDef, siteDef = null, venturi = [], use = null }) {
const cands = findCandidates({ anchors, bed, siteDef });
if (!cands.length) return { cands, rows: [], winners: [], marginalWinners: [], verdict: { ok: false, code: 'no-cover', best: null } };
export function auditSweep({ anchors, bed, stormDef, siteDef = null, venturi = [], use = null, constraints = [] }) {
const con = applyNightConstraints({ cands: findCandidates({ anchors, bed, siteDef }), anchors, constraints });
const cands = con.cands;
if (!cands.length) {
return { cands, rows: [], winners: [], marginalWinners: [],
verdict: { ok: false, code: 'no-cover', best: null },
budget: con.budget, constrainedOut: con.dropped };
}
// 2. peak corner loads, flown the way the GAME flies them. Every clause here
// is a bug some harness shipped:
@ -114,8 +139,43 @@ export function auditSweep({ anchors, bed, stormDef, siteDef = null, venturi = [
const wind = windForSite(stormDef, siteDef ?? { wind: { venturi } }, anchors);
use?.(wind);
const rows = cands.map((cnd) => priceCandidate(cnd, { anchors, stormDef, wind }));
return judgeSweep(cands, rows);
const rows = cands.map((cnd) => priceCandidate(cnd, { anchors, stormDef, wind, budget: con.budget }));
return { ...judgeSweep(cands, rows), budget: con.budget, constrainedOut: con.dropped };
}
/**
* SPRINT17 gate 2 [B] the night's client constraints, applied to the
* sweep's inputs. ONE function shared by the sync and async drivers, because
* their purity contract ("every number equal, to the byte") extends to which
* candidates exist and what budget prices them.
*
* noAnchorFamily drops every candidate that touches a banned-family anchor
* including the site's pinned separation line, if it does:
* the pin is a fact about the YARD, but tonight's card
* recommends lines for tonight's CLIENT, and a card that
* recommends a forbidden line is the card lying.
* budgetCap caps the budget that decides `affordable`/`clean` the
* client caps the invoice, not your wallet, so the numbers
* below the cap don't move; lines above it stop being
* answers.
*
* Shapes validated at the door (rigging.js's validator the same teeth the
* session bites with, so the audit and the enforcement can never disagree
* about what a constraint means).
*
* @returns {{ cands, budget, dropped }}
*/
export function applyNightConstraints({ cands, anchors, constraints = [] }) {
for (const c of constraints) validateNightConstraint(c);
let budget = START_BUDGET, out = cands, dropped = 0;
const fams = new Set(constraints.filter((c) => c.kind === 'noAnchorFamily').map((c) => c.family));
if (fams.size) {
const banned = new Set(anchors.filter((a) => fams.has(a.type)).map((a) => a.id));
out = cands.filter((c) => !c.ids.some((id) => banned.has(id)));
dropped = cands.length - out.length;
}
for (const c of constraints) if (c.kind === 'budgetCap') budget = Math.min(budget, c.cap);
return { cands: out, budget, dropped };
}
/**
@ -166,9 +226,10 @@ export function findCandidates({ anchors, bed, siteDef = null }) {
* same wind at the same seconds, chunking cannot change a number the
* scorecard selftest asserts exactly that (chunked === sync, to the byte).
*/
export function priceCandidate(cnd, { anchors, stormDef, wind }) {
// shade cloth (porosity 0.30): the fabric a competent player takes into a windy night
const rig = new SailRig({ anchors, gridN: 10, porosity: 0.30 })
export function priceCandidate(cnd, { anchors, stormDef, wind, budget = START_BUDGET }) {
// shade cloth (AUDIT.POROSITY): the fabric a competent player takes into a
// windy night — the sweep charter, named on the card since SPRINT17 gate 0.3
const rig = new SailRig({ anchors, gridN: 10, porosity: AUDIT.POROSITY })
.attach(cnd.ids, Array(4).fill({ name: 'audit', cost: 0, rating: Infinity }), 1.0);
for (let i = 0; i < stormDef.duration * 60; i++) rig.step(FIXED_DT, wind, i * FIXED_DT);
@ -196,10 +257,14 @@ export function priceCandidate(cnd, { anchors, stormDef, wind }) {
const hw = tiers.reduce((s, c) => s + (c.tier ? c.tier.cost : 0), 0);
const cleanHw = tiers.every((c) => c.cleanTier)
? tiers.reduce((s, c) => s + c.cleanTier.cost, 0) : null;
// `budget` is START_BUDGET on an unconstrained night and the client's cap on
// a capped one (gate 2): the loads and tiers above never move — the client
// caps the invoice, not the physics — but a line the cap can't buy stops
// being an answer.
return { ...cnd, tiers, unholdable, marginal, hw, cleanHw,
total: hw + AUDIT.SPARE_COST,
affordable: !unholdable.length && hw <= START_BUDGET,
clean: cleanHw != null && cleanHw <= START_BUDGET };
affordable: !unholdable.length && hw <= budget,
clean: cleanHw != null && cleanHw <= budget };
}
/**
@ -250,20 +315,25 @@ export function judgeSweep(cands, rows) {
* @param {number} [o.yieldEvery] flights per yield (macrotask); 1 = every flight
*/
export async function auditSweepAsync({ anchors, bed, stormDef, siteDef = null, venturi = [], use = null,
onProgress = null, yieldEvery = 1 }) {
const cands = findCandidates({ anchors, bed, siteDef });
if (!cands.length) return { cands, rows: [], winners: [], marginalWinners: [], verdict: { ok: false, code: 'no-cover', best: null } };
onProgress = null, yieldEvery = 1, constraints = [] }) {
const con = applyNightConstraints({ cands: findCandidates({ anchors, bed, siteDef }), anchors, constraints });
const cands = con.cands;
if (!cands.length) {
return { cands, rows: [], winners: [], marginalWinners: [],
verdict: { ok: false, code: 'no-cover', best: null },
budget: con.budget, constrainedOut: con.dropped };
}
const wind = windForSite(stormDef, siteDef ?? { wind: { venturi } }, anchors);
use?.(wind);
const rows = [];
for (let i = 0; i < cands.length; i++) {
rows.push(priceCandidate(cands[i], { anchors, stormDef, wind }));
rows.push(priceCandidate(cands[i], { anchors, stormDef, wind, budget: con.budget }));
onProgress?.({ phase: 'sweep', done: i + 1, total: cands.length });
if ((i + 1) % Math.max(1, yieldEvery) === 0) await yieldToEventLoop();
}
return judgeSweep(cands, rows);
return { ...judgeSweep(cands, rows), budget: con.budget, constrainedOut: con.dropped };
}
/**

View File

@ -0,0 +1,237 @@
{
"id": "site_04_pool_yard",
"name": "The Pool Yard",
"blurb": "Fourteen perfect fence posts and not one of them is yours to use. The only steel standing where the shade wants a corner went in before the pool did.",
"_design": [
"THE YARD WHERE EVERYTHING SAYS NO EXCEPT THE THING THAT SHOULD NOT.",
"ROADMAP arc-2 thesis: fence compliance + corroded cheap hardware. The pool ring is",
"FOURTEEN honest noes (every post tie_off:false in the GLB — a certified pool barrier",
"is not an anchor, at any rating, ever); the corroded post c1 is the one thing that",
"DOES adopt on the east side, at 0.55, and it stands exactly where the bed's NE",
"corner wants steel. That is not an accident, it is the whole level:",
" - the bed is poolside; every 92%-cover quad in the band routes through c1.",
" Full shade in this yard has rust in it, or it does not exist (measured, SCORE IT:",
" the three 92% lines are t1,p1,p2,c1 / t1,t1b,p2,c1 / t1b,p1,p2,c1 — no others).",
" - the honest steel (gum t1/t1b, posts p1,p2,p3, the familiar 0.35 fascia) tops out",
" at 58% cover for $40 — the cheap safe rig leaves the bed's pool corner open.",
" - c1 is SHAPE-SENSITIVE, and that is the corroded tier working: the same drooped",
" eye is clean at $65 rated on t1,p1,p2,c1, and UNHOLDABLE AT ANY PRICE on the two",
" quads that pull it harder (t1,t1b,p2,c1 and t1b,p1,p2,c1). You cannot read which",
" off the object — E built the rung so you gamble rather than compute (their 0.55",
" reasoning, THREADS S17), and this yard is that gamble with a fence around it.",
"WIND: the venturi is the channel between the house's east end and the pool enclosure",
"— real geometry, gate-side, running N-S (axis 1.571, gain 1.4, r 5, throat (3.1,-3.5)).",
"It bites c1 and h3: the two lie-tiers are the two corners in the wind. The wreck",
"apron is PAID FOR (E's placement facts): c1 falls +Z ~3.9 m and the shaft lands in",
"the path between bed (x<=2.5) and ring (x>=3.7) — clear of both by ~0.3 m each side.",
"One corroded post ON PURPOSE: two would share the baked collateral key",
"(structFor/wreckStructure match the first entry; C's envelope dedupes keys), so the",
"second failure would bill and wreck the wrong post. Filed in THREADS [D] S17 —",
"a second rust rung in one yard needs its own key plumbing first."
],
"_why": [
"Authored COLD in the yard editor, SPRINT17 gate 3.2 — palette shapes, editor",
"placement, iterative SCORE IT (three passes: 2 quads -> tightened to band -> 13).",
"Every number below is the SCORE IT card, storm_03_southerly, FUNNEL ON, 9 dressed",
"anchors, export-clone world, 136.5 s flight. Confirmed by audit.html over the saved",
"file (same engine, second route) before shipping.",
" WINNABLE at $80 — 13 quads in band, 6 clean, 1 marginal.",
" cheapest honest line h1,t1b,p2,p3 — $40 hardware (+$15 spare), 45 m2, 58% cover,",
" garden 92.7 FULL, +9.0 HP over bare. No rust in it.",
" the rust gate full cover exists ONLY through c1 (92% x3, above); cheapest",
" clean rust line t1,p1,p2,c1 $65 — corrosion prices as +$25 of",
" hardware you must rate UP, plus a $45 stake on a 0.55 eye.",
" margin flags c1 4% headroom on h1,h2,p2,c1 (wrecks the corroded post $45);",
" p1 2% / t1b 2% / t1 4% — the 15% rule, marginal is not PASS.",
" collateral exposure gutter $90 (h1-h3) + corroded post $45 (c1) = $135 from",
" anchors; board exposure $160 with the gnome (exposureOf).",
" bare bed 83.7 FULL — the southerly cannot kill an open bed (its pea",
" hail takes 10.7 HP). The sail here is for the STEEL and the",
" collateral, per A's S17 gate-0.4 ruling — which is this",
" yard's thesis said as canon."
],
"yard": {
"width": 24,
"depth": 16
},
"sun": {
"elevationDeg": 55,
"azimuthDeg": -125
},
"gardenBed": {
"x": 0,
"z": 1.5,
"w": 5,
"d": 3.5
},
"house": {
"model": "house_yardside_v1",
"wreckedModel": "house_yardside_wrecked_v1",
"collateralKey": "gutter",
"collateralValue": 90,
"collateralLabel": "the gutter",
"x": -3,
"z": -6,
"anchors": [
{
"id": "h1",
"node": "fascia_anchor_01",
"type": "house",
"work": "bracket"
},
{
"id": "h2",
"node": "fascia_anchor_02",
"type": "house",
"work": "bracket"
},
{
"id": "h3",
"node": "fascia_anchor_03",
"type": "house",
"work": "bracket"
}
]
},
"structures": [
{
"id": "c1",
"model": "sail_post_corroded_v1",
"wreckedModel": "sail_post_corroded_wrecked_v1",
"x": 3.1,
"z": -2,
"rotYDeg": 0,
"solid": true,
"collateralKey": "corroded_post",
"collateralValue": 45,
"collateralLabel": "the corroded post",
"anchors": [
{
"id": "c1_a1",
"node": "top_anchor",
"type": "corroded_post",
"work": "cloth"
}
]
},
{
"id": "pool",
"model": "pool_kit_01_v1",
"x": 6.5,
"z": 1.5,
"rotYDeg": 0,
"solid": true,
"anchors": []
}
],
"trees": [
{
"id": "t1",
"model": "tree_gum_02_v1",
"x": -6.8,
"z": 2,
"phase": 1.7,
"trunkH": 3.8,
"anchorY": 3.1,
"anchors": [
{
"id": "t1",
"node": "branch_anchor_01",
"type": "tree",
"work": "cloth"
},
{
"id": "t1b",
"node": "branch_anchor_02",
"type": "tree",
"work": "cloth"
}
]
}
],
"posts": [
{
"id": "p1",
"x": -3.4,
"z": 4.6,
"h": 4,
"type": "post",
"work": "cloth"
},
{
"id": "p2",
"x": 3.4,
"z": 4.6,
"h": 4,
"type": "post",
"work": "cloth"
},
{
"id": "p3",
"x": -5.8,
"z": -1.5,
"h": 4,
"type": "post",
"work": "cloth"
}
],
"fence": {
"sides": [
"north",
"south",
"east",
"west"
]
},
"shed": {
"model": "shed_01_v1",
"x": -9.5,
"z": -5.5,
"rotYDeg": -90
},
"shedTable": {
"model": "shed_table_v1",
"x": -6.5,
"z": 7,
"rotYDeg": -90,
"pickupNode": "pickup_anchor"
},
"gnome": {
"model": "garden_gnome_01_v1",
"x": 4.6,
"z": 4.4,
"rotYDeg": 0,
"collateralValue": 25
},
"wind": {
"_venturi": [
"The pool-terrace channel: house east end to pool enclosure, gate side, running N-S.",
"Axis 1.571 is the channel's GEOMETRY (a line, mod pi — site_02's reconciled lesson),",
"not a storm heading. Gain 1.4 / r 5 puts c1 (1.4 m off-throat) and h3 (3.7 m) in the",
"wind: the corroded post is the venturi post, which is the temptation priced."
],
"venturi": [
{
"x": 3.1,
"z": -3.5,
"axis": 1.571,
"gain": 1.4,
"radius": 5,
"sharp": 3
}
]
},
"_separation_finding": [
"SPRINT17 gate 3.2 [D] — a separation pin was ATTEMPTED and honestly REFUSED, the",
"third refusal in the family (site_02, site_03, now here) and the first authored",
"AFTER A's gate-0.4 ruling made the pattern canon. Measured (SCORE IT, funnel ON,",
"storm_03_southerly, 2026-07-21): bare bed 83.7 FULL (hail 10.7 + rain 5.6 HP);",
"best flown 92.7 FULL (h1,t1b,p2,p3, $40); best gain +9.0 HP. bareMustLoseBelow",
"cannot be written at any threshold — bare WINS the night by 33.7 over the win",
"line. On this night the sail is for the steel and the collateral (A, S17 0.4,",
"ruled canon, taught by the board's exposure line): what c1 separates is not the",
"bed from the hail but $45 and your margin from your greed. When B's garden-BONUS",
"separation shape lands (A ruled FOR it, deferred to its own gate), re-measure —",
"this yard's +9.0 over bare at $40 is a bonus-stake candidate, not a win/lose one."
]
}

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

@ -0,0 +1,380 @@
/**
* 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.',
},
{
// SPRINT17 gate 3.2 [D] — THE POOL YARD. The first yard whose ONLY route
// into the game is being chosen: it is in no NIGHTS slot, so declining it
// forever is a complete campaign. That is the arc-2 promise made literal,
// and it is why the entry lives here and not in week.js.
id: 'pool_yard_southerly',
storm: 'storm_03_southerly', site: 'site_04_pool_yard',
client: 'the Karalis place', addr: '5 Clearwater Ct — the pool yard',
brief: 'New pool, new fence, certified in March — and the certifier is back Thursday, so '
+ 'nothing touches that fence. Not a rope, not a clamp. The old sail post from before '
+ 'the pool went in is still standing, they say, and they say it like a selling point. '
+ 'It has been breathing pool air for years. Tuesday\'s southerly, the bed\'s by the water.',
_why: 'AUTHORED COLD + AUDITED (D, S17 gate 3.2 — SCORE IT card, funnel ON, confirmed by '
+ 'audit.html over the shipped file): 13 quads in band, 6 clean, 1 marginal; cheapest '
+ 'hold $40 (h1,t1b,p2,p3 → garden 92.7 FULL, 58% cover, no rust in it). The trap is '
+ 'priced, not padded: every 92%-cover line routes through the corroded post c1 (0.55, '
+ '$45 collateral, IN the venturi) — clean at $65 rated on t1,p1,p2,c1, UNHOLDABLE on '
+ 'the two shapes that pull the eye harder. Southerly = the storm nights 2 and 4 teach; '
+ 'the yard is the only new variable (one-variable law). Separation REFUSED with '
+ 'receipts in the site file (bare 83.7 FULL — mild-night canon per A\'s 0.4 ruling); '
+ 'exposure $160 (gutter 90 + corroded post 45 + gnome 25), the fence\'s fourteen '
+ 'posts deliberately absent from that number because none of them can be tied to.',
},
];
/**
* A deterministic draw. `rng` is contracts.js's mulberry32 the same PRNG the
* storm system reproduces against, so there is exactly one seeded-randomness
* story in this repo rather than two.
*
* The seed mixes the WEEK and the NIGHT INDEX (SPRINT17 gate 1.1's words), with
* odd multipliers so that week+1 and night+1 don't collide onto the same draw
* `seed*1 + index*1` would offer week 2 night 1 and week 1 night 2 the same job,
* which looks like a bug to a player and is one.
*/
export function seedFor(weekSeed, nightIndex) {
return ((weekSeed | 0) * 0x9E37 + (nightIndex | 0) * 0x85EB + 0x27D4) >>> 0;
}
/**
* Is this pool entry a sane alternative to tonight's scripted job?
*
* The one rule: an alternative must be a DIFFERENT JOB. Offering the same
* yard under the same storm as "the other option" is the board lying about
* having offered a choice and it is exactly what a naive index-into-the-pool
* produces on the nights whose pairing is in the pool (nights 3 and 4 both are).
*/
export function eligibleAlternatives(scripted, pool = POOL) {
return pool.filter((p) => !(p.storm === scripted.storm && p.site === scripted.site));
}
/**
* Tonight's alternative one entry, drawn deterministically.
*
* @param {object} scripted tonight's authored night (nightAt(index))
* @param {number} weekSeed
* @param {number} nightIndex
*/
export function alternativeFor(scripted, weekSeed, nightIndex, pool = POOL) {
const eligible = eligibleAlternatives(scripted, pool);
if (!eligible.length) return null;
const r = rng(seedFor(weekSeed, nightIndex));
return eligible[Math.floor(r() * eligible.length)] ?? null;
}
/**
* COLLATERAL EXPOSURE the risk surface the offer card is required to show.
*
* SPRINT17 gate 1.2: the offer shows "the risk surface (collateral exposure at
* minimum)". This is that number: every priced piece of the client's property
* in that yard which YOUR failure can bill you for. Not a score, not a
* prediction an exposure, the way a tradie reads a site before quoting it.
*
* **THE SECOND-HARNESS SEAM, DECLARED (C's gate 1.2 job).** world.js prices
* collateral at FAILURE time by resolving a collateral KEY off the anchor that
* let go; this walks the SITE JSON up front and sums what is priced there. Two
* routes to one set of dollars, which is precisely the shape this repo has been
* bitten by (`collateralFor` resolved the carport for five sprints only because
* site_02 happens to id its structure "carport"). They must agree per item or
* one of them is wrong: a.test pins the totals against the shipped sites, and
* C is crossing it against their envelope on at least one offer. If they ever
* disagree, find the variable before tuning either it will be the harness.
*
* Reads the same fields world.js's collateralFor prefers (`spec.collateralValue`
* first, the GLB extra as fallback and proposal), so the two cannot drift on
* price even though they differ on question.
*/
export function exposureOf(site) {
const items = [];
const push = (what, cost) => {
if (Number.isFinite(cost) && cost > 0) items.push({ what, cost });
};
if (site?.house) push(site.house.collateralLabel ?? 'the house', site.house.collateralValue);
for (const s of site?.structures ?? []) push(s.collateralLabel ?? s.id, s.collateralValue);
// Named props, explicitly — there is deliberately no generic `props: []` in a
// site (docs/MANUAL.md: every prop is a named top-level key, wired on
// purpose), so this list grows by hand when a priced prop is added. That is
// the intended friction: a prop that quietly joined an exposure total without
// anyone deciding it should is the "free failure" bug wearing a new hat.
if (site?.gnome) push(site.gnome.collateralLabel ?? 'garden gnome', site.gnome.collateralValue);
const total = items.reduce((s, i) => s + i.cost, 0);
return { total, items };
}
/**
* BUILD THE MORNING'S BOARD — two offers, tonight's scripted night first.
*
* Order is not cosmetic and is not seeded: the scripted job is ALWAYS offer 0.
* The campaign is the thing the player is in the middle of, and a board that
* shuffled which door was which would make "take the job you were going to do"
* a reading exercise every morning.
*
* @param {object} o
* @param {object} o.scripted tonight's authored night
* @param {number} o.weekSeed
* @param {number} o.nightIndex 0-based
* @param {(night:object) => object} [o.priced] per-offer money/forecast/exposure,
* injected by the caller because it needs storm defs and site JSON that this
* pure module deliberately does not import. Shape is documented on the offer
* typedef in THREADS' seam contract; anything it returns is merged onto the
* offer. Absent the board still builds (client, site, brief, constraints),
* which is what keeps this testable with no server.
*/
export function offersFor({ scripted, weekSeed, nightIndex, pool = POOL, priced = null }) {
const alt = alternativeFor(scripted, weekSeed, nightIndex, pool);
const mk = (night, kind, id) => {
for (const c of night.constraints ?? []) validateConstraint(c, id);
const offer = {
id, kind, night,
constraints: night.constraints ?? [],
premium: premiumFor(night.constraints),
};
return priced ? Object.assign(offer, priced(night)) : offer;
};
const offers = [mk(scripted, 'scripted', 'scripted')];
if (alt) offers.push(mk(alt, 'callout', `alt:${alt.id}`));
return offers;
}

View File

@ -336,6 +336,33 @@ export const PALETTE = [
})),
}),
},
{
// SPRINT17 gate 3.1, E. The corroded tier — the pool yard's other half.
// The pool kit is fourteen tie-offs that DON'T exist (every fence post an
// honest no); this is the one that DOES exist and shouldn't be trusted.
// 0.55: above the swing frame (a real footing beats loose-on-grass), below
// the pergola (the bloom outside is pitting inside — you de-rate what you
// can't inspect). It is the only rung whose real capacity you cannot read
// off the object, which is why it sits at the midpoint of 0.45→0.65.
// ⚠️ `_v1` is LOAD-BEARING and its absence is SILENT: adoptAnchor does
// `rating_hint ?? 1`, so a missing model does not fail — it becomes the
// BEST STEEL IN THE GAME. A typo here rates the corroded post 1.00 and the
// trap inverts into the safest anchor in the yard. (S14, D's cold pass.)
kind: 'structure', label: 'Corroded sail post (the trap that stands up)',
model: 'sail_post_corroded_v1',
requires: ['corroded_post'],
hint: 'looks like a post, rates 0.55 — rust at the base and head, and the pad eye has sagged',
make: (id, x, z) => ({
id, model: 'sail_post_corroded_v1',
wreckedModel: 'sail_post_corroded_wrecked_v1',
x, z, rotYDeg: 0, solid: true,
collateralKey: 'corroded_post',
collateralValue: 45, collateralLabel: 'the corroded post',
anchors: [
{ id: `${id}_a1`, node: 'top_anchor', type: 'corroded_post', work: 'cloth' },
],
}),
},
{
// The collateral apex. NO anchors, on purpose — nothing about a glasshouse
// may adopt (the ridge carries tie_off:false in the GLB). collateralKey is

View File

@ -186,6 +186,13 @@ function boot() {
function render(out, s, ms) {
out.replaceChildren();
// gate 2 (SPRINT17): the budget every affordability sentence on this card is
// denominated in — START_BUDGET on an open night, the client's cap on a
// capped one. The engine decided with this number; the card must print with
// the same one or the two tell different stories.
const BUDGET = s.budget ?? START_BUDGET;
const capped = BUDGET < START_BUDGET;
// ── the header card: WHAT WAS SCORED, and the funnel state, loudly ────────
// Sprint 11 shipped a headline built with the funnel off, and the number
// looked entirely reasonable. Three harnesses made that mistake because
@ -197,6 +204,20 @@ function render(out, s, ms) {
kv(head, 'venturi', s.funnelOn
? s.venturi.map((v) => `(${v.x},${v.z}) axis ${v.axis} gain ${v.gain}`).join(' · ')
: 'none declared in site.wind');
// SPRINT17 gate 0.3 [B, A consulted] — the fabric charter, printed on EVERY
// card, not only where somebody already suspects a problem: "a disclosure
// that appears only where somebody already knows there's a problem teaches
// that its absence means no problem" (A's consult, adopted). Same discipline
// as the funnel row above — a score whose fabric you can't see is a rumour.
kv(head, 'fabric', `${s.fabric.name} (sweep charter)`, s.fabricLeaks ? 'ed-warn' : null);
if (s.fabricLeaks) {
// D's soaker finding, worded with A: name what is MISSING from the card,
// not what key to press — the binding can move, the missing line can't.
head.append(el('div', 'ed-note ed-warn',
'Scored on SHADE CLOTH, and tonight\'s stones pass that weave — on this night the '
+ 'fabric is the bet, and the audit does not place it. The membrane line is not in '
+ 'these numbers: a DEAD garden table below means the cloth loses, not the night.'));
}
kv(head, 'anchors', `${s.anchorCount} ${s.dressed ? 'dressed ✓' : '⚠ UNDRESSED'}`,
s.dressed ? 'ed-ok' : 'ed-err');
if (!s.dressed) {
@ -206,6 +227,17 @@ function render(out, s, ms) {
+ 'are not what ships. Fix the page before believing this card.'));
}
kv(head, 'bed', `${s.bed.w}×${s.bed.d} m at (${s.bed.x}, ${s.bed.z})`);
// gate 2: the client's terms this score ran under, on the header with the
// other things that change what the numbers MEAN (funnel, fabric). Nothing
// prints on an unconstrained score — absence must keep meaning absence.
if (s.constraints?.length) {
for (const c of s.constraints) kv(head, 'client terms', `${c.label}`, 'ed-warn');
if (s.constrainedOut > 0) {
head.append(el('div', 'ed-note ed-warn',
`${s.constrainedOut} line(s) in the audit band touch banned steel and were not scored — `
+ 'they are not answers on this night, whatever they hold.'));
}
}
kv(head, 'took', `${(ms / 1000).toFixed(1)} s`);
// ── 1. winnability at $80 ────────────────────────────────────────────────
@ -213,16 +245,22 @@ function render(out, s, ms) {
const vcls = V.code === 'pass' ? 'ed-ok' : V.code === 'marginal-only' ? 'ed-warn' : 'ed-err';
const vhead = { pass: '✓ WINNABLE', 'marginal-only': '⚠ MARGINAL ONLY',
unaffordable: '✗ UNAFFORDABLE', 'no-cover': '✗ NO LINE COVERS THE BED' }[V.code] ?? V.code;
const cw = card(out, `${vhead} — at ${money(START_BUDGET)}`, vcls);
const cw = card(out, `${vhead} — at ${money(BUDGET)}${capped ? ' (client cap)' : ''}`, vcls);
kv(cw, 'quads in band', `${s.cands} candidate${s.cands === 1 ? '' : 's'}`);
kv(cw, 'clean lines', String(s.winners.length), s.winners.length ? 'ed-ok' : 'ed-err');
kv(cw, 'marginal lines', String(s.marginalWinners.length), s.marginalWinners.length ? 'ed-warn' : null);
if (V.code === 'no-cover') {
cw.append(el('div', 'ed-note ed-err',
'No quad in the audit band shades the bed at all. This yard cannot be rigged: it needs an '
+ 'anchor near the bed before it ships. (The band is an availability FLOOR, not a ceiling — '
+ 'a yard failing here is failing on geometry, not on the heuristic.)'));
cw.append(el('div', 'ed-note ed-err', s.constrainedOut > 0
// gate 2: same verdict code, different fact — a yard the BAN emptied is
// not a yard with no geometry, and saying so tells the author which
// knob is wrong (the constraint's, not the yard's).
? `Every quad that shades the bed touches banned steel (${s.constrainedOut} removed by the `
+ 'client\'s terms). The yard has geometry; this CLIENT forbids all of it — a night the '
+ 'board must not offer, or a constraint that needs re-ruling.'
: 'No quad in the audit band shades the bed at all. This yard cannot be rigged: it needs an '
+ 'anchor near the bed before it ships. (The band is an availability FLOOR, not a ceiling — '
+ 'a yard failing here is failing on geometry, not on the heuristic.)'));
} else if (V.code === 'marginal-only') {
const b = V.best;
cw.append(el('div', 'ed-note ed-warn',
@ -234,7 +272,7 @@ function render(out, s, ms) {
const b = V.best;
cw.append(el('div', 'ed-note ed-err',
`No affordable holding line. Cheapest is ${b.ids.join(',')} at ${money(b.hw)} on a `
+ `${money(START_BUDGET)} budget`
+ `${money(BUDGET)}${capped ? ' (client-capped)' : ''} budget`
+ (b.unholdable.length
? `, and ${b.unholdable.map((c) => c.id).join('/')} is over the shops ceiling at any price.`
: '.')));
@ -249,14 +287,15 @@ function render(out, s, ms) {
if (ch) {
kv(cc, 'line', ch.row.ids.join(',') + (ch.row.pinned ? ' 📌 pinned' : ''));
kv(cc, 'hardware', money(ch.row.cleanHw) + (ch.row.hw < ch.row.cleanHw ? ` (holds at ${money(ch.row.hw)})` : ''));
kv(cc, '+ spare', money(ch.total), ch.total <= START_BUDGET ? 'ed-ok' : 'ed-warn');
kv(cc, '+ spare', money(ch.total), ch.total <= BUDGET ? 'ed-ok' : 'ed-warn');
kv(cc, 'area', `${ch.row.area.toFixed(0)}`);
kv(cc, 'garden', `${ch.garden.hp} ${ch.garden.state.toUpperCase()}`,
ch.garden.state === 'full' ? 'ed-ok' : ch.garden.state === 'tattered' ? 'ed-warn' : 'ed-err');
kv(cc, 'worth', `${ch.garden.hp - s.bare.hp >= 0 ? '+' : ''}${(ch.garden.hp - s.bare.hp).toFixed(1)} HP over bare`);
if (ch.total > START_BUDGET) {
if (ch.total > BUDGET) {
cc.append(el('div', 'ed-note ed-warn',
`Buyable, but there is no room left for a ${money(ch.total - ch.row.cleanHw)} spare — `
`Buyable, but there is no room left for a ${money(ch.total - ch.row.cleanHw)} spare`
+ `${capped ? ' under the client\'s cap' : ''}`
+ 'a night with no spare is a repair the player cannot make.'));
}
} else {

View File

@ -305,6 +305,49 @@ 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; }
/* SPRINT17 [C]: the weather band. The hail line is lit brighter than the rest
because the soaker's whole trap is that its wind reads gentle the one line
a player must not skim past. */
#hud-card .offer .line.wx { color:#8ba0ad; }
#hud-card .offer .line.hail span { color:#cbd8e0; }
/* 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 +777,102 @@ 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 ?? [];
// SPRINT17 [C], applied by the integrator: the offer maps C's ORDERED
// band (wind · hail · rain · change · confidence) instead of picking
// two forecast fields, so an offer card can never hide a night's hail.
// The .hail line is lit brighter because it is the one a player must
// not skim past — the soaker's whole trap is a gentle-reading wind.
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>
${(o.band ?? []).map((l) => `<div class="line wx ${l.key}"><span>${l.text}</span></div>`).join('')}
${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 +936,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 +1028,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 +1190,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 +1246,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

View File

@ -17,7 +17,7 @@ import * as THREE from '../vendor/three.module.js';
import { FIXED_DT, PHASES, STORM_LEN, HARDWARE, SPARE_COST, Emitter } from './contracts.js';
import { createWorld, loadSite } from './world.js';
import { createCameraRig, spawnYawFor } from './camera.js';
import { loadStorm, createWind } from './weather.js';
import { loadStorm, createWind, forecastLines, offerBand } 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,78 @@ 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,
// SPRINT17 [C], applied by the integrator: the ORDERED band the offer
// card maps, so a night's hail can never be dropped by the card picking
// two fields. Night 7 read softer than the buster on wind+stones alone
// (55 vs 76 km/h) while carrying 6× the hail — the board recommending
// its own trap. offerBand carries every fact the storm has.
band: def ? offerBand(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
@ -990,6 +1085,21 @@ export async function boot(opts = {}) {
// main.js's one private touch into their session (`_startBudget` + reset(),
// asked in THREADS, landed, fake deleted — same route reset() took).
rigging.session.setBudget(week.bank);
// SPRINT17 gate 2 — B's filed wiring line, landed at the boundary B named
// (the setBudget pattern: the night's terms arrive with the night's bank).
// Runs AFTER week.take(), so these are the CHOSEN job's constraints — a
// callout's client speaks here, not the spine's. The `?? []` is
// load-bearing and B's entry says why: setConstraints survives the
// session's reset() (so "play again" replays the same client), which makes
// the empty array the ONLY thing that clears the previous client's terms —
// drop it and the Vasilaros cap follows you to the Hendersons'.
// `?.` because lane/b owns the method and it lands at integration; until
// then this is a documented no-op (the D lesson: a `?.()` LOOKS like a
// call) and constraints stay paper-only, priced but unenforced — the seam
// contract's own interim state. Integrator: after merge, take a
// constrained offer and watch the session refuse in the ticker; if it
// doesn't, this line is the first suspect.
rigging.session.setConstraints?.(week.job?.constraints ?? []);
spentThisNight = 0;
// SPRINT11 — the job sheet reads three new things off the week: tonight's
// JOB (client + brief), the QUOTE (what it pays, before you rig it, which is
@ -1073,7 +1183,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 +1290,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 -------------------------------------------------------------

View File

@ -113,18 +113,109 @@ const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
const OK = { ok: true };
const fail = (reason) => ({ ok: false, reason });
/**
* SPRINT17 gate 2 [B] CLIENT CONSTRAINTS: A's data, this file's teeth.
*
* The shapes are A's seam contract (THREADS 2026-07-21, posted before either
* side was load-bearing), verbatim:
*
* { kind:'noAnchorFamily', family:'house', label:'…', says:'…' }
* { kind:'budgetCap', cap:45, label:'…', says:'…' }
*
* `label` is what the papers print (A's half); `says` is the client's own
* voice, and it is what this session's refusals return the ticker on a
* refused pick is the CLIENT talking, not the software. `family` is an
* ANCHOR_TYPE to refuse; `cap` is a hard ceiling on the night's SPEND (the
* client caps the invoice, not your wallet), enforced where the spending
* happens and asserted again at commit.
*
* The enum is CHECKED on both ends of the seam on purpose: A's
* validateConstraint guards the data source (board.js), this guards the
* enforcement door a constraint that arrives here malformed is a caller bug
* (setHardware's class, so it THROWS and names the problem), never a player
* mistake to ticker at.
*/
export const CONSTRAINT_KINDS = Object.freeze(['noAnchorFamily', 'budgetCap']);
export function validateNightConstraint(c) {
if (!c || typeof c !== 'object') {
throw new TypeError(`night constraint must be an object, got ${JSON.stringify(c)}`);
}
if (!CONSTRAINT_KINDS.includes(c.kind)) {
throw new TypeError(
`unknown constraint kind "${c.kind}" — known: ${CONSTRAINT_KINDS.join(', ')}. `
+ 'An unenforced enum is decoration (the carport-typed-as-a-post scar); if A shipped a '
+ 'new kind, the session needs its enforcement written before a night can carry it.');
}
if (typeof c.says !== 'string' || !c.says || typeof c.label !== 'string' || !c.label) {
throw new TypeError(
`constraint "${c.kind}" is missing says/label — a constraint with no client words `
+ 'renders an empty quote block on two papers and an empty refusal ticker here.');
}
if (c.kind === 'noAnchorFamily' && (typeof c.family !== 'string' || !c.family)) {
throw new TypeError('noAnchorFamily with no family bans nothing — decoration.');
}
if (c.kind === 'budgetCap' && !(Number.isFinite(c.cap) && c.cap > 0)) {
throw new TypeError(`budgetCap cap must be a positive number, got ${JSON.stringify(c.cap)}`);
}
return c;
}
export class RiggingSession {
/**
* @param {object} opts
* @param {Array} opts.anchors world.anchors [{id, pos, type, sway?}]
* @param {number} opts.budget starting cash
* @param {Array} opts.constraints the night's client constraints (A's
* shapes, validated here) see setConstraints
*/
constructor({ anchors = [], budget = START_BUDGET } = {}) {
constructor({ anchors = [], budget = START_BUDGET, constraints = [] } = {}) {
this.anchors = anchors;
this._startBudget = budget;
this.setConstraints(constraints);
this.reset();
}
/**
* The client's terms for tonight (gate 2). Validated at the door a
* malformed constraint throws with the kind named, because by the time one
* reaches the session it has already passed A's board validator, so a bad
* one here is a caller bug, not data.
*
* SURVIVES reset() deliberately, like `_startBudget`: "play again" on the
* same night replays the same client. The wiring contract (filed for A, the
* setBudget pattern): main.js calls
* `session.setConstraints(week.job?.constraints ?? [])` at the same night
* boundary that re-banks the shop the `?? []` is what clears the previous
* client's terms, so an unconstrained night cannot inherit a ban.
*/
setConstraints(list = []) {
for (const c of list) validateNightConstraint(c);
this._constraints = Object.freeze([...list]);
return this;
}
get constraints() { return this._constraints; }
/** The ban that covers this anchor, or null. (gate 2, noAnchorFamily) */
_banFor(anchor) {
return this._constraints.find((c) => c.kind === 'noAnchorFamily' && c.family === anchor.type) ?? null;
}
/**
* The cap refusal for spending `amount` more, or null. (gate 2, budgetCap)
*
* Checked at SPEND time, not only at commit: a cap the player discovers
* four corners deep is a restart wearing a rule's name, and the refusal
* reason is the client's own words so the ticker reads as the client
* querying the invoice, mid-shop. Refunds are always allowed the way BACK
* under the cap must never be blocked by the cap.
*/
_capRefusal(amount) {
if (!(amount > 0)) return null;
const c = this._constraints.find((x) => x.kind === 'budgetCap');
if (!c) return null;
return (this.spent + amount > c.cap) ? fail(c.says) : null;
}
/**
* Back to an empty prep phase, same anchors and starting budget. Lane A's
* "play again" reaches into the state machine to fake a fresh round rather
@ -172,7 +263,11 @@ export class RiggingSession {
return this.reset();
}
get spent() { return START_BUDGET - this.budget; }
// Against _startBudget, not the global (gate 2's incidental fix): the two
// are identical on every shipped night, but a re-banked session measuring
// its spend against the DEFAULT bank would misprice the cap check the
// moment those diverge — the same class of quiet lie as the frozen sway.
get spent() { return this._startBudget - this.budget; }
get canStart() { return this.picks.length === MAX_CORNERS; }
isRigged(anchorId) { return this.picks.some((p) => p.anchorId === anchorId); }
pickOf(anchorId) { return this.picks.find((p) => p.anchorId === anchorId) || null; }
@ -188,8 +283,14 @@ export class RiggingSession {
rig(anchorId) {
const a = this.anchors.find((x) => x.id === anchorId);
if (!a) return fail('no such anchor');
// gate 2: the house ban — refused in the CLIENT's words. The ticker is the
// client talking ("Nothing goes on the house…"), not the software.
const ban = this._banFor(a);
if (ban) return fail(ban.says);
if (this.isRigged(anchorId)) return fail('already rigged');
if (this.picks.length >= MAX_CORNERS) return fail('a sail has four corners');
const capNo = this._capRefusal(HARDWARE[0].cost);
if (capNo) return capNo;
if (!this._spend(HARDWARE[0].cost)) return fail('not enough budget');
this.picks.push({ anchorId, hw: HARDWARE[0] });
this._reorder();
@ -214,6 +315,8 @@ export class RiggingSession {
const p = this.pickOf(anchorId);
if (!p) return fail('not rigged');
const next = HARDWARE[(HARDWARE.indexOf(p.hw) + 1) % HARDWARE.length];
const capNo = this._capRefusal(next.cost - p.hw.cost);
if (capNo) return capNo;
if (!this._spend(next.cost - p.hw.cost)) return fail('not enough budget');
p.hw = next;
return OK;
@ -247,6 +350,8 @@ export class RiggingSession {
}
const p = this.pickOf(anchorId);
if (!p) return fail('not rigged');
const capNo = this._capRefusal(hw.cost - p.hw.cost);
if (capNo) return capNo;
if (!this._spend(hw.cost - p.hw.cost)) return fail('not enough budget');
p.hw = hw;
return OK;
@ -260,6 +365,8 @@ export class RiggingSession {
setFabric(f) {
const pick = typeof f === 'string' ? FABRIC.find((x) => x.id === f) : f;
if (!pick || !FABRIC.includes(pick)) return fail('unknown fabric');
const capNo = this._capRefusal(pick.cost - this.fabric.cost);
if (capNo) return capNo; // both fabrics are $0 today; the cap covers the day one isn't
if (!this._spend(pick.cost - this.fabric.cost)) return fail('not enough budget');
this.fabric = pick;
return OK;
@ -275,6 +382,12 @@ export class RiggingSession {
setSpares(n) {
n = Math.max(0, Math.floor(n));
const delta = (n - this.spares) * SPARE_COST;
// gate 2: the cap covers spares too — the client caps the INVOICE, and the
// spare is on it. "$40 of steel + a $15 spare" under a $45 cap is the
// constraint's own squeeze, said out loud here rather than discovered at
// settlement.
const capNo = this._capRefusal(delta);
if (capNo) return capNo;
if (!this._spend(delta)) return fail('not enough budget');
this.spares = n;
return OK;
@ -327,6 +440,26 @@ export class RiggingSession {
/** Hand the finished rig to the sim. Mirrors contracts.js sailRig.attach(). */
commit(rig) {
if (!this.canStart) throw new Error(`sail needs ${MAX_CORNERS} corners, have ${this.picks.length}`);
// gate 2, the promise the sprint doc names ("enforces the budget cap at
// commit"): by now the refusals above have made these states unreachable
// through the session's own API, so reaching one is a caller walking
// around the doors — setHardware's class. THROW, don't ticker: a sail
// attached over the client's terms is a bug on its way to an invoice.
for (const p of this.picks) {
const a = this.anchors.find((x) => x.id === p.anchorId);
const ban = a && this._banFor(a);
if (ban) {
throw new Error(
`commit: ${p.anchorId} is ${a.type} steel and the client's terms say "${ban.label}" — `
+ 'a pick got past the rig() refusal. Whoever built these picks skipped the session.');
}
}
const cap = this._constraints.find((c) => c.kind === 'budgetCap');
if (cap && this.spent > cap.cap) {
throw new Error(
`commit: $${this.spent} spent under the client's $${cap.cap} cap — `
+ 'a spend got past the session\'s cap refusals.');
}
// Fabric before attach: porosity scales the wind pressure term and decides
// whether the cloth ponds, so the sail has to know what it's made of before
// it is built. (It's also half of the wild night's only winnable line —

View File

@ -9,6 +9,7 @@
import { RiggingSession, FABRIC, fabricNoteFor } from './rigging.js';
import { SailRig, TENSION_MIN, TENSION_MAX } from './sail.js';
import { HARDWARE, START_BUDGET, SPARE_COST } from './contracts.js';
import { factoryExtras } from './ratings.js';
const [CARABINER, SHACKLE, RATED] = HARDWARE;
@ -392,6 +393,168 @@ test('fabricNoteFor: speaks only when the bet mattered, silent when a sentence w
return `leak note fires at 0.7-size stones + >=${fabricNoteFor.LEAK_MATTERS_HP} HP; pond note at >${fabricNoteFor.POND_MATTERS_KG} kg; all else silent`;
});
// ---------- SPRINT17 gate 2: client constraints — A's shapes, this session's teeth ----------
// The literals below are A's seam contract verbatim (THREADS 2026-07-21): the
// session must enforce exactly the shape the board ships, not a friendlier
// cousin of it. If A's shapes move, these move in the same commit or go red.
const HOUSE_BAN = {
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.',
};
const CAP_45 = {
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.',
};
const FAKE_RIG = { setFabric() {}, attach: () => 'attached' };
test('gate 2: the house ban refuses the pick in the CLIENT\'s words, charges nothing, bans a family not a yard', () => {
const s = session().setConstraints([HOUSE_BAN]);
for (const id of ['h1', 'h2', 'h3']) {
const r = s.rig(id);
assert(!r.ok, `${id} rigged under a house ban`);
assert(r.reason === HOUSE_BAN.says,
`the refusal must be the client's own words for the ticker, got "${r.reason}"`);
}
assert(s.budget === START_BUDGET, 'a refused pick was charged');
assert(s.picks.length === 0, 'a refused pick was kept');
// the rest of the yard stays open — the ban is a family, not a lockout
for (const id of ['t1', 't2', 'p1', 'p2']) assert(s.rig(id).ok, `${id} refused without being banned`);
assert(s.canStart, 'four honest corners must still close the quad under a house ban');
assert(s.commit(FAKE_RIG) === 'attached', 'a legal rig failed to commit under the ban');
return 'h1/h2/h3 refused with the client talking, t/p corners rigged and committed';
});
test('gate 2: the cap is a wall at spend time — every path, exact at the boundary, refunds always free', () => {
const s = session().setConstraints([CAP_45]);
for (const id of ['t1', 't2', 'p1', 'p2']) assert(s.rig(id).ok, 'carabiners must fit any sane cap');
assert(s.setHardware('t1', SHACKLE).ok, 'upgrade inside the cap refused'); // $30
assert(s.setSpares(1).ok, 'spend TO the cap refused — the cap is a ceiling, not a strict bound'); // $45, exact
assert(s.spent === CAP_45.cap, `expected exactly $${CAP_45.cap} spent, got $${s.spent}`);
// every remaining spend path refuses, in the client's words, charging nothing
for (const [what, r] of [
['cycleHardware', s.cycleHardware('t2')],
['setHardware', s.setHardware('t2', RATED)],
['setSpares', s.setSpares(2)],
]) {
assert(!r.ok, `${what} spent past the client's cap`);
assert(r.reason === CAP_45.says, `${what} refusal must be the client's words, got "${r.reason}"`);
}
assert(s.spent === CAP_45.cap && s.pickOf('t2').hw === CARABINER && s.spares === 1,
'a refused spend changed state anyway');
// the way BACK under the cap is never blocked — refund, then respend
assert(s.setSpares(0).ok, 'a refund was blocked by the cap'); // $30
assert(s.setHardware('t2', SHACKLE).ok, 'freed room under the cap refused'); // $40
assert(s.commit(FAKE_RIG) === 'attached', 'a rig at $40 under a $45 cap failed to commit');
return `spent to $${CAP_45.cap} exactly, three paths refused past it, refund freed $10 of room`;
});
test('gate 2: commit is belt-and-braces — a caller who walked around the doors gets a THROW, not an invoice', () => {
// Both states below are unreachable through the session's own API (the
// refusal tests above prove the doors), so these poke the internals the way
// only a buggy caller could — and commit must refuse to attach the sail.
const s = session().setConstraints([HOUSE_BAN]);
for (const id of ['t1', 't2', 'p1']) s.rig(id);
s.picks.push({ anchorId: 'h1', hw: CARABINER }); // banned steel, no door used
let threw = null;
try { s.commit(FAKE_RIG); } catch (e) { threw = e; }
assert(threw && /h1/.test(threw.message), 'commit attached a sail to banned steel');
const s2 = session().setConstraints([CAP_45]);
for (const id of ['t1', 't2', 'p1', 'p2']) s2.rig(id);
s2.picks[0].hw = RATED; s2.picks[1].hw = RATED; // $70 of steel, budget never asked
s2.budget = START_BUDGET - 2 * RATED.cost - 2 * CARABINER.cost;
threw = null;
try { s2.commit(FAKE_RIG); } catch (e) { threw = e; }
assert(threw && new RegExp(`\\$${CAP_45.cap}`).test(threw.message),
'commit attached a sail spent past the client\'s cap');
return 'banned pick and cap overspend both throw at commit, naming the term broken';
});
test('gate 2: the constraint door is a checked enum, and the terms survive reset but not setConstraints([])', () => {
const throwsOn = (list) => {
try { session().setConstraints(list); return false; } catch { return true; }
};
assert(throwsOn([{ kind: 'noSwearing', label: 'x', says: 'y' }]),
'unknown kind accepted — the enum is decoration (the carport-typed-as-a-post scar)');
assert(throwsOn([{ kind: 'budgetCap', cap: 0, label: 'x', says: 'y' }]), 'cap $0 accepted — a soft-lock');
assert(throwsOn([{ kind: 'budgetCap', cap: NaN, label: 'x', says: 'y' }]), 'cap NaN accepted — NaN compares false and caps nothing');
assert(throwsOn([{ kind: 'noAnchorFamily', family: 'house' }]), 'a constraint with no client words accepted — empty ticker, empty papers');
assert(throwsOn([{ kind: 'noAnchorFamily', label: 'x', says: 'y' }]), 'a ban with no family accepted — bans nothing');
// persistence: reset() is "play again" on the SAME night, so the client's
// terms ride through it (the _startBudget pattern); [] is the only clear,
// which is why the wiring contract is setConstraints(job?.constraints ?? [])
const s = session().setConstraints([HOUSE_BAN]);
s.reset();
const r = s.rig('h1');
assert(!r.ok && r.reason === HOUSE_BAN.says, 'reset() dropped the client\'s terms — "play again" rigged the house');
s.setConstraints([]);
assert(s.rig('h1').ok, 'setConstraints([]) did not clear the ban — the next client inherited it');
return 'five malformed shapes thrown out at the door; terms survive reset, cleared only explicitly';
});
// --- SPRINT17 gate 3 support: THE CORRODED TIER PRICES HONESTLY -------------
// E landed the corroded post (rating_hint 0.55, its own ANCHOR_TYPE word, a
// $45 collateral); the gates-0.2/0.3/2 pass DEFERRED gate 3, and this is it.
// B owns the pricing surfaces the card reads, so the number the shop charges
// for a corroded corner is pinned HERE, off E's real resolver — not a
// hand-typed 0.55. The collateral half ($45 through world.collateralFor → the
// exposure line) is pinned in gardenfly.selftest, where a dressed world exists;
// this is the STEEL half: a corroded post is weaker, so the same load costs
// MORE steel to hold, which is what "prices honestly" means and is B's tier
// math (_effRating), not E's mesh.
//
// ⚠️ CROSS-LANE SEAM. The corroded tier lives on lane/e; on lane/b in
// isolation `factoryExtras('corroded_post')` is null (no gen entry, no GLB).
// These pins SKIP WITH A DISCLOSURE on a bare lane/b and go live the moment
// lane/e merges — the "build against the shape as pushed" seam the sprint runs
// on. Measured GREEN on the scratch merge (lane/a + lane/e): hint 0.55.
const CORRODED_TIER_LIVE = !!(factoryExtras('corroded_post'));
const CORRODED_SKIP = 'SKIPPED — corroded tier is on lane/e, absent on bare lane/b; this pin is '
+ 'inert here and goes live at integration (measured GREEN on the scratch merge, hint 0.55)';
test('gate 3: the corroded tier is 0.55, read off E\'s resolver not a literal', () => {
if (!CORRODED_TIER_LIVE) return CORRODED_SKIP;
const ex = factoryExtras('corroded_post');
assert(ex && ex.ratingHint === 0.55,
`factoryExtras('corroded_post') gave ${JSON.stringify(ex)} — the card reads this for a node-less `
+ 'corroded placement, and if E re-rules the number it must move here, not be re-typed');
assert(ex.collateral === 'corroded_post',
'the corroded anchor carries no collateral key — the exposure line would read "not scored" over a $45 trap');
return `corroded_post → hint ${ex.ratingHint}, collateral "${ex.collateral}"`;
});
test('gate 3: a corroded corner costs MORE steel than a sound post at the same load', () => {
if (!CORRODED_TIER_LIVE) return CORRODED_SKIP;
// _effRating is what the panel's arrow and the audit's tier both price
// against: hardware.rating × the anchor's ratingHint. A corroded post fails
// at 0.55 of the steel's spec, so a load a carabiner holds on a sound post
// may need a shackle on a corroded one. Built off E's real hint.
const hint = factoryExtras('corroded_post').ratingHint;
const SOUND = { id: 'ps', type: 'post', pos: { x: -6.4, y: 3.9, z: 7.4 }, sway: () => ({ x: -6.4, y: 3.9, z: 7.4 }) };
const COR = { id: 'pc', type: 'corroded_post', pos: { x: 5.3, y: 3.9, z: 8 }, ratingHint: hint,
sway: () => ({ x: 5.3, y: 3.9, z: 8 }) };
const s = new RiggingSession({ anchors: [SOUND, COR, ...ANCHORS] });
s.rig('ps'); s.rig('pc'); s.rig('h1'); s.rig('t1');
const sound = s._effRating(s.pickOf('ps'));
const cor = s._effRating(s.pickOf('pc'));
// Both start on carabiners (rating 1200): the sound post holds 1200 N, the
// corroded holds 1200 × 0.55 = 660 N. Same steel, less strength — measured.
assert(sound === CARABINER.rating, `sound post effRating ${sound}, expected the carabiner's ${CARABINER.rating}`);
assert(Math.abs(cor - CARABINER.rating * hint) < 1e-9,
`corroded effRating ${cor}, expected ${CARABINER.rating} × ${hint} = ${CARABINER.rating * hint}`);
assert(cor < sound,
'a corroded post held as much as a sound one — the trap is priced as safe steel, which inverts it');
// The tier consequence: a load in the corroded gap (6601200 N) is HELD by a
// carabiner on the sound post and OVER a carabiner on the corroded one.
const load = 900;
assert(load < sound && load > cor,
`pick a load in the corroded gap (${cor}..${sound} N) so the tier difference is real`);
return `same carabiner: sound holds ${sound} N, corroded holds ${cor} N — the rust costs a tier`;
});
export const RIGGING_TESTS = TESTS;
export function runRiggingSelftest() {

View File

@ -970,6 +970,26 @@ export class SailRig {
// the spring network, and the cloth a player sees after the bang is a
// shredded z-fighting mess, not a cloth. Heal ONLY on a divergence break —
// an honest overload break has finite state and must not be touched.
//
// SPRINT17 gate 0.2 — THE SECOND WIRE (D's poison poke, S16 filing). The
// loop above is the heal's only trigger and it SKIPS broken corners, so
// once all four are gone there is no corner load left to read NaN through:
// a fully-lost sail that diverged again stayed NaN forever and rendered as
// nothing. A lost sail is still cloth in the yard — it must LOOK lost, not
// vanish. The sentinel runs ONLY on a fully-broken rig (the corner wire
// covers every other case): sum the state — any NaN/±Inf makes the sum
// non-finite (InfInf is NaN), and finite cloth cannot overflow it
// (positions are metres, prev within a step of pos). Branch-free O(n)
// adds, no mutation, so a finite lost sail is untouched byte-for-byte —
// the negative control in sail.selftest.js pins that.
if (!diverged
&& this.corners[0].broken && this.corners[1].broken
&& this.corners[2].broken && this.corners[3].broken) {
const pos = this.pos, prev = this.prev;
let s = 0;
for (let i = 0; i < pos.length; i++) s += pos[i] + prev[i];
if (!Number.isFinite(s)) diverged = true;
}
if (diverged) this._healNonFinite();
if (this._dirtyRest) { this._applyRestLengths(); this._dirtyRest = false; }
}

View File

@ -327,6 +327,51 @@ test('divergence heal: negative control — honest overload breaks never trigger
return `${broke.length} honest break(s), zero heals — the heal only answers divergence`;
});
test('divergence heal: re-arms on a fully-lost sail (D\'s second poison) — and stays silent on a finite one', () => {
// SPRINT17 gate 0.2, D's S16 filing verbatim: "with all four corners already
// divergence-broken, a SECOND poison has no unbroken corner left to arm the
// heal (_checkFailure skips broken corners), so a fully-lost sail that
// diverges again stays NaN and renders as nothing." The fix is the sentinel
// in _checkFailure — it watches ONLY when every corner is broken. This test
// is D's gait replayed: cascade, then poke the corpse.
const r = new SailRig({ anchors: makeAnchors(HEIGHTS_FLAT), gridN: 10 });
r.attach(ALL_IDS, Array(4).fill(HARDWARE[2]), 1.0);
const heals = [];
r.events.on('heal', (e) => heals.push(e));
const w = makeStubWind({ stormLen: 90 });
runStorm(r, w, 2);
// First poison: the cascade D observed — all four corners let go by
// divergence inside the run, the S16 heal answers it.
r.pos[(r.N + 1) * 3 + 1] = NaN;
runStorm(r, w, 2);
assert(r.corners.every((c) => c.broken),
`first poison broke only ${r.corners.filter((c) => c.broken).length}/4 corners — D's cascade did not happen and this test is not testing the edge`);
assert(heals.length > 0, 'the first divergence never healed — S16 gate 2.1 regressed, this test cannot reach the edge');
const healsAfterCascade = heals.length;
// NEGATIVE CONTROL first: a fully-broken sail with FINITE state rides on
// with zero heals — the sentinel must never mistake "lost" for "sick".
runStorm(r, w, 5);
assert(heals.length === healsAfterCascade,
`${heals.length - healsAfterCascade} heal event(s) on a finite lost sail — the sentinel is touching healthy state`);
// The second poison: before the sentinel, this NaN had no wire to trip —
// no unbroken corner reads a load — and the corpse stayed poisoned forever.
r.pos[(r.N + 1) * 3 + 1] = NaN;
let steps = 0;
const cap = Math.round(1 / SIM_DT);
while (heals.length === healsAfterCascade && steps < cap) {
r.step(SIM_DT, w, 9 + steps * SIM_DT); steps++;
}
assert(heals.length > healsAfterCascade,
'second poison on a fully-broken sail never healed — the re-arm edge is back (_checkFailure has no wire when all four corners are broken)');
for (const v of r.pos) assert(Number.isFinite(v), 'node position still non-finite after the re-armed heal');
for (const v of r.prev) assert(Number.isFinite(v), 'node prev still non-finite after the re-armed heal');
// ...and it HOLDS: a full second of sim later the corpse is still cloth.
runStorm(r, w, 1);
for (const v of r.pos) assert(Number.isFinite(v), 'lost sail went non-finite again inside 1 s — the re-armed heal did not hold');
for (const v of r.water) assert(Number.isFinite(v), 'water non-finite after the re-armed heal');
return `cascade 4/4 → 5 s finite, no heal (control) → second poison healed in ${steps} substep(s), finite 1 s on`;
});
test('ponding: consolidation cannot stack one node past the cap (post-flow clamp)', () => {
// The rain-add clamp only ran WHILE raining; once rain stopped, downhill
// flow could pile a basin node arbitrarily high and nothing trimmed it.

View File

@ -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).

View File

@ -14,10 +14,21 @@
*/
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,
// SPRINT17, the second half of gate 1 (see weather.js's divider): what the
// card PRINTS, and which yard a storm may be printed over.
offerBand, PAIRING_LAW, pairingRefusal, pairingRefusals,
} 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, nightAt } 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 +1295,306 @@ 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`);
}
});
t.test('GATE 1 (S17): the offer band carries every fact the storm delivers, and none it does not', () => {
// ⚠️ THE FINDING THIS CASE EXISTS FOR. A's board prints `f.wind` and
// `f.stones` — a reasonable pair — and under that pair NIGHT 7 LIES:
// soaker (scripted) gusts to ~55 km/h · fine pea stones
// early buster (alt) gusts to ~76 km/h · pea stones
// On both printed lines the soaker is the softer job, while delivering
// 14.9s of hail against 2.4s and 44 mm/hr against 16.5. `offerBand` returns
// the ORDERED LIST instead, so the forecast decides which facts a night
// carries and a card cannot drop the next one somebody adds.
for (const nm of offerStormNames) {
const def = offerDefs[nm];
const st = stormStats(def);
const lines = offerBand(def, 0);
const keys = lines.map((l) => l.key);
const text = (k) => lines.find((l) => l.key === k)?.text ?? '';
assert(keys.includes('wind'), `${nm}: no wind line on the offer band`);
assert(keys.includes('rain'), `${nm}: no rain-rate line on the offer band`);
assert(keys.includes('change'), `${nm}: no change line on the offer band`);
assert(!keys.includes('confidence'),
`${nm}: tonight's band hedged its own confidence — lead 0 is exact`);
// HAIL, BOTH DIRECTIONS — the assert night 7 needed. Present iff it hails:
// a missing line recommends the trap, an invented one teaches that the
// absence of a hail line means somebody checked.
assert(keys.includes('hail') === (st.hailSeconds > 0),
`${nm}: hails for ${st.hailSeconds.toFixed(1)}s but the band's hail line is `
+ `${keys.includes('hail') ? 'present' : 'MISSING'}`);
if (st.hailSeconds > 0) {
assert(/fine pea|pea|marble|golf ball/.test(text('hail')),
`${nm}: hail line has no stone word — "${text('hail')}"`);
assert(/\ds of it/.test(text('hail')),
`${nm}: hail line does not say how long it hails — "${text('hail')}"`);
}
// The rate is the OTHER half of the soaker's trap, and the unit ponding
// reads — its absence is what made the two-field card lie.
assert(new RegExp(`${Math.round(st.rainPeakMmPerHour)} mm/hr`).test(text('rain')),
`${nm}: peaks at ${st.rainPeakMmPerHour.toFixed(1)} mm/hr, band says "${text('rain')}"`);
if (st.changeAt != null) {
assert(text('change').includes(`${Math.round(st.changeAt)}s`),
`${nm}: changes at ${st.changeAt}s, band says "${text('change')}"`);
} else {
assert(!/\d/.test(text('change')), `${nm}: never changes, band says "${text('change')}"`);
}
for (const l of lines) {
assert(!/NaN|Infinity|undefined/.test(l.text),
`${nm}: the ${l.key} line does not word its own numbers — "${l.text}"`);
}
}
});
t.test('GATE 1 (S17): the two buster variants are TELLABLE APART on a board card', () => {
// storm_03 and storm_03b are IDENTICAL in every number the band prints
// (sustained 13.0, gustPeak 21.4/21.2, rain 16.5 mm/hr, hail 2.4s at size
// 0.70) and differ ONLY in when the change lands, 30s against 18s. The
// board offers exactly that pair — the buster is the drawn alternative on
// nights 5 and 7. Drop the change line and the board offers two jobs whose
// weather it has just claimed to describe, described identically, and calls
// it a choice. That is why the band is a list and not two named fields.
const a = offerBand(storms.storm_03_southerly, 0).map((l) => l.text);
const b = offerBand(storms.storm_03b_earlybuster, 0).map((l) => l.text);
const differing = a.filter((line, ix) => line !== b[ix]);
assert(differing.length > 0,
'storm_03 and storm_03b word IDENTICAL offer bands — the board would offer two '
+ 'indistinguishable jobs and call it a choice');
assert(differing.some((l) => /change/.test(l)),
`the two busters differ on ${differing.join(' / ')} — expected the CHANGE TIME, since `
+ 'that is the only thing that differs in the storms');
});
// ── The PAIRING half of gate 1. `forecastHonest` above asks whether the
// forecast can describe a STORM; a band can be perfectly honest about a storm
// and still be an invitation to a night nobody can work. NIGHTS POOL again,
// this time carrying the SITE — the dimension the storm-side walk cannot see.
const offerPairings = [
...NIGHTS.map((_, ix) => {
const n = nightAt(ix);
return { id: `night ${ix + 1}`, storm: n.storm, site: n.site };
}),
...POOL.map((p) => ({ id: `pool:${p.id}`, storm: p.storm, site: p.site })),
];
t.test('GATE 1 (S17): every offerable PAIRING respects the measured yard/storm law', () => {
assert(offerPairings.length >= 12,
`only ${offerPairings.length} pairings found — the NIGHTS POOL walk is broken`);
const refused = pairingRefusals(offerPairings);
assert(refused.length === 0,
`the board would offer ${refused.length} pairing(s) refused by measurement:\n `
+ refused.map((r) => `${r.id}: ${r.why}`).join('\n '));
// Vacuity guard, on this exact input: without it the assert above passes on
// a law that returns [] unconditionally — the failure mode I shipped in S13
// and named. Move ONE night's yard and exactly one refusal must appear.
const moved = offerPairings.map((p) =>
(p.storm === 'storm_06_soaker' ? { ...p, site: 'backyard_01' } : p));
assert(pairingRefusals(moved).length >= 1,
'moving the soaker onto backyard_01 did not trip the law — the assert above is '
+ 'passing on a gate that cannot say no');
});
t.test('GATE 1 (S17): the pairing law refuses what it was measured to refuse, and nothing else', () => {
// MINE, with the S16 receipt in the message: the soaker over backyard_01
// has no win in it (hail cover capped at ~31%, both fabrics pond-tearing).
assert(pairingRefusal('storm_06_soaker', 'backyard_01') !== null,
'the soaker over the backyard is offerable — the ~31% hail-cover measurement has '
+ 'stopped being enforced');
assert(/31%/.test(pairingRefusal('storm_06_soaker', 'backyard_01')),
'the refusal does not carry the measurement that justifies it');
assert(pairingRefusal('storm_06_soaker', 'site_03_swing_lawn') !== null,
'the soaker over the swing lawn is offerable, and nobody has ever flown it');
assert(pairingRefusal('storm_06_soaker', 'site_02_corner_block') === null,
'the soaker over site_02 is REFUSED — that is the pairing it shipped for');
// A's two, enforced the same way and credited to A in the data.
assert(pairingRefusal('storm_02_wildnight', 'site_02_corner_block') !== null,
'the wild night is offerable off backyard_01, where its separation pin lives');
assert(pairingRefusal('storm_02b_icenight', 'site_03_swing_lawn') !== null,
'the ice night is offerable off backyard_01, where gardenBeyondSaving was measured');
assert(PAIRING_LAW.storm_02_wildnight.owner === 'A' && PAIRING_LAW.storm_06_soaker.owner === 'C',
'the law has lost track of who ruled which entry — the owner field is how a reader '
+ 'knows whose measurement to go and read');
// And it must NOT over-refuse: absence of a measurement is not a refusal.
assert(pairingRefusal('storm_03_southerly', 'site_03_swing_lawn') === null,
'the southerly over the swing lawn is refused — that is night 4, the shipped ladder');
assert(pairingRefusal('storm_99_unmeasured', 'anywhere') === null,
'a storm with no entry in the law was refused — inventing a refusal for a pairing '
+ 'nobody flew is the same offence in the other direction');
});
// ── 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, S11S14, 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');
});
}

View File

@ -24,6 +24,13 @@ import { loadStorm, createWind } from '../weather.js';
// file read the ladder. week.js is contracts-only underneath, so this stays
// headless-safe.
import { NIGHTS, nightAt } from '../week.js';
// SPRINT17 gate 3.2: the pool-yard pins at the bottom read the board's POOL
// (pure data, zero THREE) and loadSite (fetch + validateSite — the teeth; a
// site file this suite merely fetch()ed raw could rot invalid and stay green).
// loadSite pulls world.js which pulls THREE — browser-only, same as the GLB
// facts everywhere else in this repo; the node runner never imports this file.
import { POOL, exposureOf } from '../board.js';
import { loadSite } from '../world.js';
const DT = FIXED_DT;
@ -1229,4 +1236,96 @@ export default async function run(t) {
"the wildnight sits directly before the icenight — the icenight's brief says 'less wind than "
+ "last night', and the paper never lies: re-order these and re-word that brief in the same commit");
});
// ── SPRINT17 gate 3.2: THE POOL YARD — authored cold, entered through the ──
// board, pinned by its author. These pins carry the yard's three claims:
// it is CHOSEN or absent (never scripted), its fence is fourteen honest noes
// and its rust is one priced yes, and its refusal + wreck apron are facts
// with numbers, not vibes. The site file itself goes through loadSite here,
// so a hand-edit that breaks validateSite goes red in THIS suite too, not
// only at boot.
let poolSite = null, poolSiteErr = null;
try { poolSite = await loadSite('site_04_pool_yard'); }
catch (err) { poolSiteErr = String((err && err.stack) || err); }
t.test('pool yard (S17 3.2): in the game ONLY through the board — chosen, never scripted', () => {
const entry = POOL.find((p) => p.site === 'site_04_pool_yard');
assert(entry, 'the pool yard has a POOL entry — its one door into the game');
assertEq(entry.storm, 'storm_03_southerly',
'it flies the southerly — the storm nights 2 and 4 teach, so the yard is the only new variable; '
+ 'any other pairing needs its own gauntlet run FIRST (the _why receipt describes this one)');
assert(entry._why && entry._why.includes('audit'),
'the entry carries its audit receipt — an unmeasured pairing is a night the board is guessing about');
const scripted = NIGHTS.map((_, i) => nightAt(i));
assert(!scripted.some((n) => n.site === 'site_04_pool_yard'),
'NO scripted night flies the pool yard — "the first yard whose only route in is being chosen" '
+ 'is the arc-2 promise, and a week.js slot would quietly break it');
});
t.test('pool yard (S17 3.2): the ring says no fourteen times, the rust says yes once', () => {
if (poolSiteErr) throw new Error(`site load died: ${poolSiteErr}`);
const ring = poolSite.structures.find((s) => s.model === 'pool_kit_01_v1');
assert(ring, 'the pool ring is in the yard — it IS the thesis');
assertEq((ring.anchors ?? []).length, 0,
'the ring adopts NOTHING in site data — its fourteen noes are baked tie_off:false in the GLB, '
+ 'and a site-level anchor here would override a certified fence into steel');
assert(ring.collateralValue == null,
'the ring is unpriced BY RULING (the bike rule) — nothing can bend a pool fence yet');
const rust = poolSite.structures.find((s) => s.model === 'sail_post_corroded_v1');
assert(rust, 'the corroded post stands');
assertEq((rust.anchors ?? []).length, 1, 'exactly one adoptable eye');
assertEq(rust.anchors[0].type, 'corroded_post', 'typed to E\'s tier so it rates 0.55, not 1.00');
assertEq(rust.collateralValue, 45, 'the make-safe bill, E\'s number, adopted with its reasoning');
assert(rust.wreckedModel === 'sail_post_corroded_wrecked_v1',
'the wreck variant is wired — a trap that vanishes instead of falling is a free failure');
const rusts = poolSite.structures.filter((s) => s.model === 'sail_post_corroded_v1');
assertEq(rusts.length, 1,
'ONE corroded post on purpose: two would share the baked collateral key and the second failure '
+ 'would bill/wreck the wrong one (structFor matches first; filed in THREADS [D] S17)');
});
t.test('pool yard (S17 3.2): separation-JUDGED — refused with receipts, XOR the pin', () => {
if (poolSiteErr) throw new Error(`site load died: ${poolSiteErr}`);
const pinned = !!poolSite.separation, finding = !!poolSite._separation_finding;
assert(pinned || finding, 'the yard is JUDGED — silence is not an option (S16 gate 2.3)');
assert(!(pinned && finding), 'both at once means the finding went stale in the pinning commit');
assert(finding, 'today it is a REFUSAL: bare wins the southerly (mild-night canon, A\'s 0.4 ruling)');
const text = poolSite._separation_finding.join(' ');
assert(text.includes('83.7') && text.includes('92.7'),
'the refusal carries its measured receipts (bare 83.7 / best flown 92.7) — a finding without '
+ 'numbers is a shrug wearing a ruling\'s clothes');
});
t.test('pool yard (S17 3.2): the wreck apron threads the needle between bed and ring', () => {
if (poolSiteErr) throw new Error(`site load died: ${poolSiteErr}`);
// E's placement facts (THREADS S17 gate 3.1): the corroded post falls +Z
// ~3.9 m, shaft half-width ~0.26 m. The fallen shaft must land in the path
// — clear of the bed's east edge AND the ring's west run — or the wreck
// interpenetrates client property the moment the trap fires.
const rust = poolSite.structures.find((s) => s.model === 'sail_post_corroded_v1');
const ring = poolSite.structures.find((s) => s.model === 'pool_kit_01_v1');
const bedEast = poolSite.gardenBed.x + poolSite.gardenBed.w / 2;
const shaftWest = rust.x - 0.26, shaftEast = rust.x + 0.26;
const ringWest = ring.x - 2.8; // ring proper 5.6 wide (E's measured bbox)
assert(bedEast <= shaftWest,
`bed east edge ${bedEast} overlaps the fallen shaft (west ${shaftWest.toFixed(2)}) — move c1 or the bed`);
assert(shaftEast <= ringWest,
`fallen shaft east ${shaftEast.toFixed(2)} reaches the ring (west ${ringWest.toFixed(2)}) — move c1 or the pool`);
const apronTip = rust.z + 3.9;
assert(apronTip <= poolSite.yard.depth / 2,
`the shaft falls past the south fence (tip z ${apronTip}) — it must land IN the yard, that is the point`);
});
t.test('pool yard (S17 3.2): the offer card prices the rust and never the fence', () => {
if (poolSiteErr) throw new Error(`site load died: ${poolSiteErr}`);
const { total, items } = exposureOf(poolSite);
assertEq(total, 160, 'exposure $160 = gutter 90 + corroded post 45 + gnome 25');
const labels = items.map((i) => i.what).sort().join(' · ');
assertEq(labels, 'garden gnome · the corroded post · the gutter',
'three priced items exactly — the fourteen fence posts are deliberately NOT exposure: '
+ 'nothing can bend a pool fence yet (the bike rule), and a $0 line item would teach "free"');
const rustItem = items.find((i) => i.what === 'the corroded post');
assertEq(rustItem.cost, 45,
'the rust is the cheapest structure you can break — the stake E sized the gamble in');
});
}

View File

@ -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,272 @@ 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,·), width0 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 };
}
// ─────────────────────────────────────────────────────────────────────────────
// THE OFFER BAND, and THE PAIRING HALF OF THE GATE (SPRINT17 gate 1)
//
// Landed ON TOP of forecastHonest above, by the other half of Lane C this
// sprint — two sessions converged on gate 1 and split it cleanly without
// meaning to. `forecastHonest` answers "can the forecast describe this STORM",
// which is the storm-side gate and stays exactly as it landed. These two add
// the parts it does not reach: WHAT THE CARD ACTUALLY PRINTS, and WHICH YARD
// the storm may be printed over. Nothing above this line was changed.
// ─────────────────────────────────────────────────────────────────────────────
/**
* THE HONEST BAND FOR AN OFFER CARD an ORDERED LIST of lines, not a bag of
* named fields, and the shape is the whole point.
*
* **WHY A LIST, when `forecastLines` already returns every string a card
* could want.** A card that reads named fields prints the fields its author
* remembered. A's board shipped `f.wind` and `f.stones` a reasonable pair
* and that pair MEASURABLY LIES ABOUT NIGHT 7:
*
* | night 7's two offers | wind line | stones line |
* |---|---|---|
* | the soaker (scripted) | gusts to ~55 km/h | fine pea stones |
* | the early buster (alt) | gusts to ~76 km/h | pea stones |
*
* On both printed lines the soaker is the SOFTER job. It is not: it delivers
* **14.9 s of hail against 2.4 s, and 44 mm/hr of rain against 16.5**. The
* soaker's whole design is that its wind reads gentle while its hail is the
* trap (C, S16 gate 3.1) so a board printing only wind and stone size
* recommends the trap, in the storm's own voice, before a dollar is spent.
*
* The fix is not "also print rainRate". The fix is that the FORECAST decides
* which facts a night carries and hands over all of them, so the next fact
* added to a storm cannot be silently dropped by a card that predates it. Same
* construction, and the same reasoning, as A's `stormsToPreload(nights, pool)`
* split: an omission that is possible will eventually happen.
*
* **It never renders MORE than the truth either.** A hail line appears if and
* only if the storm hails "hail: none" on a hail-free night teaches that the
* absence of a hail line means nobody checked.
*
* @param {object} def parsed storm JSON
* @param {number} [lead] 0..1 the BOARD passes 0, and only 0: both offers are
* for TONIGHT. You are choosing which yard to stand in, not which night to
* work, and a hedged board would invent uncertainty the job sheet contradicts
* one card later.
* @returns {{key:string, text:string}[]} reading order: wind, hail, rain,
* change, confidence. `key` is for styling; `text` is the whole printed line.
*/
export function offerBand(def, lead = 0) {
const f = forecastLines(def, lead);
const ff = forecastFor(def, lead);
const t = f.truth;
const i = (v) => v.toFixed(0);
const lines = [{ key: 'wind', text: f.wind }];
// Hail, with its DURATION. `chance` at lead 0 is a duration threshold wearing
// a probability word (hailSeconds > 6 ⇒ 'likely'), which is honest enough on
// a job sheet and far too coarse on a board where two hail nights sit side by
// side: 'likely' covers both the soaker's 15 s and the ice night's 21 s, and
// 'possible' covers the buster's 2.4 s. The seconds are what a tradie buys
// steel against, so the seconds print.
if (f.stones) {
lines.push({
key: 'hail',
text: `hail ${f.hail.chance} · ${f.stones} · ${bandStr(ff.hail.seconds, i)}s of it`,
});
}
// The rain RATE, always — the line A's card had no field for, and half of the
// soaker's trap. mm/hr is the unit the ponding model reads, so the card and
// the sim agree on what "heavy" means instead of each having an opinion.
lines.push({ key: 'rain', text: f.rainRate });
// The change, always — including "no change forecast", which is information,
// not filler. storm_03_southerly and storm_03b_earlybuster are IDENTICAL in
// every other number this band prints (sustained 13.0, gustPeak 21.4/21.2,
// rain 16.5 mm/hr, hail 2.4 s at size 0.70) and differ ONLY in when the change
// lands, 30 s against 18 s. The board offers exactly that pair — the buster is
// the drawn alternative on nights 5 and 7. Drop this line and the board offers
// two jobs whose weather it has just claimed to describe, described
// identically, and calls it a choice.
lines.push({
key: 'change',
text: t.changeAt == null ? 'no change forecast'
: (lead > 0 ? `southerly change ${bandStr(ff.changeAt, i)}s`
: `southerly change at ${i(t.changeAt)}s`),
});
if (f.confidence) lines.push({ key: 'confidence', text: f.confidence });
return lines;
}
/** `band` under its own name — the module-scope helper, reused rather than retyped. */
function bandStr(b, fmt) { return band(b, fmt); }
/**
* **THE PAIRING LAW which yards a storm may be OFFERED over.**
*
* `forecastHonest` above asks whether the forecast can describe a STORM. This
* asks the other half of SPRINT17 gate 1's rule, the half that is a fact about
* GEOMETRY × STORM rather than about wording: a band can be perfectly honest
* about a storm and still be an invitation to a night nobody can work.
*
* **These three were three prose comments before they were a rule.** A's POOL
* carries them as a comment listing what is "deliberately NOT here" and this
* repo's standing verdict on that arrangement is A's own, on CONSTRAINT_KIND:
* an unenforced enum is decoration, and the carport shipped typed as a post for
* a sprint behind a JSDoc comment saying it couldn't. A comment cannot fail when
* somebody adds the sixth pool entry. Promoted to data, checked, red in the suite.
*
* **A two of these three are YOUR reasons and are recorded as yours.** I have
* not re-measured a separation pin or a beyond-saving flag; I have moved your
* stated grounds into a structure that goes red. Reword freely the mechanism
* is what I am defending, not the prose.
*
* An absent key means UNRESTRICTED, deliberately: this table records refusals
* somebody PAID FOR, and inventing one for a pairing nobody flew is the same
* offence in the other direction.
*/
export const PAIRING_LAW = Object.freeze({
storm_06_soaker: {
sites: ['site_02_corner_block'],
owner: 'C',
why: 'MEASURED (C, S16 gate 3.1, both shipped yards flown through gardenfly before the '
+ 'storm shipped): backyard_01\'s buyable geometry caps hail cover over the bed at '
+ '~31%, and its full-cover quads pond-tear at 3.33.7 kN in this mild wind on BOTH '
+ 'fabrics — best membrane line 37.5 against cloth 17.3, a fabric bet with no win in '
+ 'it. The soaker\'s forecast reads "fine pea stones · rain to 44 mm/hr" over there '
+ 'exactly as it does over site_02, and over there that band is an invitation to a '
+ 'night nobody can work. The swing lawn is not measured under it at all, which is '
+ 'the same refusal for a cheaper reason.',
},
storm_02_wildnight: {
sites: ['backyard_01'],
owner: 'A',
why: 'A (S17 gate 1, POOL): its separation is PINNED to backyard_01\'s site data. Fly it '
+ 'elsewhere and the pin describes a night nobody plays.',
},
storm_02b_icenight: {
sites: ['backyard_01'],
owner: 'A',
why: 'A (S17 gate 1, POOL), on A\'s S13 measurement: `gardenBeyondSaving` is a fact about '
+ 'THAT BED under that ice, not a property of the storm. Offered over another yard the '
+ 'night would carry the excuse without the evidence.',
},
});
/**
* May this storm be offered over this yard?
* @returns {string|null} the refusal in English, or null when the pairing is offerable
*/
export function pairingRefusal(stormKey, siteKey) {
const law = PAIRING_LAW[stormKey];
if (!law || !law.sites) return null;
if (law.sites.includes(siteKey)) return null;
return `${stormKey} over ${siteKey}: not an offerable pairing — ${law.why} `
+ `(offerable: ${law.sites.join(', ')}; ruled by ${law.owner})`;
}
/**
* Every pairing refusal in a list of candidate nights, as data.
*
* Deliberately NOT a filter. Silently dropping a bad entry leaves a morning
* with one offer and no explanation a runtime surprise wearing a fix's
* clothes. The caller (c.test today; a `board.js` load-time assert the day A
* wants one) decides how loud to be, and gets the measurement in the message.
*
* @param {Array<{id?:string, storm:string, site:string}>} pairings
* @returns {{id:string, why:string}[]} empty = every pairing is offerable
*/
export function pairingRefusals(pairings = []) {
const out = [];
for (const p of pairings) {
const why = pairingRefusal(p.storm, p.site);
if (why) out.push({ id: p.id ?? `${p.storm}×${p.site}`, why });
}
return out;
}
// 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.

View File

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