Compare commits

...

19 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
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
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
type-two
6e0608a6fb Lane E S17 gate 3.1: THE CORRODED TIER — mutations restored, tell verified at 20 m, droop pinned to geometry
Recovers an interrupted session and finishes its verification honestly.

RESTORED (the predecessor was killed mid-mutation-check):
- build_sail_post_corroded still had negative control M1 APPLIED — clean
  galvanised steel, DROOP=0 — so both shipped GLBs were the untelled post.
  Restored: steel_weathered/rust/rust_deep, DROOP_DEG = 38.
- asset_report.json held 1 of 37 assets and contact_sheet.png was a 196 KB
  truncated write. Both were interrupted artifacts, not decisions; a clean
  rebuild restores 37 assets and a 5.07 MB 1680x4200 sheet.

DETERMINISM: 37/37 GLBs byte-identical across two consecutive full runs;
report and contact sheet byte-identical too. Zero churn on the 35 pre-existing
GLBs.

ASSERT THAT COULD NOT FAIL, KILLED: the sag pin (honestY - corrodedY > 0.05)
reads 0.060 at ZERO droop, because the eye hangs off a weld line 160 mm down
the post — measured on the M-B build, anchor at (0, 3.920, 0). It measured the
weld offset and called it a sag. Replaced with the droop ANGLE derived from
the exported anchor and pinned to baked extras (padeye_droop_deg / arm_m /
weld_y_threejs / dir_threejs), all stated in three.js coords — the axis trap
closed the swing_set way, so note and mesh can only lie together.

ASSERT WHOSE REASON WAS BACKWARDS, FIXED: the 20 m pin claimed the corroded
shaft read darker. Measured off look.html's canvas at 20 m: honest lum 50,
corroded lum 94 — it renders twice as BRIGHT. Base colour is darker but
metalness drives the pixels (gal 0.90/0.35 goes near-black with no env map;
weathered 0.55/0.60 is diffuse). Test now pins both halves and names the
mechanism the player actually sees.

Three tells split into three tests so one failure cannot mask the others —
pre-split, the M-A mutation went red on rust area and never reached the
lightness or droop asserts at all.

Mutation-checked in the browser, all three witnessed red: M-A clean steel
(1/3 + 2/3 red, 3/3 green, factory GREEN throughout — the trap it cannot see,
confirmed); M-B flat eye (3/3 red, others green); M-C baked note lies while
mesh is honest (3/3 red, proving the pin reads the extra, not a literal).

Selftest 483/0/0 (474 baseline + 9 exactly). Tell verified by looking at
20 m at the game's own camera height, screenshots taken.

editor.js untouched — palette hunk filed verbatim in THREADS for A.
contracts.js ANCHOR_TYPE widening flagged for A.
Lane D flagged: the tier is placeable, gate 3.2 unblocked.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 01:51:57 +10:00
type-two
a180fce8c3 WIP Lane E S17 gate 3.1: corroded tier (PREDECESSOR STATE — negative control M1 still applied)
Preserved verbatim from the interrupted session. NOTE: build_sail_post_corroded
still carries the M1 mutation (clean galvanised steel, DROOP=0), so the two GLBs
in this commit are the UNTELLED post, not the shipped asset. Restored in the
next commit after the red-check is witnessed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 01:18:44 +10:00
20 changed files with 3828 additions and 34 deletions

1050
THREADS.md

File diff suppressed because it is too large Load Diff

View File

@ -906,6 +906,53 @@
], ],
"status": "PASS", "status": "PASS",
"problems": [] "problems": []
},
{
"name": "sail_post_corroded",
"dims": [
0.507,
0.52,
4.0516
],
"tris": 816,
"nodes": [
"footing",
"pad_eye",
"post",
"rake_pivot",
"rust",
"sail_post_corroded",
"top_anchor"
],
"anchors": [
{
"node": "top_anchor",
"anchor_type": "corroded_post",
"rating_hint": 0.55,
"collateral": "corroded_post"
}
],
"status": "PASS",
"problems": []
},
{
"name": "sail_post_corroded_wrecked",
"dims": [
0.7786,
4.0784,
0.7015
],
"tris": 836,
"nodes": [
"footing",
"pad_eye_down",
"post_down",
"post_stub",
"sail_post_corroded_wrecked"
],
"anchors": [],
"status": "PASS",
"problems": []
} }
], ],
"debris": [] "debris": []

View File

@ -137,6 +137,14 @@ PAL = {
"bike_kid": "#D8483C", # the Henderson kid's bike — bought bright on purpose "bike_kid": "#D8483C", # the Henderson kid's bike — bought bright on purpose
"bike_grip": "#3B4048", # grips and saddle "bike_grip": "#3B4048", # grips and saddle
"ref_pink": "#E85C8A", # the reference capsule — deliberately loud "ref_pink": "#E85C8A", # the reference capsule — deliberately loud
"steel_weathered": "#8D8A7E", # gal gone matte: the corroded post's shaft.
# Next to steel_gal (#B6BCC2) it reads DIRTY at
# 20 m before you can see a single bloom — the
# colour is the long-range half of the tell.
"rust_bloom": "#9C5B33", # active surface rust — orange enough to read
# against every steel/timber in the palette
"rust_deep": "#67351F", # the worst of it: fold lines, weld collars,
# streak stains — near-brown, high saturation
"alu_frame": "#C9CED2", # powder-coated aluminium — glasshouse + pool fence "alu_frame": "#C9CED2", # powder-coated aluminium — glasshouse + pool fence
"pool_water": "#4FA8BE", # chlorinated blue, NOT the pond's grey-green — "pool_water": "#4FA8BE", # chlorinated blue, NOT the pond's grey-green —
# a pool is the one water in the game that is # a pool is the one water in the game that is
@ -2256,6 +2264,284 @@ def build_sail_post(name):
return root return root
# ---------------------------------------------------------------------------
# THE CORRODED TIER (SPRINT17 gate 3.1 — 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 tie-off that DOES exist and shouldn't be trusted:
# a real sail post, real concrete footing, real pad eye — gone rusty.
#
# WHY 0.55, argued on the ladder it joins (0.22 carport < 0.30 carport_post <
# 0.35 fascia < 0.45 swing_frame < [0.55] < 0.65 pergola < 1.00 post):
# · ABOVE the swing frame (0.45): the footing is still 300 mm of concrete
# and the load path still reaches the ground. A sound welded frame
# standing loose on grass drags; a rusty post in a real footing holds
# until its steel gives — corroded anchored beats sound unanchored.
# · BELOW the pergola (0.65): the pergola's members are SOUND and its
# weakness (deck flex) is visible and bounded. Corrosion's weakness is
# section loss you cannot see the bottom of — the bloom on the outside is
# pitting on the inside, so the visible rust is a FLOOR on the damage,
# not a ceiling. You de-rate below sound timber because you're rating
# what you can't inspect.
# · And it FILLS the 0.45→0.65 gap: rungs create decisions (D's S15
# endorsement logic, the reason the pergola exists). With this rung the
# ladder steps 0.45 / 0.55 / 0.65 — three different kinds of "not quite":
# unanchored steel, eaten steel, flexing timber.
CORRODED_POST_RATING = 0.55
# The bill when it snaps. Proposal — Lane A confirms (my S17 prompt says rule
# it and let A confirm): a post IS the client's property, so a snapped one
# bills — "it was already scrap" is a warranty argument, not a free pass. But
# the steel was condemned before you touched it, so what you're billing is
# the MAKE-SAFE, not the post: cutting the fold, core-drilling the stub out
# of the footing. That's under the gutter's $90 (a full trade's morning run)
# and above the gnome's $25. Cheap ON PURPOSE: the real price of trusting
# corroded steel is the corner you lose mid-storm — the trap costs you in
# sail HP and garden exposure, and the invoice line is honest small change
# beside it. If the bill were big, the lesson would read "don't touch rust";
# at $45 it reads "rust is a bet, and here's the stake".
CORRODED_POST_COLLATERAL = 45
def build_sail_post_corroded(name):
"""The corroded sail-post variant — same bones as build_sail_post (H, R,
footing, rake_pivot mechanism all identical, so it rakes and places like
any post), different truth.
THE TELL, designed to read at two ranges (the bike lesson: LOOK at it,
and look from where the player actually stands):
· 20 m / colour: the whole shaft is weathered matte (steel_weathered)
against the honest post's bright galvanised, the footing collar wears
a rust stain skirt, and the cap sits askew. You don't see rust from
the back fence you see a DIRTY post next to clean ones.
· 3 m / detail: rust blooms where posts actually rust (the base band
where water pools on the collar, the head collar around the pad-eye
weld), deep-rust fold line just above the footing, streaks bleeding
down the shaft, and the pad eye SAGS the weld ate through and the
eye drooped toward the yard. The anchor empty sits at the DROOPED
eye, so the sag is a measured fact, not set dressing: e.test derives
the droop ANGLE back out of the exported anchor position and pins it
to `padeye_droop_deg` below. (Pinning "the corroded anchor is lower
than the honest one" would NOT have worked — the eye hangs off a weld
line 160 mm down the post, so that test reads 0.06 m and passes at
zero droop. Measure the angle, not the altitude.)
Corrosion a player can't see is a trap with no tell, and this repo
doesn't ship those — the three tells are pinned as three SEPARATE tests
in e.test.js (rust area, shaft lightness, eye droop) so that one failure
can't mask the other two. Rebuild this in clean steel at the same 0.55
hint and all three go red; flatten only the eye and only the third does.
"""
root = add_empty(name)
steel = get_material("Mat_SteelWeathered", PAL["steel_weathered"], 0.6,
metallic=0.55)
dark = get_material("Mat_SteelDark", PAL["steel_dark"], 0.45, metallic=0.8)
rust = get_material("Mat_Rust", PAL["rust_bloom"], 0.9, metallic=0.1)
rust_d = get_material("Mat_RustDeep", PAL["rust_deep"], 0.95, metallic=0.05)
conc = get_material("Mat_Concrete", PAL["concrete"], 0.95)
H, R = 4.0, 0.048 # same post the honest one is — that's the trap
DROOP_DEG = 38.0 # pad-eye sag off vertical. 38° is slumped
# past argument but still recognisably an eye
# you COULD clip to — which is the whole trap.
DROOP = math.radians(DROOP_DEG)
EYE_ARM = 0.08 # eye centre's distance from the weld line
WELD_Z = H - 0.16 # the slumped weld line the eye hinges at
# Footing identical to the honest post's, plus the stain skirt: rust
# bleeds onto the collar top, so even the GROUND LINE reads brown.
join_group([
add_cyl(f"{name}_collar", 0.26, 0.14, (0, 0, 0.05), conc, verts=14),
add_cyl(f"{name}_collar_top", 0.22, 0.04, (0, 0, 0.13), conc, verts=14),
add_cyl(f"{name}_stain", 0.23, 0.012, (0, 0, 0.148), rust_d, verts=14),
], "footing", root)
# Same rake mechanism as build_sail_post — rake is a runtime decision and
# a corroded post rakes like any other; the lean you might be tempted to
# bake here would double-apply the moment a player rakes it.
rake = add_empty("rake_pivot", (0, 0, 0.12), root, size=0.25)
rake["rake_axis"] = "x/z — rake AWAY from the load (DESIGN.md)"
rake["rake_default_deg"] = 8
above = []
above.append(join_group([
add_cyl(f"{name}_shaft", R, H, (0, 0, H / 2), steel, verts=12),
add_cyl(f"{name}_base_plate", 0.11, 0.02, (0, 0, 0.13), dark, verts=12),
# The cap sits askew — rust let the friction fit go. Silhouette tell.
add_cyl(f"{name}_cap", R * 1.2, 0.02, (0, 0, H + 0.008), dark, verts=12,
rot=(math.radians(14), 0, 0)),
], "post"))
# The corrosion itself, its own node so the mutation control can kill it
# cleanly: blooms slightly proud of the shaft (rust swells), streaks
# bleeding DOWN from each bloom. Positions are hand-placed, not rng — the
# blooms sit where posts actually rust, and determinism costs nothing.
corrosion = [
add_cyl(f"{name}_rust_base", R + 0.004, 0.34, (0, 0, 0.34), rust, verts=12),
add_cyl(f"{name}_rust_line", R + 0.007, 0.11, (0, 0, 0.185), rust_d, verts=12),
add_cyl(f"{name}_rust_head", R + 0.004, 0.26, (0, 0, H - 0.16), rust, verts=12),
add_cyl(f"{name}_rust_weld", R + 0.006, 0.06, (0, 0, WELD_Z), rust_d, verts=12),
]
for i, (ang_deg, length, z_top) in enumerate([
(200, 1.45, H - 0.24), # off the weld collar, yard side (-y)
(335, 0.95, H - 0.30),
(25, 0.70, H - 0.35),
(160, 0.80, 0.95), # wicking up off the base band
(300, 0.60, 0.85)]):
a = math.radians(ang_deg)
corrosion.append(add_box(
f"{name}_streak_{i}", (0.016, 0.016, length),
(math.cos(a) * (R + 0.003), math.sin(a) * (R + 0.003),
z_top - length / 2), rust_d, rot=(0, 0, a)))
above.append(join_group(corrosion, "rust"))
# The sagging pad eye: plate and ring both pitched DROOP about the weld
# line, hanging toward -y (three.js +Z, the yard side — same face the
# pergola presents). Built in the tilted plane directly, no post-rotation.
ring_c = (0, -math.sin(DROOP) * EYE_ARM,
WELD_Z + math.cos(DROOP) * EYE_ARM)
eye_parts = [add_box(f"{name}_padeye", (0.012, 0.07, 0.09),
(0, -math.sin(DROOP) * EYE_ARM / 2,
WELD_Z + math.cos(DROOP) * EYE_ARM / 2), rust_d,
rot=(-DROOP, 0, 0))]
r_eye, segs = 0.026, 10
pts = []
for i in range(segs + 1):
t = math.tau * i / segs
pts.append((math.cos(t) * r_eye,
ring_c[1] - math.sin(t) * r_eye * math.sin(DROOP),
ring_c[2] + math.sin(t) * r_eye * math.cos(DROOP)))
for i in range(segs):
eye_parts.append(add_tube_between(f"{name}_eye_s{i}", pts[i], pts[i + 1],
0.008, rust, verts=8))
above.append(join_group(eye_parts, "pad_eye"))
# The anchor sits AT the drooped eye — the sag is where you'd clip on,
# so the sag is data the sim sees, not paint.
e = add_empty("top_anchor", ring_c, size=0.2)
e["anchor_type"] = "corroded_post"
e["rating_hint"] = CORRODED_POST_RATING
e["collateral"] = "corroded_post"
e["why"] = ("a real post in a real footing, eaten: above the swing frame "
"(0.45) because concrete beats loose-on-grass, below the "
"pergola (0.65) because the bloom outside is pitting inside — "
"you de-rate what you can't inspect. The rung fills 0.45→0.65; "
"rungs create decisions")
above.append(e)
for o in above:
parent_keep_transform(o, rake)
stamp(root, name, "structure")
root["post_height"] = H
root["rake_note"] = "rotate about rake_pivot; rake away from the load"
root["collateral_key"] = "corroded_post"
root["collateral_value"] = CORRODED_POST_COLLATERAL
root["collateral_label"] = "the corroded post"
root["breakable"] = True
root["corrosion_note"] = ("the tell is load-bearing: e.test pins rust "
"area, shaft dullness and the eye droop — a "
"clean-steel rebuild at the same hint goes red")
# THE AXIS TRAP (E's swing_set precedent, fourth application). The droop
# is an ORIENTATION CLAIM, and orientation claims in this repo are the
# ones that rot: the exporter maps Blender (x,y,z) → three.js (x,z,y), so
# a docstring saying "-y" and a mesh leaning +Z agree only by luck. These
# extras are stated in THREE.JS coords and e.test recomputes them off the
# exported geometry — so note and mesh can only ever lie together.
# Blender ring_c (0, sinD·ARM, WELD_Z+cosD·ARM)
# → three.js (0, WELD_Z+cosD·ARM, +sinD·ARM)
# i.e. the eye hangs toward +Z, the yard side, the face the pergola
# presents — so the sag is pointed at the player who has to judge it.
root["padeye_droop_deg"] = DROOP_DEG
root["padeye_arm_m"] = EYE_ARM
root["padeye_droop_dir_threejs"] = "+Z"
root["padeye_weld_y_threejs"] = WELD_Z # blender z → three.js y
root["padeye_droop_note"] = (
"top_anchor sits EYE_ARM from the weld line, pitched DROOP_DEG off "
"vertical toward three.js +Z (the yard). e.test derives the angle "
"back out of the exported anchor position and pins it to this number: "
"flatten the eye and the note goes red with it")
return root
def build_sail_post_corroded_wrecked(name):
"""The corroded post after a loaded corner won: FOLDED at the deep-rust
line just above the footing (where the intact variant wears its worst
band the wreck fails exactly where the tell said it would), stub
standing plumb with a torn rim, the rest of the post flat on the grass
reaching ~3.8 m into the yard (-y here, three.js +Z), pad eye still
drooped at the far end with nothing left to hold. Same footing, same
numbers, same price as its twin (the carport/gutter chain, application
six). No anchor empty survives an anchor that outlives its structure
is the free-failure bug in a costume (fascia rule, fourth application)."""
root = add_empty(name)
steel = get_material("Mat_SteelWeathered", PAL["steel_weathered"], 0.6,
metallic=0.55)
dark = get_material("Mat_SteelDark", PAL["steel_dark"], 0.45, metallic=0.8)
rust = get_material("Mat_Rust", PAL["rust_bloom"], 0.9, metallic=0.1)
rust_d = get_material("Mat_RustDeep", PAL["rust_deep"], 0.95, metallic=0.05)
conc = get_material("Mat_Concrete", PAL["concrete"], 0.95)
H, R = 4.0, 0.048
FOLD = 0.46 # it let go at the rust line, not the weld:
# the fold is where the section was thinnest
join_group([
add_cyl(f"{name}_collar", 0.26, 0.14, (0, 0, 0.05), conc, verts=14),
add_cyl(f"{name}_collar_top", 0.22, 0.04, (0, 0, 0.13), conc, verts=14),
add_cyl(f"{name}_stain", 0.23, 0.012, (0, 0, 0.148), rust_d, verts=14),
], "footing", root)
# The stub: plumb (the footing held — that half of the 0.55 was honest),
# deep rust at the fold, torn rim leaning the way the post went.
join_group([
add_cyl(f"{name}_stub", R, FOLD, (0, 0, 0.14 + FOLD / 2), steel, verts=12),
add_cyl(f"{name}_base_plate", 0.11, 0.02, (0, 0, 0.13), dark, verts=12),
add_cyl(f"{name}_stub_rust", R + 0.007, 0.14, (0, 0, 0.14 + FOLD - 0.07),
rust_d, verts=12),
add_cone(f"{name}_tear", R + 0.004, 0.012, 0.09,
(0.005, -0.02, 0.14 + FOLD + 0.035), rust_d, verts=10,
rot=(math.radians(18), 0, 0)),
], "post_stub", root)
# The fallen length, resting on its radius, slightly yawed — nothing real
# falls on axis. Blooms ride along it where the intact bands were.
L = H - FOLD
yaw = math.radians(7)
rotv = (math.radians(90), 0, yaw)
def along(s): # a point s metres from the break end, down the fallen shaft
return (-math.sin(yaw) * (0.30 + s), -math.cos(yaw) * (0.30 + s), R)
down = [
add_cyl(f"{name}_shaft_down", R, L, along(L / 2), steel, verts=12, rot=rotv),
add_cyl(f"{name}_down_break", R + 0.007, 0.18, along(0.10), rust_d,
verts=12, rot=rotv),
add_cyl(f"{name}_down_mid", R + 0.004, 0.30, along(L * 0.55), rust,
verts=12, rot=rotv),
add_cyl(f"{name}_down_head", R + 0.004, 0.26, along(L - 0.35), rust,
verts=12, rot=rotv),
add_cyl(f"{name}_down_cap", R * 1.2, 0.02, along(L - 0.01), dark,
verts=12, rot=rotv),
]
join_group(down, "post_down", root)
# The pad eye at the far end, on the grass, still drooped, still rusty —
# the one part of this post that was never going to hold anything again.
ex, ey, _ = along(L - 0.10)
eye = [add_box(f"{name}_padeye_down", (0.012, 0.09, 0.07),
(ex + 0.09, ey, 0.035), rust_d,
rot=(0, math.radians(80), 0)),
add_arc_tube(f"{name}_eye_down", 0.026, 0.008, 0, math.tau, rust,
segs=10, center=(ex + 0.16, ey, 0.012), plane='XY')]
join_group(eye, "pad_eye_down", root)
stamp(root, name, "structure")
root["broken_variant_of"] = "sail_post_corroded"
root["collateral_key"] = "corroded_post"
root["collateral_value"] = CORRODED_POST_COLLATERAL
root["collateral_label"] = "the corroded post"
return root
def build_ladder_01(name): def build_ladder_01(name):
root = add_empty(name) root = add_empty(name)
alu = get_material("Mat_Steel", PAL["steel_gal"], 0.35, metallic=0.85) alu = get_material("Mat_Steel", PAL["steel_gal"], 0.35, metallic=0.85)
@ -3544,6 +3830,19 @@ ASSETS = [
dims=((5.4, 5.9), (4.1, 4.8), (1.12, 1.32)), dims=((5.4, 5.9), (4.1, 4.8), (1.12, 1.32)),
nodes=["pool_shell", "pool_water", "fence_rails", "pool_gate", nodes=["pool_shell", "pool_water", "fence_rails", "pool_gate",
"fence_post_01", "fence_post_14"]), "fence_post_01", "fence_post_14"]),
# SPRINT17 gate 3.1 — the corroded tier. The intact variant must stand
# POST height (its lie is "I'm a sail post", so it stands like one; only
# the colour and the drooped eye say otherwise). The wreck's z ceiling is
# under a metre because the post FOLDED at the rust line — a corroded
# wreck still standing tall is a wreck that never told the truth — and
# its y floor is past 3.5 because the fallen shaft is out in the yard.
dict(name="sail_post_corroded", fn=build_sail_post_corroded,
dims=((0.40, 0.60), (0.40, 0.65), (3.90, 4.15)),
nodes=["footing", "post", "rust", "pad_eye", "top_anchor",
"rake_pivot"]),
dict(name="sail_post_corroded_wrecked", fn=build_sail_post_corroded_wrecked,
dims=((0.40, 0.80), (3.50, 4.40), (0.45, 0.95)),
nodes=["footing", "post_stub", "post_down", "pad_eye_down"]),
] ]

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 MiB

After

Width:  |  Height:  |  Size: 4.8 MiB

View File

@ -128,7 +128,12 @@ export async function buildGardenflyTests() {
let sepStates = null, sepStatesErr = null; let sepStates = null, sepStatesErr = null;
try { try {
sepStates = []; 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); const sd = await loadSite(name);
sepStates.push({ name, pinned: !!sd.separation, finding: !!sd._separation_finding }); sepStates.push({ name, pinned: !!sd.separation, finding: !!sd._separation_finding });
} }
@ -268,7 +273,7 @@ export async function buildGardenflyTests() {
// but silence is not an option, and holding both means the finding went // 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). // stale the day the pin landed and must be deleted (its own text says so).
if (sepStatesErr) throw new Error(`site read died: ${sepStatesErr}`); 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) { for (const s of sepStates) {
assert(s.pinned || s.finding, assert(s.pinned || s.finding,
`${s.name} has neither a separation block nor a _separation_finding — the yard is UNJUDGED; ` + `${s.name} has neither a separation block nor a _separation_finding — the yard is UNJUDGED; ` +

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."
]
}

View File

@ -73,6 +73,13 @@ export const FACTORY_ANCHOR_RATINGS = Object.freeze({
"carport_01/post_anchor_02" "carport_01/post_anchor_02"
] ]
}, },
"corroded_post": {
"collateral": "corroded_post",
"rating_hint": 0.55,
"sources": [
"sail_post_corroded/top_anchor"
]
},
"house": { "house": {
"collateral": "gutter", "collateral": "gutter",
"rating_hint": 0.35, "rating_hint": 0.35,

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

@ -89,9 +89,21 @@ export const PHASES = ['forecast', 'prep', 'storm', 'aftermath'];
* loose-on-grass (0.45), but the deck flexes and the timber cracks before * loose-on-grass (0.45), but the deck flexes and the timber cracks before
* concrete would notice (1.00). One rating for the whole type, per the * concrete would notice (1.00). One rating for the whole type, per the
* manifest rule (THREADS [E] S15): unambiguous types stay node-less-safe. * manifest rule (THREADS [E] S15): unambiguous types stay node-less-safe.
*
* `corroded_post` (SPRINT17 gate 3.1, E): a sail post gone rusty the pool
* yard's "corroded cheap hardware" thesis as an anchor word. NOT a `post`,
* and this time the word isn't about the footing (the footing is fine
* concrete, same as ever): it's about the STEEL. "post" promises sound
* galvanised tube; this is eaten tube whose visible bloom is a floor on the
* damage, not a ceiling. rating_hint 0.55 above the swing frame (footed
* beats loose), below the pergola (sound flexing timber beats steel you
* can't inspect the inside of). One rating for the whole type, per the
* manifest rule. The GLB wears the tell (rust, a sagging pad eye); the type
* string is the player's pre-rig read of the same fact.
*/ */
export const ANCHOR_TYPE = Object.freeze([ export const ANCHOR_TYPE = Object.freeze([
'house', 'tree', 'post', 'carport', 'carport_post', 'swing_frame', 'pergola', 'house', 'tree', 'post', 'carport', 'carport_post', 'swing_frame', 'pergola',
'corroded_post',
]); ]);
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

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 // The collateral apex. NO anchors, on purpose — nothing about a glasshouse
// may adopt (the ridge carries tie_off:false in the GLB). collateralKey is // may adopt (the ridge carries tie_off:false in the GLB). collateralKey is

View File

@ -305,6 +305,49 @@ const CSS = `
} }
/* ---- end of E's kit ----------------------------------------------------- */ /* ---- end of E's kit ----------------------------------------------------- */
/* --- SPRINT17 gate 1: THE JOB BOARD ------------------------------------
A new card in the paper chain, and it has to meet the invoice's standard
so it is built out of the kit rather than beside it: same letterhead, same
.row/.cond rhythm in the offer body, same terms line at the foot.
The one NEW idea it needs is that an offer is a THING YOU PICK, so each one
is a button (the .storm affordance the old storm-select card used, widened
into a panel). Two offers, stacked, tonight's scripted job first see
board.js offersFor() for why that order is fixed and not seeded.
The constrained offer gets a visible mark, because gate 2's whole point is
that taking a constrained job is a CHOICE: an amber left border and the
client's own words in italic. A player must not be able to take one without
having been told, and "it was in the fee" is not being told. */
#hud-card .offer { display:block; width:100%; text-align:left; margin:9px 0;
padding:13px 15px; background:#121c23; border:1px solid #2f3f4a; border-radius:7px;
color:#dde5ea; cursor:pointer; font:inherit;
transition:border-color .15s, background .15s; }
#hud-card .offer:hover { border-color:#7ee0ff; background:#16242c; }
#hud-card .offer .who { font-weight:700; color:#fff; letter-spacing:.04em; }
#hud-card .offer .addr { font-size:11px; color:#6d818e; margin-bottom:7px; }
#hud-card .offer .tag { float:right; font-size:10px; letter-spacing:.14em;
color:#55707f; text-transform:uppercase; }
#hud-card .offer .line { display:flex; justify-content:space-between; gap:14px;
font-size:12px; color:#8ba0ad; padding:2px 0; }
#hud-card .offer .line b { color:#dde5ea; font-weight:700; }
/* 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. /* Tomorrow is a rumour, and it should look like one next to tonight's numbers.
(Mine, not E's — the kit predates the tomorrow line and doesn't restyle it.) */ (Mine, not E's — the kit predates the tomorrow line and doesn't restyle it.) */
#hud-card .tomorrow { display:flex; gap:10px; align-items:baseline; margin-top:9px; #hud-card .tomorrow { display:flex; gap:10px; align-items:baseline; margin-top:9px;
@ -734,6 +777,102 @@ export function createHud(d) {
// --- cards ------------------------------------------------------------ // --- cards ------------------------------------------------------------
/**
* SPRINT17 gate 1 THE JOB BOARD. The morning, before the job sheet.
*
* ROADMAP arc 2's opening move: *"mornings offer N callouts, you take one.
* Pay, risk, client and site visible before you commit."* Two offers,
* tonight's scripted night first, and taking either one makes it the night.
*
* **What an offer card shows, and why it is exactly this list** SPRINT17
* gate 1.2 asks for "what a tradie would demand before saying yes", which
* is not a feature list, it's a job interview:
* · WHO and WHERE the client and the address, off the same night data
* the letterhead bills. A job you can't name is a job you don't take.
* · WHAT THE WEATHER IS DOING C's honest forecast band at lead 0, the
* same call the job sheet makes, so the board cannot be more confident
* than the sheet it leads to.
* · THE FEE what you're paid, premium included, priced through the
* SAME calloutFee() the sheet quotes and the invoice pays.
* · THE RISK collateral exposure: what of theirs your failure can
* bill you for. This is the number that makes the choice a choice; a
* board that showed only the fee would be advertising, not quoting.
* · THE CLIENT'S CONSTRAINTS, in the client's own words, marked so
* taking a constrained job is a decision and never a surprise.
*
* The pricing is injected (main.js owns the storm defs and site JSON);
* anything absent simply doesn't render, which is the same degradation rule
* the job sheet's forecast lines follow. The card works with nothing but
* client/site/brief, and that is what lets it be tested without a server.
*
* @param {object[]} offers board.js offersFor(), already priced
* @param {object} wk {night, nights, bank, log, rep}
* @param {(offer:object) => void} onTake
*/
showBoard(offers, wk, onTake) {
const pips = Array.from({ length: wk.nights }, (_, i) => {
const done = i < wk.night - 1;
const now = i === wk.night - 1;
const held = done && wk.log?.[i]?.won;
return `<span class="pip ${now ? 'now' : done ? (held ? 'held' : 'lost') : ''}">${
now ? '◆' : done ? (held ? '●' : '○') : '·'}</span>`;
}).join('');
const offerHtml = (o, i) => {
const n = o.night;
const cons = o.constraints ?? [];
// 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 * The forecast. Its job is to sell the dread and, incidentally, to be the
* difficulty select for free three authored storms already bracket the * difficulty select for free three authored storms already bracket the
@ -797,6 +936,23 @@ showForecast({ key, def, site, tomorrowDef }, wk, onGo) {
const brief = job.brief const brief = job.brief
? `<div class="brief">${job.brief}</div>` : ''; ? `<div class="brief">${job.brief}</div>` : '';
// SPRINT17 gate 2 — THE CLIENT'S TERMS, on the sheet, in their words.
// Under the brief and above the weather on purpose: the constraint is
// part of what they're asking for, not a footnote to it, and the player
// has to have read it BEFORE the schedule tells them what it pays. The
// invoice repeats this block verbatim off the settlement (a constraint
// the invoice forgot would be the paper losing the terms it was paid
// under, which is gate 2's specific requirement).
const cons = job.constraints ?? [];
const constraintBlock = cons.length ? `
<div class="sect">THE CLIENT'S TERMS</div>
<div class="sched">
${cons.map((c) => `
<div class="row"><span>${c.label}</span><b>${
c.kind === 'budgetCap' ? `$${c.cap} cap` : 'refused'}</b></div>
<div class="cond">${c.says}</div>`).join('')}
</div>` : '';
// BUDGET Y — and this is the actual feature, not the flavour. The fee has // BUDGET Y — and this is the actual feature, not the flavour. The fee has
// existed since Sprint 8 and the player has never seen it until the money // existed since Sprint 8 and the player has never seen it until the money
// was already spent: you decided what to rig without knowing what the job // was already spent: you decided what to rig without knowing what the job
@ -872,6 +1028,7 @@ showForecast({ key, def, site, tomorrowDef }, wk, onGo) {
card.innerHTML = `<div class="card jobcard"> card.innerHTML = `<div class="card jobcard">
${letterhead} ${letterhead}
${brief} ${brief}
${constraintBlock}
<h1>NIGHT ${wk.night} OF ${wk.nights}</h1> <h1>NIGHT ${wk.night} OF ${wk.nights}</h1>
<div class="pips">${pips}</div> <div class="pips">${pips}</div>
<h2>${(f.name ?? key).replace(/_/g, ' ').toUpperCase()}${night ? ' · NIGHT' : ''}</h2> <h2>${(f.name ?? key).replace(/_/g, ' ').toUpperCase()}${night ? ' · NIGHT' : ''}</h2>
@ -1033,7 +1190,13 @@ showForecast({ key, def, site, tomorrowDef }, wk, onGo) {
const invFeeTag = w && w.feeMult && w.feeMult !== 1 ? ` · standing ×${w.feeMult.toFixed(2)}` : ''; const invFeeTag = w && w.feeMult && w.feeMult !== 1 ? ` · standing ×${w.feeMult.toFixed(2)}` : '';
const money = w ? ` const money = w ? `
<div class="ledger"> <div class="ledger">
<div class="row"><span>${w.won ? 'callout fee' : `callout fee — night lost (${Math.round(100 * w.fee / Math.max(1, w.fullFee))}%)`}${invFeeTag}</span><b>+$${w.fee}</b></div> <div class="row"><span>${w.won ? 'callout fee' : `callout fee — night lost (${Math.round(100 * w.fee / Math.max(1, w.fullFee))}%)`}${invFeeTag}${
// SPRINT17 gate 2 — the fee row names the premium it was paid at.
// The constraint moved this number, so the row that shows the
// number is where it has to be said; a premium that only appeared
// in the total would be money with no reason printed next to it.
w.premium ? ` · +${Math.round(w.premium * 100)}% on their terms` : ''
}</span><b>+$${w.fee}</b></div>
<div class="row"><span>garden bonus ${r.hp.toFixed(0)}% of the bed${ <div class="row"><span>garden bonus ${r.hp.toFixed(0)}% of the bed${
// SPRINT14 — B's pool item, the invoice half. On a beyond-saving // SPRINT14 — B's pool item, the invoice half. On a beyond-saving
// night this row is always small, and next to "callout fee — night // night this row is always small, and next to "callout fee — night
@ -1083,6 +1246,21 @@ showForecast({ key, def, site, tomorrowDef }, wk, onGo) {
<h1>MORNING${w ? ` · NIGHT ${w.night} OF ${w.nights ?? NIGHT_COUNT}` : ''}</h1> <h1>MORNING${w ? ` · NIGHT ${w.night} OF ${w.nights ?? NIGHT_COUNT}` : ''}</h1>
<h2>${r.subtitle}</h2> <h2>${r.subtitle}</h2>
${rows} ${rows}
${/* SPRINT17 gate 2 THE INVOICE REPEATS THE CONSTRAINT IT WAS PAID
UNDER. The spec's own requirement, and it earns its place: the
invoice is the record, and a record of a constrained job that
doesn't say it was constrained has lost the one term that shaped
every decision on it. Off the settlement (week.js carries
s.constraints), so it stays true when you re-read night 3's
invoice on night 5's morning. */''}
${w?.constraints?.length ? `
<div class="sect">PAID UNDER THESE TERMS</div>
<div class="sched">
${w.constraints.map((c) => `
<div class="row"><span>${c.label}</span><b>${
c.kind === 'budgetCap' ? `$${c.cap} cap` : 'refused'}</b></div>
<div class="cond">${c.says}</div>`).join('')}
</div>` : ''}
${money} ${money}
<div class="verdict ${r.win ? 'win' : 'lose'}">${r.verdict}</div> <div class="verdict ${r.win ? 'win' : 'lose'}">${r.verdict}</div>
${/* SPRINT16 the verdict speaks rep: the movement AND its reason, so ${/* SPRINT16 the verdict speaks rep: the movement AND its reason, so

View File

@ -17,7 +17,7 @@ import * as THREE from '../vendor/three.module.js';
import { FIXED_DT, PHASES, STORM_LEN, HARDWARE, SPARE_COST, Emitter } from './contracts.js'; import { FIXED_DT, PHASES, STORM_LEN, HARDWARE, SPARE_COST, Emitter } from './contracts.js';
import { createWorld, loadSite } from './world.js'; import { createWorld, loadSite } from './world.js';
import { createCameraRig, spawnYawFor } from './camera.js'; import { createCameraRig, spawnYawFor } from './camera.js';
import { loadStorm, createWind } from './weather.js'; import { loadStorm, createWind, forecastLines, offerBand } from './weather.js';
import { SailRig, createSailView } from './sail.js'; import { SailRig, createSailView } from './sail.js';
import { createPlayer } from './player.js'; import { createPlayer } from './player.js';
import { Interact, wireYardActions } from './interact.js'; import { Interact, wireYardActions } from './interact.js';
@ -25,7 +25,8 @@ import { createDebris } from './debris.js';
import { createSkyFx } from './skyfx.js'; import { createSkyFx } from './skyfx.js';
import { createRiggingUI, fabricNoteFor } from './rigging.js'; import { createRiggingUI, fabricNoteFor } from './rigging.js';
import { createHud } from './hud.js'; import { createHud } from './hud.js';
import { createWeek, NIGHTS, nightAt } from './week.js'; import { calloutFee, createWeek, NIGHTS, nightAt } from './week.js';
import { POOL, exposureOf } from './board.js';
import { createGarden } from './garden.js'; import { createGarden } from './garden.js';
/** The calm day the forecast and prep phases run under. */ /** The calm day the forecast and prep phases run under. */
@ -52,10 +53,32 @@ export const CALM_STORM = 'storm_01_gentle';
* there night one is gentle today, so the bug hides. That assert would be * there night one is gentle today, so the bug hides. That assert would be
* decoration, which is the exact failure mode this repo keeps naming. * decoration, which is the exact failure mode this repo keeps naming.
* *
* @param {string[]} [nights] storm keys, defaults to the shipped ladder * **SPRINT17 THE POOL JOINS THE LADDER HERE, and this is the same landmine
* in a new costume.** The board can put ANY pool night on tonight, so a pool
* storm that isn't preloaded is `defs[week.stormKey] === undefined` the moment
* a player takes that offer which is D's calm-day bug exactly: an invariant
* ("every storm the game can run is loaded") that used to hold by coincidence,
* because NIGHTS was the only source of nights. It isn't anymore. Both sources
* are named here, deduped, and a.test pins that a pool-only storm survives the
* list.
*
* **TWO PARAMETERS, NOT ONE CONCATENATED DEFAULT and this assert had to be
* rescued from being decoration, by mutation-checking rather than by reading
* it.** The first cut took a single `nights` list defaulting to NIGHTS+POOL,
* and a.test asserted "every pool storm is preloaded". That assert passed with
* the POOL source deleted outright, because every storm the pool flies today
* also appears in NIGHTS the same coincidence, in the same function, that hid
* the calm-day bug for a sprint. Split, the composition is testable against a
* pool storm that is NOT in the ladder the case that would actually break.
*
* @param {string[]} [nights] storm keys from the ladder
* @param {string[]} [pool] storm keys the BOARD can put on tonight
*/ */
export function stormsToPreload(nights = NIGHTS.map((_, i) => nightAt(i).storm)) { export function stormsToPreload(
return [...new Set([CALM_STORM, ...nights])]; nights = NIGHTS.map((_, i) => nightAt(i).storm),
pool = POOL.map((p) => p.storm),
) {
return [...new Set([CALM_STORM, ...nights, ...pool])];
} }
const STORMS = stormsToPreload(); const STORMS = stormsToPreload();
@ -979,6 +1002,78 @@ export async function boot(opts = {}) {
* anything. Lane B's file, flagged in THREADS main.js therefore tracks * anything. Lane B's file, flagged in THREADS main.js therefore tracks
* `spentThisNight` itself off the bank rather than trusting `.spent`. * `spentThisNight` itself off the bank rather than trusting `.spent`.
*/ */
/**
* SPRINT17 gate 1 THE MORNING: the board, then the job sheet.
*
* Site JSON is needed for the exposure number on an offer whose yard we have
* NOT loaded (and must not load loading it would rebuild the world behind
* the card for a job the player might not take). `loadSite` is a small JSON
* fetch with no GLBs, cached here so a week of boards costs three requests.
*
* The cache is keyed on site name and never invalidated on purpose: a site
* JSON cannot change inside a run, and a stale-cache bug on a number the
* player makes money decisions from would be worth more than the three
* requests it saves.
* @type {Map<string, object>}
*/
const siteJson = new Map();
async function siteJsonFor(name) {
if (!siteJson.has(name)) siteJson.set(name, await loadSite(name));
return siteJson.get(name);
}
/**
* Price every offer on this morning's board.
*
* The fee comes from `week.quote()` on a WEEK VIEW OF THAT NIGHT, not from a
* formula retyped here so the number on the offer card is the number the
* job sheet quotes and the invoice pays, by construction rather than by two
* developers agreeing. (The board showing $68 and the sheet then saying $57
* would be the quote-vs-settle lie moved one card earlier, which is exactly
* the class of bug the ledger's derivation was built to make impossible.)
*/
async function pricedOffers() {
for (const o of week.offers()) await siteJsonFor(o.night.site);
return week.offers((night) => {
const def = defs[night.storm];
const exp = exposureOf(siteJson.get(night.site));
return {
// C's honest lines at lead 0 — tonight, exact. The same call the job
// sheet makes, so the board can never be more confident than the sheet.
forecast: def ? forecastLines(def, 0) : null,
// 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() { async function showTonight() {
// SPRINT10: tonight's yard. loadSiteInto no-ops when the site is unchanged // SPRINT10: tonight's yard. loadSiteInto no-ops when the site is unchanged
// (the four backyard nights) and rebuilds + re-points everything holding the // (the four backyard nights) and rebuilds + re-points everything holding the
@ -990,6 +1085,21 @@ export async function boot(opts = {}) {
// main.js's one private touch into their session (`_startBudget` + reset(), // main.js's one private touch into their session (`_startBudget` + reset(),
// asked in THREADS, landed, fake deleted — same route reset() took). // asked in THREADS, landed, fake deleted — same route reset() took).
rigging.session.setBudget(week.bank); 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; spentThisNight = 0;
// SPRINT11 — the job sheet reads three new things off the week: tonight's // 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 // 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 // on the new world/player, not the old one mid-swap. This is the only
// phase transition that can rebuild the scene, so it's the only one that // phase transition that can rebuild the scene, so it's the only one that
// has to sequence like this. // has to sequence like this.
showTonight().then(() => { // SPRINT17: the board comes first, and the world rebuild rides the PICK
// rather than the phase change — the yard to build is not known until
// the player has chosen a job. The resets still land after the rebuild
// (same .then sequencing, one card earlier in the chain).
showMorning().then(() => {
garden.reset(); garden.reset();
resetRig(); resetRig();
player.sim.carrying = null; player.sim.carrying = null;
@ -1176,13 +1290,28 @@ export async function boot(opts = {}) {
// `opts.splash === false` skips it — the selftest, the dev benches and D's // `opts.splash === false` skips it — the selftest, the dev benches and D's
// playtest harness all boot straight into a night, and none of them should // playtest harness all boot straight into a night, and none of them should
// have to click through a door. // have to click through a door.
//
// ⚠️ SPRINT17 — THE FIRST MORNING GETS A BOARD TOO, and for an hour it did
// not. The board hangs off the forecast PHASE CHANGE, and night one never
// has one: the game boots already IN 'forecast', so this line is the only
// route into the first morning. Going through showTonight() made night 1 the
// one night of the week with no choice on it. Found by LOOKING at the running
// game, not by a test — every board assert operates on week.offers() and all
// seven mornings were correct; the CARD CHAIN was wrong at the single join no
// assert was watching. (Both card bugs in this repo's history were invisible
// to a green suite. This is the third, and it says the same thing.)
//
// `splash:false` still goes straight to the scripted night, and that is the
// documented harness door rather than an oversight: a bench that asked for a
// night wants the night, not a card waiting to be clicked. D — your playtest
// harness is unchanged; the REAL path is the one with the board on it.
hud.setAudioMuteAvailable(typeof sky?.setMute === 'function'); hud.setAudioMuteAvailable(typeof sky?.setMute === 'function');
if (!canPlayHere()) { if (!canPlayHere()) {
hud.showTouchNotice(); // no way through, on purpose: see hud.showTouchNotice hud.showTouchNotice(); // no way through, on purpose: see hud.showTouchNotice
} else if (opts.splash === false) { } else if (opts.splash === false) {
showTonight(); showTonight();
} else { } else {
hud.showSplash(() => showTonight()); hud.showSplash(() => showMorning());
} }
// --- resize ------------------------------------------------------------- // --- resize -------------------------------------------------------------

View File

@ -20,7 +20,12 @@ import { RiggingSession } from '../rigging.js';
import { createSkyFx } from '../skyfx.js'; import { createSkyFx } from '../skyfx.js';
import { SailRig, orderRing } from '../sail.js'; import { SailRig, orderRing } from '../sail.js';
import { loadStorm, createWind } from '../weather.js'; import { loadStorm, createWind } from '../weather.js';
import { createWeek, NIGHTS, nightAt, gradeFor, BROKE_BELOW, PAY, REP } from '../week.js'; import {
BROKE_BELOW, PAY, REP, calloutFee, createWeek, gradeFor, nightAt, NIGHTS,
} from '../week.js';
// SPRINT17 gate 1/2 — the board and the constraint data are mine; these are
// the seam this suite pins (offer shape, pool, exposure, constraint enum).
import { CONSTRAINT_PREMIUM, POOL, exposureOf, validateConstraint } from '../board.js';
import { createHud } from '../hud.js'; import { createHud } from '../hud.js';
import { PALETTE, boundsFaults, emptyTemplate, exportSiteJSON, placeEntry } from '../editor.js'; import { PALETTE, boundsFaults, emptyTemplate, exportSiteJSON, placeEntry } from '../editor.js';
import { assert, assertClose, assertEq, assertLess, fixedLoop } from '../testkit.js'; import { assert, assertClose, assertEq, assertLess, fixedLoop } from '../testkit.js';
@ -291,11 +296,15 @@ export default async function run(t) {
// ladder WITHOUT the gentle storm — the case that actually broke. Checked // ladder WITHOUT the gentle storm — the case that actually broke. Checked
// against the shipped NIGHTS it would pass either way (night one is gentle // against the shipped NIGHTS it would pass either way (night one is gentle
// today), which is how the bug hid in the first place. // today), which is how the bug hid in the first place.
// SPRINT17: stormsToPreload takes the LADDER and the POOL separately now
// (see its docstring — the single-list version made the pool assert
// decoration). These pass an empty pool so this pin keeps testing exactly
// what it was written to test: the calm day's survival, ladder-only.
assert(stormsToPreload().includes(CALM_STORM), assert(stormsToPreload().includes(CALM_STORM),
'the calm day is preloaded on the shipped ladder'); 'the calm day is preloaded on the shipped ladder');
assert(stormsToPreload(['storm_02_wildnight']).includes(CALM_STORM), assert(stormsToPreload(['storm_02_wildnight'], []).includes(CALM_STORM),
"...and on a ladder that doesn't contain it anywhere — the case that broke"); "...and on a ladder that doesn't contain it anywhere — the case that broke");
assertEq(stormsToPreload(['storm_01_gentle']).length, 1, assertEq(stormsToPreload(['storm_01_gentle'], []).length, 1,
'and it is still deduped, not loaded twice'); 'and it is still deduped, not loaded twice');
}); });
@ -424,6 +433,271 @@ export default async function run(t) {
'the second-to-last night is not the final night — going broke there ends the run'); 'the second-to-last night is not the final night — going broke there ends the run');
}); });
// --- SPRINT17 gate 1: THE JOB BOARD -------------------------------------
// Loaded up front — Suite.test() cannot await, and every board assert wants a
// real storm def to price a fee against.
const boardDefs = {};
for (const k of ['storm_01_gentle', 'storm_03_southerly', 'storm_03b_earlybuster']) {
try { boardDefs[k] = await loadStorm(k); } catch { /* no server: asserts below degrade */ }
}
// The raw site JSON, for the exposure pins — deliberately NOT a built world:
// exposureOf() answers "what is priced in this yard" off data a board can read
// without building anything, which is the whole reason it can price an offer
// for a yard the player has not chosen yet.
const siteJsonForTest = {};
for (const k of ['backyard_01', 'site_02_corner_block', 'site_03_swing_lawn']) {
try { siteJsonForTest[k] = await loadSite(k); } catch { /* degrades to SKIPPED */ }
}
t.test('SPRINT17: every morning offers TWO jobs — tonight, plus one alternative', () => {
const w = createWeek();
for (let i = 0; i < NIGHTS.length; i++) {
const offers = w.offers();
assertEq(offers.length, 2, `night ${i + 1} offers two jobs`);
// The scripted spine SURVIVES, and it is always offer 0 — the campaign is
// the thing the player is in the middle of, and a board that shuffled
// which door was which would make "take the job you were going to do" a
// reading exercise every morning.
assertEq(offers[0].kind, 'scripted');
assertEq(offers[0].night.storm, nightAt(i).storm, `night ${i + 1}'s scripted offer IS the ladder's night`);
assertEq(offers[0].night.site, nightAt(i).site);
assertEq(offers[1].kind, 'callout', 'the second is drawn from the pool');
// An alternative that is the same yard under the same storm is not an
// alternative — it is the board lying about having offered a choice.
assert(!(offers[1].night.storm === offers[0].night.storm
&& offers[1].night.site === offers[0].night.site),
`night ${i + 1}'s alternative must be a DIFFERENT job, got ${offers[1].night.site} × ${offers[1].night.storm}`);
w.advance();
}
});
t.test('SPRINT17: the board is DETERMINISTIC — same week, same offers, always', () => {
// The repo's oldest rule (contracts.js §Determinism) and the selftest
// depends on it: a pool that drew from Date.now or Math.random is a suite
// that goes red on a Tuesday. Three independent weeks, walked in full.
const walk = (seed) => {
const w = createWeek(seed == null ? {} : { seed });
const out = [];
for (let i = 0; i < NIGHTS.length; i++) { out.push(w.offers().map((o) => o.id).join('|')); w.advance(); }
return out.join(' ; ');
};
const a = walk(), b = walk(), c = walk();
assertEq(a, b, 'two fresh weeks must offer the same board');
assertEq(b, c, '...and a third');
// And the seed is REAL — a different week is a different board, or the
// "deterministic" pin above is passing on a constant and proving nothing.
// (This is the negative control for the assert above it: without it, an
// offersFor() that ignored the seed entirely and always returned POOL[0]
// would sail through the equality checks.)
const other = walk(7);
assert(other !== a, 'a different seed must draw a different week of offers');
});
t.test('SPRINT17: TAKE ONE — the chosen job IS the night, all the way to the invoice', () => {
if (!boardDefs.storm_03_southerly) return 'SKIPPED — no server for storm defs';
const w = createWeek();
const offers = w.offers();
const alt = offers[1];
// Precondition: the alternative really is a different job from the spine,
// or this test proves nothing about taking one.
assert(alt.night.site !== nightAt(0).site || alt.night.storm !== nightAt(0).storm,
'precondition: the alternative differs from night 1');
w.take(alt);
assertEq(w.site, alt.night.site, 'week.site follows the taken job');
assertEq(w.stormKey, alt.night.storm, 'and so does the storm');
assertEq(w.job.client, alt.night.client, 'and the client the letterhead bills');
assertEq(w.takenOffer, alt.id, 'the week records WHICH offer was taken');
// THE ASSERT GATE 1 ASKS FOR IN SO MANY WORDS: "a chosen alternative that
// skips the ledger is the board lying." Every per-night surface must work
// UNCHANGED on a chosen night — so run the whole paper chain over one.
const def = boardDefs[alt.night.storm];
const q = w.quote(def);
assert(q.base > 0, 'a taken job quotes a fee');
assertEq(q.total, q.base + q.garden + q.clean - q.warrantyTotal, 'the quote still sums');
const s = w.settle({ hp: 90, win: true, collateral: [], intactHardwareValue: 40, rigRecord: [] }, def, 40);
assertEq(s.client, alt.night.client, 'the invoice bills the client you chose');
assertEq(s.site, alt.night.site, 'the ledger records the yard you chose');
assertEq(s.taken, alt.id, 'and that it came off the board');
assertEq(s.pay, s.fee + s.bonus + s.refund + s.clean - s.collateral - s.warrantyTotal,
'the ledger adds up on a chosen night exactly as it does on a scripted one');
assertEq(q.base, s.fullFee, 'quote==settle holds on a taken job');
assert(s.rep != null && s.grade != null, 'rep and grade are computed for a chosen night');
});
t.test('SPRINT17: a taken night is INDISTINGUISHABLE in shape from an authored one', () => {
// The bug this pins was real and the board probe found it before a player
// could: take() stored the raw pool entry, and a pool entry with no `pay`
// block threw "Cannot read properties of undefined (reading 'garden')" out
// of quote(). Both doors normalise through normaliseNight() now — this
// asserts the SHAPES match rather than trusting that they do.
const w = createWeek();
const scripted = nightAt(0);
w.take({ id: 'alt:probe', night: { storm: 'storm_01_gentle', site: 'backyard_01' } });
const takenKeys = Object.keys(w.job).sort().join(',');
assertEq(takenKeys, Object.keys(scripted).sort().join(','),
'a taken night carries exactly the fields an authored night carries');
// And the defaults really defaulted, rather than being present-but-undefined.
assertEq(typeof w.job.pay, 'object', 'pay defaults to a block, not undefined');
assertEq(w.job.constraints.length, 0, 'constraints default to an empty array');
assertEq(w.job.gardenBeyondSaving, false, 'a new night cannot inherit the icenight\'s excuse');
});
t.test('SPRINT17: take is a COMMITMENT — the other offer vanishes and there is no untake', () => {
const w = createWeek();
const [scripted, alt] = w.offers();
w.take(alt);
assertEq(w.takenOffer, alt.id);
// Taking again on the same morning is the SAME take, not a re-pick. A
// player who could re-open the board after reading the job sheet would be
// shopping, not choosing — and the fee is already quoted against the job.
w.take(scripted);
assertEq(w.takenOffer, alt.id, 'the morning\'s choice is final');
assertEq(w.site, alt.night.site, 'and the yard did not change under it');
// A fresh morning is a fresh choice, though.
w.advance();
assertEq(w.takenOffer, null, 'tomorrow is unchosen');
});
t.test('SPRINT17: the SPINE runs untouched when nothing is taken — the campaign is not the board', () => {
// 474 asserts stand on the scripted week. The board must be a second door,
// not a replacement: take nothing and every per-night reading is the
// authored ladder, byte for byte.
const w = createWeek();
for (let i = 0; i < NIGHTS.length; i++) {
w.offers(); // building a board must not MOVE anything
assertEq(w.stormKey, nightAt(i).storm, `night ${i + 1} storm untouched by the board`);
assertEq(w.site, nightAt(i).site, `night ${i + 1} site untouched`);
assertEq(w.job.client, nightAt(i).client, `night ${i + 1} client untouched`);
assertEq(w.takenOffer, null, 'and nothing is marked taken');
w.advance();
}
});
t.test('SPRINT17: every pool night is a JOB, and its storm is one the game preloads', () => {
for (const p of POOL) {
assert(p.id, 'a pool entry needs an id');
assert(p.client, `${p.id} has a client`);
assert(p.addr, `${p.id} has an address — a letterhead bills somebody somewhere`);
assert(p.brief && p.brief.length > 20, `${p.id} has a brief worth reading`);
assert(p.site, `${p.id} names a yard`);
assert(p._why, `${p.id} carries its audit receipt — an unmeasured pairing is a night the board is guessing about`);
// ⚠️ D's calm-day landmine, in its SPRINT17 costume: the board can put any
// pool night on tonight, so a pool storm the loader never fetched is
// `defs[week.stormKey] === undefined` the moment somebody takes it.
assert(stormsToPreload().includes(p.storm),
`${p.id} flies ${p.storm}, which nothing preloads — taking this offer would boot into an undefined storm`);
}
// ⚠️ THE LINE ABOVE IS NOT ENOUGH, AND A MUTATION PROVED IT (A, S17): every
// storm the pool flies TODAY also appears in NIGHTS, so that assert passes
// with the whole POOL source deleted from stormsToPreload — decoration, and
// the identical coincidence that hid the calm-day bug for a sprint. THIS is
// the assert with teeth: a pool storm that is NOT in the ladder, which is
// the case that actually breaks, and the case D's pool yard will be the
// moment it brings a storm of its own.
assert(stormsToPreload(['storm_01_gentle'], ['storm_99_fixture']).includes('storm_99_fixture'),
'a POOL-ONLY storm must be preloaded — the board can put it on tonight and nothing else would load it');
assert(stormsToPreload(['storm_01_gentle'], []).includes(CALM_STORM),
'the calm day still survives a ladder without it (S11 pin, still true)');
// ...and it is still deduped, so the loader does not fetch a storm twice
// just because the pool and the ladder agree about it.
assertEq(stormsToPreload(['storm_01_gentle'], ['storm_01_gentle']).length, 1,
'a storm in BOTH the ladder and the pool is loaded once');
});
t.test('SPRINT17: collateral exposure is the yard\'s priced property, and it matches the site JSON', () => {
if (!siteJsonForTest.backyard_01) return 'SKIPPED — no server for site JSON';
// Two routes to one set of dollars (board.js walks the site JSON up front;
// world.js resolves a collateral KEY at failure time), which is exactly the
// shape this repo has been bitten by. Pin the totals against the data.
const b = exposureOf(siteJsonForTest.backyard_01);
assertEq(b.total, 115, 'the backyard: gutter 90 + gnome 25');
const c = exposureOf(siteJsonForTest.site_02_corner_block);
assertEq(c.total, 205, 'the corner block: carport 180 + gnome 25 — and no house');
const s = exposureOf(siteJsonForTest.site_03_swing_lawn);
assertEq(s.total, 255, 'the swing lawn: gutter 90 + swing set 140 + gnome 25 — the worst in the game');
// Every item priced must be a real number a player can actually be billed,
// and the total must BE the items — an exposure that didn't sum to its own
// list would be the invoice bug one card earlier.
for (const { what, cost } of [...b.items, ...c.items, ...s.items]) {
assert(what && typeof what === 'string', 'every exposure item names itself');
assert(Number.isFinite(cost) && cost > 0, `${what} is priced`);
}
assertEq(s.total, s.items.reduce((n, i) => n + i.cost, 0), 'the total IS the items');
});
// --- SPRINT17 gate 2: CLIENT CONSTRAINTS (A's data half) -----------------
t.test('SPRINT17: a constrained night pays a premium, on BOTH papers, from ONE formula', () => {
if (!boardDefs.storm_03b_earlybuster) return 'SKIPPED — no server for storm defs';
const capped = POOL.find((p) => p.constraints?.some((c) => c.kind === 'budgetCap'));
assert(capped, 'the pool ships a budget-capped job');
const def = boardDefs[capped.storm];
const plain = calloutFee(def, REP.START, []);
const withCap = calloutFee(def, REP.START, capped.constraints);
assert(withCap > plain, `a constraint must PAY — got ${withCap} vs ${plain}`);
assertEq(withCap, Math.round(plain * (1 + CONSTRAINT_PREMIUM.budgetCap)),
'and by exactly the premium the constraint declares');
// The sheet and the invoice must read the same number. quote() and settle()
// route through the same calloutFee() — this is the pin that keeps them
// there when somebody adds a fourth term.
const w = createWeek();
w.take({ id: `alt:${capped.id}`, night: capped });
const q = w.quote(def);
const s = w.settle({ hp: 90, win: true, collateral: [], intactHardwareValue: 0, rigRecord: [] }, def, 45);
assertEq(q.base, withCap, 'the job sheet quotes the premium');
assertEq(s.fullFee, withCap, 'and the invoice pays it');
assertEq(q.premium, s.premium, 'both papers state the same premium');
// The invoice repeats the TERMS it was paid under — gate 2's requirement.
assertEq(s.constraints.length, capped.constraints.length,
'the invoice repeats the constraint it was paid under');
assertEq(s.constraints[0].label, capped.constraints[0].label);
});
t.test('SPRINT17: the constraint enum is CHECKED, so a bad one cannot ship', () => {
// An unenforced enum is decoration — this repo's standing rule, learned
// when the carport shipped typed as a 'post' behind a JSDoc that said it
// couldn't. Every shipped constraint validates...
for (const p of POOL) {
for (const c of p.constraints ?? []) validateConstraint(c, p.id);
}
// ...and a bogus one throws, with the kind named in the message.
let threw = '';
try { validateConstraint({ kind: 'pay_me_double', says: 'give me money', label: 'nope' }, 'fixture'); }
catch (err) { threw = err.message; }
assert(threw.includes('pay_me_double'), `a bad kind must fail loud and name itself, got: ${threw || '(no throw)'}`);
// The fields the papers PRINT are required too — a constraint with no
// client words would render an empty quote block on two cards.
let threw2 = '';
try { validateConstraint({ kind: 'budgetCap', cap: 45, label: 'capped' }, 'fixture'); }
catch (err) { threw2 = err.message; }
assert(threw2.includes('says'), `a constraint with no client words must fail, got: ${threw2 || '(no throw)'}`);
// ...and a budgetCap with no number is not a cap.
let threw3 = '';
try { validateConstraint({ kind: 'budgetCap', says: 'keep it cheap please', label: 'capped' }, 'fixture'); }
catch (err) { threw3 = err.message; }
assert(threw3.includes('cap'), `a capless budgetCap must fail, got: ${threw3 || '(no throw)'}`);
});
t.test('SPRINT17: a budget cap is BELOW the start budget and ABOVE the four-carabiner floor', () => {
// The ruling, pinned: the client caps your SPEND, not your wallet. A cap at
// or above START_BUDGET is not a constraint (it changes nothing and the
// premium is free money); a cap under BROKE_BELOW is a soft-lock, because
// four carabiners is the cheapest legal rig and the game refuses to leave
// prep without four corners.
for (const p of POOL) {
for (const c of p.constraints ?? []) {
if (c.kind !== 'budgetCap') continue;
assertLess(c.cap, START_BUDGET, `${p.id}: a cap at or above the start budget constrains nothing`);
assert(c.cap >= BROKE_BELOW,
`${p.id}: cap $${c.cap} is under the $${BROKE_BELOW} four-carabiner floor — that is a soft-lock, not a squeeze`);
}
}
});
// Gate 2 acceptance: both sites load from data, and the corner block is not // Gate 2 acceptance: both sites load from data, and the corner block is not
// the backyard with the furniture moved. Built up front (Suite.test() can't // the backyard with the furniture moved. Built up front (Suite.test() can't
// await — the guard I added last sprint enforces it, and just caught me). // await — the guard I added last sprint enforces it, and just caught me).

View File

@ -14,10 +14,21 @@
*/ */
import * as THREE from '../../vendor/three.module.js'; 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 { 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'; 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 // 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, // 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" // 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 ` `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`); + `(${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 // file read the ladder. week.js is contracts-only underneath, so this stays
// headless-safe. // headless-safe.
import { NIGHTS, nightAt } from '../week.js'; 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; 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 " "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"); + "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

@ -116,6 +116,16 @@ const ASSETS = [
{ name: 'pool_kit_01', h: [1.12, 1.32], { name: 'pool_kit_01', h: [1.12, 1.32],
nodes: ['pool_shell', 'pool_water', 'fence_rails', 'pool_gate', nodes: ['pool_shell', 'pool_water', 'fence_rails', 'pool_gate',
'fence_post_01', 'fence_post_14'] }, 'fence_post_01', 'fence_post_14'] },
// SPRINT17 gate 3.1 — the corroded tier. The intact variant stands POST
// height on purpose: its lie is "I'm a sail post", so it must stand like
// one, and only the colour and the drooped eye say otherwise. The wreck's
// ceiling is under a metre because the post FOLDED at the rust line — a
// corroded wreck still standing 4 m tall is a wreck that never told the
// truth about where it was weakest.
{ name: 'sail_post_corroded', h: [3.90, 4.15],
nodes: ['footing', 'post', 'rust', 'pad_eye', 'top_anchor', 'rake_pivot'] },
{ name: 'sail_post_corroded_wrecked', h: [0.45, 0.95],
nodes: ['footing', 'post_stub', 'post_down', 'pad_eye_down'] },
]; ];
function sizeOf(gltf) { function sizeOf(gltf) {
@ -1050,6 +1060,273 @@ export default async function run(t) {
+ 'lie the invoice exists to kill; when debris can take a panel out, price it THEN'); + 'lie the invoice exists to kill; when debris can take a panel out, price it THEN');
}); });
// -------------------------------------------------------------------------
// SPRINT17 gate 3.1 — THE CORRODED TIER. The pool kit is fourteen tie-offs
// that don't exist; this is the one that exists and shouldn't be trusted.
// The design claim is "corrosion a player can't see is a trap with no tell,
// and this repo doesn't ship those" — so the TELL itself is pinned as
// numbers (area, lightness, sag), not adjectives. An untelled corroded post
// (same 0.55 hint, clean steel) passes the factory, passes RULE 4a/4b, and
// passes every dims/nodes check — this test is the ONLY thing that goes red
// on it, which is exactly why it exists. (Proven red in the S17 negative
// controls; receipts in THREADS.)
// -------------------------------------------------------------------------
/** Total triangle area of every mesh under a GLB scene, split by predicate
* on the mesh's material. World transforms applied; area is exact. */
const areaBy = (scene, pred) => {
scene.updateMatrixWorld(true);
let hit = 0, total = 0;
const a = new THREE.Vector3(), b = new THREE.Vector3(), c = new THREE.Vector3();
scene.traverse((o) => {
if (!o.isMesh) return;
const pos = o.geometry.attributes.position;
const idx = o.geometry.index;
const n = idx ? idx.count : pos.count;
let area = 0;
for (let i = 0; i < n; i += 3) {
const ia = idx ? idx.getX(i) : i, ib = idx ? idx.getX(i + 1) : i + 1,
ic = idx ? idx.getX(i + 2) : i + 2;
a.fromBufferAttribute(pos, ia).applyMatrix4(o.matrixWorld);
b.fromBufferAttribute(pos, ib).applyMatrix4(o.matrixWorld);
c.fromBufferAttribute(pos, ic).applyMatrix4(o.matrixWorld);
b.sub(a); c.sub(a);
area += b.cross(c).length() / 2;
}
total += area;
if (pred(o.material)) hit += area;
});
return { hit, total };
};
/** Rust, as a colour band: strongly saturated orange-brown, darker than
* mid. Nothing else in this palette is saturated in that hue (weathered
* steel and concrete are near-grey, s < 0.15; timber is lighter). Colours
* are read in the loader's working space thresholds chosen with margin
* on both sides (rust s 0.8, everything else 0.15). */
const isRust = (m) => {
const hsl = { h: 0, s: 0, l: 0 };
(m.color ?? new THREE.Color(0xffffff)).getHSL(hsl);
return hsl.h <= 0.12 && hsl.s >= 0.35 && hsl.l <= 0.45;
};
// The three tells are three SEPARATE tests on purpose. Shipped as one
// block, the first failing assert masked the other two — the S17 M1
// negative control (clean steel, flat eye) went red on rust area alone and
// never reached the lightness or droop checks, so their strength was
// unproven. Split, each tell is independently red-checkable, which is the
// only way "mutation-checked" means anything.
t.test('the corroded post TELLS (1/3): rust reads as AREA, and the honest post stays clean', () => {
const g = loaded.get('sail_post_corroded');
const honest = loaded.get('sail_post');
assert(g && honest, 'both post variants must load — the tell is a comparison');
// (a) The at-a-glance read, as a number: rust-coloured surface area. 5%
// of a 4 m post is roughly the base band + head bloom + streaks — under
// that and the corrosion is a caption, not a tell. (Shipped value ~19%.)
const { hit, total } = areaBy(g.scene, isRust);
assert(total > 0, 'no measurable surface at all');
const frac = hit / total;
assert(frac >= 0.05,
`rust covers ${(frac * 100).toFixed(1)}% of the corroded post's surface — under 5% ` +
'is corrosion nobody can read from the lawn, a trap with no tell');
// And the honest post must NOT read rusty — the tell only works if the
// clean one is clean. (Also guards the colour thresholds themselves.)
const h = areaBy(honest.scene, isRust);
assert(h.hit === 0,
`the HONEST sail post carries ${(h.hit / h.total * 100).toFixed(1)}% rust-coloured area — ` +
'either the post rusted or the rust predicate is reading galvanised steel as rust');
});
t.test('the corroded post TELLS (2/3): the shaft is NOT galvanised — base colour and PBR both moved', () => {
const g = loaded.get('sail_post_corroded');
const honest = loaded.get('sail_post');
assert(g && honest, 'both post variants must load — the tell is a comparison');
// (b) The 20 m read. This assert shipped in the S17 WIP saying the
// corroded shaft "reads darker at yard distance"; I measured the actual
// render before endorsing it and the SIGN IS BACKWARDS. Sampled off
// look.html's canvas at cam z=20 m, 1280×720, over the mid-shaft band:
//
// honest rgb(49,51,49) lum 50 sat 0.031 RB 0
// corroded rgb(98,94,86) lum 94 sat 0.126 RB +12
//
// The corroded shaft renders TWICE AS BRIGHT, not darker. Base colour is
// genuinely darker (0.524 vs 0.737) but it does not drive the pixels:
// galvanised is metalness 0.90 / roughness 0.35 and, with no environment
// map in this renderer, a near-mirror metal has almost nothing to mirror
// and goes near-black. The weathered shaft at 0.55 / 0.60 is far more
// diffuse, so it actually catches the sun. The separation is real and
// large — it is just made of METALNESS, not lightness.
//
// So the test now pins both halves, and the comment states which one the
// player is actually seeing. An assert whose stated reason is the reverse
// of the mechanism is a docstring waiting to be "fixed" in the wrong
// direction by whoever tunes this next.
const shaftOf = (gl) => {
let best = null;
gl.scene.getObjectByName('post').traverse((o) => {
if (!o.isMesh || isRust(o.material)) return;
const hsl = { h: 0, s: 0, l: 0 };
o.material.color.getHSL(hsl);
if (!best || hsl.l > best.l) best = { l: hsl.l, m: o.material };
});
return best;
};
const sHonest = shaftOf(honest), sCorroded = shaftOf(g);
assert(sHonest && sCorroded, 'could not find shaft materials on the post nodes');
assert(sCorroded.l < sHonest.l - 0.08,
`corroded shaft base lightness ${sCorroded.l.toFixed(3)} vs galvanised ${sHonest.l.toFixed(3)}` +
'the corroded shaft is wearing galvanised paint, so nothing distinguishes them but the rust');
// The half that actually reaches the screen. Weathered steel is rougher
// and less metallic than gal; if a future edit "tidies" these back to the
// galvanised values the post keeps its rust and loses its 20 m silhouette.
assert(sCorroded.m.roughness > sHonest.m.roughness + 0.15,
`corroded shaft roughness ${sCorroded.m.roughness} vs galvanised ${sHonest.m.roughness}` +
'weathered steel that is as polished as gal loses the long-range read (this is the ' +
'property that actually drove lum 94 vs 50 at 20 m, measured)');
assert(sCorroded.m.metalness < sHonest.m.metalness - 0.15,
`corroded shaft metalness ${sCorroded.m.metalness} vs galvanised ${sHonest.m.metalness}` +
'corroded steel must be the more diffuse of the two or the yard-distance contrast inverts');
});
t.test('the corroded post TELLS (3/3): the pad eye DROOPS — angle derived from geometry, pinned to the baked note', () => {
const g = loaded.get('sail_post_corroded');
const honest = loaded.get('sail_post');
assert(g && honest, 'both post variants must load — the tell is a comparison');
g.scene.updateMatrixWorld(true);
honest.scene.updateMatrixWorld(true);
const root = g.scene.getObjectByName('sail_post_corroded');
const anchor = g.scene.getObjectByName('top_anchor');
assert(root && anchor, 'root and top_anchor must both survive the export');
const u = root.userData;
// THE AXIS TRAP (E's swing_set precedent). The builder writes the droop
// as a claim in THREE.JS coords; this derives the same numbers back out
// of the exported anchor position. Note and mesh can only lie together.
//
// Why not "the corroded anchor sits lower than the honest one" — the
// assert this REPLACES? Because the eye hangs off a weld line 160 mm
// down the post, so that comparison reads 0.06 m at ZERO droop and sails
// past a `> 0.05` threshold. It measured the weld offset, not the sag:
// an assert that cannot fail on the thing it names is decoration. This
// one drives the derived angle to 0° when the eye is flat.
const baked = u.padeye_droop_deg;
const arm = u.padeye_arm_m;
const weldY = u.padeye_weld_y_threejs;
assert(typeof baked === 'number' && typeof arm === 'number' && typeof weldY === 'number',
'the droop must be BAKED as numbers on the root (deg, arm, weld y in three.js) — ' +
'an orientation claim that lives only in a docstring is the axis trap waiting to happen');
assert(baked > 0, `padeye_droop_deg is ${baked} — a corroded eye that does not droop has no tell`);
const p = anchor.getWorldPosition(new THREE.Vector3());
// The post axis is x=z=0; the eye hangs off the weld line at (0, weldY, 0).
const horiz = Math.hypot(p.x, p.z); // out from the axis
const vert = p.y - weldY; // up from the weld
const armMeasured = Math.hypot(horiz, vert);
assert(Math.abs(armMeasured - arm) < 0.004,
`the eye sits ${armMeasured.toFixed(4)} m off the weld line but the note says ${arm} m — ` +
'the baked arm and the exported geometry disagree');
const derivedDeg = Math.atan2(horiz, vert) * 180 / Math.PI;
assert(Math.abs(derivedDeg - baked) < 1.5,
`the exported pad eye droops ${derivedDeg.toFixed(1)}° but the root claims ${baked}° — ` +
'the mesh and its own note disagree about the sag (flatten the eye and this is what you see)');
// DIRECTION, measured — not asserted by reading the string. Blender y
// maps to three.js +Z, so the eye must hang toward the YARD, the face the
// player judges it from. A sag pointing into the fence is a tell nobody sees.
assert(u.padeye_droop_dir_threejs === '+Z',
`the baked droop direction is ${u.padeye_droop_dir_threejs} — this test only knows how to check +Z`);
assert(p.z > 0.5 * horiz,
`the eye hangs toward (x=${p.x.toFixed(3)}, z=${p.z.toFixed(3)}) — the note says +Z (the yard side), ` +
'so either the exporter axis mapping moved or the note is wrong');
assert(Math.abs(p.x) < 0.006,
`the eye is displaced ${p.x.toFixed(3)} m on X — the droop is meant to be purely +Z`);
// And the weaker claim, kept as a sanity check now that it is no longer
// carrying the argument: the drooped anchor does sit below the honest one.
const sag = honest.scene.getObjectByName('top_anchor')
.getWorldPosition(new THREE.Vector3()).y - p.y;
assert(sag > 0.05,
`the corroded top_anchor sits only ${sag.toFixed(3)} m below the honest one`);
});
t.test('corroded steel is a RUNG: above the swing frame, below sound timber, resolvable node-less', () => {
const T = FACTORY_ANCHOR_RATINGS.types;
const entry = T.corroded_post;
assert(entry, 'corroded_post is not in the ratings manifest — the type cannot resolve node-less');
assert(!FACTORY_ANCHOR_RATINGS.ambiguous.corroded_post,
'corroded_post is manifest-AMBIGUOUS — the one-rating-per-type rule (THREADS [E] S15) broke');
// The ladder claims, pinned against the MANIFEST, never against literals
// (a literal pin would stay green while the whole ladder reshuffled):
assert(T.swing_frame.rating_hint < entry.rating_hint,
`corroded post (${entry.rating_hint}) must out-rate the swing frame (${T.swing_frame.rating_hint}) — ` +
'a real concrete footing beats sound steel standing loose on grass');
assert(entry.rating_hint < T.pergola.rating_hint,
`corroded post (${entry.rating_hint}) must rate under the pergola (${T.pergola.rating_hint}) — ` +
'sound flexing timber beats steel whose bloom outside means pitting inside');
assert(entry.rating_hint < T.post.rating_hint,
'a corroded post rating at or above the honest post erases the tier entirely');
// It fails through the client's property: collateral named, priced, and
// priced UNDER the gutter — the bill is the make-safe, not the steel; the
// real price of trusting rust is the corner you lose mid-storm.
assert(entry.collateral === 'corroded_post',
'the manifest must carry the collateral so node-less anchors bill on failure');
const root = g('sail_post_corroded');
const gutter = g('house_yardside');
assert(typeof root.userData.collateral_value === 'number' && root.userData.collateral_value > 0,
'the corroded post names collateral nothing prices — free failure, the gutter bug');
assert(root.userData.collateral_value < gutter.userData.collateral_value,
`a snapped corroded post ($${root.userData.collateral_value}) must bill under the gutter ` +
`($${gutter.userData.collateral_value}) — condemned steel is a make-safe charge, not a rebuild`);
function g(name) {
const glb = loaded.get(name);
assert(glb, `${name} did not load`);
return glb.scene.getObjectByName(name);
}
});
t.test('the corroded wreck folded at the rust line: stub stands, shaft in the yard, nothing left to tie to', () => {
const w = loaded.get('sail_post_corroded_wrecked');
assert(w, 'sail_post_corroded_wrecked did not load');
const box = new THREE.Box3().setFromObject(w.scene);
// It FELL: nothing stands past the stub, and the fallen shaft reaches
// into the yard on +Z (the same face the pergola presents — placement
// facts in THREADS). Both directions measured, not asserted by name.
assert(box.max.y < 1.0,
`the wreck stands ${box.max.y.toFixed(2)} m — a corroded post that still stands never folded`);
assert(box.max.z > 3.2,
`the fallen shaft reaches z=${box.max.z.toFixed(2)} — it is meant to be ~3.5 m out in the yard (+Z)`);
// No anchor survives, nothing rates, nothing enrols — an anchor that
// outlives its structure is the free-failure bug in a costume (the
// fascia rule; fourth application).
assert(!w.scene.getObjectByName('top_anchor'),
'top_anchor survives the wreck — you cannot clip to a post that is lying on the grass');
const offers = [];
w.scene.traverse((o) => {
if (typeof o.userData?.rating_hint === 'number' || o.userData?.anchor_type !== undefined) {
offers.push(o.name);
}
});
assert(offers.length === 0,
`the wreck offers [${offers.join(', ')}] — a wreck must offer nothing`);
// Twin-priced, and it names its twin — the carport/gutter chain, sixth
// application, compared between the GLBs and never against a literal.
const wr = w.scene.getObjectByName('sail_post_corroded_wrecked');
const ir = loaded.get('sail_post_corroded')?.scene.getObjectByName('sail_post_corroded');
assert(wr?.userData?.broken_variant_of === 'sail_post_corroded',
'the wreck must name its intact twin so A can pair the swap');
assert(wr?.userData?.collateral_value === ir?.userData?.collateral_value,
'the wrecked post must be priced the same as the one it used to be');
assert(wr?.userData?.collateral_key === 'corroded_post',
'the wreck must keep the collateral key or the swap changes what the bill is about');
});
// One GLB carries three wilt states as siblings; Lane A toggles .visible // One GLB carries three wilt states as siblings; Lane A toggles .visible
// rather than reloading, so all three have to be present at once. // rather than reloading, so all three have to be present at once.
t.test('garden_bed carries all 3 damage states in one GLB', () => { t.test('garden_bed carries all 3 damage states in one GLB', () => {

View File

@ -23,6 +23,30 @@ export {
const kmh = (ms) => ms * 3.6; 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)}`); 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 * 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 * 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 // 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 // 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 // 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 // player which fabric tonight wants, before a dollar is spent. The word map
// hail.size units (1.0 ≈ a 1.5 cm stone): storm_03's 0.7 reads "pea" (its // itself is module-scope now (S17) — the offer-honesty gate audits it.
// 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');
const sz = f.hail.size; const sz = f.hail.size;
const stones = f.hail.chance === 'none' ? '' : ( const stones = f.hail.chance === 'none' ? '' : (
stoneWord(sz.lo) === stoneWord(sz.hi) 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 // 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/. // 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. // 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 { START_BUDGET, HARDWARE } from './contracts.js';
import { offersFor, premiumFor } from './board.js';
/** /**
* The ladder. Escalating, and each rung introduces one new idea rather than just * The ladder. Escalating, and each rung introduces one new idea rather than just
@ -187,19 +188,28 @@ export const NIGHTS = [
]; ];
/** /**
* A night entry, every shape a full JOB. * A night entry, every shape a full JOB. **The one normaliser**, SPRINT17.
* *
* The plain-string form still resolves (SPRINT10's promise), and now so does a * The plain-string form still resolves (SPRINT10's promise), and now so does a
* night with no client: an unbriefed job is a job with no letterhead, not a * night with no client: an unbriefed job is a job with no letterhead, not a
* crash. `pay` is per-job so the schedule is data see PAY for the defaults and * crash. `pay` is per-job so the schedule is data see PAY for the defaults and
* for why the clean bonus is the one number here that is NOT yet measured. * for why the clean bonus is the one number here that is NOT yet measured.
* *
* @param {number} i * **SPRINT17 WHY THIS IS A SEPARATE FUNCTION FROM `nightAt`, and it is not
* @returns {{storm:string, site:string, client:string|null, brief:string|null, pay:object}} * tidiness.** The board can install a POOL entry as tonight's night, and a pool
* entry is hand-authored data with exactly the same optional fields a NIGHTS
* entry has. The first cut of `week.take()` stored `{...offer.night}` raw, and
* the board probe found it in one call: a pool entry with no `pay` block threw
* `Cannot read properties of undefined (reading 'garden')` out of quote()
* i.e. a chosen job was NOT the same shape as a scripted one, which is the
* single promise gate 1 makes. Both doors normalise HERE now, so "a taken night
* is indistinguishable from an authored night" is a fact about the code rather
* than a thing two call sites both have to remember.
*
* @param {object|string} entry a NIGHTS entry, a pool entry, or a bare storm key
*/ */
export function nightAt(i) { export function normaliseNight(entry) {
const n = NIGHTS[i]; const base = typeof entry === 'string' ? { storm: entry, site: 'backyard_01' } : (entry ?? {});
const base = typeof n === 'string' ? { storm: n, site: 'backyard_01' } : (n ?? {});
return { return {
storm: base.storm, storm: base.storm,
site: base.site ?? 'backyard_01', site: base.site ?? 'backyard_01',
@ -210,9 +220,53 @@ export function nightAt(i) {
// False by default on purpose: a night is presumed savable unless its data // False by default on purpose: a night is presumed savable unless its data
// says otherwise, so a new night can never inherit the icenight's excuse. // says otherwise, so a new night can never inherit the icenight's excuse.
gardenBeyondSaving: base.gardenBeyondSaving ?? false, gardenBeyondSaving: base.gardenBeyondSaving ?? false,
/**
* SPRINT17 gate 2 CLIENT CONSTRAINTS, as data on the night.
*
* Empty by default, and an EMPTY ARRAY rather than null on purpose: every
* consumer (the job sheet, the invoice, the offer card, B's session) wants
* to iterate this, and a null that four surfaces each have to remember to
* guard is three surfaces away from a crash. The scripted spine ships
* unconstrained constraints arrive on the board's callouts so this
* field is `[]` on all seven nights and nothing about the campaign moves.
*/
constraints: base.constraints ?? [],
}; };
} }
/**
* Tonight's authored night, by index into the ladder.
* @param {number} i
* @returns {{storm:string, site:string, client:string|null, brief:string|null, pay:object}}
*/
export function nightAt(i) {
return normaliseNight(NIGHTS[i]);
}
/**
* The callout fee, and the ONE place it is computed. SPRINT17.
*
* Extracted because gate 2 adds a third term (the constraint premium) to a
* formula that quote() and settle() were each spelling out for themselves. Two
* copies of a two-term formula is a latent divergence; two copies of a
* three-term formula is the quote-vs-settle lie with a longer fuse and the
* sheet promising a premium the invoice doesn't pay is exactly the failure the
* warranty derivation was built to make impossible. So: one function, both
* callers, rounded ONCE at the end.
*
* Order matters and is fixed here: severity standing the client's squeeze.
* The premium is a fraction of the fee you'd otherwise be paid, so it compounds
* on the rep multiplier rather than on the raw fee a 5 tradie is paid more
* for accepting the same ban, which is the right way round.
*
* @param {object} def tonight's storm def
* @param {number} rep standing AS OF THIS MORNING
* @param {object[]} [constraints] tonight's client constraints
*/
export function calloutFee(def, rep, constraints = []) {
return Math.round(PAY.feeFor(gustPeakOf(def)) * REP.multiplier(rep) * (1 + premiumFor(constraints)));
}
/** /**
* The economy, in one place because it is the thing most likely to need tuning * The economy, in one place because it is the thing most likely to need tuning
* once somebody actually plays five nights in a row. * once somebody actually plays five nights in a row.
@ -363,6 +417,17 @@ export const REP = {
*/ */
export const BROKE_BELOW = HARDWARE[0].cost * 4; export const BROKE_BELOW = HARDWARE[0].cost * 4;
/**
* SPRINT17 the default week seed.
*
* A CONSTANT, not a clock. Every board in the shipped game is drawn from this
* and the night index, so the campaign has one fixed sequence of offers that
* the selftest can pin and John can play twice and recognise. `createWeek({seed})`
* is the door for a different week which is where replay comes from in a
* later sprint, and it costs nothing to leave open now.
*/
export const DEFAULT_WEEK_SEED = 1;
/** /**
* How the week is remembered, by gardens actually held. * How the week is remembered, by gardens actually held.
* *
@ -434,6 +499,31 @@ export function createWeek(opts = {}) {
let bank = startBank; let bank = startBank;
let done = false; let done = false;
let rep = REP.START; // SPRINT16 — the number on the letterhead let rep = REP.START; // SPRINT16 — the number on the letterhead
/**
* SPRINT17 gate 1 the week's SEED. Every morning's board is drawn from
* this and the night index, and from nothing else: no Date.now, no
* Math.random (contracts.js §Determinism, and the selftest depends on it
* an offer pool that reads the clock is a suite that goes red on a Tuesday).
* Same seed, same week, same two offers, forever.
*/
const seed = opts.seed ?? DEFAULT_WEEK_SEED;
/**
* SPRINT17 gate 1 TAKEN JOBS, keyed by night index.
*
* This is the whole mechanism behind "the chosen job IS the night", and it is
* an OVERRIDE rather than a replacement on purpose. `jobAt()` falls through to
* the authored `nightAt()` whenever nothing was taken, so:
* · the scripted week runs byte-identically to the pre-board game (474
* asserts stand on that, and none of them know this map exists);
* · a taken alternative is a night entry of exactly the same SHAPE, so
* every per-night surface downstream sheet, quote, invoice, ledger,
* warranty, rep, end card reads it through the path it already had.
* Nothing consults "was this scripted?" to decide how to do its job, which
* is the property that makes the board impossible to half-wire.
* @type {Map<number, object>}
*/
const taken = new Map();
const jobAt = (i) => taken.get(i) ?? nightAt(i);
/** @type {Settlement[]} */ /** @type {Settlement[]} */
const log = []; const log = [];
@ -463,11 +553,11 @@ export function createWeek(opts = {}) {
/** 1-based, for humans. "Night 3 of 5". */ /** 1-based, for humans. "Night 3 of 5". */
get night() { return index + 1; }, get night() { return index + 1; },
get nights() { return NIGHTS.length; }, get nights() { return NIGHTS.length; },
get stormKey() { return nightAt(index).storm; }, get stormKey() { return jobAt(index).storm; },
/** SPRINT10: which yard tonight is on. main.js loads it. */ /** SPRINT10: which yard tonight is on. main.js loads it. */
get site() { return nightAt(index).site; }, get site() { return jobAt(index).site; },
/** SPRINT11: tonight's job — client, brief, pay schedule. The job sheet reads this. */ /** SPRINT11: tonight's job — client, brief, pay schedule. The job sheet reads this. */
get job() { return nightAt(index); }, get job() { return jobAt(index); },
/** /**
* What tonight pays, BEFORE you rig it the "budget Y" half of DESIGN.md's * What tonight pays, BEFORE you rig it the "budget Y" half of DESIGN.md's
@ -482,7 +572,7 @@ export function createWeek(opts = {}) {
* @param {object} def tonight's storm def * @param {object} def tonight's storm def
*/ */
quote(def) { quote(def) {
const j = nightAt(index); const j = jobAt(index);
// SPRINT16: the fee is priced on your standing, and the deduction for // SPRINT16: the fee is priced on your standing, and the deduction for
// last dawn's broken corners is on the quote — AT QUOTE TIME, so settle // last dawn's broken corners is on the quote — AT QUOTE TIME, so settle
// pays exactly what the sheet said. Both read the same sources settle // pays exactly what the sheet said. Both read the same sources settle
@ -492,13 +582,27 @@ export function createWeek(opts = {}) {
const warranty = warrantyItems(); const warranty = warrantyItems();
const warrantyTotal = warranty.reduce((s, i) => s + i.cost, 0); const warrantyTotal = warranty.reduce((s, i) => s + i.cost, 0);
return { return {
base: Math.round(PAY.feeFor(gustPeakOf(def)) * feeMult), // SPRINT17 gate 2 — ONE fee function, shared with settle(), so the
// constraint premium cannot reach the sheet without reaching the
// invoice. (Before this it was two hand-written copies of the same
// formula agreeing by luck; adding a third term to both by hand is how
// that luck runs out.)
base: calloutFee(def, rep, j.constraints),
garden: j.pay.garden ?? PAY.gardenBonusMax, garden: j.pay.garden ?? PAY.gardenBonusMax,
clean: j.pay.clean ?? PAY.noCollateralBonus, clean: j.pay.clean ?? PAY.noCollateralBonus,
feeMult, feeMult,
rep, rep,
warranty, warranty,
warrantyTotal, warrantyTotal,
/**
* SPRINT17 gate 2 the constraints the job is quoted UNDER, on the
* sheet, and repeated verbatim on the invoice they were paid under
* (hud.js prints both off this same array). `premium` is what they
* added to the fee, so the sheet can show the client's squeeze and the
* client's compensation as the two facts they are.
*/
constraints: j.constraints ?? [],
premium: premiumFor(j.constraints),
/** /**
* SPRINT14 (B's pool item, offered S13 and accepted): the job sheet has * SPRINT14 (B's pool item, offered S13 and accepted): the job sheet has
* to say when the garden bonus is money the night cannot pay. * to say when the garden bonus is money the night cannot pay.
@ -522,6 +626,59 @@ export function createWeek(opts = {}) {
get total() { return this.base + this.garden + this.clean - this.warrantyTotal; }, get total() { return this.base + this.garden + this.clean - this.warrantyTotal; },
}; };
}, },
/**
* SPRINT17 gate 1 THIS MORNING'S BOARD. Two offers: tonight's scripted
* night, and one alternative drawn deterministically from the pool.
*
* @param {(night:object) => object} [priced] optional per-offer pricing /
* forecast / exposure, injected by main.js because it owns the storm defs
* and the site JSON. board.js stays pure; the money is computed HERE, off
* the same rep and the same calloutFee() the sheet and invoice use, so an
* offer cannot advertise a fee the job sheet then contradicts.
*/
offers(priced = null) {
return offersFor({
scripted: nightAt(index), weekSeed: seed, nightIndex: index, priced,
});
},
/**
* TAKE ONE. The other vanishes no backlog, no decline cost (SPRINT18
* decides whether saying no costs you; SPRINT17 deliberately does not
* charge for a choice it hasn't designed the consequences of).
*
* Idempotent per night and only forward: taking twice on the same morning
* is the same take, and there is no untake. That's not a limitation, it's
* the point the board is a commitment, and a player who could re-open it
* after reading the job sheet would be shopping, not choosing.
*
* @param {object} offer one of `offers()`
* @returns {object} the night now installed for tonight
*/
take(offer) {
if (!offer?.night) throw new Error('week.take: needs an offer with a .night (board.js offersFor)');
if (!taken.has(index)) {
// Stamped with the offer id so the settlement — and therefore the
// invoice and the end card — can say the job came off the board and
// WHICH job it was. Non-enumerable so it can never leak into a night's
// data shape and make a taken night structurally different from a
// scripted one: `jobAt()` must hand back something indistinguishable.
// normaliseNight, NOT a spread — see its docstring. A pool entry is
// hand-authored data with the same optional fields a NIGHTS entry has,
// and a raw one crashes quote() on its missing `pay` block.
const night = normaliseNight(offer.night);
Object.defineProperty(night, '_offerId', { value: offer.id, enumerable: false });
taken.set(index, night);
}
return taken.get(index);
},
/** Tonight's taken offer id, or null if the player is on the spine. */
get takenOffer() { return taken.get(index)?._offerId ?? null; },
/** SPRINT17 — the week's draw seed, for the board and for a.test's pins. */
get seed() { return seed; },
get bank() { return bank; }, get bank() { return bank; },
/** SPRINT16 — the letterhead's number. Stars, 0..5, half-steps. */ /** SPRINT16 — the letterhead's number. Stars, 0..5, half-steps. */
get rep() { return rep; }, get rep() { return rep; },
@ -532,6 +689,10 @@ export function createWeek(opts = {}) {
reset() { reset() {
index = 0; bank = startBank; done = false; log.length = 0; index = 0; bank = startBank; done = false; log.length = 0;
rep = REP.START; rep = REP.START;
// SPRINT17: a restarted week re-offers the same board (the seed is fixed
// for the run), so clearing the takes is what makes "play it again"
// actually replayable rather than handing you last run's choices.
taken.clear();
return week; return week;
}, },
@ -550,12 +711,15 @@ export function createWeek(opts = {}) {
// function, after the money is computed, so quote and settle agree by // function, after the money is computed, so quote and settle agree by
// ordering. Round after the multiplier, exactly as quote() does. // ordering. Round after the multiplier, exactly as quote() does.
const feeMult = REP.multiplier(rep); const feeMult = REP.multiplier(rep);
const fullFee = Math.round(PAY.feeFor(gustPeakOf(def)) * feeMult); // SPRINT17 gate 2: the SAME calloutFee() the quote printed, constraints
// and all — quote==settle by construction, extended to the premium the
// same way it was extended to the warranty deduction.
const fullFee = calloutFee(def, rep, jobAt(index).constraints);
const fee = run.win ? fullFee : Math.round(fullFee * PAY.lostNightShare); const fee = run.win ? fullFee : Math.round(fullFee * PAY.lostNightShare);
// Per-job override, PAY's default otherwise — and settle MUST read the same // Per-job override, PAY's default otherwise — and settle MUST read the same
// source the quote does, or the job sheet promises a number the invoice // source the quote does, or the job sheet promises a number the invoice
// doesn't pay. That's not a rounding difference, it's a lie on paper. // doesn't pay. That's not a rounding difference, it's a lie on paper.
const gardenMax = nightAt(index).pay.garden ?? PAY.gardenBonusMax; const gardenMax = jobAt(index).pay.garden ?? PAY.gardenBonusMax;
const bonus = Math.round((Math.max(0, run.hp) / 100) * gardenMax); const bonus = Math.round((Math.max(0, run.hp) / 100) * gardenMax);
// Only hardware still on an unbroken corner comes home, and at half. // Only hardware still on an unbroken corner comes home, and at half.
@ -566,7 +730,7 @@ export function createWeek(opts = {}) {
// The clean bonus. Per-job data first, PAY's default otherwise — so a job // The clean bonus. Per-job data first, PAY's default otherwise — so a job
// that wants to say "there's a lot here to break" can, without code. // that wants to say "there's a lot here to break" can, without code.
const job = nightAt(index); const job = jobAt(index);
const cleanMax = job.pay.clean ?? PAY.noCollateralBonus; const cleanMax = job.pay.clean ?? PAY.noCollateralBonus;
const clean = collateral === 0 ? cleanMax : 0; const clean = collateral === 0 ? cleanMax : 0;
@ -642,6 +806,15 @@ export function createWeek(opts = {}) {
gardenBeyondSaving: job.gardenBeyondSaving ?? false, gardenBeyondSaving: job.gardenBeyondSaving ?? false,
gardenMax, gardenMax,
client: job.client, addr: job.addr, site: job.site, client: job.client, addr: job.addr, site: job.site,
// SPRINT17 — the invoice repeats the constraint it was PAID UNDER
// (gate 2's requirement, and the reason this rides the settlement
// rather than being looked up: the invoice for night 3 must state
// night 3's terms even when you're reading it on night 5's morning).
// `taken` says the job came off the board — the ledger records that
// you CHOSE this, which is the whole of arc 2 in one boolean.
constraints: job.constraints ?? [],
premium: premiumFor(job.constraints),
taken: taken.has(index) ? (taken.get(index)._offerId ?? true) : false,
// SPRINT16 — the ledger. The rig record IS the work history (the night // SPRINT16 — the ledger. The rig record IS the work history (the night
// computed all of it; the week just keeps it), the warranty items are // computed all of it; the week just keeps it), the warranty items are
// tonight's sheet answered on the invoice, and the rep block is the // tonight's sheet answered on the invoice, and the rep block is the

Binary file not shown.

Binary file not shown.