Merge remote-tracking branch 'origin/main'

# Conflicts:
#	THREADS.md
#	tools/blender/contact_sheet.png
This commit is contained in:
m3ultra 2026-07-28 21:49:49 +10:00
commit 6bab107ea4
33 changed files with 7602 additions and 142 deletions

View File

@ -1110,3 +1110,96 @@ then A/B/C/E; D returns at the end for yard 4 + the cold play.
> and judge everything as the playtester — does choosing feel like running a
> business? Does a constraint read as a client or as a rule? Numbers and
> feel, your standard. Push, THREADS, verdicts per gate.
# SPRINT 18 prompts (the book of clients, part 2: the night you didn't rig)
Same rules: own clone, own branch, `git fetch origin && git reset --hard
origin/lane/<x>` on a clean tree (or merge origin/main — the integrator will
say). Remote is monster/hardyards.git. Read THREADS' last two [I] entries
(the S17 part-1 and part-2 integrations), then SPRINT18.md, then ROADMAP arc 2
and DESIGN.md lines 29 / 165 / 179 (the emergency callout, release-cut-or-ride,
the glasshouse). Ports: A 8824, B 8825, C 8826, D 8827, E 8828. Baseline
**515/0/0**. FIRING ORDER: A's gate 0 ruling + gate 1 phase land first (the
sprint's critical path); E runs independently from the start; B and C build
against A's published shapes; D returns last for the knife and the cold play.
⚠️ Every lane: the one-variable law binds hard this sprint — the emergency
callout and the glasshouse may NOT debut on the same night, however well
DESIGN.md's own sentence pairs them.
## Lane A — Sprint 18
> You are Lane A on HARD YARDS, Sprint 18. Rebase onto main, read THREADS' last
> two [I] entries, SPRINT18.md, ROADMAP arc 2. Two jobs: (1) gate 0, FIRST and
> FAST — ⚖️ John's ruling: DECLINING A BOOKED NIGHT COSTS YOU. You rule the
> number and the memory (the stood-up client remembers), the cost prints on the
> OFFER CARD before the choice and on the invoice after it, and the
> two-callouts-no-booked-job board state reads as free. (2) Gate 1's spine: THE
> EMERGENCY CALLOUT — a night that starts in storm, no prep, no quote, a
> DISPATCH instead of a job sheet, emergency rates (yours to rule), the whole
> existing ledger running unchanged. Publish the dispatch/emergency-night shapes
> in THREADS EARLY — B, C and D all build against them. Enforce the
> one-variable law in the ordering so a new job type and a new yard cannot debut
> together by accident. Selftest green; mutation-check; look at every card you
> touch. Shepherd as always.
## Lane B — Sprint 18
> You are Lane B on HARD YARDS, Sprint 18. Rebase onto main, read THREADS' last
> two [I] entries, SPRINT18.md. Three jobs, in this order: (1) ⚠️ GATE 2, which
> outranks the features — D's cold play lost p2/p3 on the wildnight flying a
> recipe gardenfly holds 0-lost. Two harnesses disagree: FIND THE VARIABLE
> (D's candidate: hail impact load reaching the rig only in-game). Name it, pin
> it, and rule which harness was lying. (2) Gate 3 — the $5-tier clean boundary:
> D flew your audit's own $40 "clean" line and tr1b broke at 1.17/1.2 kN.
> Re-rule the clean/marginal boundary at the cheap tier by measurement. (3) Gate
> 1's half — THE RIG YOU DIDN'T BUILD: someone else's work as data (hardware
> below the night, at least one corner already gone, rating hints telling the
> truth), the economy of triage (paid on the callout plus what you SAVE — you
> rule whether a garden bonus applies at all and write why), and the CUT the
> knife needs: expose cut-away on the rig, D owns the verb. Build against A's
> published shapes (fetch origin/lane/a mid-flight). Selftest green;
> mutation-check; tables in THREADS.
## Lane C — Sprint 18
> You are Lane C on HARD YARDS, Sprint 18. Rebase onto main, read THREADS' last
> two [I] entries, SPRINT18.md. Two jobs: (1) gate 1's dispatch — THE CALLOUT
> HAS NO FORECAST. You arrive mid-storm inside weather nobody briefed you on:
> rule what an honest read looks like when no paper is coming (what the sky, the
> grass and the gust front tell a tradie who just got out of the ute), and give
> the dispatch its surface. Your honesty gate covers emergency storms like any
> other — a storm that cannot be described honestly cannot be dispatched either.
> (2) The mid-storm arrival is a NEW seam for your storm clock: an entry at
> t≈30 must produce the same storm a t=0 run would have at t=30 (determinism at
> a non-zero start is exactly the class of bug this repo hunts) — pin it. Build
> against A's shapes; fetch origin/lane/a mid-flight. Optional if room: your own
> polish, filed with reasons. Selftest green; disclose any assert that can't
> fail.
## Lane E — Sprint 18 (independent — start immediately)
> You are Lane E on HARD YARDS, Sprint 18. Rebase onto main, read THREADS' last
> two [I] entries, SPRINT18.md gates 4 and 5. Two jobs, your standard: (1) THE
> GLASSHOUSE TIER — DESIGN.md's maximum-fragility asset (line 179: highest pay
> on the board, highest collateral, "your own failure is the worst debris of
> all"). Fragility readable at a glance, a collateral price that makes the gnome
> look like pocket change, a wreck state, manifest entry unambiguous or
> refuse-to-guess with the why, negative controls red-then-green,
> byte-identical factory runs, LOOK at the render. File the editor palette hunk
> verbatim in THREADS (the _v1 suffix is load-bearing) and flag A the moment
> it's placeable. (2) Gate 5 with A — the second-rust-post landmine D filed:
> two corroded posts in one yard share the baked collateral key, so first-match
> resolution plus envelope dedupe would bill or wreck the wrong post. Disarm it
> before a yard author trips it.
## Lane D — Sprint 18 (last: the knife, then the cold play)
> You are Lane D on HARD YARDS, Sprint 18, and you are last on purpose. Rebase
> onto the integrated tree (the integrator will tell you, or main). Read THREADS
> from the last two [I] entries, SPRINT18.md, DESIGN.md line 165. Two jobs:
> (1) THE KNIFE — DESIGN.md's signature decision under load is "release, cut,
> or ride it out", and the cut is yours: a deliberate cut-away that spends the
> sail to save the steel and the collateral. Input, HUD read, and its line on
> the paperwork, all in this sprint (principle 2). B exposes the cut on the rig;
> you own the verb. (2) COLD-PLAY the week through the new board: at least one
> emergency callout, at least one booked night DECLINED (feel the ruling's
> price), and judge it as the playtester — does the knife feel like a decision
> or a button? Does declining cost enough to hurt and little enough to be worth
> it sometimes? Numbers and feel, your standard. Push, THREADS, verdicts per
> gate.

124
SPRINT18.md Normal file
View File

@ -0,0 +1,124 @@
# SPRINT 18 — THE BOOK OF CLIENTS, PART 2: THE NIGHT YOU DIDN'T RIG
*Part 1 shipped whole: the board offers two jobs a morning, client terms refuse
in the client's own voice, the pool yard is in the game only by being chosen —
515/0/0. D's cold play asked one question the board couldn't answer and **John
has RULED it (2026-07-21): declining a booked night COSTS YOU. "It adds
pressure."** This sprint lands that ruling and opens the arc's second half: job
types that are not "rig for tonight". The headline is DESIGN.md's own line 29 —
**the emergency callout: mid-storm, right now, double rates, someone's sail
flogging itself to death over their glasshouse.***
Read THREADS from the last two `[I]` entries. Baseline **515/0/0**; keep it
green; mutation-check anything you'd call coverage. Every arc ends live
(ROADMAP principle 1) — main stays shippable.
⚠️ **THE ONE-VARIABLE LAW BINDS THIS SPRINT HARDER THAN ANY BEFORE IT.** We are
shipping a new JOB TYPE (the emergency callout) and a new YARD (the glasshouse)
in the same sprint, and DESIGN.md pairs them in one sentence. They may **not**
debut on the same night. The emergency callout debuts over a yard the player has
already rigged; the glasshouse debuts as an ordinary rig-for-tonight job. The
sentence they were written for is the payoff, not the introduction — it belongs
to a later week, and A's ordering must make that impossible to get wrong by
accident.
## Gate 0 — ⚖️ THE RULING LANDS: DECLINING COSTS YOU (A owns; FIRST, fast)
John's ruling on D's filing. The board currently lets a fee-maximizer dodge the
wildnight and the designed-loss icenight — the week's two hardest lessons — at
zero cost. That is the hole. Close it:
1. **A booked night is a promise.** The "TONIGHT, AS BOOKED" offer has a client
expecting you; the alternative callout does not. Taking the alternative
stands the booked client up, and standing someone up costs **standing**
the currency the whole of arc 1 built for exactly this. A rules the number
(the mirror of `REP.CLEAN` is the obvious candidate; it is yours to rule and
to write down as measured-or-unmeasured per house rule).
2. **The cost is on the CARD, before the choice.** No hidden tax — the offer
card names what declining the booked job costs, in the client's terms
("the Hendersons are expecting you"). A choice whose price is invisible
until the invoice is a trap, and this repo doesn't ship traps without tells.
3. **The stood-up client remembers.** Their next appearance on the board, or
their next brief, carries it. Cheap to build (the job log already exists),
and it is the whole point of "your work follows you".
4. **The invoice states which job you took and what it cost you to take it**
both papers, same as gate 2 constraints in part 1.
5. **Two callouts, no booked job** is a legal board state: then declining is
free, and it must READ as free. Pin the asymmetry.
## Gate 1 — THE EMERGENCY CALLOUT (the arc's new job type; A spine · B rig · C dispatch · D verbs)
ROADMAP: *"you arrive MID-STORM at double rates to someone else's failing rig:
triage, repair, cut-away. The storm phase already does everything this needs
except spawn you at t=30 with a rig you didn't build."* Ship it end to end:
1. **A — the phase.** A night that starts in `storm`, not `prep`: no rigging
session, no quote, a **dispatch** instead of a job sheet (there was no time
to quote — that's the drama). Emergency rates (A rules the multiplier), and
the ledger runs unchanged: invoice, rep, warranty, all of it.
2. **B — the rig you didn't build.** Someone else's work, authored as data:
hardware below what the night needs, at least one corner already gone, the
rating hints telling the truth about why. The economy of **triage**: paid on
the callout plus what you SAVE, not on a garden you never had a prep phase
to protect. B rules whether the garden bonus applies at all and writes why.
3. **D — the verbs, and DESIGN.md's signature decision: release, cut, or ride
it out.** The knife is canon (line 165: *cut a sail loose — lose the sail,
save the anchors*). Ship the knife: a deliberate cut-away that spends the
sail to save the steel and the collateral. It needs its input, its HUD read,
and its line on the paperwork in this sprint (principle 2, no exceptions).
Seam with B: B exposes the cut on the rig, D owns the verb and the input.
4. **C — the dispatch has no forecast.** You arrive inside weather nobody
briefed you on. C rules what an honest mid-storm read looks like (what the
sky and the grass tell you when no paper does), and the honesty gate covers
emergency storms like any other.
5. **Publish the shapes EARLY in THREADS** (the seam pattern — it is now the
thing this repo does best: five lanes, one code conflict in two sprints).
## Gate 2 — ⚠️ THE TWO HARNESSES DISAGREE (B owns; the repo's highest-priority class)
D's cold play lost p2 and p3 on the wildnight flying a recipe `gardenfly` holds
0-lost. **"When two harnesses disagree, find the variable"** is a README rule
and it has never once been wrong. D's candidate: hail impact load reaching the
rig only in-game. Find it, name it, pin it. If the played path is right, the
audit has been selling recipes it can't back — say so in the card. If the audit
is right, D's play found a sim bug. Either way this outranks new features.
## Gate 3 — THE $5-TIER CLEAN BOUNDARY (B owns)
D flew the audit's own $40 "clean" line on N3 and `tr1b`'s carabiner broke at
1.17 / 1.2 kN. A card that calls a line clean and then watches it break is the
card lying — the same defect class as part 1's charter silence, which B fixed
well. Re-rule the clean/marginal boundary at the cheap tier with measurement,
not margin-of-taste.
## Gate 4 — THE GLASSHOUSE TIER (E owns; DESIGN.md's maximum-fragility asset)
*"Highest pay on the board, highest collateral."* Supply glass as a first-class
fact your family of honesty rules covers: a glasshouse structure whose
fragility is **readable at a glance** (a tell for "this is the most expensive
thing you can break in this game"), a collateral price that makes the gnome
look like pocket change, and a wreck state — your own failure becoming the
worst debris of all is the whole point. Manifest entry unambiguous or
refuse-to-guess with the why, as always. It is the emergency callout's eventual
stage, but per the one-variable law it debuts as an ordinary night — flag A the
moment it is placeable.
## Gate 5 — THE SECOND-RUST-POST LANDMINE (E + A; D's filing, small)
Two corroded posts in one yard share the baked `collateral: 'corroded_post'`
key; first-match resolution plus envelope dedupe would bill or wreck the wrong
post. The pool yard ships exactly ONE and pins it, so this is armed, not
firing. Disarm it before a yard author trips it.
## Gate 6 — JOHN PLAYS (live URL, the standing gate)
partly.party/hardyards after integration. The week where saying no costs you,
and one night you arrive at somebody else's disaster with a knife. Three
sentences in THREADS — and this sprint they steer arc 2's close: the cowboy
audit and warranty-as-a-night are the two job types still unbuilt, and yards
56 (the granny-flat alley, the glasshouse) are waiting on an author.
**Deploy note carried from part 1:** main is one sprint ahead of live —
`tools/deploy_hardyards.sh` needs John's hand or his green light (the
integrator's session was permission-gated). The rails say the gap never exceeds
one sprint; part 2 must not ship before part 1 is up.

1910
THREADS.md

File diff suppressed because it is too large Load Diff

View File

@ -817,6 +817,73 @@
"status": "PASS",
"problems": []
},
{
"name": "hail_net_frame_01",
"dims": [
3.42,
3.02,
2.766
],
"tris": 328,
"nodes": [
"footings",
"hail_net_frame_01",
"legs",
"net_anchor_01",
"net_anchor_02",
"net_anchor_03",
"net_anchor_04",
"net_roll",
"top_frame"
],
"anchors": [
{
"node": "net_anchor_01",
"anchor_type": "hail_net_frame",
"rating_hint": 0.8,
"collateral": "glasshouse"
},
{
"node": "net_anchor_02",
"anchor_type": "hail_net_frame",
"rating_hint": 0.8,
"collateral": "glasshouse"
},
{
"node": "net_anchor_03",
"anchor_type": "hail_net_frame",
"rating_hint": 0.8,
"collateral": "glasshouse"
},
{
"node": "net_anchor_04",
"anchor_type": "hail_net_frame",
"rating_hint": 0.8,
"collateral": "glasshouse"
}
],
"status": "PASS",
"problems": []
},
{
"name": "hail_net_frame_01_wrecked",
"dims": [
4.3007,
3.02,
2.7832
],
"tris": 480,
"nodes": [
"footings",
"hail_net_frame_01_wrecked",
"legs",
"net_down",
"top_frame"
],
"anchors": [],
"status": "PASS",
"problems": []
},
{
"name": "pool_kit_01",
"dims": [

View File

@ -20,6 +20,25 @@ Outputs (resolved from this file, so no absolute home paths):
tools/blender/contact_sheet.png verification render vs the 1.7 m capsule
tools/blender/asset_report.json measured dims / tris / node names
BLENDER VERSION IS PART OF THE OUTPUT (SPRINT18, E measured, not guessed).
The shipped GLBs were exported by Blender **5.1.2** (glTF I/O v5.1.20), which
`asset_report.json` records in its `blender` field check it before you
build. On Blender 5.0.1 (I/O v5.0.21) this script is still perfectly
deterministic three consecutive full runs gave 56/56 byte-identical
artifacts but its output DIFFERS from the shipped files in two ways, and
both are cosmetic:
· GLB accessor min/max drift in the 7th decimal (~6e-8 m, i.e. 60 nm).
Node graph, mesh topology, materials and every custom prop are
bit-identical; only float formatting of the bounds moves.
· The generated PNGs lose their sRGB/gAMA/cHRM chunks 73 bytes per file.
IDAT pixel data decompresses bit-identically; the newer exporter simply
writes colour-space chunks that 5.0.x omits.
So a full run on the wrong Blender rewrites all 45 binaries with no change
anybody asked for, and quietly DROPS the colour-space chunks. If your `git
status` shows every GLB and texture modified after adding one asset, that is
this and not your work: `git checkout --` the ones you did not author, and
commit only the new files. Better: build on 5.1.2 and the churn is zero.
Idiom follows ~/Documents/Destroyulater/3D-STORE/racks_to_glb.py:
reset_to_empty() per asset -> build under a root empty AT THE ORIGIN ->
join by group -> stamp custom props -> export_scene.gltf(export_yup=True,
@ -150,6 +169,23 @@ PAL = {
# a pool is the one water in the game that is
# supposed to look inviting
"deck_board": "#A8845C", # decking oil, a shade lighter than fence timber
"net_frame_white": "#E6E7E1", # SPRINT18 gate 4 — the hail-net frame's
# powder-coat, and the BRIGHTEST entry in this
# palette on purpose. The reason is metalness,
# not lightness — S17's measured lesson applied
# deliberately this time. Galvanised ships at
# metallic 0.90, and with no environment map a
# near-mirror has nothing to mirror, so every
# steel you are ALLOWED to trust renders
# near-black at yard distance (measured: rgb
# 49,51,49 at 20 m). Powder-coat is paint —
# metallic 0.05, diffuse — so this frame reads
# as a white cage standing over the glass from
# the far fence, and can never be misread as
# the galvanised steel of an honest sail post.
"net_mesh": "#3A4340", # knitted HDPE hail mesh: dark, matte, and the
# only large dark mass any yard carries up at
# 2.7 m. Rolled it is a bar; down it is a heap.
}
# Rainwater caught in a sail is not swimming-pool blue. It's shallow, murky, it
@ -1953,6 +1989,283 @@ def build_glasshouse_01_wrecked(name):
return root
# ---------------------------------------------------------------------------
# THE HAIL-NET FRAME (SPRINT18 gate 4 — the glasshouse tier's LIVE half)
# ---------------------------------------------------------------------------
# SPRINT16 shipped the glasshouse and priced it at 320. SPRINT18 read the gate
# and found the thing the price was waiting on, written in e.test's own words:
# "nothing in the sim wrecks a glasshouse yet — the value is inert data". The
# apex could not be reached. main.js bills collateral off a BROKEN RIG CORNER
# (`world.anchor(c.anchorId).collateral`), and a glasshouse deliberately offers
# no anchors — correctly, it is the one rule pinned from every direction — so
# the most expensive object in the game could not cost anybody a cent. That is
# the free-failure bug inverted: not a hazard billing nothing by accident, but
# a hazard that cannot bill at all by construction.
#
# DESIGN.md line 179 already named the missing asset: "Jobs: hail nets and mesh
# sails rigged OVER glass, where your own failure is the worst debris of all."
# You do not tie to the glasshouse. You tie to the frame that STRADDLES it —
# and every corner of that frame is a corner whose failure drops steel, cloth
# and hardware through fourteen panes.
#
# So this is the tier, and its teaching is new. Every other trap in this game
# punishes you with a bad number: the carport beam at 0.22, the fascia at 0.35,
# the corroded post at 0.55 whose real capacity you cannot read. This one is
# GOOD hardware — purpose-built, plumb, on its own footings, factory eyes —
# and it is the most dangerous anchor in the game because of WHERE IT STANDS.
# The rating is not the hazard. The position is.
NET_FRAME = dict(
CLEAR_X=0.30, # portal stands this far clear of each gable end
CLEAR_Y=0.45, # legs stand this far outside the long walls, on soil
HEAD_H=2.75, # head-beam height. MUST clear the ridge (2.28) — a frame
# that fouled the glass it protects is a joke, and
# e.test measures the clearance off the two shipped GLBs.
TUBE=0.032, # 32 mm net-frame section
PAD=0.22, # footing pad
)
# The rung. PROPOSAL — Lane A rules the number, same as every rung before it.
#
# Ladder as shipped: 0.22 carport beam < 0.30 carport post < 0.35 house fascia
# < 0.45 swing frame < 0.55 corroded post < 0.65 pergola < 1.00 sail post.
# This goes at 0.80, and the argument has two halves:
#
# · WHY IT OUT-RATES THE PERGOLA (0.65) BY A DISTANCE. The pergola is a deck
# structure screwed to a house and it flexes; the corroded post's capacity
# is unknowable; the swing frame stands on grass. This is an engineered
# portal frame on four real footings with welded eyes, and it is the only
# structure in the palette that was BUILT to be tied to. Rating it near the
# bad steel would be the lie the corroded post exists to punish, inverted:
# honest steel de-rated for looking dangerous.
# · WHY IT IS NOT THE SAIL POST (1.00). The section is 32 mm sized for hail
# mesh — a couple of hundred grams per square metre, and near-zero drag
# because mesh SPILLS wind rather than catching it. A 25 m² impermeable
# shade sail in a southerly is a load one order of magnitude larger. 0.80
# is a 20% de-rate for "engineered for a load you are about to exceed".
#
# And 0.80 is chosen so that IT USUALLY HOLDS. That is the point of the tier,
# not a softness in it: a corner that fails one night in five costs you nothing
# on a sail post and 320 dollars here. The player who learns "0.80 is fine" on
# every other yard is exactly the player this yard is for. The lesson is not
# "don't trust this steel" — the steel is fine. It is "the same failure has a
# different price depending on what is underneath it", which is the sentence
# DESIGN.md line 179 has been waiting for an asset to say.
NET_FRAME_RATING = 0.80
def _net_frame(name, root, steel, conc, mesh_m, *, wrecked=False):
"""The portal frame, shared by intact and wreck so the two cannot drift.
The wreck fails at the -x portal ONLY: its feet kick out of their pads and
its head beam comes down onto the glasshouse ridge. +x stands, holding the
eave rails up, which is why the wreck keeps the intact height a hail-net
frame does not fall over, it folds at one end, and the end it folds is the
end that empties the glass."""
G, N = GLASSHOUSE, NET_FRAME
hx = G["W"] / 2 + N["CLEAR_X"] # 1.60 — clear of the gable ends
hy = G["D"] / 2 + N["CLEAR_Y"] # 1.40 — clear of the long walls
H, r, pad_h = N["HEAD_H"], N["TUBE"] / 2, 0.06
def foot(sx, sy):
"""Where the leg meets the ground. Wrecked -x feet are OUT of the pad —
a foot beside its own footing is the most legible steel failure there
is, and it costs one offset to say."""
if wrecked and sx < 0:
return (sx * hx - 0.45, sy * hy, pad_h + (0.14 if sy > 0 else 0.0))
return (sx * hx, sy * hy, pad_h)
def head(sx, sy):
"""Where the leg meets the top frame. The wrecked -x portal has come
down and is RESTING ON THE RIDGE (2.28) rather than passing through it:
the glasshouse's own wreck keeps its aluminium frame standing at full
height, so a beam that ended up below the ridge line would be inside
it. Lying ACROSS the ridge is both true and non-interpenetrating.
The head comes IN (+x, dragged by the sail) while the feet kick OUT
(-x) the portal RACKS into a parallelogram, the carport's failure
mode on a lighter section. The offsets are deliberately large (0.45
each way, ~22° of lean over 2.3 m) because I looked at 0.30/0.16 in
the render and the top frame still read as a clean level rectangle:
a 0.43 m drop on a 2.77 m frame of 32 mm tube is invisible in
perspective, and a wreck you cannot see is not a wreck."""
if wrecked and sx < 0:
return (sx * hx + 0.45, sy * hy * 0.90, G["RIDGE_H"] + 0.05)
return (sx * hx, sy * hy, H)
def mid(sx):
a, b = head(sx, -1), head(sx, 1)
return ((a[0] + b[0]) / 2, 0.0, (a[2] + b[2]) / 2)
pads, legs = [], []
for sx in (-1, 1):
for sy in (-1, 1):
pads.append(add_box(f"{name}_pad_{sx}_{sy}", (N["PAD"], N["PAD"], pad_h),
(sx * hx, sy * hy, pad_h / 2), conc))
legs.append(add_tube_between(f"{name}_leg_{sx}_{sy}",
foot(sx, sy), head(sx, sy), r, steel, verts=8))
join_group(pads, "footings", root)
join_group(legs, "legs", root)
top = []
for sx in (-1, 1): # portal head beams, spanning y
top.append(add_tube_between(f"{name}_head_{sx}", head(sx, -1), head(sx, 1),
r, steel, verts=8))
for sy in (-1, 1): # eave rails, spanning x
top.append(add_tube_between(f"{name}_eave_{sy}", head(-1, sy), head(1, sy),
r, steel, verts=8))
# The tensioning wire down the middle, deliberately thinner than the frame:
# at 20 m the gauge difference is the "made for a net" read, and it is the
# honest reason the whole thing rates 0.80 instead of 1.00.
top.append(add_tube_between(f"{name}_ridge_line", mid(-1), mid(1),
r * 0.35, steel, verts=6))
join_group(top, "top_frame", root)
if not wrecked:
# Rolled along the +y eave, because the JOB is to rig it. A deployed
# net would be a finished yard, and this asset exists to be worked on.
join_group([add_tube_between(f"{name}_roll", (-hx, hy, H - 0.11),
(hx, hy, H - 0.11), 0.085, mesh_m, verts=10)],
"net_roll", root)
else:
# YOUR debris, not the storm's. The net has come off its lashings at the
# collapsed end and is draped down over the glass and heaped out past
# the footing — deterministic scatter, so the same wreck twice.
rng = rng_for(name)
drape = []
pts = [(hx * 0.85, hy, H - 0.10),
(hx * 0.10, hy * 0.92, H - 0.78),
(-hx * 0.55, hy * 0.80, G["RIDGE_H"] * 0.55),
(-hx - 0.28, hy * 0.55, 0.24),
(-hx - 0.80, hy * 0.20, 0.06)]
for i in range(len(pts) - 1):
drape.append(add_tube_between(f"{name}_net_sag_{i}", pts[i], pts[i + 1],
0.055, mesh_m, verts=6))
for i in range(9):
a = rng.uniform(0, math.tau)
rr = rng.uniform(0.05, 0.45)
drape.append(add_box(
f"{name}_net_heap_{i}",
(rng.uniform(0.22, 0.50), rng.uniform(0.16, 0.40), rng.uniform(0.03, 0.09)),
(-hx - 0.58 + math.cos(a) * rr, hy * 0.20 + math.sin(a) * rr * 0.8,
rng.uniform(0.02, 0.07)),
mesh_m, rot=(0, 0, rng.uniform(0, math.pi))))
join_group(drape, "net_down", root)
return hx, hy
def build_hail_net_frame_01(name):
"""The hail-net frame — four good anchors in the worst place in the game.
The glasshouse tier's live half (SPRINT18 gate 4). This is what DESIGN.md
line 179 means by "hail nets and mesh sails rigged OVER glass": a portal
frame that straddles the glasshouse, clear of it on all four sides, with a
head height that clears the ridge and a rolled mesh net waiting to go up.
THE TELL, and it is a position rather than a defect. Every reading of every
other trap in this game is a bad number on the object. This object's numbers
are GOOD 0.80, plumb, four footings, welded eyes and what the player has
to read instead is the plan view: the frame BRACKETS the glass. e.test pins
that geometrically off the two shipped GLBs (frame footprint strictly
contains the glasshouse footprint; head height strictly clears the ridge),
because "it stands over the glass" has to be a measurement, not a vibe.
The 20 m read is the powder-coat: white, matte, metallic 0.05, against a
palette where every trustworthy steel is metallic 0.90 and therefore renders
near-black. A white cage over a glass box, visible from the far fence.
WHAT THE ANCHORS SAY. All four bake collateral="glasshouse". They are the
only nodes in the palette whose collateral names a DIFFERENT structure than
the one they sit on (the house's fascia→"gutter" is the precedent, and it is
the same shape: the anchor names what its failure TAKES, not what it is).
Break a corner here and the sail, the shackle and the turnbuckle go through
fourteen panes: your own failure is the worst debris of all, wired as data.
AND WHAT THIS ROOT DELIBERATELY DOES NOT CARRY: collateral_key. See below
it is the whole of gate 5 in one omission.
"""
root = add_empty(name)
# Powder-coat is PAINT, so metallic stays near zero. This is not a style
# choice, it is the S17 measurement: high metalness with no environment map
# renders near-black, and this asset's entire long-range tell is that it is
# the brightest thing in the yard.
steel = get_material("Mat_NetFrame", PAL["net_frame_white"], 0.50, metallic=0.05)
mesh_m = get_material("Mat_HailNet", PAL["net_mesh"], 0.85, metallic=0.0)
conc = get_material("Mat_Concrete", PAL["concrete"], 0.95)
hx, hy = _net_frame(name, root, steel, conc, mesh_m)
# The four eyes, at the four corners of the top frame — where a net corner
# ties, and therefore where a sail corner will be tied instead.
for i, (sx, sy) in enumerate(((-1, -1), (1, -1), (1, 1), (-1, 1))):
e = add_empty(f"net_anchor_{i + 1:02d}",
(sx * hx, sy * hy, NET_FRAME["HEAD_H"] - 0.06), root, size=0.18)
e["anchor_type"] = "hail_net_frame"
e["rating_hint"] = NET_FRAME_RATING
e["collateral"] = "glasshouse"
e["why"] = ("sound steel, and the most expensive corner on the board: it is "
"rated for hail mesh, and everything it drops lands on glass")
stamp(root, name, "structure")
root["site_hint"] = "glasshouse"
root["is_anchor_trap"] = True
root["rated_for"] = ("knitted hail mesh, ~0.2 kg/m2, near-zero drag — mesh spills "
"wind. A 25 m2 impermeable sail is an order of magnitude more.")
# ⚠️ NO collateral_key ON THIS ROOT, AND THAT IS THE POINT (gate 5).
#
# world.js resolves a collateral key to a structure with structFor(), which
# returns the FIRST structure in site order whose key matches. If this frame
# claimed collateral_key="glasshouse" — which looks like the helpful thing
# to do, since its anchors bill that key — then in a yard where the frame is
# authored before the glasshouse, every failure would bill "the glasshouse"
# for 320 dollars and then WRECK THE FRAME: the player watches their own
# frame fold while the invoice charges them for panes that are still in
# their rebates. The bill and the wreck would agree with each other and
# both be wrong, which is the only kind of wrong this repo cannot see.
#
# So exactly ONE structure owns a key, and it is the one that owns the
# price. This frame POINTS at the key and never claims it. The pointer
# lives in a field nothing resolves, so it can document intent without
# arming anything.
root["collateral_target"] = "glasshouse"
root["rigs_over"] = "glasshouse_01"
return root
def build_hail_net_frame_01_wrecked(name):
"""The frame after the corner you trusted let go.
The -x portal has folded: both its feet are out of their pads, and its head
beam is lying across the glasshouse ridge with the net draped down over the
glass and heaped out past the footing. The +x portal stands, holding the
eave rails at full height so this wreck is the same height as the intact
frame, which is the honest read: a portal frame folds at one end, it does
not lie down.
It carries NO GLASS. The glasshouse has its own wreck variant and swaps
independently; a net-frame wreck that shipped shards would draw them twice
the moment both swapped on the same night, and would also quietly claim
the glass's failure as its own. e.test pins the absence.
UNPRICED, BY RULING the bike/jacaranda pattern. A folded portal frame is
obviously a repair bill, but the player is being taught about the glass, and
one broken corner can only name one collateral key. Inventing a second bill
with no mechanism to split it between them is exactly the guess the invoice
exists to refuse. Price it when a break can bill two things."""
root = add_empty(name)
steel = get_material("Mat_NetFrame", PAL["net_frame_white"], 0.50, metallic=0.05)
mesh_m = get_material("Mat_HailNet", PAL["net_mesh"], 0.85, metallic=0.0)
conc = get_material("Mat_Concrete", PAL["concrete"], 0.95)
_net_frame(name, root, steel, conc, mesh_m, wrecked=True)
stamp(root, name, "structure")
root["broken_variant_of"] = "hail_net_frame_01"
# No anchor survives the wreck — the fascia rule, sixth application. An
# anchor that outlives its structure is the free-failure bug in a costume.
root["collateral_target"] = "glasshouse"
return root
# ---------------------------------------------------------------------------
# THE POOL KIT (SPRINT16 gate 5 — the compliant ring whose steel all says no)
# ---------------------------------------------------------------------------
@ -3968,6 +4281,25 @@ ASSETS = [
dict(name="glasshouse_01_wrecked", fn=build_glasshouse_01_wrecked,
dims=((3.4, 5.4), (2.6, 3.8), (2.15, 2.40)),
nodes=["frame", "door", "panes_left", "shards"]),
# SPRINT18 gate 4 — the glasshouse tier's live half. Every band here is a
# design claim about the glass underneath it, which is why they are tight:
# x and y floors must both EXCEED the glasshouse's ceilings (2.8 / 2.1), or
# the frame has stopped straddling the thing it bills; z must clear the
# ridge (2.40 ceiling) or a frame built to protect glass is resting on it.
dict(name="hail_net_frame_01", fn=build_hail_net_frame_01,
dims=((3.30, 3.55), (2.90, 3.15), (2.70, 2.85)),
nodes=["footings", "legs", "top_frame", "net_roll",
"net_anchor_01", "net_anchor_02", "net_anchor_03", "net_anchor_04"]),
# Folded at one end, not felled: the SAME z band as the intact frame,
# because the +x portal stands and holds the eave rails up. If z ever
# collapses, someone has laid a portal frame down flat and it never does
# that. x runs WIDE on the -x side — the net heaped out past the footing,
# and the wreck apron a yard author has to keep clear.
# 4.10 floor, not 4.30: the measured span is 4.30 and it is set by the NET
# HEAP, not by the racked feet, so it must not sit on the band boundary.
dict(name="hail_net_frame_01_wrecked", fn=build_hail_net_frame_01_wrecked,
dims=((4.10, 5.30), (2.90, 3.15), (2.70, 2.85)),
nodes=["footings", "legs", "top_frame", "net_down"]),
# The compliance ring. z is pinned tight around 1.2 m because the height
# IS the flavour (AS 1926.1); y reaches past the ring for the ajar gate.
dict(name="pool_kit_01", fn=build_pool_kit_01,

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 MiB

After

Width:  |  Height:  |  Size: 5.1 MiB

View File

@ -19,7 +19,11 @@
* are winners. The garden side of the audit lives in gardenfly.js (browser).
*/
import { SailRig, orderRing } from '../../web/world/js/sail.js';
// OVERLOAD_SECS / OVERLOAD_RECOVER are the failure law itself, imported rather
// than re-typed: gate 3 replaces a percentage of taste with sail.js's own rule,
// and a second copy of the rule would be the same drift the shared sweep exists
// to prevent.
import { SailRig, orderRing, OVERLOAD_SECS, OVERLOAD_RECOVER } from '../../web/world/js/sail.js';
import { windForSite } from '../../web/world/js/weather.js';
import { HARDWARE, START_BUDGET, FIXED_DT } from '../../web/world/js/contracts.js';
// gate 2 (SPRINT17): one validator for the constraint shapes, shared with the
@ -32,12 +36,23 @@ export const AUDIT = {
MIN_COVER: 0.25, // A's "shades the bed" bar
SPARE_COST: 15, // a $15 spare is the difference between a repair and a prayer
/**
* C's margin rule (SPRINT13 correction): even a corrected bench under-reads
* the real UI's peaks by ~515% on site_02 (cause unfound, in the pool).
* Until it's found, a corner priced within this fraction of its effective
* rating "breaks in the game" the bench held q4 at 1.24-vs-1.2 and read
* 91.9 FULL; the real game pushed it over and shipped D's 39.8 TATTERED.
* A line with a marginal corner is flagged, never sold as a clean PASS.
* C's margin rule ITS CAUSE IS FOUND, AND IT NO LONGER DECIDES ANYTHING
* (SPRINT18 gate 3).
*
* It was invented because "even a corrected bench under-reads the real UI's
* peaks by ~515% on site_02, cause unfound". The cause was `porosity`: the
* shipped game never called `rig.setFabric`, so the sim flew the waterproof
* membrane (full wind load) every night while this sweep correctly flew the
* shade cloth every card promised. Gate 2's measurement, on two sites and two
* storms: with the fabric held equal the bench and the FULL game chain agree to
* three decimals on all four corners. There is no residual left to pad.
*
* So the 15% is not a physical fact and never was; it was a fudge factor
* cancelling a missing function call, and keeping it would now make the audit
* lie in the other direction (selling $15 of steel nobody needs). The
* clean/marginal boundary is ruled by sail.js's OWN failure law instead see
* `priceCandidate`. This constant survives only as the DISPLAY band the
* front-ends have always printed, so their prose keeps a number to name.
*/
MARGIN: 0.15,
/**
@ -231,7 +246,34 @@ export function priceCandidate(cnd, { anchors, stormDef, wind, budget = START_BU
// windy night — the sweep charter, named on the card since SPRINT17 gate 0.3
const rig = new SailRig({ anchors, gridN: 10, porosity: AUDIT.POROSITY })
.attach(cnd.ids, Array(4).fill({ name: 'audit', cost: 0, rating: Infinity }), 1.0);
for (let i = 0; i < stormDef.duration * 60; i++) rig.step(FIXED_DT, wind, i * FIXED_DT);
// SPRINT18 gate 3 — REPLAY THE FAILURE LAW, don't pad the peak.
//
// sail.js breaks a corner on DWELL, not on peak: `overload` fills at +dt above
// the effective rating, bleeds at OVERLOAD_RECOVER·dt below it, and the corner
// lets go past OVERLOAD_SECS. A peak-only price cannot express that, which is
// why the audit needed a 15% pad to stand in for it. So run the real timer, for
// every corner against every tier in the shop, as the flight happens: 12
// accumulators, no trace buffers, and no second copy of the law (the constants
// are imported from sail.js).
//
// `worst` is the closest that corner/tier pair ever came to letting go, in
// seconds of the 0.4 s it takes. worst === 0 means the load never once reached
// the rating; 0 < worst < OVERLOAD_SECS means it went over and survived on
// TIMING; worst >= OVERLOAD_SECS means the game breaks it.
const dwell = rig.corners.map(() => HARDWARE.map(() => ({ o: 0, worst: 0 })));
for (let i = 0; i < stormDef.duration * 60; i++) {
rig.step(FIXED_DT, wind, i * FIXED_DT);
for (let k = 0; k < rig.corners.length; k++) {
const c = rig.corners[k], hint = c.anchor.ratingHint ?? 1, load = c.load;
for (let h = 0; h < HARDWARE.length; h++) {
const d = dwell[k][h];
if (load > HARDWARE[h].rating * hint) d.o += FIXED_DT;
else d.o = Math.max(0, d.o - FIXED_DT * OVERLOAD_RECOVER);
if (d.o > d.worst) d.worst = d.o;
}
}
}
// Price each corner against its anchor's EFFECTIVE strength. c.anchor is the
// resolved anchor handed in above; `?? 1` mirrors sail.js for bare fixtures.
@ -245,15 +287,32 @@ export function priceCandidate(cnd, { anchors, stormDef, wind, budget = START_BU
// A corner whose `tier` sits inside the margin band is `marginal`: it
// holds on this bench and "breaks in the game" (the residual's working
// rule). The row's clean price is what closing that gap costs.
const tiers = rig.corners.map((c) => {
const tiers = rig.corners.map((c, k) => {
const hint = c.anchor.ratingHint ?? 1;
const tier = tierFor(c.peakLoad, hint);
return { id: c.anchorId, peak: c.peakLoad, hint, tier,
cleanTier: tierFor(c.peakLoad / (1 - AUDIT.MARGIN), hint),
const d = dwell[k];
// The cheapest tier the LAW lets live — it may go over its rating, so long as
// no excursion lasts the 0.4 s that breaks it. This is the gamble price.
const tier = HARDWARE.find((h, h_i) => d[h_i].worst < OVERLOAD_SECS) ?? null;
// The cheapest tier that never reaches its rating at all. THIS is "clean",
// measured: the steel is simply stronger than the night, with no reliance on
// a gust being brief. It replaces `tierFor(peak / (1 - MARGIN))`.
const cleanTier = HARDWARE.find((h, h_i) => d[h_i].worst === 0) ?? null;
return { id: c.anchorId, peak: c.peakLoad, hint, tier, cleanTier,
// seconds of the 0.4 s fuse the CHOSEN tier burned — 0 is untouched steel
dwell: tier ? +d[HARDWARE.indexOf(tier)].worst.toFixed(3) : null,
// effective rating in newtons, so a reader never has to do hint arithmetic:
// a $5 carabiner is 1.2 kN on the price list and 1.056 on a 0.88 branch, and
// that haircut is the whole of the cheap tier's danger (gate 3).
effN: tier ? Math.round(tier.rating * hint) : null,
cleanEffN: cleanTier ? Math.round(cleanTier.rating * hint) : null,
headroom: tier ? +(1 - c.peakLoad / (tier.rating * hint)).toFixed(3) : null };
});
const unholdable = tiers.filter((c) => !c.tier);
const marginal = tiers.filter((c) => c.tier && c.headroom < AUDIT.MARGIN);
// MARGINAL, re-ruled by measurement: the tier survives the night only because
// every excursion above its rating was shorter than the fuse. That is a real
// knife edge with a real mechanism — "it held on timing, not on strength" — and
// it is the thing the 15% pad was groping for.
const marginal = tiers.filter((c) => c.tier && c.dwell > 0);
const hw = tiers.reduce((s, c) => s + (c.tier ? c.tier.cost : 0), 0);
const cleanHw = tiers.every((c) => c.cleanTier)
? tiers.reduce((s, c) => s + c.cleanTier.cost, 0) : null;

View File

@ -119,21 +119,46 @@ test('pricing reads the ANCHOR, not just the steel — ratingHint reaches tierFo
'an over-ceiling corner must land in unholdable, or the verdict lies');
});
test('the margin rule: a corner inside 15% of its effective rating is not a clean win', () => {
// SPRINT13, C's residual: even a corrected bench under-reads the real UI's
// peaks by ~5-15%, so "any corner within ~15% of rating breaks in the game".
// The sweep must therefore refuse to call a knife-edge line a winner — the
// wild-night 91.9-FULL-with-q4-at-1.24-vs-1.2 headline died of exactly this
// (D's UI: 39.8 TATTERED). Craft a hint that leaves the best steel ~8%
// headroom on a1's measured peak: still priced, still "holds" on paper,
// and the verdict must refuse to bless it. Delete the marginal logic from
// sweep.js and this goes red on the verdict code.
test('SPRINT18 gate 3: the boundary is DWELL against the fuse, not a percentage of peak', () => {
// WHAT THIS REPLACES. SPRINT13 priced every corner twice — `tier` (peak <=
// rating) and `cleanTier` (peak <= rating × 0.85) — because C had measured the
// bench under-reading the real UI by 515% with "cause unfound", so 15% of
// taste stood in for it. Gate 2 found the cause (the shipped game never called
// `rig.setFabric`, so it flew the membrane while every card said shade cloth);
// with the fabric held equal the bench and the full game chain agree to three
// decimals. There is no residual left, so the pad is not covering anything.
//
// But the real reason a percentage had to go is that NO percentage can work.
// Measured on the wildnight pin, one flight, two corners on identical $15
// shackles (THREADS [B] SPRINT18 gate 3):
//
// p1 peak 3.528 kN / 3.20 = 10.3% OVER its rating -> HELD (dwell 0.150 s)
// p2 peak 3.493 kN / 3.20 = 9.2% OVER its rating -> BROKE (dwell 0.400 s)
//
// p1 goes HIGHER than p2 and survives. Ordering by peak is INVERTED against
// the outcome, so a rule of the form "peak within X% of rating" is wrong at
// every X: 15% condemns p1's perfectly good steel, 9% blesses p2's break.
// sail.js has never used peak — it fills an `overload` timer while a corner is
// over its rating, bleeds it at OVERLOAD_RECOVER, and lets go past
// OVERLOAD_SECS. `priceCandidate` replays exactly that, so the audit and the
// sim now answer "will it hold" with the same arithmetic.
//
// What this fixture can prove: a corner with 8% headroom is now CLEAN (its load
// never reaches the rating at all), where the old rule called it marginal —
// that IS the re-ruling. Take the dwell replay out of sweep.js and `cleanTier`
// falls back to a pad, `cleanHw` stops equalling `hw`, and this goes red.
const sweep = (anchors) => auditSweep({ anchors, bed: BED, stormDef: STORM, venturi: [] });
const honest = sweep(ANCHORS);
const a1h = honest.rows[0].tiers.find((c) => c.id === 'a1');
assert(a1h.tier, 'fixture drift: a1 must be holdable at hint 1');
assert(honest.verdict.ok && honest.verdict.code === 'pass',
'fixture drift: the honest yard must be a clean pass or this test asserts nothing');
assert(honest.rows[0].tiers.every((c) => c.dwell === 0),
`the honest yard burned fuse on ${honest.rows[0].tiers.filter((c) => c.dwell > 0).map((c) => c.id)}` +
'a yard nothing is over-loaded on must replay a dwell of exactly 0');
assert(honest.rows[0].cleanHw === honest.rows[0].hw,
`cleanHw $${honest.rows[0].cleanHw} != hw $${honest.rows[0].hw} on a yard where no corner ever reaches ` +
'its rating. That gap is the retired 15% pad — the audit is charging for steel the night cannot need');
const RATED = HARDWARE.at(-1);
const hint = a1h.peak / (RATED.rating * 0.92); // → best steel holds a1 with 8% headroom
@ -142,11 +167,26 @@ test('the margin rule: a corner inside 15% of its effective rating is not a clea
const a1 = row.tiers.find((c) => c.id === 'a1');
assert(a1.tier === RATED, `a1 at the crafted hint must price to the rated shackle, got ${a1.tier?.name}`);
assert(a1.headroom > 0 && a1.headroom < AUDIT.MARGIN,
`fixture: a1's headroom ${a1.headroom} must sit inside (0, ${AUDIT.MARGIN})`);
assert(row.marginal.some((c) => c.id === 'a1'), 'a1 must land in row.marginal');
assert(row.affordable && !row.clean, 'the line is affordable but must NOT be clean');
assert(!lied.verdict.ok && lied.verdict.code === 'marginal-only',
`the only affordable line is marginal — verdict must say so, got ${lied.verdict.code}/${lied.verdict.ok}`);
`fixture: a1's headroom ${a1.headroom} must sit inside (0, ${AUDIT.MARGIN}) — the band the OLD rule condemned`);
assert(a1.dwell === 0,
`a1 burned ${a1.dwell}s of fuse at 8% headroom. Headroom means the load never reached the rating, so the ` +
'fuse must never start — if this fires, the replay is reading the wrong quantity');
assert(a1.cleanTier === RATED && row.clean,
`8% headroom must now read CLEAN (it did not, got clean=${row.clean}): the steel is simply stronger than ` +
'the night, and calling it a knife edge was the pad talking');
assert(lied.verdict.ok && lied.verdict.code === 'pass',
`the verdict must bless a line no corner is ever over, got ${lied.verdict.code}/${lied.verdict.ok}`);
// and the other side of the boundary: drop the ceiling UNDER the peak and the
// tier is refused outright — this fixture's gusts are seconds long, so any
// excursion outlasts the 0.4 s fuse and there is no timing to survive on.
const tooWeak = sweep(ANCHORS.map((a) => (a.id === 'a1'
? { ...a, ratingHint: a1h.peak / (RATED.rating * 1.05), sway: a.sway } : a)));
const weak = tooWeak.rows[0].tiers.find((c) => c.id === 'a1');
assert(!weak.tier && tooWeak.rows[0].unholdable.some((c) => c.id === 'a1'),
`a1 priced to ${weak.tier?.name} with its ceiling 5% UNDER the peak — on a gust that lasts seconds the ` +
'fuse burns through and the corner is simply unholdable');
return 'clean = the fuse never starts (8% headroom passes); over the ceiling on a long gust = unholdable';
});
export const SWEEP_TESTS = TESTS;

View File

@ -95,7 +95,13 @@ windProxy.setSheltersFromTrees(world.anchors.filter((a) => a.type === 'tree'));
const rig = new SailRig({ anchors: world.anchors });
let sailView = null;
async function rigSail(anchorIds, hwChoices, tension) {
async function rigSail(anchorIds, hwChoices, tension, fabric) {
// SPRINT18 gate 2: the fabric BEFORE the attach. Without this line the sail
// flies `new SailRig`'s default porosity (0 — the waterproof membrane) no
// matter what the F key says, which is exactly the bug the shipped game had:
// the panel sold shade cloth and the sim flew full wind load. This is the
// shape of the one-line pickup filed for main.js's own rigSail.
if (fabric) rig.setFabric(fabric);
rig.attach(anchorIds, hwChoices, tension);
if (sailView) {
scene.remove(sailView);
@ -109,7 +115,7 @@ let msg = '';
let msgT = 0;
const ui = await createRiggingUI({
scene, camera: cameraRig.object, domElement: canvas, world,
onCommit: (ids, hw, tension) => { rigSail(ids, hw, tension); wind = wildWind; t = 0; phase = 'storm'; },
onCommit: (ids, hw, tension, fabric) => { rigSail(ids, hw, tension, fabric); wind = wildWind; t = 0; phase = 'storm'; },
onMessage: (m) => { msg = m; msgT = 2; },
});

View File

@ -80,6 +80,16 @@ export const FACTORY_ANCHOR_RATINGS = Object.freeze({
"sail_post_corroded/top_anchor"
]
},
"hail_net_frame": {
"collateral": "glasshouse",
"rating_hint": 0.8,
"sources": [
"hail_net_frame_01/net_anchor_01",
"hail_net_frame_01/net_anchor_02",
"hail_net_frame_01/net_anchor_03",
"hail_net_frame_01/net_anchor_04"
]
},
"house": {
"collateral": "gutter",
"rating_hint": 0.35,

View File

@ -265,6 +265,373 @@ export const POOL = [
},
];
/**
* **SPRINT18 gate 0 DECLINING A BOOKED NIGHT COSTS YOU. JOHN'S RULING,
* 2026-07-25: "it adds pressure."**
*
* D's cold play found the hole and named it from the chair: the board was a FREE
* OPTION. Twice in one week it offered a fee-maximizer a dodge around the
* campaign's own set-pieces the wildnight for a Tuesday southerly at similar
* money, and the designed-loss icenight against a winnable constrained job at
* the SAME $68 and saying no cost nothing. A player could skip the week's two
* hardest lessons and never know they existed.
*
* THE NUMBER, AND WHY IT IS THIS NUMBER
*
* Standing is the currency, because standing is the currency arc 1 built for
* exactly this: `week.js` §REP already prices your name, moves it at dawn with
* its reasons printed, and pays you less when it's low. A new "reliability"
* number beside it would be two scores for one idea, and the second one would
* be the one nobody read.
*
* The cost is **the mirror of `REP.CLEAN`** a stood-up client costs you
* exactly what a perfect night earns. That is not an aesthetic choice, it is
* the sentence the week already says in numbers: *one night of flawless work,
* cancelled by one phone call you didn't make.* Any smaller and the dodge is
* still free with extra steps; any larger and it outweighs `REP.COLLATERAL`
* (1.0), which would rank standing a client up above WRECKING THEIR PROPERTY,
* and the invoice would be arguing with itself.
*
* The number itself lives in `week.js` (`REP.STOOD_UP`) with the rest of the
* reputation ruling this file must not import week.js (week imports board;
* the cycle is the reason board.js is pure data + pure functions). What lives
* HERE is the structural half: **who a take stands up**, which is a fact about
* the board and nothing else. `a.test` pins the mirror (`STOOD_UP === -CLEAN`)
* so the two halves cannot drift apart in silence.
*
* UNMEASURED the house rule, same status as `CONSTRAINT_PREMIUM` above and
* `REP` itself. Nobody has played a week where declining costs. The lever is one
* constant in week.js; if John's next play says the price is wrong, move THAT.
* Do not fund it by touching `REP.CLEAN`, which the whole rep ladder is measured
* against.
*
* THE ASYMMETRY, AND IT IS THE POINT
*
* You owe standing to a client who is EXPECTING you, and nobody else. The
* fiction is a tradie's two sources of work: **the spine slot is your diary, the
* pool is your phone.**
*
* · **the booked night** (`kind:'scripted'`) the diary. A client has you down
* for tonight; the ruling's own words are the "TONIGHT, AS BOOKED" offer.
* Taking anything else stands them up.
* · **a callout** (`kind:'callout'`) the phone. Nobody has you down, so
* declining it costs a fee and not a name. THE FIRST CUT OF THIS GOT IT
* BACKWARDS and the probe caught it in one call: I derived `booked` from the
* night alone, and the pool yard came back booked so taking the scripted
* job printed "the Karalis place is expecting you" over a client who had
* never been promised anything. A callout you turn down is a callout somebody
* else takes. Recorded because the wrong version READ plausible.
* · **an EMERGENCY callout in the diary slot (gate 1)** the storm is already
* running and the phone rang at ten at night. It is not a booking even though
* it arrives in the booking's chair: nobody had you down, so nobody is stood
* up. A morning whose board is two callouts and no booked job is a LEGAL
* board state, declining is genuinely FREE there, and the card must SAY so
* "no cost shown" and "no cost" have to be the same sentence on a document.
*
* So a booking is a DIARY ENTRY THAT IS NOT AN EMERGENCY, both halves checked,
* and the free case falls out of the filter returning nothing rather than out of
* an `if` somebody has to remember to write.
*
* @param {string} kind the offer's provenance — 'scripted' (the diary) or 'callout'
* @param {object} night the night entry
*/
export function isBooked(kind, night) {
return kind === 'scripted' && !night?.emergency;
}
/**
* Who taking `taking` stands up: every OTHER offer on this board that a client
* is expecting you at.
*
* Returns a LIST, not a boolean, for the reason board.js returns lists
* everywhere: today a morning has one booked job and at most one client can be
* stood up, and a `standsUpSomeone` boolean would have to be rewritten the first
* morning that offers three. The cost is `list.length × REP.STOOD_UP`, so the
* arithmetic scales with the board on its own.
*/
export function standUpsIfTaken(offers, taking) {
return (offers ?? []).filter((o) => o !== taking && o.booked);
}
/**
* 🚨 **SPRINT18 gate 1 THE EMERGENCY CALLOUT. DESIGN.md line 29, finally in
* the game:** *"Emergency callout mid-storm, right now, double rates. The
* chaos levels. Someone's sail is flogging itself to death over their
* glasshouse."* ROADMAP arc 2 sizes it exactly: *"the storm phase already does
* everything this needs except spawn you at t=30 with a rig you didn't build."*
*
* WHY THIS IS ITS OWN TABLE AND NOT A POOL ENTRY
*
* Both reasons are load-bearing and neither is tidiness:
*
* 1. **THE DRAW.** `alternativeFor` indexes the ELIGIBLE POOL by seed. A seventh
* pool entry therefore re-rolls every night's alternative it would change
* the board on six nights it isn't even on, invalidating the shipped board
* pins and D's seven-night cold-play record for nothing. Scheduled per night,
* every other morning's draw is byte-identical and `offers().length === 2`
* still holds.
* 2. **THE ONE-VARIABLE LAW.** An emergency is a SET PIECE, and the law needs an
* AUTHOR to place it: *"has the player already rigged this yard by night N"*
* is only a decidable question if N is authored. You cannot ask an RNG to
* respect a law about ordering. `oneVariableOffences` below is that law in
* code rather than in a comment, which SPRINT18's own spec demands of me
* ("A's ordering must make that impossible to get wrong by accident").
*
* `kind` stays `'callout'` its provenance is the phone, and an emergency is
* the most callout-y callout there is. The costume comes off `night.emergency`,
* so there is one source of truth and every existing pin stays green.
*
* THE SLOT: NIGHT 5's MORNING, OPPOSITE THE WILDNIGHT. RULED, with every
* other slot rejected for a stated reason
*
* · **site_02 × storm_03_southerly** the yard night 3 rigs, the storm night 2
* survives. **The only new variable is the JOB TYPE**, which is the law
* satisfied literally rather than argued around.
* · **not night 4** that morning's booked job is the swing lawn's DEBUT. Two
* new things on one board, and taking the emergency would kill the swing
* lawn's only slot in the whole campaign.
* · **not nights 6 or 7** the MEMORY needs somewhere to land. The Hendersons
* are on the board nights 1, 2, 5 and 6, so standing them up on night 5 puts
* the grudge on the icenight's card the very next morning. From 6 or 7,
* gate 0 clause 3 is unreachable in the shipped week a mechanic nobody
* would ever see.
* · and it answers D's cold-play finding #1 in its own words: *"the wildnight
* was declinable for a Tuesday southerly at similar money."* It still is
* at double rates now, and ½, both printed before you choose.
*/
export const EMERGENCIES = [
{
/** 1-based, the MORNING it arrives. Read by emergencyFor(index). */
night: 5,
id: 'corner_block_emergency',
storm: 'storm_03_southerly', site: 'site_02_corner_block',
client: 'the Vasilaros place', addr: '2 Bight Rd — the corner block',
brief: 'Nobody quoted this one. You are being asked how fast you can be there.',
emergency: {
/**
* Seconds into the storm you turn up ROADMAP's own number. STORM_LEN is
* 90, so an emergency night is 60 seconds long and every one of them is
* inside weather that is already working.
*/
arriveAt: 30,
/**
* THE PHONE CALL. This is the dispatch's whole brief, and it is deliberately
* not a forecast and not a quote: *there was no time* is the drama, and a
* client who reads you a wind speed at nine at night is a client who is not
* panicking. What the WEATHER is doing when you arrive is C's `midStormRead`,
* because nobody briefed you you have to look at the sky yourself.
*/
dispatch: 'Ten past nine and the phone goes. The corner block — they got a second sail put '
+ 'up over the carport side by a mob off a card in the letterbox, and it is coming '
+ 'apart in the dark. She is not asking what it costs. She is asking how fast you '
+ 'can be there.',
/**
* **B OWNS THIS: THE RIG YOU DIDN'T BUILD.** Per-corner
* `{anchorId, hw, broken?}` see THREADS [A] 2026-07-25 §2 for the shape
* and the seam. This is a MINIMAL FIRST CUT so the phase is verifiable end
* to end in my tree; it is flagged for B and revert-and-tell-me applies.
*
* What it says as authored, and why these four anchors: **the cowboy took
* the bait the player was warned about.** `cb1` is the carport beam the
* worst steel in the game (ratingHint under 0.35, a.test pins it below the
* fascia), $180 of collateral under it, and the client has been selling it
* as "plenty to tie off to" since night 3. A $5 carabiner on a 0.22 beam is
* ~264 N effective against a southerly, so **this corner is failing while
* you walk up the drive** emergently, through the real overload path,
* with the real event and the real invMass release, not through a poked
* flag. That is the night: **release, cut, or ride it out**, with $180 on
* the line and a knife in the ute. DESIGN.md line 165's signature decision,
* over the one piece of collateral in this game that a player has already
* been taught to fear.
*/
/**
* **B's AUTHORED SET, ADOPTED VERBATIM landed by D, and it should have landed at
* integration.** B published it in THREADS with the measurements ([B] gate 1 §5) and A
* pre-authorised the swap in the note above ("revert-and-tell-me applies yours is canon the
* moment it lands"). It never landed, because it is DATA IN A's FILE while B rules the
* semantics. **I found it by playing the night:** the dispatch card read *"CB1 carabiner ON
* THE GROUND"*, which is a different night from the one this sprint is about.
*
* Why the difference IS the gate and not a detail: in the placeholder the CARPORT CORNER
* ARRIVES ALREADY DOWN, so the $180 carport is `theirs` unbillable, unwreckable and there
* is nothing left for the knife to save. DESIGN.md line 165's signature decision had no
* object. In B's set `cb1` is a SHACKLE on the 0.22 beam (704 N effective, holding their roof)
* which the southerly puts over its ceiling from t=35 to t=42, and `tr1b` the 0.88 branch on
* the cheapest steel is the corner that arrived gone. That is B's measured triage window:
* ride it out cb1 blows @35.9 s and takes q1 the carport WRECKED, $180
* cut at t=33 nothing breaks, the carport saved
* 5.9 seconds. Measured by B on the rig; re-flown by D through the shipped game.
*/
rig: [
{ anchorId: 'q3', hw: 'shackle' }, // sound post, still holding
{ anchorId: 'q1', hw: 'carabiner' }, // under-specced
{ anchorId: 'tr1b', hw: 'carabiner', broken: true }, // 0.88 branch + cheapest steel: already gone
{ anchorId: 'cb1', hw: 'shackle' }, // ⚠️ the CARPORT BEAM, hint 0.22
],
},
/**
* **B's RULING ON THE ECONOMY OF TRIAGE, landed by D same story as the rig above: a ruling
* B made and could not apply, because it is per-job DATA in A's file.** ([B] gate 1 §2.)
*
* **The garden BONUS does not apply on a callout.** It is a per-HP payment for a bed you chose
* hardware to protect; you arrive at t=30 with thirty seconds of somebody else's storm already
* in that bed, under steel you did not buy. Paying per-HP prices the PREVIOUS contractor's
* geometry and docks you for their mistakes. Data, not a branch so A's acceptance criterion
* (§10: `settle()` runs unchanged on an emergency) holds exactly.
*
* **The garden's WIN still applies, and that is the knife's price.** `win = hp >= 50` gates the
* FEE (`PAY.lostNightShare`), so cutting spends garden HP and can spend 65% of the callout
* to buy certainty on their property. Ride it out for the full double rate and gamble their
* carport; cut and take the smaller cheque. The garden is the STAKE, not the wage.
*/
pay: { garden: 0 },
_why: 'THE PAIRING IS THE POOL\'S OWN AUDITED corner_block_southerly (A, S17 gate 1, '
+ 'audit.html): 19 holding lines / 7 marginal / 38 unholdable, cheapest hold $40 '
+ '(tr1,tr1b,q1,q3 → garden 87.6 FULL). ⚠️ THAT AUDIT ANSWERS A DIFFERENT QUESTION and '
+ 'the difference is flagged, not smoothed over: it measures whether a rig-for-tonight '
+ 'job can win here. Whether the INHERITED rig is TRIAGEABLE — what a knife, a spare and '
+ 'sixty seconds can do to somebody else\'s carabiners — is B\'s to measure, and the '
+ 'admission price for a board entry (this file\'s own rule) is not paid until they have. '
+ 'One-variable: the yard is night 3\'s, the storm is night 2\'s, the JOB TYPE is new.',
},
];
/**
* Tonight's scheduled emergency, or null. 0-based index in, 1-based `night` out
* the table is authored in the numbers a human counts in, because a table of
* set-pieces is read by people far more often than by code.
*/
export function emergencyFor(nightIndex, table = EMERGENCIES) {
return (table ?? []).find((e) => e.night === (nightIndex | 0) + 1) ?? null;
}
/**
* Fails loud on a malformed emergency `validateConstraint`'s precedent, and the
* same reasoning: a content bug must be a crash at the board, not a runtime
* surprise sixty seconds into a storm the player cannot leave.
*/
export function validateEmergency(night, where = '?') {
const e = night?.emergency;
if (!e) return night;
const bad = [];
if (!Number.isFinite(e.arriveAt) || e.arriveAt <= 0) {
bad.push('needs a positive numeric `arriveAt` — the storm clock you turn up on');
}
if (!e.dispatch || String(e.dispatch).length < 20) {
bad.push('needs `dispatch` — the phone call. There is no forecast and no quote; this is the whole brief');
}
if (!Array.isArray(e.rig) || e.rig.length !== 4) {
bad.push(`needs a 4-corner \`rig\` (the rig you didn't build), got ${e.rig?.length ?? 'none'}`);
}
for (const c of e.rig ?? []) {
if (!c?.anchorId) bad.push('a rig corner with no `anchorId`');
if (!c?.hw) bad.push(`rig corner ${c?.anchorId ?? '?'} has no \`hw\` — someone tied it to something`);
}
if (bad.length) throw new Error(`board: emergency on ${where} is invalid:\n ${bad.join('\n ')}`);
return night;
}
/**
* **THE ONE-VARIABLE LAW, IN CODE.** ROADMAP principle 5, and SPRINT18 is the
* first sprint that could break it by accident: a new JOB TYPE (the emergency
* callout) and a new YARD (E's glasshouse) ship in the same sprint, and DESIGN.md
* pairs them in one sentence. **They may not debut on the same night.** That
* sentence is the payoff, not the introduction.
*
* Constructed as C's `pairingRefusals` is, and for their stated reason: it
* **ACCUSES, it does not silently filter.** Dropping a bad entry would leave a
* morning with one offer and no explanation a runtime surprise wearing a fix's
* clothes. Every offence carries its measurement in the message.
*
* The three rules, and what each one actually catches:
* 1. **a NIGHTS emergency at index i needs its site in NIGHTS at some j < i**
* the emergency debuts over a yard the player has already rigged.
* 2. **an EMERGENCIES entry for night N needs its site rigged by an earlier
* night** same rule, the other table. This is the one the shipped data
* lives under.
* 3. **a POOL emergency needs its site in NIGHTS at all** a yard whose ONLY
* route into the game is under the new job type is precisely the accident.
* This is the rule that protects E: the glasshouse is not in NIGHTS, so it
* CANNOT be an emergency's stage this sprint, no matter who wires what.
*
* @param {object} o
* @param {object[]} o.nights the ladder (week.js NIGHTS)
* @param {object[]} [o.pool]
* @param {object[]} [o.emergencies]
*/
export function oneVariableOffences({ nights, pool = POOL, emergencies = EMERGENCIES }) {
const offences = [];
const siteOf = (n) => (typeof n === 'string' ? 'backyard_01' : n?.site ?? 'backyard_01');
const ladder = (nights ?? []).map(siteOf);
/** The first night index that rigs this yard, or -1. */
const firstRig = (site) => ladder.indexOf(site);
(nights ?? []).forEach((n, i) => {
if (typeof n === 'string' || !n?.emergency) return;
const j = firstRig(siteOf(n));
if (!(j >= 0 && j < i)) {
offences.push(`NIGHTS[${i}] (night ${i + 1}) is an EMERGENCY over ${siteOf(n)}, a yard `
+ `${j < 0 ? 'no night rigs' : `first rigged on night ${j + 1}`} — the new JOB TYPE and `
+ 'the yard would debut together. The emergency callout debuts over a yard the player has already rigged.');
}
});
for (const e of emergencies ?? []) {
const i = (e.night | 0) - 1;
const j = firstRig(siteOf(e));
if (!(j >= 0 && j < i)) {
offences.push(`EMERGENCIES '${e.id ?? '?'}' arrives on night ${e.night} over ${siteOf(e)}, a yard `
+ `${j < 0 ? 'no scripted night rigs' : `first rigged on night ${j + 1}`} — a player taking it `
+ 'would meet a new JOB TYPE and a new YARD on the same night.');
}
}
for (const p of pool ?? []) {
if (!p?.emergency) continue;
if (firstRig(siteOf(p)) < 0) {
offences.push(`POOL '${p.id ?? '?'}' is an EMERGENCY over ${siteOf(p)}, which is in no NIGHTS `
+ 'slot at all — that yard\'s only route into the game would be under the new job type.');
}
}
return offences;
}
/**
* **SPRINT18 gate 4 E ASKED FOR A SECOND LAW HERE (a new TIER must not debut
* with the callout). I WROTE IT, MEASURED IT, AND DELETED IT, because RULE 2 ABOVE
* ALREADY IMPLIES IT and a law that cannot fire is decoration this file has
* refused before.**
*
* E's worry is exact and worth having had: `hail_net_frame` is not a yard, it is an
* ANCHOR TYPE with $320 under it, placeable into an EXISTING yard so it looked
* like it could land in site_02_corner_block, which is the emergency's own stage,
* and debut alongside the new job type having passed every check I had written.
*
* It cannot, and the proof is one line of the ladder:
*
* 1 backyard_01 · 2 backyard_01 · 3 site_02 · 4 site_03 · 5 backyard_01
* the emergency's yard, rigged on NIGHT 3
*
* Rule 2 requires an emergency's yard to be rigged by a STRICTLY EARLIER scripted
* night. So every tier resident in that yard necessarily debuts on that earlier
* ordinary rig-for-tonight night night 3 here, two nights before the callout. The
* tier question reduces to the yard question, and the yard question is already
* pinned with a three-way vacuity guard.
*
* I ran the version that checked types anyway: **it returned zero offences on E's
* own poisoned scenario** (a hail-net frame dropped into site_02), because
* `earlier` legitimately contains site_02's own types. Green, permanently, for a
* structural reason the exact shape of A's own S17 `stormsToPreload` filing
* ("every pool storm also lives in NIGHTS, so the assert passes with the source
* deleted"). Shipping it would have added a row to the suite and no teeth to the
* repo.
*
* **The case that is NOT implied, and where the real risk lives:** a tier whose
* ONLY route into the game is a POOL or EMERGENCIES entry a glasshouse yard
* authored straight into the pool. That is rule 3, which already refuses it by
* name, and it is the rule E's asset is actually protected by.
*/
/**
* A deterministic draw. `rng` is contracts.js's mulberry32 the same PRNG the
* storm system reproduces against, so there is exactly one seeded-randomness
@ -298,7 +665,19 @@ export function eligibleAlternatives(scripted, pool = POOL) {
* @param {number} weekSeed
* @param {number} nightIndex
*/
export function alternativeFor(scripted, weekSeed, nightIndex, pool = POOL) {
export function alternativeFor(scripted, weekSeed, nightIndex, pool = POOL, emergencies = EMERGENCIES) {
/**
* 🚨 SPRINT18 gate 1 A SCHEDULED EMERGENCY DISPLACES THE DRAW, and it is
* checked BEFORE the rng is even constructed. Deliberate: an emergency is an
* authored set-piece, so the night it lands on is not the seed's business, and
* a board that rolled dice about whether the phone rings would make the
* one-variable law unenforceable (you cannot ask an RNG to respect an ordering
* rule). Every OTHER night's draw is byte-identical to before this existed
* `rng` is constructed fresh from seedFor(week, night) with no cross-night
* state, so skipping the call here cannot shift tomorrow's.
*/
const emergency = emergencyFor(nightIndex, emergencies);
if (emergency) return emergency;
const eligible = eligibleAlternatives(scripted, pool);
if (!eligible.length) return null;
const r = rng(seedFor(weekSeed, nightIndex));
@ -344,6 +723,69 @@ export function exposureOf(site) {
return { total, items };
}
/**
* **SPRINT18 gate 5 (A's half) THE SECOND-RUST-POST LANDMINE, DISARMED AS A
* TRIPWIRE.** D's filing, S17 gate 3.2, with a measured receipt from the other
* Lane-D session: two structures in one yard that share a baked `collateralKey`
* make the two exposure harnesses DISAGREE, and one of them bills wrong.
*
* THE MEASUREMENT, D's, on a real two-corroded-post yard:
* · `exposureOf()` (this file, the OFFER CARD) read **$115** per STRUCTURE.
* · `collateralExposure` (B/C, the FAILURE route) read **$45** per KEY, deduped,
* because `collateralFor`/`wreckStructure` match the FIRST structure carrying a
* key and C's failure envelope dedupes keys.
* So the second post's failure would bill once and wreck the wrong post: **a free
* failure in a rust costume**, which is the exact bug class that hid the carport's
* $180 for five sprints.
*
* **WHY THIS REPORTS AND DOES NOT THROW, and why it does not "fix" `exposureOf`.**
* Two deliberate refusals:
* 1. **No throw.** D and E are authoring yards in parallel right now. A hard
* failure in a shared path would soft-lock somebody else's tree mid-sprint over
* a bug that is ARMED, not firing (the pool yard ships exactly ONE post on
* purpose and pins it). It accuses instead C's `pairingRefusals`
* construction, and their reasoning applies unchanged.
* 2. **I did not make `exposureOf` dedupe by key.** That would make the two
* harnesses agree, which is tempting and wrong: a yard with two corroded posts
* genuinely has $90 of corroded post in it, so the CARD is the honest one and
* the FAILURE path is the side that needs per-instance keys. Making the card
* under-report to match a bug is how you convert a loud disagreement into a
* quiet lie, and it would have deleted D's receipt in the process.
*
* **THE REAL FIX, for the joint E+A pass:** per-instance collateral keys the
* anchor naming the STRUCTURE it will wreck, not the KIND of thing it is. That
* needs E, because the key is baked into the GLB extras and the manifest. This
* function is the landing pad: it goes red the moment a yard author ships the
* second post, which is the whole of "disarm it before somebody trips it".
*
* @param {object} site a site JSON
* @returns {string[]} one accusation per clashing key, with its measurement
*/
export function collateralKeyClashes(site) {
/** @type {Map<string, object[]>} */
const byKey = new Map();
for (const s of site?.structures ?? []) {
// No key ⇒ world.js's collateralFor resolves it by `id`, which is unique by
// construction (the editor generates s1, s2, …) — so an unkeyed structure
// cannot clash and must not be reported as if it could.
if (!s.collateralKey) continue;
if (!byKey.has(s.collateralKey)) byKey.set(s.collateralKey, []);
byKey.get(s.collateralKey).push(s);
}
const out = [];
for (const [key, group] of byKey) {
if (group.length < 2) continue;
const perStructure = group.reduce((n, s) => n + (s.collateralValue ?? 0), 0);
const perKey = group[0].collateralValue ?? 0;
out.push(`${site?.name ?? site?.id ?? 'this yard'}: ${group.length} structures share the `
+ `collateral key '${key}' (${group.map((s) => s.id).join(', ')}). The offer card would read `
+ `$${perStructure} (per structure) and the failure route $${perKey} (per key, deduped) — `
+ `so ${group.length - 1} of them would fail FREE and wreck '${group[0].id}' instead. `
+ 'Per-instance collateral keys are needed first (SPRINT18 gate 5, E+A).');
}
return out;
}
/**
* BUILD THE MORNING'S BOARD — two offers, tonight's scripted night first.
*
@ -363,18 +805,58 @@ export function exposureOf(site) {
* 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 }) {
export function offersFor({ scripted, weekSeed, nightIndex, pool = POOL, priced = null, nights = null }) {
const alt = alternativeFor(scripted, weekSeed, nightIndex, pool);
/**
* SPRINT18 gate 1 THE ONE-VARIABLE LAW, ENFORCED AT THE DOOR. `nights` is
* threaded in by week.js's `offers()` (which owns NIGHTS; this file must not
* import it), so the law runs in the REAL GAME every morning and not only in
* the suite. Omit it and the board still builds that is what keeps board.js
* testable with no ladder but the shipped path always supplies it, and
* a.test pins that it does. THROW, not filter: a board that quietly dropped an
* offer would leave a morning with one door and no explanation.
*/
if (nights) {
const offences = oneVariableOffences({ nights, pool, emergencies: EMERGENCIES });
if (offences.length) {
throw new Error(`board: THE ONE-VARIABLE LAW IS BROKEN (ROADMAP principle 5) — a new job `
+ `type and a new yard may not debut together:\n ${offences.join('\n ')}`);
}
}
const mk = (night, kind, id) => {
for (const c of night.constraints ?? []) validateConstraint(c, id);
validateEmergency(night, id);
const offer = {
id, kind, night,
constraints: night.constraints ?? [],
premium: premiumFor(night.constraints),
/**
* SPRINT18 gate 0 is a client EXPECTING you tonight? See isBooked: the
* diary slot, minus emergencies so gate 1's emergency reads as unbooked
* without this function learning what an emergency is.
*/
booked: isBooked(kind, night),
};
return priced ? Object.assign(offer, priced(night)) : offer;
};
const offers = [mk(scripted, 'scripted', 'scripted')];
if (alt) offers.push(mk(alt, 'callout', `alt:${alt.id}`));
/**
* SPRINT18 gate 0 WHAT EACH TAKE COSTS, ON THE OFFER ITSELF.
*
* Annotated here rather than computed by the card, for the reason the fee is:
* a price the card works out for itself is a price the invoice can disagree
* with. week.take() charges off this same field, so the number the player read
* before choosing IS the number that moves their standing by construction,
* not by two surfaces agreeing.
*
* Summaries, not offer references: an offer that pointed at its sibling would
* be a cycle the moment anything tried to serialise a board.
*/
for (const o of offers) {
o.standsUp = standUpsIfTaken(offers, o).map((x) => ({
id: x.id, client: x.night.client ?? null, addr: x.night.addr ?? null,
}));
}
return offers;
}

View File

@ -100,10 +100,26 @@ export const PHASES = ['forecast', 'prep', 'storm', 'aftermath'];
* 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.
*
* `hail_net_frame` (SPRINT18 gate 4, E): the frame that straddles a glasshouse
* so a hail net can be rigged OVER the glass (DESIGN.md line 179). It is the
* first anchor word in this enum whose danger is not in its own number.
* rating_hint 0.80 the highest rung below the honest post, and it EARNS it:
* an engineered portal frame on four real footings with welded eyes, the only
* structure in the palette that was built to be tied to. The 20% de-rate is
* for section, not condition (32 mm sized for hail mesh, which spills wind;
* a 25 impermeable sail is an order of magnitude more load).
* What makes it the most dangerous anchor in the game is that its baked
* `collateral` names the GLASSHOUSE, not itself the `house`"gutter"
* precedent, and the same shape: an anchor names what its failure TAKES, not
* what it is. Break a corner here and the sail, the shackle and the turnbuckle
* go through fourteen panes at $320. The type string is the player's pre-rig
* read of a hazard that is a POSITION, and the reason it must not be typed
* `post`: "post" promises a corner whose failure costs a shackle.
*/
export const ANCHOR_TYPE = Object.freeze([
'house', 'tree', 'post', 'carport', 'carport_post', 'swing_frame', 'pergola',
'corroded_post',
'corroded_post', 'hail_net_frame',
]);
// ---------------------------------------------------------------------------

View File

@ -46,7 +46,39 @@ export function createDebris(o = {}) {
// contracts.js documents world.heightAt as the thing Lane C bounces debris off.
// Flat fallback so this still runs against a graybox yard.
const groundAt = o.heightAt || (() => o.groundY ?? 0);
const rand = rng(((wind && wind.seed) || 1) ^ 0x5eed1e);
/**
* A crate's jitter is keyed on THE EVENT, not on a shared stream. [SPRINT18]
*
* This was `const rand = rng(seed ^ 0x5eed1e)` one stream, drawn from inside
* spawn(). A stream's POSITION encodes how many pieces have already spawned,
* so history leaked into every later crate, and it cost us two determinism
* breaks measured on the wildnight (crate heights, m):
*
* the same storm twice through one module night 1 y 1.582 night 2 y 1.165
* (main.js builds ONE debris module at boot and clear()s it per phase
* change; clear() rewound leafDist/leafEma and NOT this stream, so night 2
* of the seven-night week flew different crates than night 1 live on
* main today, no emergency callout required.)
* an entry at t=45, past the t=38 event t=0 run y 0.937 sx 0.394 ph 4.413
* t=45 y 1.623 sx 0.581 ph 0.839
* (every crate after a skipped event wore the SKIPPED crate's clothes: the
* stream sat 5 draws behind. Skipping history corrupted the future, not
* just the present the thing that makes a mid-storm arrival unpinnable.)
*
* Keyed on `ev.t` (stable authored data, unique per event in every shipped
* storm) so the crate at t=38 is the same crate whether you watched the first
* 38 seconds or arrived at 30. Manual spawns A's debug keys, tuning have no
* `ev.t` and fall back to the spawn time, which differs per keypress.
*
* This is the same move weather.core.js's header argues for: determinism
* STRUCTURAL, not a promise. A crate is now a pure function of its event.
*/
const seedBase = (((wind && wind.seed) || 1) ^ 0x5eed1e) | 0;
const eventRng = (ev, t) => {
const key = Math.round((Number.isFinite(ev.t) ? ev.t : t) * 1000);
return rng((seedBase ^ Math.imul(key, 374761393)) | 0);
};
const pieces = [];
const w = new THREE.Vector3();
@ -122,6 +154,7 @@ export function createDebris(o = {}) {
function spawn(ev, t) {
const spec = { ...(MODEL_SPEC[ev.model] || DEFAULT_SPEC) };
if (Number.isFinite(ev.mass)) spec.mass = ev.mass;
const rand = eventRng(ev, t); // this crate's own stream — see eventRng above
// upwind of the yard, offset sideways, so it crosses the whole thing
const d = wind.dirAt(t);
@ -282,6 +315,57 @@ export function createDebris(o = {}) {
}
},
/**
* ARRIVE MID-STORM at t0. [SPRINT18 the emergency callout's entry]
*
* The storm ran; you just weren't there. Three classes of state, and only one
* of them can be teleported:
*
* 1. closed-form in t the wind field, rain rate, hail rate. Measured
* order-independent (sample t=30 cold, or after walking 030, or after
* scrambling 90/0/45/12/88/30: byte-identical). Needs nothing.
* 2. a pure function of its own event crate jitter, as of eventRng above.
* Needs nothing, now.
* 3. a genuine path integral `leafDist` (metres travelled downwind) and
* `leafEma` (2.5 s smoothed speed). These CANNOT be teleported, and
* pretending otherwise is what made the arrival read wrong: measured on
* the wildnight, a t=0 run has 6 leaves crossing the grass at t=30 and a
* cold entry has 0 and stays becalmed for ~7 s while the EMA climbs
* off zero. DESIGN.md line 149 makes the moving grass the gust front's
* tell, and gate 1's whole premise is that the sky and the grass are the
* only briefing a callout gets. Losing the tell for the first seven
* seconds robs exactly the read the dispatch is asking the player to make.
*
* So class 3 is RUN, not skipped: this is `fixedLoop(t0, dt, step)` and
* nothing more the same ladder, the same code path, no shortcut. It is
* cheap because the ambient layer is two scalars and a wind sample (crates
* spawn, fly and despawn honestly on the way past). Do NOT "optimise" this
* into a teleport; the pin in c.test.js exists to catch that, and the
* negative control beside it records what the teleport actually costs.
*
* The ladder ACCUMULATES `t += dt`, not `i * dt`. I wrote it as `i * dt`
* first (exact multiplication, no drift) and the pin went red: 6 leaves in
* both yards, in different places. `i * dt` is the MORE ACCURATE ladder and
* that is precisely why it is wrong main.js's `phaseT += dt`, testkit's
* `fixedLoop` and sail.js's `this.t += SIM_DT` all accumulate, so a warm-up
* that multiplies reproduces a storm the game never flies. Measured: over
* 1800 steps the two ladders drift 4.2e-13 s apart in total, they pass a
* different `t` on 1770 of those 1800 steps, and they first disagree at step
* SIX (0.09999999999999999 against 0.1). A number gathered from a harness
* that is a hair better than the game's is still a number from the wrong
* harness the class of bug this repo hunts, in its smallest possible form.
*
* @param {number} t0 storm time to arrive at, seconds
* @param {number} dt fixed step pass contracts.FIXED_DT, the same ladder
* the caller will keep stepping on
*/
enterAt(t0, dt) {
const steps = Math.round(t0 / dt);
let t = 0;
for (let i = 0; i < steps; i++) { debris.step(dt, t, {}); t += dt; }
return t;
},
/** Drop everything (phase change, restart). */
clear() {
for (const p of pieces) despawn(p);

View File

@ -377,6 +377,56 @@ export const PALETTE = [
anchors: [],
}),
},
{
// SPRINT18 gate 4, E — APPLIED VERBATIM (leadFor precedent, eighth
// application; E could not use the PLACE panel at all until this landed, so
// they verified in tools/assetcheck/look.html instead).
//
// The glasshouse tier's LIVE half — the frame you rig a hail net FROM,
// straddling the glass you must never rig TO. DESIGN.md line 179: "hail nets
// and mesh sails rigged over glass, where your own failure is the worst
// debris of all." Place it at the same x/z as a glasshouse and it straddles
// correctly; the eyes land at y=2.690, clear of the 2.28 ridge.
//
// ⚖️ **0.80, RULED (A) — E's proposal, ADOPTED AS PROPOSED, reasoning
// endorsed and not re-derived.** It earns the highest rung below the sail
// post because it is the only structure in the palette that was BUILT to be
// tied to (engineered portal, four footings, welded eyes), and it is short of
// 1.00 for SECTION rather than condition — 32 mm sized for ~0.2 kg/m² of
// knitted mesh that spills wind, against a 25 m² impermeable sail in a
// southerly. De-rating honest steel for looking dangerous would be the
// corroded post's lie inverted, and this ladder already has one of those.
//
// AND IT USUALLY HOLDS. That is the tier, not a softness in it — the whole
// sentence line 179 has been waiting for an asset to say is **"the same
// failure has a different price depending on what is underneath it."** The
// player who learned "0.80 is fine" everywhere else is exactly the player
// this yard is for.
//
// ⚠️ `_v1` is LOAD-BEARING and its absence is SILENT: adoptAnchor does
// `rating_hint ?? 1`, so a typo does not fail — it rates these four eyes
// 1.00, the best steel in the game, on the highest-collateral anchor there
// is. (S14, D's cold pass.)
// ⚠️ THIS ITEM WRITES NO collateralKey, ON PURPOSE. The anchors bill
// 'glasshouse'; the GLASSHOUSE owns that key. structFor() returns the FIRST
// structure matching a key, so a frame that claimed it would bill the panes
// and then wreck the FRAME — both papers agreeing and both wrong.
// ⚠️ PLACE IT OVER A GLASSHOUSE. With no glasshouse in the site,
// collateralFor('glasshouse') returns null and these four corners are a FREE
// failure — the gutter bug. e.test pins that per-yard and names the anchor.
kind: 'structure', label: 'Hail-net frame (0.80 — over the glass, $320 under it)',
model: 'hail_net_frame_01_v1',
requires: ['hail_net_frame'],
hint: 'four good eyes at 2.7 m, rated 0.80 — and everything they drop lands on the glasshouse',
make: (id, x, z) => ({
id, model: 'hail_net_frame_01_v1',
wreckedModel: 'hail_net_frame_01_wrecked_v1',
x, z, rotYDeg: 0, solid: true,
anchors: [1, 2, 3, 4].map((i) => ({
id: `${id}_a${i}`, node: `net_anchor_0${i}`, type: 'hail_net_frame', work: 'cloth',
})),
}),
},
{
// The compliance ring. Fourteen posts, every one tie_off:false in the GLB;
// unpriced BY RULING (the bike rule — price it when debris can bend it).

View File

@ -19,7 +19,7 @@ import { forecastLines, leadFor } from './weather.js';
// SPRINT16 — the end card compares the final rep against neutral. A constant,
// not state: the HUD still only READS. week.js has no DOM and no THREE, so
// this import costs nothing it didn't already cost main.js.
import { NIGHTS, REP } from './week.js';
import { NIGHTS, PAY, REP } from './week.js';
// SPRINT16 gate 4 (Lane D, flagged for A): the week is data — NIGHTS.length —
// and these surfaces used to hardcode "five". A six-night week whose splash,
@ -66,6 +66,42 @@ const docketNo = (night) => 1040 + night;
* the business is the constant, and this is the one number the business has.
*/
const repFmt = (r) => `${Number(r).toFixed(1)}`;
/**
* SPRINT18 gate 0 a MOVEMENT in standing, in the letterhead's own units.
*
* Half-stars written as halves, because that is how the number is ruled
* (week.js §REP: half-steps, so every printed value stays exact). "0.5★" is a
* float on a business document; "−½★" is a rating. The sign is always printed
* a cost with no sign on it is a number the player has to guess the direction of.
*/
const starDelta = (d) => {
const a = Math.abs(Number(d) || 0);
const whole = Math.floor(a);
const half = a - whole >= 0.5;
return `${d < 0 ? '' : '+'}${whole || !half ? whole : ''}${half ? '½' : ''}`;
};
/**
* **SPRINT18 gate 1.3 [Lane D] WHAT STANDING A CLIENT UP WILL ACTUALLY COST, WHICH IS NOT
* ALWAYS `REP.STOOD_UP`.** Found by playing the week to 0.0 and taking the emergency anyway.
*
* `settle()` clamps rep at `REP.MIN`, so at 0.0 a ½ debit moves NOTHING and both papers went on
* promising it. On the offer card that is a price the game will not charge; on the invoice it is
* worse, because the same card carries the row *"stood up the Hendersons · −½★ standing"* four lines
* above *"reputation ★ 0.0 → ★ 0.0"*. **The invoice arguing with itself**, which is the defect class
* this repo has spent five sprints hunting, on John's own ruling.
*
* This function is the PAPER half only, and the split is deliberate: whether the ruling should have
* teeth at the floor is a DESIGN question and it is John's (filed in THREADS with the measurement
* at 0.0 a fee-maximiser can dodge every booked night for free again, which is precisely the player
* the ruling was written for). Until he rules, the documents tell the truth about the number.
*
* @param {number} n how many clients would be stood up
* @param {number|null} rep the standing the charge lands against (`wk.rep` / `w.repBefore`)
*/
const standUpCost = (n, rep) => (rep == null
? REP.STOOD_UP * n
: Math.max(REP.MIN, rep + REP.STOOD_UP * n) - rep);
// Load bar colours: green → amber → red. The amber band is deliberately wide;
// a corner at 60% in a lull is one gust from 100% and the player should feel
@ -342,6 +378,59 @@ const CSS = `
#hud-card .offer .fee b { color:#ffd9a3; }
#hud-card .offer .risk b { color:#ff8f86; }
#hud-card .offer.constrained { border-left:3px solid #ffc24a; }
/* SPRINT18 gate 0 THE PRICE OF SAYING NO, on the card, before the choice.
Deliberately styled like the risk row and NOT like the constraint mark: a
constraint is something the client asks of you, this is something taking the
job costs you. Red-side, because it is a debit and it sits directly under
the fee, so the two numbers a dodge is decided on are adjacent. The whole
point of the ruling is that this is never a surprise on the invoice. */
#hud-card .offer .cost b { color:#ff8f86; }
#hud-card .offer .cost span { color:#c99a94; }
/* The memory. Grey, italic, quiet a client who remembers is not a warning
label, it is a fact about the room you are walking into. */
#hud-card .offer .remembers, #hud-card .remembers {
margin-top:7px; font-size:11px; font-style:italic; color:#8a7f74;
}
#hud-card .board-note.free { color:#8ba0ad; }
/* 🚨 SPRINT18 gate 1 an emergency on the board. Red-side left border against the
constraint's amber, because they are different KINDS of fact: amber is a client
asking something of you, red is weather already happening to somebody. */
#hud-card .offer.emergency { border-left:3px solid #ff8f86; }
#hud-card .offer .line.siren span { color:#ff8f86; letter-spacing:.04em; }
/* --- 🚨 SPRINT18 gate 1: THE DISPATCH ------------------------------------
The paper chain's fourth document, and the design brief for it is one line:
IT IS NOT A JOB SHEET. A job sheet is a quote a letterhead, a schedule,
three amounts each carrying its condition. A dispatch is a phone call written
down at ten at night. So it wears the kit's letterhead (one outfit, four
papers) and then deliberately does NOT have the sched block: no garden bonus,
no clean bonus, no total, because nobody costed this job. The one number on it
is the RATE, because double rates are the reason you get in the ute.
Red, not amber. The board's constraint mark is amber (a client asking
something of you); this is the only card in the game that opens while weather
is already happening to somebody. */
#hud-card .dispatch .docket .kind { color:#ff8f86; }
#hud-card .dispatch h1 { color:#ffd9a3; }
#hud-card .dispatch .siren { margin:2px 0 12px; font-size:11px; letter-spacing:.18em;
text-transform:uppercase; color:#ff8f86; }
/* The call itself, in the client's voice, and given the room a brief gets
it IS the brief. There is no other one. */
#hud-card .dispatch .call { margin:10px 0 4px; padding:11px 13px; border-left:3px solid #ff8f86;
background:#1a1417; font-size:13px; line-height:1.6; color:#e6d8d5; }
/* C's mid-storm read: what the sky and the grass tell you, since no paper does.
Same rhythm as the offer card's weather band, one shade colder nobody
briefed this, you are reading it off the yard. */
#hud-card .dispatch .read { display:flex; justify-content:space-between; gap:14px;
font-size:12px; color:#8ba0ad; padding:2px 0; }
#hud-card .dispatch .read.hail span { color:#cbd8e0; }
#hud-card .dispatch .noread { font-size:11px; font-style:italic; color:#6d818e; margin:8px 0 2px; }
/* The inherited rig, listed. A corner that arrives on the ground is named
before you go, not discovered the tell rule (DESIGN.md: traps have tells). */
#hud-card .dispatch .theirs { font-size:12px; color:#8ba0ad; padding:2px 0;
display:flex; justify-content:space-between; gap:14px; }
#hud-card .dispatch .theirs b { color:#dde5ea; font-weight:700; }
#hud-card .dispatch .theirs.down b { color:#ff8f86; }
#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;
@ -827,19 +916,71 @@ export function createHud(d) {
// 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>
<button class="offer${cons.length ? ' constrained' : ''}${n.emergency ? ' emergency' : ''}" data-offer="${i}">
${/* 🚨 SPRINT18 gate 1 AN EMERGENCY MUST NOT LOOK LIKE AN ORDINARY
CALLOUT, and my first cut let it. On the live board it wore the same
grey "callout" tag as the Tuesday alternative beside it: double
rates, no prep, no quote, somebody else's rig the biggest new
thing in the game and the card said nothing. A player would find
out one click later, which is the tell rule broken on the exact
surface the tell rule exists for.
So: its own tag, its own left border, and one line naming what is
different about it, above the money. */''}
<span class="tag">${
n.emergency ? 'right now · emergency' : o.kind === 'scripted' ? 'tonight, as booked' : 'callout'
}</span>
<div class="who">${n.client ?? 'no client'}</div>
<div class="addr">${n.addr ?? ''}</div>
${n.emergency ? `<div class="line siren"><span>storm already running · no prep · no quote · you arrive ${
n.emergency.arriveAt}s in</span></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` : ''
}${/* the doubled number gets its reason printed beside it money with no
stated cause is the thing every ledger comment in this repo is about */''}${
n.emergency ? ` · ${PAY.emergencyRate}× emergency rate` : ''
}</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>` : ''}
${/* SPRINT18 gate 0, clause 2 WHAT TAKING THIS COSTS, IN THE
CLIENT'S TERMS, BEFORE THE CHOICE. John's ruling is explicit that
this may not wait for the invoice: "a choice whose price is
invisible until the invoice is a trap, and this repo doesn't ship
traps without tells." board.js works out WHO is stood up and
week.js rules the number; the card's whole job is to say it out
loud, next to the fee that is tempting you.
When nothing on the board is booked (gate 1's two-callout
morning) `standsUp` is empty and this renders NOTHING the free
case reads as free because there is no line, not because a line
says "$0". */''}
${/* "ARE", ALWAYS, and it is not a typo I am defending. My first cut
agreed the verb with the NUMBER OF CLIENTS STOOD UP and rendered
"the Hendersons IS expecting you" on the live board bad English
on the one line John's ruling says must be readable before the
choice. The count is the wrong thing to agree with: every client in
this game is a collective noun (a household, a place), whose number
no code here can infer, and the repo's own voice already treats them
as plural night 7's brief says "The Vasilaros place again THEY
asked for you by name". The spec's own example wording is "the
Hendersons are expecting you". So: no agreement logic at all. */''}
${/* SPRINT18 gate 1.3 [D] the cost AS IT WILL BE CHARGED (standUpCost): at 0.0 the
clamp eats it, and a card promising a price the invoice will not take is the same lie
as a card hiding one. The words change with it, because "0★" would be gibberish. */''}
${o.standsUp?.length ? `<div class="line cost"><span>${
o.standsUp.map((s) => s.client ?? 'a client').join(' and ')
} are expecting you</span><b>${
standUpCost(o.standsUp.length, wk.rep) === 0
? 'nothing left to dock'
: `${starDelta(standUpCost(o.standsUp.length, wk.rep))} standing`}</b></div>` : ''}
${cons.length ? `<div class="cons">⚖ ${cons.map((c) => c.label).join(' · ')}</div>` : ''}
${cons.map((c) => `<div class="says">“${c.says}”</div>`).join('')}
${/* gate 0, clause 3 and if you have done this to THEM before, it is
on their card. main.js injects week.remembers(client). */''}
${o.remembers?.length ? `<div class="remembers">They remember night ${
o.remembers.map((s) => s.night).join(' and night ')
} you took another job.</div>` : ''}
</button>`;
};
@ -860,7 +1001,20 @@ export function createHud(d) {
<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>
${/* SPRINT18 gate 0, clause 5 THE ASYMMETRY, SAID IN WORDS.
A morning with a booked job on it: turning it down costs your name,
and the offer that costs says so on its own card. A morning with
NOTHING booked (two callouts legal, and what gate 1's emergency
makes reachable): declining is genuinely free, and the note SAYS
free rather than leaving the player to infer it from an absence.
"No cost shown" and "no cost" have to be the same sentence on a
document, or the quiet morning teaches the same lie the loud one
would. */''}
<div class="board-note${offers.some((o) => o.booked) ? '' : ' free'}">${
offers.some((o) => o.booked)
? 'Both are tonight. You cannot work two yards in one storm.'
: 'Nobody has you booked tonight. Neither of these costs you anything to turn down.'
}</div>
<div class="terms">Fees quoted on the forecast at time of callout. Weather is not a variation.</div>
</div>`;
card.classList.add('on');
@ -873,6 +1027,92 @@ export function createHud(d) {
}
},
/**
* 🚨 SPRINT18 gate 1 **THE DISPATCH.** The paper an emergency callout gets
* INSTEAD of a job sheet, and the "instead" is the whole design: SPRINT18's
* spec says *"a dispatch instead of a job sheet (there was no time to quote
* that's the drama)"*, so what makes this card right is mostly what it refuses
* to print.
*
* WHAT IT SHOWS, and why exactly this:
* · **THE CALL** `emergency.dispatch`, in the client's voice. It is the
* brief, because there is no other one.
* · **WHAT THE SKY IS DOING** C's `midStormRead(def, arriveAt)`, MAPPED as
* an ordered list exactly as the offer card maps `offerBand`, so a fact C
* adds cannot be dropped by this card picking two fields. Absent one
* honest italic line saying nobody briefed you, which is the truth and not
* a placeholder.
* · **THE RIG YOU DIDN'T BUILD** every corner named with its hardware, and
* the ones already on the ground marked. DESIGN.md's tell rule: the trap is
* allowed to be brutal, it is not allowed to be a surprise. A player who
* reads "carabiner — ON THE GROUND" against the carport has been told.
* · **THE RATE** the fee and the multiplier it came from. The one number.
*
* WHAT IT REFUSES TO SHOW: the garden bonus, the clean bonus, and a total.
* `quote.emergency` exists so this card can be a VIEW of the same money object
* the invoice settles against (see week.js) while printing none of the rows
* nobody quoted.
*
* @param {object} d {job, quote, def, site, read, rig}
* @param {object} wk {night, nights, bank, rep}
* @param {() => void} onRoll
*/
showDispatch({ job, quote, read, site }, wk, onRoll) {
const em = job?.emergency ?? {};
const q = quote ?? {};
const rigRows = (em.rig ?? []).map((c) => `
<div class="theirs${c.broken ? ' down' : ''}"><span>${c.anchorId?.toUpperCase()}</span><b>${
c.hw ?? '?'}${c.broken ? ' — ON THE GROUND' : ''}</b></div>`).join('');
card.innerHTML = `<div class="card jobcard dispatch">
<div class="letterhead">
<div>
<div class="mark">${BUSINESS.mark}</div>
<div class="trade">${BUSINESS.trade}</div>
</div>
<div class="docket">
<div class="kind">DISPATCH</div>
<div class="no">CALL ${docketNo(wk.night)} · ${ABN}</div>
${wk.rep != null ? `<div class="rep">standing ${repFmt(wk.rep)}</div>` : ''}
</div>
</div>
<div class="billto">
<div class="who">${job?.client ?? 'no client'}</div>
${job?.addr ? `<div class="addr">${job.addr}</div>` : ''}
</div>
<h1>RIGHT NOW</h1>
<div class="siren">no quote · no prep · you arrive ${em.arriveAt ?? 0}s into it</div>
${site ? `<div class="stat" style="color:#7ee0ff">${site.name}${site.blurb ? `${site.blurb}` : ''}</div>` : ''}
${em.dispatch ? `<div class="call">${em.dispatch}</div>` : ''}
${/* C's seam. An ordered list, mapped — never two hand-picked fields. */''}
<div class="sect">WHAT YOU CAN SEE FROM THE DRIVE</div>
${read?.length
? read.map((l) => `<div class="read ${l.key}"><span>${l.text}</span></div>`).join('')
: '<div class="noread">Nobody briefed you. You will find out what this storm is doing by standing in it.</div>'}
${rigRows ? `<div class="sect">THE RIG THAT IS ALREADY UP</div>${rigRows}` : ''}
<div class="sect">WHAT IT PAYS</div>
<div class="sched">
<div class="row"><span>emergency callout${
q.emergency?.rate ? `${q.emergency.rate}× the going rate` : ''
}${q.feeMult && q.feeMult !== 1 ? ` · standing ×${q.feeMult.toFixed(2)}` : ''}</span><b>$${q.base ?? 0}</b></div>
<div class="cond">Nobody costed this job. What the garden is worth, and what you owe for
anything that breaks, is on the invoice in the morning.</div>
</div>
${(q.warranty ?? []).length ? `
<div class="sect">STILL OWED FROM LAST NIGHT</div>
<div class="sched">
${q.warranty.map((it) => `
<div class="row"><span>warranty ${it.anchorId.toUpperCase()}, ${it.hw}, no charge to the client</span><b>$${it.cost}</b></div>`).join('')}
</div>` : ''}
<div class="row" style="margin-top:12px"><span>in the bank</span><b>$${wk.bank}</b></div>
<button class="go">ROLL</button>
<div class="terms">Emergency attendance. Rates as advised by telephone. No pre-attendance inspection was possible.</div>
</div>`;
card.classList.add('on');
hud.setVisible(false);
card.querySelector('.go').addEventListener('click', () => { hud.hideCard(); onRoll(); });
},
/**
* The forecast. Its job is to sell the dread and, incidentally, to be the
* difficulty select for free three authored storms already bracket the
@ -936,6 +1176,15 @@ showForecast({ key, def, site, tomorrowDef }, wk, onGo) {
const brief = job.brief
? `<div class="brief">${job.brief}</div>` : '';
// ⚖️ SPRINT18 gate 0, clause 3 — you have stood THIS client up before, and
// the sheet says so under their brief. The board's card is where the memory
// changes a decision; this is where it changes the room you walk into.
// Off the same week ledger, so it cannot disagree with the card.
const remembers = (wk.remembers ?? []).length ? `
<div class="remembers">They have not forgotten night ${
wk.remembers.map((s) => s.night).join(' and night ')
}. You are here on their second-choice terms.</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
@ -1028,6 +1277,7 @@ showForecast({ key, def, site, tomorrowDef }, wk, onGo) {
card.innerHTML = `<div class="card jobcard">
${letterhead}
${brief}
${remembers}
${constraintBlock}
<h1>NIGHT ${wk.night} OF ${wk.nights}</h1>
<div class="pips">${pips}</div>
@ -1253,6 +1503,72 @@ showForecast({ key, def, site, tomorrowDef }, wk, onGo) {
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. */''}
${/* SPRINT18 gate 0, clause 4 THE INVOICE STATES WHICH JOB YOU TOOK
AND WHAT IT COST YOU TO TAKE IT. Both papers, same as gate 2's
constraints: the offer card priced the decision, this is the record
of having made it. Off the settlement (week.js carries `taken` and
`stoodUp`), so night 5's invoice still names night 5's broken promise
when you re-read it on night 7's morning.
The standing row prints even at zero cost an off-the-board job on a
morning where nothing was booked SAYS so ("nobody was expecting you"),
because gate 0's asymmetry has to be legible on the paper as well as
on the card. This is the one place the free case is worth a line
rather than a silence: the invoice is the document that explains, and
a player who paid the price on Tuesday should be able to see why
Thursday was free. */''}
${w?.taken ? `
<div class="sect">OFF THE BOARD</div>
<div class="sched">
<div class="row"><span>you took this callout</span><b>${w.client ?? 'no client'}</b></div>
${w.stoodUp?.length
? w.stoodUp.map((s) => `
<div class="row ${standUpCost(1, w.repBefore) === 0 ? '' : 'bad'}"><span>stood up ${
s.client ?? 'a client'} they were expecting you</span><b>${
standUpCost(1, w.repBefore) === 0
? 'nothing left to dock'
: `${starDelta(standUpCost(1, w.repBefore))} standing`}</b></div>
<div class="cond">${standUpCost(1, w.repBefore) === 0
? 'Your standing is already on the floor, so this one was free. They will remember the phone call anyway.'
: 'No invoice for that one. They will remember the phone call.'}</div>`).join('')
: '<div class="row"><span>nobody was expecting you — nothing to stand up</span><b>—</b></div>'}
</div>` : ''}
${/* 🚨 SPRINT18 gate 1 THE EMERGENCY'S PAPERWORK. Three things the invoice
for a night you didn't rig has to say, and every one of them exists
because the alternative is the paper lying:
· IT WAS A CALLOUT, at what rate. The fee row already carries the
money; this says why it was double.
· WHOSE CORNERS WERE ALREADY DOWN named, and explicitly NOT BILLED.
This is the split-the-blame line, the same job the icenight's
invoice does ("the shackle at P2 is the part that's on you"). Without
it a player reads a $180 carport against a corner they never chose.
· THE KNIFE (D's verb, principle 2 a verb with no line on the
paperwork is a verb the game does not admit to). Silent until D's
input lands, which is the degradation rule, not a stub. */''}
${w?.emergency ? `
<div class="sect">EMERGENCY ATTENDANCE</div>
<div class="sched">
<div class="row"><span>called out mid-storm arrived ${w.emergency.arriveAt}s in, no prep, no quote</span><b>${w.emergency.rate}× rate</b></div>
${(w.inherited ?? []).map((c) => `
<div class="row"><span>${c.anchorId.toUpperCase()} ${c.hw}, already on the ground when you arrived</span><b>not billed</b></div>
<div class="cond">That corner is what the callout was for. It is not on you.</div>`).join('')}
</div>` : ''}
${/* 🚨 SPRINT18 gate 1.3 [Lane D] **THE KNIFE'S LINE, ON ANY NIGHT.** It shipped inside the
EMERGENCY ATTENDANCE block, so a sail cut loose on an ordinary Tuesday printed NOTHING
the verb is available every night the sail is up, and principle 2 does not have a "only
on callouts" clause. Its own section for the same reason the emergency has one: this is
not a charge and not a bonus, it is a decision, and the ledger below has no row shape for
that. `reason` is printed because B put it in the payload for the paperwork and D fills
it with what the cut was FOR the sentence a player would say out loud. */''}
${w?.cutAways?.length ? `
<div class="sect">THE KNIFE</div>
<div class="sched">
${w.cutAways.map((c) => `
<div class="row"><span>sail cut loose${c.corners?.length ? ` at ${c.corners.map((x) => String(x).toUpperCase()).join(', ')}` : ''} ${
Math.round(c.t)}s in, deliberate${c.reason ? ` · for ${c.reason}` : ''}</span><b>your call</b></div>
<div class="cond">You spent the sail to keep the steel. That is the trade the knife is for.</div>`).join('')}
</div>` : ''}
${w?.constraints?.length ? `
<div class="sect">PAID UNDER THESE TERMS</div>
<div class="sched">

336
web/world/js/knife.js Normal file
View File

@ -0,0 +1,336 @@
/**
* knife.js THE KNIFE. (Lane D, SPRINT18 gate 1.3)
*
* DESIGN.md line 165: *"knife (cut a sail loose — lose the sail, save the anchors)"*, and the
* signature decision under load: **release, cut, or ride it out.** B built the physics half
* (`rig.cutAway`); this is the verb the input, the reach rule, the read the decision is made off,
* and the panel that finally puts this lane's verbs on the glass (`createVerbPanel`, at the bottom).
*
* The panel lives HERE rather than in player.js for one reason and it is the suite: player.js
* statically imports GLTFLoader, which is why d.test.js has never been able to import it (it tests
* player.sim.js instead the same rule ladder.js and broom.js follow by loading their GLBs lazily).
* A prompt surface that only a browser can construct is a prompt surface with no asserts, which is
* precisely how it went five sprints unnoticed that nothing drew it.
*
* WHY IT IS ONE HOLD AND NOT FOUR
* The knife cuts the WHOLE SAIL, which is what the verb means in DESIGN.md and what B's
* `cutAway()` does with no arguments. The per-corner cut B also exposes is NOT this verb: DESIGN.md
* lists the **quick-release corner** ("installed in prep: deliberately depower a sail mid-storm")
* as its own tool, bought in the shop, and depowering one corner is that tool's job. One sprint,
* one verb; the shipped emergency is decided by the whole-sail cut and the corner version can
* arrive with the thing that sells it.
*
* WHERE YOU CAN CUT, AND WHY IT IS THE GROUND
* At the cleat, not at the corner. ladder.js's own header states the yard's rule: *"a sail post is
* tensioned from a cleat at its base, a tree anchor is a strop you throw"* while a fascia or
* carport bracket is worked at height. So the knife reaches a post or a tree from the grass, and it
* CANNOT reach a bracket corner.
*
* That is not a limitation, it is the mechanic, and the shipped emergency is the proof: the corner
* about to take the $180 carport is CB1, a bracket at y=2.36 that needs a ladder you have no time
* to fetch B measured the window at 5.9 s. **You cannot get to the corner that is failing, so you
* cut the sail at the one you can reach and the whole thing goes limp.** The ladder is for repairs
* (slow, two trips, both hands); the knife is for the seconds you actually have. They are
* complements, and neither is redundant.
*
* THE HOLD, AND WHAT IT DOES *NOT* TOUCH
* X, held for `cutSecs`, standing at a cleat. It is a hold and not a keypress on purpose: this is
* the most irreversible input in the game, and one tap of a key that spends the sail is a button,
* not a decision. Interruptible by walking out of reach, by releasing X, and by the storm putting
* you down mid-cut the same three ways a hold-E dies.
*
* It deliberately does NOT write `player.state`. interact.js's header states the invariant: *"this
* module both ENTERS and LEAVES the player's `busy` state. Nothing else writes it, so a dropped
* release cannot strand the player."* A second writer of `busy` is how a player gets stranded in a
* locked state with nothing to release them, so the knife instead REFUSES to start while the player
* is busy (you cannot cut mid-crank or mid-poke) and cancels itself if a locked state arrives while
* it is running. The cost is that there is no cut animation; filed rather than faked.
*
* Hands are NOT a gate, and that is a ruling. A knife is on your belt and cutting one line is a
* one-handed job but the real reason is that the alternative is worse: gating the game's
* signature decision behind dropping the spare would put an undiscoverable inventory step inside a
* 5.9-second window, and the player would read it as the game refusing rather than as a choice.
*/
import { needsLadder } from './ladder.js';
export const KNIFE_TUNE = {
/**
* How long the cut takes. Sits between the broom's poke (1.5 s) and the turnbuckle trim (1.2 s):
* a loaded sail cuts FAST that is why it is dangerous so this is not a long hold. It is long
* enough to be a decision you can change your mind about and short enough to fit the window B
* measured (cut at t=33 saves the carport; the corner blows at 35.9).
*/
cutSecs: 1.2,
/** How near the cleat/strop you must stand, m. The broom's `standRadius` is 2.0 for the same job. */
reach: 2.0,
};
/**
* Can a knife reach this corner from the grass?
*
* A live corner (there is nothing to cut on one that is already down) on an anchor whose work
* happens at the cloth rather than at a bracket `needsLadder` is the repo's one rule for that
* distinction and it reads the site's own `work` field, so a new yard follows with no code change.
*/
export const cuttable = (corner, anchor) =>
!!corner && !corner.broken && !needsLadder(anchor);
/**
* The cleat: the base of the post, the foot of the tree. Ground level at the anchor's x/z, which is
* where the line comes down to and where a person stands to work it.
*/
export const cleatOf = (anchor) => (anchor?.pos ? { x: anchor.pos.x, z: anchor.pos.z } : null);
/**
* What the cut is FOR, in one phrase, for the paperwork's `reason` field (B's payload carries it;
* their own example is 'the glasshouse'). The most expensive thing the live corners are still
* holding up because that is the sentence a player would say out loud.
*/
export function reasonFor(saves) {
if (!saves || !saves.length) return 'the steel';
const worst = saves.reduce((a, b) => (b.cost > a.cost ? b : a));
// The article, because this phrase is read INSIDE a sentence on the invoice and the collateral
// labels do not agree with each other about it: site JSON ships "the carport", "the gutter" and
// "garden gnome". Played it and the invoice said "cut loose at Q3 — 9s in, deliberate · for garden
// gnome", which reads like a typo rather than a reason.
return /^the /.test(worst.what) ? worst.what : `the ${worst.what}`;
}
/**
* @param {object} deps
* getRig() -> SailRig|null (live: rigSail() REPLACES the object)
* player -> PlayerSim
* getWorld() -> contracts World|null (anchors + collateral prices)
* getPhase() -> 'storm'| (optional; absent = "any time there is a sail")
* getGarden() -> {hp} (optional; absent = no cost line)
* collateralAtRisk(corners, world) -> {items,total} (optional; main.js injects ITS OWN walk, so
* the read and the invoice cannot disagree about what a cut saves. Absent = no saves line.)
* secsLeft() -> number (optional; how long the bed would be bare)
*/
export function createKnife(deps = {}) {
const tune = { ...KNIFE_TUNE, ...(deps.tune || {}) };
const player = deps.player;
const state = { progress: 0, cut: null };
const rigOf = () => (deps.getRig ? deps.getRig() : null);
const worldOf = () => (deps.getWorld ? deps.getWorld() : null);
const anchorOf = (c) => {
const w = worldOf();
return c?.anchor || (w && w.anchor ? w.anchor(c.anchorId) : null) || null;
};
/** Every corner still flying — what a cut would release, and what is still at risk if you don't. */
function liveCorners() {
const rig = rigOf();
if (!rig || !rig.rigged || !Array.isArray(rig.corners)) return [];
return rig.corners.filter((c) => !c.broken);
}
/** The nearest cleat within reach, or null. Ground only — you cannot cut from up a ladder. */
function target() {
if (!player || (player.climbY ?? 0) > 0.02) return null;
let best = null, bestD = Infinity;
for (const c of liveCorners()) {
const a = anchorOf(c);
if (!cuttable(c, a)) continue;
const cleat = cleatOf(a);
if (!cleat) continue;
const d = Math.hypot(cleat.x - player.pos.x, cleat.z - player.pos.z);
if (d <= tune.reach && d < bestD) { best = c; bestD = d; }
}
return best;
}
/** True when there is a sail to cut in a phase where cutting means something. */
function live() {
if (deps.getPhase && deps.getPhase() !== 'storm') return false;
return liveCorners().length > 0;
}
/**
* What the decision is made off and it is the whole of the HUD read, because "cut or ride it
* out" is unanswerable without both halves of the trade printed at once:
* saves the client's property the live corners are still holding up, priced through main.js's
* OWN collateral walk (injected), so this can never advertise a saving the invoice
* won't honour.
* costs the bed, bare for whatever is left of the storm. The knife's price, per B's ruling:
* the garden is the STAKE, not the wage.
*/
function read() {
if (!live()) return { state: 'none' };
const corners = liveCorners();
const w = worldOf();
const risk = (deps.collateralAtRisk && w) ? deps.collateralAtRisk(corners, w) : null;
const hp = deps.getGarden ? deps.getGarden()?.hp : null;
const secs = deps.secsLeft ? Math.max(0, Math.round(deps.secsLeft())) : null;
const at = target();
return {
state: state.cut ? 'cutting' : at ? 'ready' : 'unavailable',
at: at ? at.anchorId : null,
corners: corners.map((c) => c.anchorId),
progress: state.progress,
saves: risk ? risk.items : [],
savesTotal: risk ? risk.total : 0,
hp: hp == null ? null : Math.round(hp),
secsLeft: secs,
/**
* The refusal, and it has to name the REAL reason which is two different reasons.
*
* Played it and caught this in my own read: the line was one constant that always blamed the
* bracket, so on the swing lawn (a yard with no bracket corner in the quad) it told the player
* to fetch a ladder for a corner that does not exist. That is `needsLadder`'s own disease from
* SPRINT10 a rule keyed on the wrong thing, failing plausibly on the surface the rule is
* supposed to explain. So: is there a cleat to walk to, or is every live corner up a wall?
*/
whyNot: at ? null
: corners.some((c) => cuttable(c, anchorOf(c)))
? 'get to a post cleat or a tree strop'
: 'every corner left is bracket work — the knife cannot reach it',
};
}
/**
* @param {number} dt @param {number} t
* @param {boolean} holding is the knife key down this frame
* @returns {object} read() so the caller can draw the panel off one call
*/
function step(dt, t, holding) {
const at = target();
const locked = !!player && player.busy;
if (state.cut) {
// the three ways a cut dies, and they are the hold-E rules: let go, walk off, get put down
if (!holding || !at || at !== state.cut || locked) {
state.cut = null;
state.progress = 0;
}
} else if (holding && at && !locked && !state.latched) {
state.cut = at;
state.progress = 0;
}
if (!holding) state.latched = false;
if (state.cut) {
state.progress += dt / Math.max(1e-6, tune.cutSecs);
if (state.progress >= 1) {
const rig = rigOf();
const risk = read();
state.cut = null;
state.progress = 0;
// One press, one cut. Without the latch a held key re-arms the instant the sail is down and
// the next frame's `cutAway` refuses forever — a ticker full of "the sail is already down".
state.latched = true;
const res = rig && rig.cutAway ? rig.cutAway(null, reasonFor(risk.saves)) : { ok: false };
state.last = { t, ...res };
}
}
return read();
}
return {
tune,
read,
step,
target,
liveCorners,
get progress() { return state.progress; },
get cutting() { return !!state.cut; },
/** The last cutAway result, for a ticker or a test. */
get last() { return state.last ?? null; },
};
}
/**
* THE VERBS ON THE GLASS. (Lane D, SPRINT18 gate 1.3)
*
* **What I found building the knife, and it is five sprints old:** `interact.step()` returns
* `{target, progress, label, holding, usable}` "for hud.js to draw the prompt + radial" its own
* words and in the SHIPPED GAME nobody has ever drawn it. main.js calls
* `interact.step(dt, t, sim, keyboard.holding)` and throws the return value away; hud.js has no
* prompt element; the only renderer of a prompt in this repo is `dev_player.html`, a bench. So every
* sentence this lane has written for the player's hands *"out of reach — needs the ladder"*,
* *"push the water off (169 kg)"*, *"hands full"*, and the whole greyed-prompt surface I built for
* Lane A in SPRINT13 has never reached a player. The hold-E verbs have shipped with no prompt and
* no radial: you walk to a broken corner, hold a key you were told about on a help line, and the
* game says nothing until it is over. Same disease as `setFabric` (B, this sprint) and the F key
* before it the code was right, the wire was missing, and no assert could see the gap because the
* gap was on the glass.
*
* So this panel exists, and the knife is not the reason it exists the knife is just the verb that
* finally made it unignorable, because a cut with no radial is a keypress that eats your sail.
*
* It is D's own overlay rather than an edit to hud.js for two reasons: hud.js is Lane A's file and
* says so in line 2, and its own header already assigns *"the repair prompt, which Lane D already
* owns"* to this lane. z-index 9 UNDER #hud (10) and the cards (30), so a card always wins.
*/
export function createVerbPanel(opts = {}) {
if (typeof document === 'undefined') return { update() {}, dispose() {} }; // headless: no-op
const style = document.createElement('style');
style.textContent = `
#verbs { position:fixed; left:50%; bottom:40px; transform:translateX(-50%); z-index:9;
font:12px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace; color:#dde5ea; text-align:center;
text-shadow:0 1px 3px #000a; pointer-events:none; user-select:none; display:none; }
#verbs.on { display:block; }
#verbs .verb { background:#0d1418d9; border:1px solid #2c3a44; border-radius:6px;
padding:6px 12px; margin-top:5px; display:inline-block; min-width:200px; }
#verbs .key { font-weight:700; letter-spacing:.06em; }
#verbs .off { color:#8ba0ad; border-color:#243038; }
#verbs .bar { height:5px; background:#00000066; border-radius:3px; overflow:hidden; margin-top:5px; }
#verbs .bar i { display:block; height:100%; width:0; background:#ffd27a; }
/* The knife is the one irreversible input in the game. It gets the invoice's own red, and its two
consequence lines are coloured for what they mean: what you keep, what you spend. */
#verbs .knife { border-color:#7a2a22; background:#1a0f0dd9; }
#verbs .knife .key { color:#ff8f86; }
#verbs .knife .bar i { background:#ff5b4a; }
#verbs .saves { color:#6ee06e; }
#verbs .costs { color:#ffc24a; }`;
document.head.appendChild(style);
const root = document.createElement('div');
root.id = 'verbs';
document.body.appendChild(root);
let last = '';
const bar = (p) => `<div class="bar"><i style="width:${Math.round(Math.min(1, p) * 100)}%"></i></div>`;
/**
* @param {object|null} prompt interact.step()'s return
* @param {object|null} knife knife.read()
*/
function update(prompt, knife) {
const showable = !opts.getPhase || opts.getPhase() === 'storm' || opts.getPhase() === 'prep';
let html = '';
if (showable && prompt && prompt.target) {
html += `<div class="verb ${prompt.usable ? '' : 'off'}">`
+ `<span class="key">${prompt.usable ? '[E]' : '···'}</span> ${prompt.label || ''}`
+ `${prompt.holding ? bar(prompt.progress) : ''}</div>`;
}
if (showable && knife && knife.state !== 'none') {
const ready = knife.state === 'ready' || knife.state === 'cutting';
// The trade, both halves, always — "cut or ride it out" is unanswerable off one of them.
// "$N at risk" is the BOARD's own vocabulary for collateral, deliberately: the offer card
// priced this night at "$205 at risk" and the knife is the one move that takes that number off
// the table. Same words, same meaning, two cards apart. The items are named because a bare
// total is what B's gate 3 caught the SCORE IT card doing.
const saves = knife.saves?.length
? `<div class="saves">saves $${knife.savesTotal} at risk · ${
knife.saves.map((s) => s.what).join(', ')}</div>`
: '';
const costs = knife.hp == null ? ''
: `<div class="costs">costs the garden — ${knife.hp}% now, bare${
knife.secsLeft != null ? ` for ${knife.secsLeft}s` : ''}</div>`;
html += `<div class="verb knife ${ready ? '' : 'off'}">`
+ `<span class="key">${ready ? '[X]' : '···'}</span> ${
ready ? 'CUT THE SAIL LOOSE — hold' : `the knife: ${knife.whyNot}`}`
+ `${ready ? saves + costs : ''}`
+ `${knife.state === 'cutting' ? bar(knife.progress) : ''}</div>`;
}
if (html !== last) { last = html; root.innerHTML = html; }
root.classList.toggle('on', !!html);
}
return {
update,
get el() { return root; },
dispose() { root.remove(); style.remove(); },
};
}

View File

@ -18,6 +18,13 @@ import { FIXED_DT, PHASES, STORM_LEN, HARDWARE, SPARE_COST, Emitter } from './co
import { createWorld, loadSite } from './world.js';
import { createCameraRig, spawnYawFor } from './camera.js';
import { loadStorm, createWind, forecastLines, offerBand } from './weather.js';
// SPRINT18 gate 1 — a NAMESPACE import beside the named ones, for exactly one
// reason: C's `midStormRead` (the dispatch's mid-storm read) lands at
// integration, and a named import of a function that does not exist yet is a
// hard module error rather than a graceful no-op. `weather.midStormRead?.()` is
// the `setConstraints?.()` seam pattern, adapted to ES modules. Delete this
// import the day the named one can carry it.
import * as weather from './weather.js';
import { SailRig, createSailView } from './sail.js';
import { createPlayer } from './player.js';
import { Interact, wireYardActions } from './interact.js';
@ -216,7 +223,7 @@ export function applyMute(sky, on) {
*
* @returns {{verdict: string, mode: string}}
*/
export function verdictFor({ hp, lost, win, dmg, pondPeak, pondDumped, beyondSaving = false }) {
export function verdictFor({ hp, lost, cut = [], win, dmg, pondPeak, pondDumped, beyondSaving = false }) {
// SPRINT12 — B's flag, actioned: "weakest link that went first" reduces on
// the EFFECTIVE rating (rating × ratingHint), matching sail.js's failure
// line and B's summary.weakest tie-break fix. On the bare rating a rated
@ -230,6 +237,33 @@ export function verdictFor({ hp, lost, win, dmg, pondPeak, pondDumped, beyondSav
const named = worst ? `${worst.hw.name} at ${worst.anchorId.toUpperCase()}` : '';
const hailKilled = dmg.hail > dmg.rain;
/**
* 🚨 SPRINT18 gate 1.3 [Lane D] **THE KNIFE'S OWN VERDICT, and without it this function calls a
* deliberate cut "the hail fell where your sail wasn't".**
*
* That is mode `uncovered`, and it is the single worst thing the card could say to a player who
* just made the game's signature decision: it blames placement on a night where the sail came down
* because they cut it down. The verdict's whole charter is that it reads what ACTUALLY happened.
*
* It fires only when nothing of YOURS failed, and that composition rule is deliberate rather than
* tidy: if a corner blew as well, that failure is the louder fact and the modes below say it
* truthfully the cut is still named on the invoice's own line. So the knife speaks when the knife
* is the story, and shuts up when it isn't.
*
* "Nothing of theirs" is true BY CONSTRUCTION here, not by hope: `collateralWalk` prices `yours`,
* and with `yours` empty the gnome's two-corner rule and every structure key are empty too.
*/
if (cut.length && !lost.length) {
const where = cut.map((c) => c.anchorId.toUpperCase()).join(', ');
return win
? { mode: 'cut-held',
verdict: `YOU CUT IT LOOSE at ${where} — and the bed came through anyway. `
+ 'Nothing of theirs is on the ground, and nothing of yours broke.' }
: { mode: 'cut',
verdict: `YOU CUT IT LOOSE at ${where}. The bed went with it — and their property didn't. `
+ 'That was the trade.' };
}
// SPRINT9 decision 1. This case had to come FIRST because it did not used to
// exist: `lost >= 2` was unconditionally a cascade-and-a-loss, and under the
// new rule it can be the best night the game has. A sail that tore itself
@ -322,10 +356,18 @@ export function verdictFor({ hp, lost, win, dmg, pondPeak, pondDumped, beyondSav
* partition semantics are exactly the kind of rule that would otherwise only
* ever be "tested" by a green suite that never ran it.
*
* 🚨 SPRINT18 gate 1 `theirs` marks a corner that ARRIVED BROKEN on an emergency
* callout, and it is not a cosmetic flag. Without it the ledger books TOMORROW'S
* WARRANTY on a cowboy's carabiner: you would drive back, unpaid, to redo work
* you never did, and your standing would drop for a corner that was on the ground
* before you got out of the ute. week.js's `warrantyItems()` and the rep block
* both skip it. See the ruling in scoreRun.
*
* @param {Array} corners rig.corners at dawn
* @param {Map<string,{broke:number,repaired:number}>} [tally] the night's break/repair events per anchor
* @param {Set<string>} [theirs] anchor ids that arrived broken (emergency callouts)
*/
export function rigRecordFor(corners, tally = new Map()) {
export function rigRecordFor(corners, tally = new Map(), theirs = new Set()) {
return corners.map((c) => {
const tal = tally.get(c.anchorId);
const broke = (tal?.broke ?? 0) > 0 || !!c.broken;
@ -337,10 +379,178 @@ export function rigRecordFor(corners, tally = new Map()) {
broke,
repaired: broke && !c.broken,
brokenAtDawn: !!c.broken,
theirs: theirs.has(c.anchorId),
/**
* SPRINT18 gate 1.3 [D] you let this one go on purpose. week.js reads it exactly where it
* already reads `theirs`, and for the same reason: warranty and standing are about work that
* FAILED. A corner still down at dawn because you cut it booked a free callout to go and
* re-hang your own decision, and docked half a star for taking it.
*
* NOTE it does not suppress `broke`/`brokenAtDawn`. Those two are the RECORD the sail really
* is on the ground and the client can see it and a record that edits itself to be flattering
* is the one thing this ledger has never done.
*/
cut: !!c.cut,
};
});
}
/**
* WHAT AN OFFER CARD PRINTS pure, exported, and it did not use to be either.
*
* **EXTRACTED BECAUSE IT SHIPPED A LIVE BUG THE SUITE COULD NOT SEE.** This was
* an inline closure inside `boot()`, unreachable by any assert, and on night 5's
* real board it advertised the emergency callout at **$57 while the dispatch one
* click later quoted $114** it had simply never been given the emergency term.
* That is exactly the quote-vs-settle lie this file's own comment says the board
* must not be able to tell ("the board can never be more confident than the
* sheet"), and it was invisible to 535 asserts because the money lived behind a
* WebGL context. Eyes on the card found it; being a function is what stops the
* next one.
*
* The rule it now keeps by construction: **every number on an offer card comes
* from the same function the job sheet and the invoice use.** `calloutFee` for the
* money, C's `forecastLines`/`offerBand` for the weather, `exposureOf` for the
* risk nothing retyped here.
*
* @param {object} night a normalised night (week.js normaliseNight)
* @param {object} ctx
* @param {object} [ctx.def] the storm def; absent the weather lines degrade away
* @param {object} [ctx.site] the site JSON, for the exposure walk
* @param {number} ctx.rep standing as of this morning the fee is priced on it
* @param {object[]} [ctx.remembers] this client's grudges (week.remembers)
*/
export function priceOffer(night, { def = null, site = null, rep = 3, remembers = [] } = {}) {
const exp = exposureOf(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 6x the hail — the board recommending its own trap.
band: def ? offerBand(def, 0) : null,
// 🚨 SPRINT18 gate 1 — the fourth argument IS the bug above, fixed: an
// emergency pays double ON THE CARD, because the card is where the player
// decides whether double is worth it.
fee: def ? calloutFee(def, rep, night.constraints, !!night.emergency) : null,
exposure: exp.total,
exposureItems: exp.items,
remembers,
};
}
/**
* 🚨 **SPRINT18 gate 1 WHO IS ANSWERABLE FOR WHICH CORNER.** The ruling that
* makes an emergency invoice honest, as a pure function so that the RULING is
* pinnable and not merely its consequences.
*
* Exported for the `rigRecordFor` reason, stated in that function's own note:
* scoreRun lives inside `boot()` behind a WebGL context where no assert can reach
* it, and this is exactly the kind of rule that would otherwise only ever be
* "tested" by a green suite that never ran it. It was worth extracting twice over,
* because rendering the first real emergency invoice found TWO bugs downstream of
* this split the verdict blaming an inherited corner, and the gnome billable off
* corners the player never chose.
*
* **A corner that was already down when you arrived is the JOB, not your failure.**
* It bills nothing, wrecks nothing, books no warranty and costs no standing it is
* the REASON there is a callout. Without the split, an emergency night charges you
* $180 for the carport the cowboy was already losing, bills his $5 carabiner, docks
* your standing for his corner, and books you a free warranty callout tomorrow to
* go and fix his work: four surfaces, all agreeing, all wrong.
*
* Two lists out, deliberately, because they answer two different questions:
* · `lost` every corner down. REPORTING (the HUD's corners-intact count). It
* must stay honest: reading 4/4 over a sail with three corners on the ground
* would be its own lie.
* · `yours` the ones you are ANSWERABLE for. Billing, collateral, the subtitle
* and the verdict read this.
*
* @param {Array} corners rig.corners at dawn
* @param {object|null} emergency the night's `emergency` block, if it is one
*/
export function answerableFor(corners, emergency = null) {
const theirs = new Set((emergency?.rig ?? []).filter((c) => c.broken).map((c) => c.anchorId));
const lost = (corners ?? []).filter((c) => c.broken);
/**
* 🚨 **SPRINT18 gate 1.3 [Lane D] A CORNER YOU CUT IS NOT A CORNER THAT FAILED, AND WITHOUT
* THIS LINE THE KNIFE IS A TRAP.**
*
* B's `cutAway` deliberately emits no `break` ("warranty must never chase a corner the player
* chose to let go") but every ledger surface downstream reads `c.broken`, and `_releaseCorner`
* sets it for a cut exactly as it does for an overload. So on the tree as merged, taking DESIGN.md
* line 165's signature decision billed you for it on five surfaces at once: the cut corner's
* hardware went on `bill`, the collateral walk BILLED AND WRECKED the $180 carport you cut the
* sail to save, a four-corner cut tripped the gnome's two-corner rule, `clean` was voided by the
* collateral it caused, and week.js booked warranty and docked standing for every corner you let
* go. The invoice would have printed *"You spent the sail to keep the steel. That is the trade the
* knife is for"* directly above a $180 charge for the thing you kept.
*
* Found by playing it, not by a test the same way every card bug in this repo has been found.
* A is the author of both halves and neither half is wrong; nobody joined them, because the join
* is a flag on an object and there was no verb setting the flag until now.
*
* The ruling, and it is B's extended to every surface that reads a corner: **a cut is a DECISION.
* It bills nothing, wrecks nothing, books no warranty and costs no standing. Its price is the
* garden and through the garden, the night's fee and that is enough.** `lost` still counts it
* (the HUD's corners-intact read must stay honest: the sail IS on the ground), and `cut` is named
* so the paper can say who put it there.
*/
const cut = lost.filter((c) => c.cut);
return {
theirs,
lost,
cut,
yours: lost.filter((c) => !theirs.has(c.anchorId) && !c.cut),
inherited: lost.filter((c) => theirs.has(c.anchorId))
.map((c) => ({ anchorId: c.anchorId, hw: c.hw?.name ?? '?' })),
};
}
/**
* 🚨 **SPRINT18 gate 1.3 [Lane D] THE COLLATERAL WALK, EXTRACTED.** What a set of failed corners
* costs you in the client's property. Lifted OUT of scoreRun unchanged, for the two reasons
* `answerableFor` and `rigRecordFor` were extracted before it, and one new one:
*
* · scoreRun lives inside `boot()` behind a WebGL context where no assert can reach it.
* · **the knife's HUD read has to promise exactly what the invoice will honour.** The panel says
* "saves the carport $180" before the cut and the invoice charges $0 after it; if those two ever
* came off different arithmetic, the game would be advertising a saving. One walk, two readers
* the same discipline that made `calloutFee` one function for the board, the dispatch and the
* invoice.
*
* PURE, and that is load-bearing: `wreckStructure()` stays at the call site. A HUD asking what a cut
* would save must not swap a structure for its wreck as a side effect of the question.
*
* @param {Array} corners the corners to price `yours` at dawn, or the LIVE ones for the knife
* @param {object} world contracts World (anchor(), collateralFor(), gnome)
* @returns {{items: {what:string,cost:number}[], keys: string[], total: number}}
*/
export function collateralWalk(corners, world) {
const items = [];
const keys = [];
// The gnome is collateral if the sail came down over it (DESIGN.md: the worst debris in any storm
// is your own failed work). Two corners, per the original rule.
if ((corners ?? []).length >= 2 && world?.gnome) {
items.push({ what: 'garden gnome', cost: world.gnome.collateralValue });
}
/**
* Priced per STRUCTURE, not per corner: two beams letting go is one carport gone, not $360.
*/
const taken = new Set();
for (const c of corners ?? []) {
const key = world?.anchor ? world.anchor(c.anchorId)?.collateral : null;
const priced = key && world.collateralFor ? world.collateralFor(key) : null;
if (!priced || taken.has(key)) continue;
taken.add(key);
keys.push(key);
items.push({ what: priced.label, cost: priced.cost });
}
return { items, keys, total: items.reduce((s, i) => s + i.cost, 0) };
}
// ---------------------------------------------------------------------------
// Phase machine
// ---------------------------------------------------------------------------
@ -361,13 +571,33 @@ export function createGame() {
on(type, fn) { return emitter.on(type, fn); },
/** @param {'forecast'|'prep'|'storm'|'aftermath'} next */
setPhase(next) {
/**
* @param {'forecast'|'prep'|'storm'|'aftermath'} next
* @param {{at?:number}} [opts] 🚨 SPRINT18 gate 1 THE EMERGENCY CALLOUT'S
* ONE LINE OF ENGINE WORK. `at` opens the phase clock at that many seconds
* instead of 0, which is the entire mechanism behind *"a night that starts
* in storm"*: ROADMAP sized this gate as *"the storm phase already does
* everything this needs except spawn you at t=30 with a rig you didn't
* build"*, and this is the t=30 half.
*
* Everything downstream is untouched BY CONSTRUCTION rather than by care,
* which is why it is one argument here and not a new phase:
* · `windTime()` already returns `phaseT` during a storm, so the wind, the
* sky, the hail and the storm's own authored events are all mid-flight
* the moment you get out of the ute you do not arrive at a calm yard
* that then starts blowing.
* · `tick()` already ends the storm at `phaseT >= STORM_LEN`, so an
* emergency night is `STORM_LEN at` long and no clock needed telling.
* · `phaseChange` still fires with the same payload, so every listener
* (sky rebuild, rigging deactivation, pond/tally resets) runs as usual.
*/
setPhase(next, { at = 0 } = {}) {
if (!PHASES.includes(next)) throw new Error(`unknown phase '${next}'`);
if (next === phase) return;
if (!(at >= 0) || !Number.isFinite(at)) throw new Error(`setPhase('${next}', {at}) — at must be a finite number ≥ 0, got ${at}`);
const from = phase;
phase = next;
phaseT = 0;
phaseT = at;
emitter.emit('phaseChange', { from, to: next });
},
@ -599,7 +829,18 @@ export async function boot(opts = {}) {
// world into its ground clamp, solid collider, ladder and broom, none of
// which have a setter (Lane D's modules). A site change only happens at a
// forecast, never mid-storm, so a clean rebuild is honest and cheap.
player = await createPlayer(scene, world, cameraRig, { wind, interact });
// SPRINT18 gate 1.3 — D's knife rides in on the opts it already had. Every one of these is a
// GETTER because `game` and `garden` are declared below the first call to this function (the
// TDZ trap documented twice above); the knife reads them at frame time, when they exist.
player = await createPlayer(scene, world, cameraRig, {
wind,
interact,
getPhase: () => game.phase,
getGarden: () => garden,
/** One walk, two readers: what the panel promises is what the invoice honours. */
collateralAtRisk: collateralWalk,
secsLeft: () => STORM_LEN - game.phaseT,
});
// SPRINT13 gate 2.5 — the opening frame, per YARD rather than per game.
// Done here because the spawn is here: the player and the yard are rebuilt
@ -713,8 +954,60 @@ export async function boot(opts = {}) {
sailView = null;
}
async function rigSail(anchorIds, hwChoices, tension = 1.0) {
rig.attach(anchorIds, hwChoices, tension);
/**
* **SPRINT18 gate 2 THE FABRIC REACHES THE SIM. B FOUND IT; THIS IS THE
* PICKUP, AND IT IS THE WHOLE FIX.**
*
* `rig.setFabric()` had exactly ONE call site in the repo `RiggingSession
* .commit(rig)` — and **the shipped commit path never reached it.** The rigging
* UI's `commit()` calls `onCommit(...)`, and this function went straight to
* `rig.attach()`. So `session.commit(rig)` ran in TESTS AND BENCHES ONLY, and
* the sim's rig built once at boot as `new SailRig({anchors})`, the
* constructor's `porosity = 0` **flew the WATERPROOF MEMBRANE at full wind
* load every night of every playtest**, while the prep panel, the SCORE IT
* card, the HUD row, the invoice and every audit number said "shade cloth".
* **The F key has been moving the paperwork and never the physics**, and it was
* honest exactly half the time by coincidence: pick membrane and the sim agrees
* with you by accident; pick cloth the DEFAULT and it does not.
*
* B's table on the wildnight's pinned recipe, one variable: cloth 0.30 loses
* **0/4**; membrane 0 loses **p2 at 3.48 then p3 at 3.90** which is D's cold
* play to the decimal, and the site's own `_playedReference`. So **the harness
* that was lying was the GAME**: the bench flew the fabric the cards promised,
* every audit number stands, and D's play found a real bug rather than an
* artefact. It also retires C's `AUDIT.MARGIN` residual with the fabric held
* equal, bench and full game chain agree to three decimals on all four corners.
*
* **BOTH ROUTES, deliberately, and neither is redundant:**
* · the ARGUMENT is B's filed hunk and the better contract `dev_rigging.html`
* has no session to reach into and had the identical bug.
* · the SESSION FALLBACK is what makes this LIVE ON lane/a TODAY. B's
* `commit()` passes the 4th argument on lane/b; on this branch `commit()`
* still calls `onCommit` with three, so the argument alone would be an inert
* no-op until integration and B's own words are "until this lands the sim
* still flies the membrane". A fix that waits for a merge to start working is
* not a fix yet. `session.fabric` is the expression this file already reads
* for the paperwork, so the physics and the invoice now read ONE source.
* **AT INTEGRATION (S18, integrator): B's rigging.js is merged, the argument
* now arrives on both wired routes and the fallback STAYS.** A's filed
* instruction was to delete it here; it is deliberately not deleted, and A can
* revert this on sight. The reason is a third caller neither lane enumerated:
* `rigSail` is exposed on the `SHADES` API, where a bench or a cold play can
* still call it with three arguments. Deleting the fallback would make that
* path fly the membrane while every card said cloth the exact five-sprint bug
* this fix exists to kill, reintroduced through the one door the suite cannot
* see (this wire lives inside `boot()` behind a WebGL context). The fallback is
* one `??` and it agrees with the argument on every wired path.
*/
/**
* @param {object} [opts] `{at, wind}` B's `attach` door (sail.js). Absent/`at:0` is
* byte-for-byte the old behaviour, so every ordinary night and every bench is untouched.
* See the emergency's note on `onCommit` for the one caller that passes it.
*/
async function rigSail(anchorIds, hwChoices, tension = 1.0, fabric, opts) {
const cloth = fabric ?? rigging?.session?.fabric;
if (cloth) rig.setFabric(cloth);
rig.attach(anchorIds, hwChoices, tension, opts);
disposeSailView();
sailView = await createSailView(rig);
scene.add(sailView);
@ -821,7 +1114,31 @@ export async function boot(opts = {}) {
camera: cameraRig.object,
domElement: canvas,
world,
onCommit: (ids, hw, tension) => { void rigSail(ids, hw, tension); },
// SPRINT18 gate 2 — the fabric rides through with the steel. B's filed hunk,
// verbatim; `fabric` is undefined on bare lane/a (their commit() passes it,
// and that lands at integration) and rigSail falls back to session.fabric so
// the sim flies the right cloth on both branches. See rigSail's note.
/**
* 🚨 SPRINT18 gate 1.3 [D] **`{at, wind}`: THE CLOTH FINALLY ARRIVES IN THE STORM IT IS
* STANDING IN.** C measured it and B fixed it and nobody wired it, so on the merged tree the one
* night that starts mid-storm attached its sail at t=0: measured live by D on the shipped
* emergency, **`rig.clockSkew` read 30.02** while the sky, garden, debris and player were all
* thirty seconds in. B's own number for what that hides: the same rig entering the same storm
* pulls **90 N at t=0 and 655 N at t=40**. The triage that this whole gate is about was being
* judged against a sail feeling a seventh of the load, and the knife's own invoice line said
* *"cut loose 9s in"* on a night you arrived at 30 because the log stamps the SAIL's clock.
*
* The night is asked, not a flag: `week.job.emergency` is a fact the night already owns, so this
* needs no new state and no ordering change (`adoptEmergencyRig` runs before `setPhase`, so
* reading `game.phaseT` here would read the forecast's clock which is how it would go wrong).
* With a `wind` B's `attach` genuinely PRE-FLIES the thirty seconds rather than teleporting the
* cloth into a gale, so the drape and the belly's water are real history.
*/
onCommit: (ids, hw, tension, fabric) => {
const em = week.job?.emergency;
void rigSail(ids, hw, tension, fabric,
em ? { at: em.arriveAt, wind: winds[week.stormKey] } : undefined);
},
onMessage: pushEvent,
panel: true,
});
@ -849,12 +1166,31 @@ export async function boot(opts = {}) {
/** What the storm cost, assembled at the moment it ends. */
function scoreRun() {
const lost = rig.corners.filter((c) => c.broken);
const bill = lost.reduce((s, c) => s + c.hw.cost, 0);
const collateral = [];
// The gnome is collateral if the sail came down over it. DESIGN.md: the
// worst debris in any storm is your own failed work.
if (lost.length >= 2) collateral.push({ what: 'garden gnome', cost: world.gnome.collateralValue });
/**
* 🚨 **SPRINT18 gate 1 THE RULING THAT MAKES AN EMERGENCY INVOICE HONEST:
* A CORNER THAT WAS ALREADY DOWN WHEN YOU ARRIVED IS THE JOB, NOT YOUR
* FAILURE.** It bills you nothing, wrecks nothing, books no warranty and costs
* no standing it is the REASON there is a callout.
*
* Without this, the shape of an emergency night is grotesque and would have
* shipped looking correct: you turn up at somebody else's collapsing rig and
* the invoice charges you $180 for the carport the cowboy was already losing,
* bills you for his $5 carabiner, docks your standing for his corner, and then
* books you a free warranty callout tomorrow to fix his work. The paper has to
* split the blame, and this repo already knows how to say it the icenight's
* invoice does exactly this ("The shackle at P2 letting go is the part that's
* on you").
*
* Two lists, deliberately, because they answer two different questions:
* · `lost` every corner down at dawn. This is REPORTING (the HUD's
* "corners intact" count), and it must stay honest: reading 4/4 over a
* sail with three corners on the ground would be its own lie.
* · `yours` the ones you are ANSWERABLE for. Billing, collateral and the
* subtitle read this.
*/
const em = week.job.emergency;
const { theirs, lost, yours, cut, inherited } = answerableFor(rig.corners, em);
const bill = yours.reduce((s, c) => s + c.hw.cost, 0);
/**
* SPRINT11 the carport trap finally BILLS (§gate 1).
@ -873,16 +1209,13 @@ export async function boot(opts = {}) {
*
* Priced per STRUCTURE, not per corner: two beams letting go is one carport
* gone, not $360. It bills once and the wreck swaps once.
*
* SPRINT18 gate 1.3 [D] the ARITHMETIC moved out to `collateralWalk` (pure, exported, above)
* so the knife's HUD read prices what it saves through this exact walk. The WRECK stays here:
* a HUD asking "what would a cut save" must not swap a structure for its wreck.
*/
const taken = new Set();
for (const c of lost) {
const key = world.anchor(c.anchorId)?.collateral;
const priced = key ? world.collateralFor(key) : null;
if (!priced || taken.has(key)) continue;
taken.add(key);
collateral.push({ what: priced.label, cost: priced.cost });
world.wreckStructure(key); // false offline/graybox — the bill still lands
}
const { items: collateral, keys: wrecked } = collateralWalk(yours, world);
for (const key of wrecked) world.wreckStructure(key); // false offline/graybox — the bill still lands
const s = rigging.summary;
const hp = garden.hp;
/**
@ -905,7 +1238,19 @@ export async function boot(opts = {}) {
*/
const win = hp >= 50;
const dmg = garden.damage;
const { verdict, mode } = verdictFor({ hp, lost, win, dmg, pondPeak, pondDumped,
/**
* **`yours`, NOT `lost` AND THIS WAS A LIVE BUG ON THE FIRST EMERGENCY
* INVOICE I RENDERED.** With `lost` the verdict narrated the corner the player
* INHERITED as their failure: the ledger said *"CB1 already on the ground when
* you arrived · not billed · That corner is what the callout was for. It is not
* on you"* and four lines below it the verdict said *"the carabiner at CB1 went
* first and took 1 more with it."* One card, two contradictory accounts of the
* same corner the invoice arguing with itself, which is the defect class this
* repo has spent five sprints hunting. The verdict speaks about work you are
* answerable for; the corner count above it still reports every corner down,
* because those are two different facts and both papers say so honestly.
*/
const { verdict, mode } = verdictFor({ hp, lost: yours, cut, win, dmg, pondPeak, pondDumped,
beyondSaving: week.job.gardenBeyondSaving });
// SPRINT16 gate 2.2 [Lane B] — the fabric bet on the record. The F key
@ -931,9 +1276,19 @@ export async function boot(opts = {}) {
collateral,
budgetLeft: Math.max(0, s.budget - bill - collateral.reduce((a, c) => a + c.cost, 0)),
win,
subtitle: lost.length
? `${lost.map((c) => `${c.hw.name} at ${c.anchorId.toUpperCase()}`).join(', ')} let go.`
: 'Every corner held.',
subtitle: yours.length
? `${yours.map((c) => `${c.hw.name} at ${c.anchorId.toUpperCase()}`).join(', ')} let go.`
// SPRINT18 gate 1.3 [D] — a cut sail must not be headlined "Every corner held." The card
// would then carry that line, a CUT LOOSE row in the ledger and a cut verdict underneath:
// three accounts of one night, one of them false. `cut` before `inherited` because on the
// shipped emergency both are true and the cut is the thing the PLAYER did.
: cut.length
? `You cut it loose at ${cut.map((c) => c.anchorId.toUpperCase()).join(', ')}.`
: inherited.length
// An emergency you held: nothing of yours went, and the corner that was
// already down stays named rather than quietly counted as a hold.
? `Nothing of yours let go. ${inherited.map((c) => c.anchorId.toUpperCase()).join(', ')} was on the ground when you got there.`
: 'Every corner held.',
/** Decision 13's headline: how much of the bed's damage the cloth stopped. */
hailBlocked: dmg.hail + dmg.rain > 0
? `${Math.round(dmg.hail)} HP to hail, ${Math.round(dmg.rain)} to rain`
@ -944,16 +1299,40 @@ export async function boot(opts = {}) {
* that rode out a gale isn't new any more. Broken gear is worth nothing,
* which is the whole of `bill`.
*/
intactHardwareValue:
rig.corners.filter((c) => !c.broken).reduce((sum, c) => sum + c.hw.cost, 0)
+ (rigging.session.spares ?? 0) * SPARE_COST,
/**
* 🚨 SPRINT18 gate 1 RULED: **ON AN EMERGENCY, THE INTACT HARDWARE IS
* NOT YOURS TO UNCLIP.** It is the cowboy's steel in the client's yard, and
* you leave it there. Only the spare out of your own ute comes home.
*
* This is also the check that keeps `emergencyRate: 2.0` from being free
* money: without it, a double-rate callout would ALSO hand you half of
* somebody else's four rated shackles at dawn, and the biggest earner in the
* game would be turning up to other people's disasters and salvaging them.
*/
/**
* SPRINT18 gate 1.3 [D] **"LOSE THE SAIL, SAVE THE ANCHORS" IS A LEDGER LINE, NOT A
* SLOGAN.** `!c.broken` alone excluded a CUT corner from the refund, so the knife also quietly
* took your steel: you cut the cloth, and the shackle still bolted to the post undamaged,
* unclippable in thirty seconds at dawn paid you nothing. DESIGN.md line 165 promises the
* opposite in the same breath as the verb, and `bill` already agrees (a cut corner is not
* `yours`, so its hardware is not written off). This is the other half of that sentence.
* On an EMERGENCY it changes nothing none of that steel is yours to unclip either way.
*/
intactHardwareValue: em
? (rigging.session.spares ?? 0) * SPARE_COST
: rig.corners.filter((c) => !c.broken || c.cut).reduce((sum, c) => sum + c.hw.cost, 0)
+ (rigging.session.spares ?? 0) * SPARE_COST,
pondPeak,
pondDumped,
verdict,
/** Which failure mode the verdict is speaking from. Asserted in a.test.js. */
verdictMode: mode,
/** SPRINT16 gate 1 — the work history. See rigRecordFor above. */
rigRecord: rigRecordFor(rig.corners, nightTally),
rigRecord: rigRecordFor(rig.corners, nightTally, theirs),
/** SPRINT18 gate 1 — their corners, still down, named on the invoice and unbilled. */
inherited,
/** SPRINT18 gate 1 — D's knife. Tallied off rig.events; see the cutAway note. */
cutAways: cutAwayLog.slice(),
};
}
@ -1034,24 +1413,18 @@ export async function boot(opts = {}) {
*/
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,
};
});
return week.offers((night) => priceOffer(night, {
def: defs[night.storm],
site: siteJson.get(night.site),
rep: week.rep,
// ⚖️ SPRINT18 gate 0, clause 3 — THE STOOD-UP CLIENT REMEMBERS. Injected
// with the rest of the per-offer facts because the memory is week state and
// board.js is pure: same seam as the fee and the exposure, and the card
// renders nothing when the list is empty (the degradation rule every other
// injected field follows). A client you left waiting on Tuesday who is on
// the board again on Thursday is the whole of "your work follows you".
remembers: week.remembers(night.client),
}));
}
/**
@ -1074,6 +1447,89 @@ export async function boot(opts = {}) {
}));
}
/**
* 🚨 SPRINT18 gate 1 **THE RIG YOU DIDN'T BUILD.** Someone else's work,
* installed from `night.emergency.rig` (B's data) before the storm opens.
*
* Through the session's own PUBLIC MOVES, exactly as `resetRig()` is, and for
* the same reason: the economy stays the single source of truth and nothing
* here reaches into B's fields. Two things worth stating out loud:
*
* **1. THE UTE CARRIES ONE SPARE. RULED (A).** You had no prep phase, so you
* bought nothing but a tradie's ute has a shackle rolling around in it, and
* without one, triage is a verb with no object: you would arrive at a broken
* corner unable to do the only thing the job is for. It is FREE, and the way it
* is made free is visible arithmetic rather than a private door: bank the
* session at `week.bank + SPARE_COST`, buy the spare, and the session's budget
* lands back on exactly `week.bank`. `spentThisNight` therefore reads $0, which
* is the truth there was no shop. (B: if you would rather expose a
* `grantSpare()`, say so in THREADS and I will move to it.)
*
* **2. THE PRE-BROKEN CORNER IS B'S DOOR TO OPEN.** `broken: true` needs more
* than a flag sail.js's own comment says so: a break must also hand the node
* its mass back (`invMass`), dump the pond and emit, or the corner stays welded
* in mid-air instead of flying. There is no public `breakCorner`, so this calls
* one feature-detected (`setConstraints?.()`'s pattern) and the night does not
* depend on it: the authored corner is a $5 carabiner on the carport beam, which
* is ~264 N effective under a southerly, so it fails within seconds THROUGH THE
* REAL OVERLOAD PATH whether or not the door exists. Emergent beats poked.
*/
async function adoptEmergencyRig(em) {
const s = rigging.session;
resetRig();
/**
* **THE ARITHMETIC, AND THE BUG IT FIXES CAUGHT LIVE, NOT BY A TEST.** The
* first cut banked the session at `week.bank + SPARE_COST` and the real game came
* up reading **budget $60 against a bank of $80**: the session had charged me $20
* for the cowboy's four carabiners. The LEDGER was fine (`spentThisNight` is
* forced to 0 on the ROLL) but the SHOP was lying by $20 and `run.budgetLeft`,
* plus every refusal B prices off `budget`, reads that number.
*
* You arrive having spent NOTHING: their steel is not yours, you did not buy it,
* and the ute's spare is free. So the session is pre-banked by exactly what
* adopting the night is about to cost it and the budget lands back on
* `week.bank`. Computed FROM THE RIG DATA rather than hardcoded, so B can author
* rated shackles into a cowboy's rig without this line silently going wrong.
*/
const theirCost = (em.rig ?? []).reduce(
(sum, c) => sum + (HARDWARE.find((h) => h.name === c.hw)?.cost ?? 0), 0);
s.setBudget(week.bank + theirCost + SPARE_COST);
for (const c of em.rig ?? []) {
s.rig(c.anchorId);
const hw = HARDWARE.find((h) => h.name === c.hw);
if (hw) s.setHardware(c.anchorId, hw);
}
s.setSpares(1);
if (!rigging.commit()) {
// Loud, not silent: an emergency whose rig would not commit is a night with
// no sail in it, and the player would be standing in a storm wondering what
// they were called out for.
throw new Error(`main: the emergency rig would not commit — ${em.rig?.length ?? 0} corners `
+ `on ${week.site}. Check every anchorId exists in that yard's site JSON.`);
}
/**
* Corners that ARRIVED gone.
*
* **SPRINT18 gate 1.3 [D] THE DOOR IS CALLED `failCorner`, AND FOR THIS WHOLE SPRINT THIS
* LINE CALLED A METHOD THAT DOES NOT EXIST.** B opened it ([B] gate 1 §4) under the name
* `failCorner`; this asked for `breakCorner?.()`, and an optional call to a renamed method is a
* silent no-op that reads exactly like a call **the `rigging.setWorld` lesson this file quotes
* elsewhere, arriving through the door built to prevent it.** Measured live:
* `typeof rig.failCorner === 'function'`, `typeof rig.breakCorner === 'undefined'`, and the
* corner every card called "ON THE GROUND" was flying.
*
* A's fallback reasoning ("the authored corner is a $5 carabiner on the carport beam, so it fails
* within seconds through the real overload path") was doing the work and hid the break but it
* only holds for A's placeholder rig. Under B's canon set the arrived-broken corner is `tr1b`, a
* carabiner on a 0.88 branch that does NOT self-destruct, so the dispatch would have promised a
* corner on the ground and the sim would have flown it all night. Still feature-detected, because
* the pattern is right; only the name was wrong.
*/
for (const c of em.rig ?? []) {
if (c.broken) rig.failCorner?.(c.anchorId);
}
}
async function showTonight() {
// SPRINT10: tonight's yard. loadSiteInto no-ops when the site is unchanged
// (the four backyard nights) and rebuilds + re-points everything holding the
@ -1101,6 +1557,56 @@ export async function boot(opts = {}) {
// doesn't, this line is the first suspect.
rigging.session.setConstraints?.(week.job?.constraints ?? []);
spentThisNight = 0;
/**
* 🚨 SPRINT18 gate 1 **THE FORK: A DISPATCH INSTEAD OF A JOB SHEET.** The one
* branch this whole gate needed in the phase flow, and it is deliberately the
* last thing in the morning rather than a new phase: everything above this line
* (the yard, the bank, the client's terms) is identical for both job types,
* because an emergency IS a job it just isn't a quote.
*
* Two orderings in here are load-bearing:
* · `stormKey` is set BEFORE the phase change, exactly as the ordinary path
* does on its GO button, because `phaseChange` points the wind router at
* `winds[stormKey]`. Set it after and you would stand in a calm yard while
* the card said gale.
* · **the rig is adopted on the BUTTON, not before the card.** The forecast
* phase's own handler runs `showMorning().then(() => { garden.reset();
* resetRig(); })`, and `resetRig()` would strip an emergency rig installed
* any earlier a night with no sail in it, and the player standing in a
* storm wondering what they were called out for. It is also better fiction:
* you roll, and THEN you are there.
*/
const em = week.job.emergency;
if (em) {
stormKey = week.stormKey;
const emDef = defs[week.stormKey];
hud.showDispatch(
{
job: week.job,
quote: week.quote(emDef),
site: siteMeta[week.site],
/**
* C's seam (THREADS [A] 2026-07-25 §8): what the sky and the grass tell
* you when no paper briefed you. Called through the NAMESPACE import on
* purpose a named import of a function lane/c has not exported yet is a
* hard module error, not a graceful no-op, so `weather.midStormRead?.()`
* is the `setConstraints?.()` pattern adapted to ES modules. Until it
* lands the card prints one honest italic line saying nobody briefed you.
*/
read: emDef ? (weather.midStormRead?.(emDef, em.arriveAt) ?? null) : null,
},
{ night: week.night, nights: week.nights, bank: week.bank, rep: week.rep },
() => {
adoptEmergencyRig(em).then(() => {
spentThisNight = 0; // there was no shop; the ute's spare is free.
game.setPhase('storm', { at: em.arriveAt });
});
},
);
return;
}
// 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
// the half of DESIGN.md's brief the game never showed), and TOMORROW's storm
@ -1117,6 +1623,11 @@ export async function boot(opts = {}) {
// (quote.rep), but the sheet renders a letterhead even on nights whose
// quote degrades away, so it rides the week block explicitly.
rep: week.rep,
// ⚖️ SPRINT18 gate 0 — and if you have stood THIS client up before, the
// sheet says so under their brief. The board's card is where the memory
// changes a decision; the sheet is where it changes the room you walk
// into. Same week ledger, read by name.
remembers: week.remembers(week.job?.client),
},
() => { stormKey = week.stormKey; game.setPhase('prep'); },
);
@ -1153,6 +1664,31 @@ export async function boot(opts = {}) {
rig.events.on('break', (e) => { tallyFor(e.anchorId).broke++; });
rig.events.on('repair', (e) => { tallyFor(e.anchorId).repaired++; });
/**
* 🚨 SPRINT18 gate 1 **D'S KNIFE, THE TALLY AND THE PAPER HALF.** DESIGN.md
* line 165: *"knife (cut a sail loose — lose the sail, save the anchors)"*, and
* the signature decision under load: **release, cut, or ride it out.**
*
* The seam agreed in THREADS [A] 2026-07-25 §9: **D owns the verb, the input and
* the HUD read; I own the tally, the settlement field and the line on the
* paperwork.** D emits `cutAway` on `rig.events` with `{t, corners, reason?}`
* the same Emitter `break`/`repair`/`pondDump` already ride, so this needed no
* new wiring on either side. Subscribed once (the Emitter survives a re-rig) and
* cleared at storm start, exactly like nightTally above: a cut on the calm day
* is not the night's story.
*
* Principle 2, no exceptions a verb with no line on the paperwork is a verb the
* game does not admit to. Until D's input lands this log stays empty and every
* surface renders nothing, which is the degradation rule rather than a stub.
* @type {{t:number, corners:string[], reason:string|null}[]}
*/
const cutAwayLog = [];
rig.events.on('cutAway', (e) => {
const corners = Array.isArray(e?.corners) ? [...e.corners] : (e?.anchorId ? [e.anchorId] : []);
cutAwayLog.push({ t: e?.t ?? game.phaseT, corners, reason: e?.reason ?? null });
pushEvent(`sail cut loose${corners.length ? ` at ${corners.map((c) => String(c).toUpperCase()).join(', ')}` : ''}`);
});
// --- phases -------------------------------------------------------------
game.on('phaseChange', ({ to }) => {
// Prep and forecast happen on the calm day; the storm you picked only
@ -1173,7 +1709,7 @@ export async function boot(opts = {}) {
if (paused) { paused = false; hud.setPaused(false); }
if (muted) setMuted(true);
if (to === 'storm') { pondPeak = 0; pondDumped = 0; nightTally.clear(); }
if (to === 'storm') { pondPeak = 0; pondDumped = 0; nightTally.clear(); cutAwayLog.length = 0; }
if (to === 'forecast') {
hud.setDawn(false);
@ -1207,7 +1743,11 @@ export async function boot(opts = {}) {
// that doesn't wait), and M only once C's bus has a tap — hud owns that
// question, because a help line that lists a dead key is a help line that
// teaches a stranger the game is broken.
hud.setHelp(`WASD move · shift run · E repair/pickup · C brace · RMB orbit · ${hud.comfortKeysHint()}`);
// SPRINT18 gate 1.3 — X is on the help line because a key nobody advertises is a key nobody
// presses, and the knife is the one verb the game has to offer BEFORE the moment it is needed.
// "hold X cut sail" rather than "X cut sail": it is a hold, and the difference matters when the
// window is six seconds long.
hud.setHelp(`WASD move · shift run · E repair/pickup · hold X cut sail · C brace · RMB orbit · ${hud.comfortKeysHint()}`);
}
if (to === 'aftermath') {
// The dawn comes up BEFORE the scoreboard, on E's 2.2 s ease. Their note

View File

@ -18,8 +18,10 @@ import { GLTFLoader } from '../vendor/addons/loaders/GLTFLoader.js';
import { PlayerSim, STATES, TUNE, clipFor, onLadder } from './player.sim.js';
import { createLadder } from './ladder.js';
import { createBroom } from './broom.js';
import { createKnife, createVerbPanel, KNIFE_TUNE } from './knife.js';
export { PlayerSim, STATES, TUNE, clipFor, onLadder, createLadder, createBroom };
export { PlayerSim, STATES, TUNE, clipFor, onLadder, createLadder, createBroom,
createKnife, createVerbPanel, KNIFE_TUNE };
export const CHAR_URL = './models/player_01.glb';
export const ANIM_URL = './models/player_anims.glb';
@ -306,6 +308,25 @@ export async function createPlayer(scene, world, cameraRig, opts = {}) {
const broom = (opts.broom === false || !opts.interact)
? null : createBroom(scene, world, opts.interact, sim, () => opts.interact.sailRig);
/**
* THE KNIFE (SPRINT18 gate 1.3). Same self-wiring as the ladder and the broom the rig is read
* LIVE off interact, because `rigSail()` replaces the object on every commit. Everything main.js
* has to supply is optional and injected as a GETTER, not a value: `game` and `garden` are
* declared in boot() BELOW the first `await loadSiteInto()` that builds the player, so a plain
* `{ game, garden }` would be a temporal-dead-zone throw at boot the trap A has documented twice
* in that file. A getter is read at frame time, when both exist.
*/
const knife = (opts.knife === false || !opts.interact) ? null : createKnife({
player: sim,
getRig: () => opts.interact.sailRig,
getWorld: () => world,
getPhase: opts.getPhase,
getGarden: opts.getGarden,
collateralAtRisk: opts.collateralAtRisk,
secsLeft: opts.secsLeft,
});
const verbs = opts.verbPanel === false ? null : createVerbPanel({ getPhase: opts.getPhase });
return {
get pos() { return sim.pos; },
get carrying() { return sim.carrying; },
@ -318,7 +339,10 @@ export async function createPlayer(scene, world, cameraRig, opts = {}) {
sim.step(dt, t, input, opts.wind);
if (ladder) ladder.update(dt, t, input);
if (broom) broom.update(dt, t, input);
if (opts.interact) opts.interact.step(dt, t, sim, keyboard.holding);
// The prompt state is KEPT now rather than dropped on the floor — see createVerbPanel.
const prompt = opts.interact ? opts.interact.step(dt, t, sim, keyboard.holding) : null;
const cut = knife ? knife.step(dt, t, keyboard.cutting) : null;
verbs?.update(prompt, cut);
view.sync(sim, dt);
},
@ -328,11 +352,16 @@ export async function createPlayer(scene, world, cameraRig, opts = {}) {
view,
ladder,
broom,
knife,
verbs,
keyboard,
dispose() {
keyboard.dispose(); view.dispose();
if (ladder) ladder.dispose();
if (broom) broom.dispose();
// The panel is rebuilt with the player on every site change (loadSiteInto disposes the old
// one), so a leaked overlay would stack a second #verbs on the third night.
if (verbs) verbs.dispose();
},
};
}
@ -356,6 +385,18 @@ export class KeyboardInput {
get holding() { return this.keys.has('KeyE'); }
/**
* SPRINT18 THE KNIFE'S KEY. X, held.
*
* Its own key rather than a third hold-E target, because at a post base E already offers two
* things (re-rig, tighten) and `interact.nearest()` picks by DISTANCE a knife registered at the
* same position would race the repair for the same key, and the one input the player must never
* misfire is the irreversible one. X is free (WASD/arrows, shift, C brace, E hold, P, M, Enter,
* and prep's [ ] S F are the whole keyboard), and it is next to C on the row your left hand
* already lives on during a storm.
*/
get cutting() { return this.keys.has('KeyX'); }
/** @param {number} camYaw radians */
read(camYaw = 0) {
const k = this.keys;

View File

@ -297,15 +297,101 @@ export class RiggingSession {
return OK;
}
/**
* SPRINT18 GATE 1 THE RIG YOU DIDN'T BUILD. Adopt somebody else's work.
* [B rules the semantics; installs Lane A's `night.emergency.rig` data]
*
* An emergency callout drops you at t=30 under a sail you never quoted, never
* bought and never hung. Lane A's spine wanted to install it through `rig()` +
* `setHardware()` the public-moves discipline, which is right but those
* moves are a SHOP, and putting someone else's rig through the shop charges the
* player for it. Two things then go wrong, and the second one is the bad one:
*
* 1. the wallet. $40 of another contractor's shackles comes off tonight's
* bank, for steel that was already in the yard when the phone rang.
* 2. THE REFUND INVERTS THE KNIFE. week.js pays back half of every intact
* corner's hardware (`intactHardwareValue` × refundShare). If the inherited
* steel counts as the player's, then every corner they CUT costs them
* money and DESIGN.md's signature decision becomes a thing the ledger
* punishes you for getting right. A rule that pays you to let a stranger's
* sail take out a glasshouse is not a hard trade-off, it's a bug.
*
* So: adopted corners cost $0, are flagged `inherited`, and are excluded from
* `intactHardwareValue`. The steel is real to the PHYSICS (its rating is what
* fails) and invisible to the WALLET, which is exactly what "someone else's
* work" means. `spent`/`budget` never move, so the invoice's spend line reads
* $0 on a callout with no branch anywhere in the ledger (A's acceptance §10).
*
* Client terms are still honoured a house ban still refuses, because the ban
* is about what may be TOUCHED, not about who paid. A refusal here is a
* content bug (the author hung a sail off banned steel), so it comes back as a
* result for the caller to surface, not a throw: A installs this at the same
* boundary `setConstraints` uses and can ticker it.
*
* @param {{anchorId: string, hw: string, broken?: boolean}[]} spec A's shape:
* `hw` is a HARDWARE *name*, since this arrives from authored JSON
* @returns {{ok: boolean, reason?: string, broken?: string[]}} `broken` lists
* the corners that must arrive DOWN hand them to `SailRig.failCorner`
* after commit (the pond and the freed node are the sail's business, not the
* session's)
*/
inherit(spec) {
if (!Array.isArray(spec) || spec.length !== MAX_CORNERS) {
return fail(`an inherited rig is ${MAX_CORNERS} corners, got ${Array.isArray(spec) ? spec.length : typeof spec}`);
}
const before = { budget: this.budget, picks: this.picks };
this.picks = [];
const broken = [];
for (const s of spec) {
const a = this.anchors.find((x) => x.id === s.anchorId);
if (!a) { this.picks = before.picks; return fail(`inherit: no anchor ${s.anchorId} in this yard`); }
const ban = this._banFor(a);
if (ban) { this.picks = before.picks; return fail(`inherit: ${s.anchorId} is ${a.type} steel and the client's terms say "${ban.label}"`); }
const hw = HARDWARE.find((h) => h.name === s.hw);
if (!hw) {
this.picks = before.picks;
return fail(`inherit: "${s.hw}" is not shop hardware — the yard sells ${HARDWARE.map((h) => h.name).join(', ')}`);
}
if (this.picks.some((p) => p.anchorId === s.anchorId)) {
this.picks = before.picks;
return fail(`inherit: ${s.anchorId} named twice`);
}
this.picks.push({ anchorId: s.anchorId, hw, inherited: true });
if (s.broken) broken.push(s.anchorId);
}
this._reorder();
// the money never moved, and that is the whole point
this.budget = before.budget;
return { ...OK, broken };
}
/**
* What the player would get back for hardware still standing at dawn with
* inherited steel excluded, because you cannot sell a shackle you never
* bought. main.js reads `rig.corners` for this today; a callout needs the
* SESSION's view, which is the only one that knows who paid.
*/
intactHardwareValue(rig) {
const corners = rig?.corners ?? [];
return corners.reduce((sum, c) => {
if (c.broken) return sum;
const p = this.pickOf(c.anchorId);
return p?.inherited ? sum : sum + c.hw.cost;
}, 0);
}
/**
* Unrig a corner and refund its hardware. Not in the prototype (which had no
* way back from a misclick) but it is a pure refund, so it costs the economy
* nothing and saves the player a restart.
*
* SPRINT18: an INHERITED corner refunds nothing pulling somebody else's
* shackle off their post is not a trip to the returns counter.
*/
unrig(anchorId) {
const i = this.picks.findIndex((p) => p.anchorId === anchorId);
if (i < 0) return fail('not rigged');
this.budget += this.picks[i].hw.cost;
if (!this.picks[i].inherited) this.budget += this.picks[i].hw.cost;
this.picks.splice(i, 1);
return OK;
}
@ -315,13 +401,29 @@ export class RiggingSession {
const p = this.pickOf(anchorId);
if (!p) return fail('not rigged');
const next = HARDWARE[(HARDWARE.indexOf(p.hw) + 1) % HARDWARE.length];
const capNo = this._capRefusal(next.cost - p.hw.cost);
const price = this._priceOfChange(p, next);
const capNo = this._capRefusal(price);
if (capNo) return capNo;
if (!this._spend(next.cost - p.hw.cost)) return fail('not enough budget');
if (!this._spend(price)) return fail('not enough budget');
p.hw = next;
p.inherited = false; // you paid for this one; it is yours now
return OK;
}
/**
* What changing a corner's hardware costs. [SPRINT18 gate 1]
*
* Normally the difference the shop takes the old piece back, because you
* bought it here ten seconds ago. On an INHERITED corner there is nothing to
* trade in: the carabiner on that post belongs to whoever rigged it last, and
* crediting its $5 against your shackle would have the player selling somebody
* else's steel to fund their own. So an inherited corner is priced at FULL
* whack, and replacing it is exactly as expensive as triage should feel.
*/
_priceOfChange(pick, next) {
return pick.inherited ? next.cost : next.cost - pick.hw.cost;
}
/**
* Put specific hardware on a rigged corner, paying the difference.
*
@ -350,10 +452,12 @@ export class RiggingSession {
}
const p = this.pickOf(anchorId);
if (!p) return fail('not rigged');
const capNo = this._capRefusal(hw.cost - p.hw.cost);
const price = this._priceOfChange(p, hw);
const capNo = this._capRefusal(price);
if (capNo) return capNo;
if (!this._spend(hw.cost - p.hw.cost)) return fail('not enough budget');
if (!this._spend(price)) return fail('not enough budget');
p.hw = hw;
p.inherited = false; // paid for, so it counts as yours at dawn
return OK;
}
@ -437,8 +541,18 @@ export class RiggingSession {
this.picks = ring.map((a) => byId.get(a.id));
}
/** Hand the finished rig to the sim. Mirrors contracts.js sailRig.attach(). */
commit(rig) {
/**
* Hand the finished rig to the sim. Mirrors contracts.js sailRig.attach().
*
* `opts` is forwarded to `attach` untouched, which exists for exactly one
* reason (SPRINT18, Lane C's finding): an emergency callout's storm is already
* 30 seconds old, and `attach({at, wind})` is what stops the cloth flying the
* wrong sixty seconds of it. Without this the only way to seed the clock would
* be a SECOND call after commit a step a caller can forget, on the one night
* where forgetting it silently understates every load by 4x. The session's
* public-moves discipline and a correct clock should not be a choice.
*/
commit(rig, opts = undefined) {
if (!this.canStart) throw new Error(`sail needs ${MAX_CORNERS} corners, have ${this.picks.length}`);
// gate 2, the promise the sprint doc names ("enforces the budget cap at
// commit"): by now the refusals above have made these states unreachable
@ -466,7 +580,7 @@ export class RiggingSession {
// shade cloth + C's 0.40 downdraft drop p1 to 1.08 kN, under a $5
// carabiner's rating, which is the $10 that closes the $90 -> $80 gap.)
if (rig.setFabric) rig.setFabric(this.fabric);
return rig.attach(this.picks.map((p) => p.anchorId), this.picks.map((p) => p.hw), this.tension);
return rig.attach(this.picks.map((p) => p.anchorId), this.picks.map((p) => p.hw), this.tension, opts);
}
/**
@ -557,7 +671,14 @@ export class RiggingSession {
anchorId: p.anchorId, hw: p.hw.name, rating: p.hw.rating, cost: p.hw.cost,
effRating: this._effRating(p),
hint: this.anchors.find((x) => x.id === p.anchorId)?.ratingHint ?? 1,
// SPRINT18 gate 1: whose steel this is. The dispatch card wants to say
// "somebody else's carabiner on a 0.88 branch" and the invoice wants to
// leave it out of the refund; both need the flag, not an inference.
inherited: !!p.inherited,
})),
// $0 on an ordinary night; on a callout, what the previous contractor left
// hanging. A number the dispatch can print instead of a spend line.
inheritedValue: this.picks.reduce((s, p) => s + (p.inherited ? p.hw.cost : 0), 0),
// The weak link is the lowest EFFECTIVE rating — hw.rating × the anchor's
// ratingHint, the same product sail.js fails a corner on (SPRINT12, A's
// ruling). D's #7: this used to reduce on hw.rating alone with strict <,
@ -591,7 +712,10 @@ export class RiggingSession {
* @param {object} o.camera cameraRig.object what we raycast from
* @param {Element} o.domElement renderer.domElement where clicks land
* @param {object} o.world needs world.anchors
* @param {function} o.onCommit (anchorIds, hwChoices, tension) => void Lane A's rigSail
* @param {function} o.onCommit (anchorIds, hwChoices, tension, fabric) => void Lane A's
* rigSail. The FOURTH argument is the FABRIC entry, and it is
* not decoration see commit() below for the sprint-18 gate-2
* finding that put it there.
* @param {function} [o.onMessage] (text) => void refusals, for the event ticker
* @param {boolean} [o.panel=true] draw the built-in prep panel; false if hud.js takes it over
*/
@ -879,7 +1003,37 @@ export async function createRiggingUI({
}
},
/** Hand the finished rig to Lane A's rigSail. Returns false if it isn't four corners. */
/**
* Hand the finished rig to Lane A's rigSail. Returns false if it isn't four
* corners.
*
* SPRINT18 GATE 2 THE FABRIC RIDES ALONG NOW, AND HERE IS WHY.
*
* `RiggingSession.commit(rig)` sets the fabric on the sail (`rig.setFabric`)
* and it is the ONLY place in the repo that does. This function the
* shipped commit path, the one the player's Enter key reaches does not
* call it: it calls `onCommit`, and Lane A's `rigSail` goes straight to
* `rig.attach()`. So `session.commit(rig)` runs in TESTS AND BENCHES ONLY,
* and the sim's rig (built once at main.js boot as `new SailRig({anchors})`,
* porosity defaulting to 0) has flown the WATERPROOF MEMBRANE every night of
* every playtest, while the panel, the SCORE IT card, the HUD row, the
* invoice and every audit number said "shade cloth".
*
* Measured, sprint 18 (the wildnight pin p5/p1/p2/p3, backyard_01):
* shade cloth 0.30 (what every card promised) 0/4 lost · p2 3.07 · p3 1.78
* membrane 0 (what the sim actually flew) 2/4 lost · p2 3.48 · p3 3.90
* The second row IS D's cold play and IS the pin's own `_playedReference`
* (hp 55.7, p2 then p3) reproduced in the shipped game to the decimal.
* Two harnesses, one variable: `porosity`. The GAME was the liar; the bench
* flew what the card sold. This also retires C's "benches under-read the
* real UI by 515%, cause unfound": with the fabric held equal, the bench and
* the full game chain agree to three decimals on both sites.
*
* So the fabric is now an ARGUMENT, not a thing the caller must remember to
* go and fetch. Lane A's one-line pickup is filed in THREADS; until it
* lands, `rigSail` dropping a 4th argument is exactly as broken as it was
* before and no more, which is why this is safe to ship on its own.
*/
commit() {
if (!session.canStart) {
onMessage(`rig ${MAX_CORNERS - session.picks.length} more corner(s) first`);
@ -889,6 +1043,7 @@ export async function createRiggingUI({
session.picks.map((p) => p.anchorId),
session.picks.map((p) => p.hw),
session.tension,
session.fabric,
);
return true;
},

View File

@ -555,6 +555,95 @@ test('gate 3: a corroded corner costs MORE steel than a sound post at the same l
return `same carabiner: sound holds ${sound} N, corroded holds ${cor} N — the rust costs a tier`;
});
// --- SPRINT18 gate 1: THE RIG YOU DIDN'T BUILD -----------------------------
const INHERITED = [
{ anchorId: 'h1', hw: 'carabiner' },
{ anchorId: 'h3', hw: 'shackle' },
{ anchorId: 'p1', hw: 'carabiner', broken: true },
{ anchorId: 'p2', hw: 'carabiner' },
];
test('gate 1: an inherited rig costs the player nothing and names its dead corner', () => {
const s = session();
const r = s.inherit(INHERITED);
assert(r.ok, `inherit refused: ${r.reason}`);
assert(s.budget === START_BUDGET,
`adopting somebody else's rig charged $${START_BUDGET - s.budget}. That steel was in the yard when the ` +
'phone rang — the callout pays for the callout, not for the previous contractor\'s shackles');
assert(s.spent === 0, `spent reads $${s.spent} on a rig the player did not buy`);
assert(s.picks.length === 4 && s.canStart, 'an inherited rig is not startable');
assert(s.picks.every((p) => p.inherited), 'some adopted corners are not flagged inherited');
assert(r.broken.join(',') === 'p1',
`inherit reported broken corners "${r.broken.join(',')}" — Lane A hands these to SailRig.failCorner`);
assert(s.summary.inheritedValue === CARABINER.cost * 3 + SHACKLE.cost,
`inheritedValue reads $${s.summary.inheritedValue} — the dispatch prints this where a spend line would go`);
return `4 corners adopted for $0 (worth $${s.summary.inheritedValue}), p1 arrives down, budget still $${s.budget}`;
});
test('gate 1: ⚠️ the refund cannot pay you for a stranger\'s steel — the knife stays honest', () => {
const s = session();
assert(s.inherit(INHERITED).ok, 'inherit refused');
// Stand in for the sail at dawn: nothing broken, so every corner is "intact".
const rig = { corners: s.picks.map((p) => ({ anchorId: p.anchorId, hw: p.hw, broken: false })) };
assert(s.intactHardwareValue(rig) === 0,
`an all-inherited rig returned $${s.intactHardwareValue(rig)} of refundable hardware. week.js pays back half ` +
'of that, so every corner the player CUT would cost them money — DESIGN.md\'s signature decision, ' +
'punished by the ledger for being taken');
// Buy one corner yourself and it becomes yours — at FULL price, no trade-in.
const before = s.budget;
assert(s.setHardware('p2', RATED).ok, 'buying an inherited corner up was refused');
assert(before - s.budget === RATED.cost,
`upgrading an inherited carabiner cost $${before - s.budget}, not $${RATED.cost}. Crediting the old ` +
'piece would have the player selling somebody else\'s steel to fund their own');
const rig2 = { corners: s.picks.map((p) => ({ anchorId: p.anchorId, hw: p.hw, broken: false })) };
assert(s.intactHardwareValue(rig2) === RATED.cost,
`after paying for p2, refundable value reads $${s.intactHardwareValue(rig2)} — it should be exactly the ${RATED.name} the player bought`);
// and a corner that is GONE at dawn refunds nothing either way
const rig3 = { corners: s.picks.map((p) => ({ anchorId: p.anchorId, hw: p.hw, broken: p.anchorId === 'p2' })) };
assert(s.intactHardwareValue(rig3) === 0, 'a broken corner still came home');
return `inherited steel refunds $0; the $${RATED.cost} the player bought refunds, and only that`;
});
test('gate 1: commit carries the mid-storm clock through to the sail', () => {
// The emergency flow is inherit -> commit -> put the dead corner down -> roll.
// If commit cannot pass `at`, seeding the clock becomes a separate call on the
// one night where forgetting it understates every load ~4x (C's finding).
const s = session();
assert(s.inherit(INHERITED).ok, 'inherit refused');
const rig = s.commit(new SailRig({ anchors: ANCHORS, gridN: 10 }), { at: 30 });
assert(rig.t === 30,
`commit({at:30}) left the cloth at t=${rig.t}. It is now flying the storm's FIRST sixty seconds ` +
'under a sky flying its last sixty, and nothing about that is visible from outside');
assert(rig.clockSkew === 0, `a freshly seeded commit already reads ${rig.clockSkew}s of skew`);
// and the ordinary night is untouched — no opts, clock at 0
const plain = session();
for (const id of ['h1', 'h3', 'p1', 'p2']) plain.rig(id);
assert(plain.commit(new SailRig({ anchors: ANCHORS, gridN: 10 })).t === 0,
'an ordinary commit no longer starts its storm at 0');
return 'commit(rig, {at:30}) seeds the cloth to 30s; a plain commit still starts at 0';
});
test('gate 1: an inherited rig still obeys the client, and refuses a rig it cannot build', () => {
const s = session();
s.setConstraints([HOUSE_BAN]);
const banned = s.inherit(INHERITED); // h1/h3 are house steel
assert(!banned.ok && /h1/.test(banned.reason),
`a banned anchor was adopted anyway: ${JSON.stringify(banned)} — the ban is about what may be TOUCHED, not who paid`);
assert(s.picks.length === 0, 'a refused inherit left half a rig behind');
const s2 = session();
assert(!s2.inherit(INHERITED.slice(0, 3)).ok, 'a three-corner sail was adopted');
assert(!s2.inherit([...INHERITED.slice(0, 3), { anchorId: 'p1', hw: 'gaffer tape' }]).ok,
'hardware that is not in the shop was adopted — an authored typo would silently fly a carabiner');
assert(!s2.inherit([...INHERITED.slice(0, 3), { anchorId: 'h1', hw: 'shackle' }]).ok,
'the same anchor twice was adopted');
assert(!s2.inherit([...INHERITED.slice(0, 3), { anchorId: 'nowhere', hw: 'shackle' }]).ok,
'an anchor this yard does not have was adopted');
assert(s2.budget === START_BUDGET, `a refused inherit moved the money to $${s2.budget}`);
return 'refuses banned steel, wrong corner counts, unknown hardware, duplicates and strangers — money never moves';
});
export const RIGGING_TESTS = TESTS;
export function runRiggingSelftest() {

View File

@ -96,8 +96,20 @@ const DEBRIS_SKIN = 0.06; // contact margin, ~cloth thickness
// real load; POND_BELLY_MAX below is what stops a sail bellying to 5 m first.
const DIVERGENCE_N = 80000; // 80 kN — past here the solver has actually blown up
const POND_BELLY_MAX = 4.0; // metres of sag before the sail tears and dumps (DESIGN.md "sudden dump… tear")
const OVERLOAD_SECS = 0.4; // prototype: 0.4 s sustained overload before it lets go
const OVERLOAD_RECOVER = 2.0; // prototype: overload timer bleeds off at 2x
/**
* THE FAILURE LAW, and it is EXPORTED because a second copy of it is the exact
* drift this repo keeps paying for (SPRINT18 gate 3).
*
* A corner does not fail the instant it passes its rating: `overload` fills at
* +dt while over and bleeds at 2dt while under, and the corner lets go when it
* passes OVERLOAD_SECS. So "will this hold" is a question about DWELL, not about
* peak load a corner can peak 10% over its rating and survive if the gust is
* short, and the audit was answering it with a percentage of taste
* (AUDIT.MARGIN) instead of the law itself. tools/site_audit replays these two
* numbers against the load trace now; they must never be re-typed there.
*/
export const OVERLOAD_SECS = 0.4; // prototype: 0.4 s sustained overload before it lets go
export const OVERLOAD_RECOVER = 2.0; // prototype: overload timer bleeds off at 2x
const LOAD_TAU = 0.11; // load meter smoothing time constant, s
export const TENSION_MIN = 0.6;
@ -172,8 +184,16 @@ export class SailRig {
* @param {string[]} anchorIds 4 anchor ids; reordered into a ring internally
* @param {object[]} hwChoices hardware per anchor id, same order as anchorIds
* @param {number} tension 0.6 (loose, flogs) .. 1.4 (drum tight, shock-loads)
* @param {object} [opts]
* @param {number} [opts.at=0] STORM SECONDS ALREADY ELAPSED when this sail
* enters the sim. 0 for every ordinary night see the clock note below, and
* SPRINT18 gate 1 / Lane C's finding for the night where it is not 0.
* @param {object} [opts.wind=null] if given alongside `at`, the sail is FLOWN
* from 0 to `at` through this wind instead of teleported there the
* difference between a sail that has been up all storm and one that appears
* mid-gale with no drape, no pond and no history.
*/
attach(anchorIds, hwChoices, tension = 1.0) {
attach(anchorIds, hwChoices, tension = 1.0, { at = 0, wind = null } = {}) {
if (anchorIds.length !== 4) throw new Error(`sail needs exactly 4 corners, got ${anchorIds.length}`);
const picked = anchorIds.map((id) => {
@ -215,9 +235,29 @@ export class SailRig {
* caller comes through (boot, the picking adapter, balance.test, the audit),
* and commit attach game.advance() storm all land in a single keypress,
* so t=0 at attach IS t=0 at the first storm frame.
*
* SPRINT18 THAT LAST SENTENCE IS A PRECONDITION, AND THE EMERGENCY
* CALLOUT IS THE FIRST NIGHT THAT BREAKS IT. [Lane C's finding, B's fix]
*
* `_substep` samples the wind at `this.t`, NOT at the `t` handed to step()
* deliberately, because the internal fixed-step clock is what makes a 144 Hz
* browser and a fast-forwarded bench produce byte-equal traces. That is only
* safe while the two clocks START together. An emergency night opens its storm
* phase at t=30, so a sail attached the old way would fly the storm's FIRST
* 60 seconds while the sky, the hail and the garden fly its LAST 60. C
* measured the gap through that door: the sky blowing 9.074 m/s over a cloth
* feeling 4.468 (4.12x understated load) and raining 5.000 mm/hr onto a cloth
* ponding 0.054 (93x light) an emergency that would demo fine and merge
* fine and simply read as "triage is easy".
*
* So the clock is SEEDED rather than assumed, and `clockSkew` below makes any
* future divergence readable instead of silent. `at` is 0 for every ordinary
* night, which is byte-for-byte the behaviour above.
*/
this.t = 0;
this.t = at;
this._acc = 0;
/** Largest |caller's t this.t| seen since attach. 0 means the clocks agree. */
this._skew = 0;
this.corners = ring.map((a) => ({
anchorId: a.id,
anchor: a,
@ -226,15 +266,66 @@ export class SailRig {
peakLoad: 0,
overload: 0,
broken: false,
// SPRINT18 gate 1 — HOW a corner came to be down, not just that it is.
// Declared here with the rest so a re-rig clears them: a sail cut away on
// night 5 that still read `cut` on night 6 would put last night's knife on
// tonight's invoice, which is the phantom-sail bug wearing a new hat.
cut: false, // the player chose to let this one go (`cutAway`)
inherited: false, // it was already gone when you arrived (`failCorner`)
trim: 1.0,
loadVec: { x: 0, y: 0, z: 0 }, // reaction direction, not just magnitude — see _measureLoads
}));
this._build(ring);
this.rigged = true;
// A sail that has ALREADY been up for `at` seconds. Flown, not teleported:
// the drape, the pond and the peak loads of those seconds are the difference
// between inheriting somebody's rig and finding a brand-new one hanging in a
// gale. Costs `at`/SIM_DT substeps once (30 s ≈ 1800, single-digit ms) and it
// is the only way the pre-broken corner and the belly's water mean anything.
// Without a wind we can only seed the clock, which gets the WEATHER right and
// says nothing about the history — stated rather than hidden.
if (at > 0 && wind) {
this.t = 0;
for (let i = 0, n = Math.round(at / SIM_DT); i < n; i++) this._substep(SIM_DT, wind, this.t, null);
this.t = at;
this._skew = 0;
}
return this;
}
/**
* Move the sail's own clock to `t` seconds of storm. [SPRINT18 gate 1]
*
* The post-attach door, for a caller that only learns where the storm is after
* committing (Lane A's emergency flow: adopt the rig commit put the dead
* corner down roll). `attach(..., {at})` is the better door when you know the
* number in time; this exists so the knowledge arriving late cannot mean the
* cloth flies the wrong sixty seconds.
*/
seedClock(t) {
if (!Number.isFinite(t)) return { ok: false, reason: `seedClock needs a finite time, got ${t}` };
this.t = t;
this._acc = 0;
this._skew = 0;
return { ok: true };
}
/**
* How far the caller's storm clock has drifted from the cloth's own, in seconds.
*
* Reads 0 on every honest night. It is a DIAGNOSTIC rather than a throw because
* one legitimate harness diverges on purpose balance.test's `fly()` settles
* 12 s on the calm day before the storm loop restarts its own `t` at 0, which is
* the phantom settle the pinned separation target is calibrated against. What
* this catches is the silent case: a phase that opens mid-storm while the cloth
* starts from scratch (Lane C's emergency finding). If it reads non-zero on a
* night you did not deliberately skew, the sail is in a different storm from the
* sky and every load you are looking at is fiction.
*/
get clockSkew() { return this._skew; }
_build(ring) {
const N = this.N;
const nodeCount = N * N;
@ -419,6 +510,15 @@ export class SailRig {
*/
step(dt, wind, t, debris = null) {
if (!this.rigged) return;
// `t` is not used to sample the wind (that is `this.t`, on purpose — see
// attach) but it IS the caller telling us where it thinks the storm is, and
// comparing the two costs nothing. SPRINT18: this is the only reason Lane C's
// emergency-clock finding is detectable from inside the sail rather than by
// noticing that triage felt suspiciously easy.
if (Number.isFinite(t)) {
const d = Math.abs(t - this.t);
if (d > this._skew) this._skew = d;
}
const pieces = debris ? (debris.pieces ?? debris) : null;
this._acc += dt;
let n = 0;
@ -925,6 +1025,130 @@ export class SailRig {
* anchor (DEFAULT_RATING_HINT, a.test pins it), because `load > rating *
* undefined` is `load > NaN`: always false, i.e. an UNBREAKABLE anchor.
*/
/**
* ONE definition of what it means for a corner to stop holding extracted in
* SPRINT18 because gate 1 adds two more ways for it to happen (a corner that
* arrives already gone, and the knife) and three copies of this body would be
* exactly the drift this repo keeps paying for.
*
* Every caller gets the same five facts: the corner is broken, its overload
* fuse and load are zeroed, THE NODE GETS ITS MASS BACK (this is the line every
* good thing about a failure comes from the freed corner stops being pinned,
* so it flies on the wind and the flogging is emergent rather than animated),
* the belly dumps whatever it was holding, and something is emitted.
*
* `reason` also decides WHICH event, and that is the design, not plumbing:
* · 'overload' / 'divergence' `break`. Your hardware failed. Warranty, rep.
* · 'cut' / 'inherited' no `break` at all. A cut is a DECISION and a
* corner you found already gone is somebody else's failure; billing either
* as your break would put another contractor's shackle on your invoice.
* `cutAway` carries the knife; the inherited case is silent by construction.
*
* The pond dump carries a reason for the same accounting: main.js only counts a
* REASONLESS dump as broom work (`if (!e.reason) pondDumped += e.kg`), so a
* belly that empties because the sail was cut is not credited to the player's
* arms.
*
* @returns {boolean} false if the corner was already gone (idempotent)
*/
_releaseCorner(k, reason) {
const c = this.corners[k];
if (c.broken) return false;
c.broken = true;
c.overload = 0;
c.load = 0;
c.loadVec.x = c.loadVec.y = c.loadVec.z = 0;
if (reason === 'cut') c.cut = true;
if (reason === 'inherited') c.inherited = true;
this.invMass[this.cornerIdx[k]] = 1 / this.nodeMass;
this.dumpPond(reason === 'cut' ? 'sail cut away'
: reason === 'inherited' ? 'arrived down' : 'corner blew');
if (reason === 'overload' || reason === 'divergence') {
this.events.emit('break',
{ type: 'break', corner: c, anchorId: c.anchorId, hw: c.hw.name, t: this.t, reason });
}
return true;
}
/** Index of a corner by its anchor id, or 1. */
_cornerIndex(anchorId) {
return this.corners.findIndex((c) => c.anchorId === anchorId);
}
/**
* SPRINT18 gate 1 A CORNER THAT WAS ALREADY GONE WHEN YOU GOT THERE.
*
* Lane A's emergency callout installs someone else's rig through the session's
* public moves and then needs one corner to arrive DOWN which public moves
* cannot express, because nothing in the shop sells a broken shackle. A asked
* for the door on this side (their THREADS shapes, §2) and this is it.
*
* It is deliberately NOT a `break`: you did not break it, and an invoice that
* says you did is the same lie as billing the player for the previous
* contractor's steel. Silent, so every existing break listener is untouched.
*
* @param {string} anchorId
* @returns {{ok: boolean, reason?: string}} the session's result shape, so a
* caller can put a refusal in the ticker instead of catching a throw
*/
failCorner(anchorId) {
if (!this.rigged) return { ok: false, reason: 'there is no rig to fail a corner on' };
const k = this._cornerIndex(anchorId);
if (k < 0) return { ok: false, reason: `no corner at ${anchorId} — this sail is on ${this.corners.map((c) => c.anchorId).join(', ')}` };
if (!this._releaseCorner(k, 'inherited')) return { ok: false, reason: `${anchorId} is already down` };
return { ok: true };
}
/**
* THE KNIFE DESIGN.md line 165: *cut a sail loose: lose the sail, save the
* anchors.* The signature decision under load, and the physics half of it.
* [SPRINT18 gate 1; B exposes the cut, D owns the verb, the input and the HUD]
*
* There is no new physics here and that is the point. A cut corner is released
* through the same `_releaseCorner` an overload uses, so the cloth goes limp and
* flies on exactly the wind it already had the difference is entirely in what
* it COSTS, and the economy falls out of rules the game already has:
*
* · what the cut SAVES is certain. A released corner carries zero load
* forever, so no anchor it hangs off can be torn out and no collateral that
* anchor reaches can be wrecked. On a yard whose steel holds a carport beam
* or a glasshouse, that is the whole wage of a callout (`clean`, week.js
* "about THEIR property, not your success").
* · what the cut COSTS is the garden, and it costs it honestly: the bed is
* bare for every second left in the storm, hail included, and `win` is
* `hp >= 50`. So the knife spends garden HP and possibly the night's fee
* to buy certainty on somebody's property. Ride it out for the full money
* and gamble their glasshouse, or cut and take the smaller cheque.
*
* NOT a `break` event, per `_releaseCorner`: cutting is a judgement call, not a
* hardware failure, and warranty must never chase a corner the player chose to
* let go. `cutAway` is the record the payload is Lane A's requested shape
* (their §9) so their night tally and the invoice line need no new wiring.
*
* @param {string[]|null} [anchorIds] corners to cut; null/omitted = the whole
* sail, which is what the verb means in DESIGN.md and what D's key will send
* @param {string} [reason] free text for the paperwork ('the glasshouse')
* @returns {{ok: boolean, cut?: string[], reason?: string}}
*/
cutAway(anchorIds = null, reason = null) {
if (!this.rigged) return { ok: false, reason: 'there is no sail to cut' };
const ids = anchorIds ?? this.corners.map((c) => c.anchorId);
const unknown = ids.filter((id) => this._cornerIndex(id) < 0);
if (unknown.length) {
return { ok: false, reason: `no corner at ${unknown.join(', ')} — this sail is on ${this.corners.map((c) => c.anchorId).join(', ')}` };
}
const cut = [];
for (const id of ids) if (this._releaseCorner(this._cornerIndex(id), 'cut')) cut.push(id);
// Every named corner was already down. Refuse rather than emit an empty cut:
// a paperwork line saying you cut nothing loose is worse than no line.
if (!cut.length) return { ok: false, reason: 'the sail is already down' };
this.events.emit('cutAway', { type: 'cutAway', t: this.t, corners: cut, reason });
return { ok: true, cut };
}
/** Corners released by the knife tonight — the paperwork's own read. */
get cutCorners() { return this.corners.filter((c) => c.cut).map((c) => c.anchorId); }
_checkFailure(dt) {
let diverged = false;
for (let k = 0; k < 4; k++) {
@ -937,32 +1161,13 @@ export class SailRig {
// path, not just under the test-only watchDivergence tripwire. The event
// carries reason:'divergence' so a log can tell it from an honest blow.
if (!Number.isFinite(c.load)) {
c.broken = true;
c.overload = 0;
c.load = 0;
c.loadVec.x = c.loadVec.y = c.loadVec.z = 0;
this.invMass[this.cornerIdx[k]] = 1 / this.nodeMass;
this.dumpPond('corner blew');
this.events.emit('break', { type: 'break', corner: c, anchorId: c.anchorId, hw: c.hw.name, t: this.t, reason: 'divergence' });
this._releaseCorner(k, 'divergence');
diverged = true;
continue;
}
if (c.load > c.hw.rating * (c.anchor.ratingHint ?? 1)) c.overload += dt;
else c.overload = Math.max(0, c.overload - dt * OVERLOAD_RECOVER);
if (c.overload > OVERLOAD_SECS) {
c.broken = true;
c.overload = 0;
c.load = 0;
// Hand the node its mass back. Everything good about a failure comes
// from this one line: the freed corner stops being pinned, so it flies
// on the wind and the flogging is emergent rather than animated.
// Without it a "blown" corner stays welded in mid-air.
this.invMass[this.cornerIdx[k]] = 1 / this.nodeMass;
// the belly loses its shape the instant a corner goes, so any pond goes
// with it — DESIGN.md's "sudden dump", onto whatever is below.
this.dumpPond('corner blew');
this.events.emit('break', { type: 'break', corner: c, anchorId: c.anchorId, hw: c.hw.name, t: this.t });
}
if (c.overload > OVERLOAD_SECS) this._releaseCorner(k, 'overload');
}
// SPRINT16 gate 2.1 — THE HEAL. The break above made divergence detectable
// (the fresh-eyes review's fix); this makes it survivable to LOOK at. The

View File

@ -1314,6 +1314,160 @@ test('ruling: at MAX tension the carport still goes first — and the hint is do
return `max tension: ${breaks[firstCb].id} first at t=${breaks[firstCb].t.toFixed(1)}s; hint-free control held 4/4`;
});
// --- SPRINT18 gate 1: THE KNIFE, and the corner that was already gone -------
//
// DESIGN.md line 165 — *cut a sail loose: lose the sail, save the anchors.* The
// physics half is B's; D owns the verb. These pin the two facts the rest of the
// game will be built on: a cut corner carries NO load ever again (that is the
// whole "save the anchors"), and a cut is NOT a break (warranty must never chase
// a corner the player chose to let go).
test('gate 1: the knife releases the sail and the anchors stop being loaded', () => {
const r = new SailRig({ anchors: makeAnchors(HEIGHTS_FLAT), gridN: 10 });
r.attach(ALL_IDS, Array(4).fill(HARDWARE[0]), 1.0); // cheapest steel, so it is genuinely at risk
const w = makeStubWind({ stormLen: 90 });
const broke = [], cuts = [];
r.events.on('break', (e) => broke.push(e));
r.events.on('cutAway', (e) => cuts.push(e));
runStorm(r, w, 3);
const before = r.corners.map((c) => c.load);
assert(before.some((l) => l > 1), `nothing was under load before the cut (${before.map((l) => l.toFixed(0))}) — this test would prove nothing`);
const res = r.cutAway(null, 'the glasshouse');
assert(res.ok && res.cut.length === 4, `cutAway refused or cut only ${res.cut?.length}: ${res.reason}`);
assert(cuts.length === 1 && cuts[0].corners.length === 4,
`cutAway emitted ${cuts.length} events with ${cuts[0]?.corners?.length} corners — Lane A's tally reads this payload`);
assert(cuts[0].reason === 'the glasshouse', 'the cut dropped its reason — the invoice line needs it');
assert(Number.isFinite(cuts[0].t), 'the cut has no time on it');
// THE POINT: keep flying and no anchor can ever be torn out again.
runStorm(r, w, 20);
assert(r.corners.every((c) => c.load === 0),
`a cut corner is still carrying load (${r.corners.map((c) => c.load.toFixed(0))}) — "save the anchors" is not true`);
assert(broke.length === 0,
`the knife emitted ${broke.length} break event(s). A cut is a DECISION, not a hardware failure — ` +
'billing it as a break puts warranty and rep damage on the player for doing exactly what the knife is for');
assert(r.cutCorners.length === 4, `cutCorners reads ${r.cutCorners.length}, not 4`);
return `cut 4 corners on ${cuts[0].t.toFixed(1)}s of storm; 20 s more wind, 0 breaks, every corner at 0 N`;
});
test('gate 1: the knife refuses an empty cut, and a re-rig forgets last night\'s', () => {
const r = new SailRig({ anchors: makeAnchors(HEIGHTS_FLAT), gridN: 10 });
const bare = r.cutAway();
assert(!bare.ok && /no sail/.test(bare.reason), `cutting an unrigged sail gave ${JSON.stringify(bare)}`);
r.attach(ALL_IDS, Array(4).fill(HARDWARE[1]), 1.0);
assert(!r.cutAway(['nope']).ok, 'cutting a corner that is not on this sail was allowed');
assert(r.cutAway(['a0']).ok, 'a single-corner cut was refused');
const again = r.cutAway(['a0']);
assert(!again.ok && /already down/.test(again.reason),
`cutting the same corner twice gave ${JSON.stringify(again)} — a second paperwork line for one knife stroke`);
// the phantom-sail lesson, one field over: state that outlives its night lies.
r.attach(ALL_IDS, Array(4).fill(HARDWARE[1]), 1.0);
assert(r.cutCorners.length === 0, `a re-rig kept ${r.cutCorners.join(',')} cut — last night's knife on tonight's invoice`);
return 'refuses no-sail / unknown corner / already-cut; re-rig clears the flags';
});
test('gate 1: a corner can arrive ALREADY GONE, and it is not your break', () => {
const r = new SailRig({ anchors: makeAnchors(HEIGHTS_FLAT), gridN: 10 });
r.attach(ALL_IDS, Array(4).fill(HARDWARE[1]), 1.0);
const broke = [];
r.events.on('break', (e) => broke.push(e));
const res = r.failCorner('a2');
assert(res.ok, `failCorner refused: ${res.reason}`);
const c = r.corners.find((x) => x.anchorId === 'a2');
assert(c.broken && c.inherited, 'the corner is not marked broken-and-inherited');
assert(!c.cut, 'a corner that arrived down was flagged as CUT — that is the player taking blame for a stranger');
assert(broke.length === 0,
`failCorner emitted a break. You did not break it: on an emergency callout that line would bill the ` +
'player warranty for the previous contractor\'s shackle');
// and the freed node really is free — the sim must fly it, not weld it
const w = makeStubWind({ stormLen: 90 });
runStorm(r, w, 5);
assert(c.load === 0, `a corner that arrived down is carrying ${c.load.toFixed(0)} N`);
assert(!r.failCorner('a2').ok, 'failing the same corner twice was allowed');
assert(!r.failCorner('ghost').ok, 'failCorner accepted an anchor this sail has never heard of');
return 'a2 arrives down: broken, inherited, 0 N, and NOT a break event';
});
// --- SPRINT18: THE CLOCK A MID-STORM ARRIVAL BREAKS ------------------------
//
// Lane C's finding, B's file. `_substep` samples the wind at `this.t`, not at the
// `t` step() is handed — deliberate (it is what makes 144 Hz and a bench agree),
// and safe only while the two clocks START together. `attach()`'s own comment
// says so: "commit → attach → storm all land in a single keypress, so t=0 at
// attach IS t=0 at the first storm frame." An emergency callout opens the storm
// phase at t=30 and that precondition is gone — the cloth would fly the storm's
// first 60 s under a sky flying its last 60.
test('the sail can be attached INTO a storm already running, and feels that storm', () => {
const w = makeStubWind({ stormLen: 90 });
// A wind whose speed climbs with t, so "which second is the cloth in" is
// readable straight off the load. If the two clocks agree, these differ.
const early = new SailRig({ anchors: makeAnchors(HEIGHTS_FLAT), gridN: 10 });
early.attach(ALL_IDS, Array(4).fill(HARDWARE[2]), 1.0);
runStorm(early, w, 4);
const earlyLoad = Math.max(...early.corners.map((c) => c.load));
const late = new SailRig({ anchors: makeAnchors(HEIGHTS_FLAT), gridN: 10 });
late.attach(ALL_IDS, Array(4).fill(HARDWARE[2]), 1.0, { at: 40 });
assert(late.t === 40, `attach({at:40}) left the clock at ${late.t}`);
runStorm(late, w, 4, undefined);
const lateLoad = Math.max(...late.corners.map((c) => c.load));
assert(Math.abs(earlyLoad - lateLoad) > 1e-6,
`a sail attached at t=40 pulled exactly what one attached at t=0 pulled (${earlyLoad.toFixed(1)} N). ` +
'The seed never reached the wind sampling, so an emergency callout is flying the wrong sixty seconds ' +
"of its own storm — C's 4.12x understated load, undetectable from the outside");
return `t=0 entry pulls ${earlyLoad.toFixed(0)} N, t=40 entry pulls ${lateLoad.toFixed(0)} N — different storms, as they must be`;
});
test('clockSkew reads 0 when the clocks agree and NAMES the drift when they do not', () => {
const w = makeStubWind({ stormLen: 90 });
const r = new SailRig({ anchors: makeAnchors(HEIGHTS_FLAT), gridN: 10 });
r.attach(ALL_IDS, Array(4).fill(HARDWARE[2]), 1.0);
for (let i = 0; i < 120; i++) r.step(SIM_DT, w, i * SIM_DT);
assert(r.clockSkew < SIM_DT * 2,
`an honest night reads a skew of ${r.clockSkew.toFixed(3)}s — the diagnostic is crying wolf and will be ignored`);
// now the emergency shape, done WRONG: phase at 30, cloth from scratch
const bad = new SailRig({ anchors: makeAnchors(HEIGHTS_FLAT), gridN: 10 });
bad.attach(ALL_IDS, Array(4).fill(HARDWARE[2]), 1.0);
for (let i = 0; i < 120; i++) bad.step(SIM_DT, w, 30 + i * SIM_DT);
assert(bad.clockSkew > 25,
`a sail stepped with a caller 30 s ahead of it reported a skew of ${bad.clockSkew.toFixed(3)}s. This is the ` +
'one thing that made the emergency-clock bug invisible — it must be readable');
// and seeding it closes the gap
const good = new SailRig({ anchors: makeAnchors(HEIGHTS_FLAT), gridN: 10 });
good.attach(ALL_IDS, Array(4).fill(HARDWARE[2]), 1.0, { at: 30 });
for (let i = 0; i < 120; i++) good.step(SIM_DT, w, 30 + i * SIM_DT);
assert(good.clockSkew < SIM_DT * 2,
`a correctly seeded mid-storm arrival still reads ${good.clockSkew.toFixed(3)}s of skew`);
return `honest 0.000s · unseeded mid-storm arrival ${bad.clockSkew.toFixed(1)}s · seeded ${good.clockSkew.toFixed(3)}s`;
});
test('a sail that has ALREADY been up carries the history, not just the clock', () => {
const w = makeStubWind({ stormLen: 90 });
// seeded only: right weather, no past. flown: the 30 s actually happened.
const seeded = new SailRig({ anchors: makeAnchors(HEIGHTS_FLAT), gridN: 10 });
seeded.attach(ALL_IDS, Array(4).fill(HARDWARE[2]), 1.0, { at: 30 });
const flown = new SailRig({ anchors: makeAnchors(HEIGHTS_FLAT), gridN: 10 });
flown.attach(ALL_IDS, Array(4).fill(HARDWARE[2]), 1.0, { at: 30, wind: w });
assert(seeded.t === 30 && flown.t === 30, `clocks are ${seeded.t} / ${flown.t}, both should be 30`);
assert(flown.corners.some((c) => c.peakLoad > 0),
'a flown pre-history left every peak at 0 — the substeps never ran, so the sail did not live through anything');
assert(seeded.corners.every((c) => c.peakLoad === 0),
'the seeded-only sail has peaks already, so it is not the clean control this comparison needs');
// and it must not have secretly advanced past where it was told to be
assert(flown.clockSkew === 0, `the pre-flight left ${flown.clockSkew} s of skew behind it`);
return `both at t=30; flown carries peaks up to ${Math.max(...flown.corners.map((c) => c.peakLoad)).toFixed(0)} N, seeded carries none`;
});
export const SAIL_TESTS = TESTS;
export function runSailSelftest() {

View File

@ -14,6 +14,9 @@ import { createCameraRig, spawnYawFor } from '../camera.js';
import {
CALM_STORM, accumulate, applyMute, canPlayHere, createGame, createWindRouter, enterCommits,
rigRecordFor, stormsToPreload, verdictFor,
// SPRINT18 gate 1 — both extracted BECAUSE rendering a real emergency card found
// bugs the suite could not see: the board's pricing and the answerability split.
answerableFor, priceOffer,
} from '../main.js';
import { createGarden } from '../garden.js';
import { RiggingSession } from '../rigging.js';
@ -22,10 +25,21 @@ import { SailRig, orderRing } from '../sail.js';
import { loadStorm, createWind } from '../weather.js';
import {
BROKE_BELOW, PAY, REP, calloutFee, createWeek, gradeFor, nightAt, NIGHTS,
// SPRINT18 gate 1 — priceOffer takes a NORMALISED night, the same shape the board
// installs, so the pin prices what the game prices rather than a raw table entry.
normaliseNight,
} 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 {
CONSTRAINT_PREMIUM, POOL, exposureOf, validateConstraint,
// SPRINT18 gate 0 — the ruling's structural half: who a take stands up.
isBooked, standUpsIfTaken,
// SPRINT18 gate 1 — the emergency callout, and the one-variable law in code.
EMERGENCIES, oneVariableOffences, validateEmergency,
// SPRINT18 gate 5 (A's half) — the second-rust-post tripwire.
collateralKeyClashes,
} from '../board.js';
import { createHud } from '../hud.js';
import { PALETTE, boundsFaults, emptyTemplate, exportSiteJSON, placeEntry } from '../editor.js';
import { assert, assertClose, assertEq, assertLess, fixedLoop } from '../testkit.js';
@ -698,6 +712,529 @@ export default async function run(t) {
}
});
// --- ⚖️ SPRINT18 gate 0: DECLINING A BOOKED NIGHT COSTS YOU ---------------
// John's ruling, 2026-07-25, on D's cold-play filing: "it adds pressure."
// The board was a free option — a fee-maximizer could dodge the wildnight AND
// the designed-loss icenight at zero cost and never meet the week's two
// hardest lessons. These pin the ruling's five clauses.
t.test('SPRINT18 gate 0: the DIARY is booked and the PHONE is not — the asymmetry, every night', () => {
const w = createWeek();
for (let i = 0; i < NIGHTS.length; i++) {
const [booked, callout] = w.offers();
assertEq(booked.booked, true, `night ${i + 1}: the scripted job is a booking — a client has you down`);
assertEq(callout.booked, false, `night ${i + 1}: a pool callout is the phone ringing, not a promise`);
// Taking the job you were booked for stands NOBODY up. This is the half
// that would silently invert if `booked` were ever derived from the night
// alone (it was, in the first cut, and the pool yard came back booked —
// taking the spine then printed "the Karalis place is expecting you" over
// a client nobody had promised anything).
assertEq(booked.standsUp.length, 0, `night ${i + 1}: keeping your word costs nothing`);
assertEq(callout.standsUp.length, 1, `night ${i + 1}: taking the callout stands the booked client up`);
assertEq(callout.standsUp[0].client, nightAt(i).client,
`night ${i + 1}: and it names WHO, in the client's own name, on the card`);
w.advance();
}
});
t.test('SPRINT18 gate 0: the number is the MIRROR of a clean night, and cannot outrank a wrecked carport', () => {
// ⚖️ A's ruling. The structural half lives in board.js and the number in
// week.js §REP (board.js must not import week.js — week imports board). This
// is the pin that stops the two halves drifting into a coincidence.
assertEq(REP.STOOD_UP, -REP.CLEAN,
'a stood-up client must cost exactly what a flawless night earns — one night of perfect '
+ 'work, cancelled by one phone call you didn\'t make');
assertLess(Math.abs(REP.STOOD_UP), Math.abs(REP.COLLATERAL),
'standing a client up must not rank above WRECKING THEIR PROPERTY, or the invoice argues with itself');
assert(REP.STOOD_UP < 0, 'and it is a cost, not a reward');
// Half-steps, like every other rep constant — the letterhead prints exact
// numbers and a third of a star is a float on a business document.
assertEq(REP.STOOD_UP * 2, Math.round(REP.STOOD_UP * 2), 'the cost lands on a half-star');
});
t.test('SPRINT18 gate 0: the cost lands at DAWN, on the invoice, and does NOT move tonight\'s fee', () => {
if (!boardDefs.storm_01_gentle) return 'SKIPPED — no server for storm defs';
const w = createWeek();
const [booked, callout] = w.offers();
const def = boardDefs[callout.night.storm];
if (!def) return 'SKIPPED — no def for the drawn alternative';
// The fee as the OFFER CARD printed it, priced at this morning's standing.
const cardFee = calloutFee(def, w.rep, callout.night.constraints);
w.take(callout);
// ⚠️ THE SEAM THIS PIN EXISTS FOR. Charging the standing inside take() would
// drop rep between the board and the job sheet, and the fee is priced off
// rep — so the card would advertise one number and the sheet, one click
// later, would quote another. That is the quote-vs-settle lie moved one card
// earlier, which is the exact bug calloutFee() was extracted to make
// impossible. The cost lands at DAWN with every other rep movement.
assertEq(w.rep, REP.START, 'standing does not move at the board — the fee is quoted off it');
assertEq(w.quote(def).base, cardFee, 'the sheet quotes exactly what the card advertised');
const s = w.settle({ hp: 100, win: true, collateral: [], intactHardwareValue: 0, rigRecord: [] }, def, 0);
assertEq(s.fullFee, cardFee, 'and the invoice pays it');
// Clause 4: the invoice states which job you took and what it cost to take it.
assertEq(s.taken, callout.id, 'the invoice says the job came off the board');
assertEq(s.stoodUp.length, 1, 'and names the client it stood up');
assertEq(s.stoodUp[0].client, booked.night.client);
assertEq(s.standUpCost, REP.STOOD_UP, 'with the standing it cost, already multiplied');
assert(s.repReasons.some((r) => r.includes('stood up')),
`the repline must say WHY standing moved, got ${JSON.stringify(s.repReasons)}`);
// ADDITIVE, not exclusive: a clean night at the yard you went to instead
// nets to level. You did good work — for somebody else, on a night you were
// promised elsewhere.
assertEq(s.rep, REP.START + REP.CLEAN + REP.STOOD_UP,
'a clean night on a stood-up promise nets to level, and that is the ruling working');
// ⚠️ A GAP IN MY OWN PIN, FOUND BY PROBING IT (A, S18 mutation M7). Deleting
// the `repDelta += REP.STOOD_UP` line and nothing else left the REASON still
// printing on the invoice while the NUMBER never moved — "stood up the
// Hendersons" over standing that didn't budge, which is the paper lying in
// the most persuasive way available to it. The asserts above caught it, but
// only through the net-to-level arithmetic. This is the DIFFERENTIAL that
// says it directly: the same night, kept, must leave you strictly better off.
const kept = createWeek();
const keptOffers = kept.offers();
kept.take(keptOffers[0]); // took the booking — nobody stood up
const sKept = kept.settle({ hp: 100, win: true, collateral: [], intactHardwareValue: 0, rigRecord: [] },
boardDefs[keptOffers[0].night.storm] ?? def, 0);
assertEq(sKept.stoodUp.length, 0, 'precondition: keeping your word stands nobody up');
assertLess(s.rep, sKept.rep, 'a broken promise must leave you BELOW the same night kept');
assertEq(Number((sKept.rep - s.rep).toFixed(2)), Math.abs(REP.STOOD_UP),
'and below it by exactly the ruled amount — a reason with no movement behind it is the paper lying');
});
t.test('SPRINT18 gate 0: the stood-up client REMEMBERS — and one decision is charged once', () => {
const w = createWeek();
const [booked, callout] = w.offers();
assertEq(w.remembers(booked.night.client).length, 0, 'nobody remembers anything on night one');
w.take(callout);
const mem = w.remembers(booked.night.client);
assertEq(mem.length, 1, 'the client you left waiting remembers it');
assertEq(mem[0].night, 1, 'and remembers WHICH night — the card and the sheet print it');
assertEq(mem[0].took, callout.night.client, 'the ledger records who you went to instead');
// Taking twice on one morning is the SAME take (S17's commitment rule), so it
// must be ONE broken promise. A double charge for one decision would be the
// worst kind of bug: invisible, and in the direction of punishment.
w.take(callout);
w.take(booked);
assertEq(w.standUps.length, 1, 'one decision, one broken promise');
// And the memory is READ-ONLY to its readers — hud.js's charter is that the
// face never takes a decision, and a getter handing out the live array is a
// card one `.push` away from inventing a grudge.
w.standUps.push({ night: 99, client: 'a card that pushed' });
assertEq(w.standUps.length, 1, 'week.standUps hands out a copy');
// A new week is a clean slate — the clients forget when the run does.
w.reset();
assertEq(w.standUps.length, 0, 'reset clears the ledger; a memory outliving its run is a bug');
});
t.test('SPRINT18 gate 0: TWO CALLOUTS, NO BOOKED JOB — declining is FREE, and that is not vacuous', () => {
// Clause 5. A legal board state the moment gate 1's emergency exists: an
// emergency arrives in the diary's chair but nobody booked you, so nobody is
// stood up. Built here from the predicate rather than from a shipped night,
// because the point is that the FREE case falls out of the filter returning
// nothing — not out of an `if` somebody remembered to write.
assertEq(isBooked('scripted', { storm: 'storm_01_gentle' }), true,
'an ordinary diary night is a booking');
assertEq(isBooked('scripted', { storm: 'storm_01_gentle', emergency: { arriveAt: 30 } }), false,
'an EMERGENCY in the diary slot is not a booking — the phone rang, nobody had you down');
assertEq(isBooked('callout', { storm: 'storm_01_gentle' }), false, 'a callout is never a booking');
const free = [
{ id: 'scripted', booked: false, night: { client: 'the Karalis place' } },
{ id: 'alt:x', booked: false, night: { client: 'the Delaneys' } },
];
assertEq(standUpsIfTaken(free, free[0]).length, 0, 'nothing booked ⇒ taking either costs nothing');
assertEq(standUpsIfTaken(free, free[1]).length, 0, 'in both directions');
// ⚠️ THE VACUITY GUARD, and it is the whole reason this test is worth its
// line: a `standUpsIfTaken` that always returned [] would pass every assert
// above. Flip ONE flag and the charge must appear.
const booked = [{ ...free[0], booked: true }, free[1]];
assertEq(standUpsIfTaken(booked, booked[1]).length, 1,
'and the free result is produced by an empty board, not by a function that cannot charge');
assertEq(standUpsIfTaken(booked, booked[0]).length, 0, 'while taking the booking itself is still free');
});
t.test('SPRINT18 gate 0: the price is on the CARD before the choice, and the free morning SAYS free', () => {
if (typeof document === 'undefined') return 'SKIPPED — no DOM';
// "A choice whose price is invisible until the invoice is a trap, and this
// repo doesn't ship traps without tells." The ruling names the card
// explicitly, so the card is pinned in DOM — the S12 lesson: both of that
// sprint's card bugs were invisible to a suite that never rendered one.
const w = createWeek();
const offers = w.offers();
const hud = createHud({ scene: new THREE.Scene() });
try {
hud.showBoard(offers, { night: 1, nights: NIGHTS.length, bank: 80, log: [], rep: 3.0 }, () => {});
const cards = [...document.querySelectorAll('#hud-card .offer')];
assertEq(cards.length, 2, 'two offers rendered');
assert(!cards[0].querySelector('.cost'), 'the booked job costs nothing to take — no cost line on it');
const cost = cards[1].querySelector('.cost');
assert(cost, 'the CALLOUT card must print what taking it costs, before the choice');
const text = cost.textContent;
assert(text.includes(nightAt(0).client),
`in the client's own terms — expected "${nightAt(0).client}", got "${text}"`);
assert(text.includes('expecting you'), `and in plain words, got "${text}"`);
assert(text.includes('½★'), `as a half-star, not a float, got "${text}"`);
assert(text.includes(''), `and signed as a debit, got "${text}"`);
// The free morning: nothing booked, and the note SAYS free rather than
// leaving a player to infer it from an absence.
const note = document.querySelector('#hud-card .board-note')?.textContent ?? '';
assert(note.includes('cannot work two yards'), `a booked morning reads as normal, got "${note}"`);
hud.hideCard();
const freeOffers = offers.map((o) => ({ ...o, booked: false, standsUp: [] }));
hud.showBoard(freeOffers, { night: 1, nights: NIGHTS.length, bank: 80, log: [], rep: 3.0 }, () => {});
const freeNote = document.querySelector('#hud-card .board-note')?.textContent ?? '';
assert(freeNote.includes('costs you anything to turn down'),
`a morning with nothing booked must SAY declining is free, got "${freeNote}"`);
assert(!document.querySelector('#hud-card .offer .cost'),
'and no offer on it carries a cost line');
} finally { hud.dispose(); }
});
// --- 🚨 SPRINT18 gate 1: THE EMERGENCY CALLOUT (A's spine) ----------------
t.test('SPRINT18 gate 1: a night that STARTS in storm — setPhase({at}) is the whole engine change', () => {
const g = createGame();
// ROADMAP sized this gate as "the storm phase already does everything this
// needs except spawn you at t=30 with a rig you didn't build". This is the
// t=30 half, and the pin is that NOTHING ELSE had to move.
g.setPhase('storm', { at: 30 });
assertEq(g.phase, 'storm', 'an emergency night opens in the storm, not in prep');
assertEq(g.phaseT, 30, 'and the storm clock opens where the storm already is');
// The storm is STORM_LEN long TOTAL, not STORM_LEN long from your arrival —
// you get what is left of somebody else's night. 90 30 = 60.
g.tick(STORM_LEN - 30 - 1);
assertEq(g.phase, 'storm', 'still in it a second before the end');
g.tick(2);
assertEq(g.phase, 'aftermath', `an emergency night is STORM_LEN at long, not STORM_LEN`);
// The ordinary path is untouched — `at` defaults to 0, so every existing
// phase change opens on zero exactly as it always did.
const g2 = createGame();
g2.setPhase('prep'); g2.setPhase('storm');
assertEq(g2.phaseT, 0, 'an ordinary storm still starts at t=0');
// And a nonsense clock is a loud content bug, not a silent NaN storm.
let threw = '';
try { createGame().setPhase('storm', { at: NaN }); } catch (err) { threw = err.message; }
assert(threw.includes('at must be'), `a NaN arrival must fail loud, got: ${threw || '(no throw)'}`);
});
t.test('SPRINT18 gate 1: the emergency is on the board, priced at DOUBLE, and stands nobody up', () => {
if (!boardDefs.storm_03_southerly) return 'SKIPPED — no server for storm defs';
const em = EMERGENCIES[0];
assert(em, 'the sprint ships one emergency');
const w = createWeek();
for (let i = 0; i < em.night - 1; i++) w.advance();
const offers = w.offers();
assertEq(offers.length, 2, 'an emergency morning still offers two jobs');
assertEq(offers[1].kind, 'callout', 'its provenance is the phone — the most callout-y callout there is');
assertEq(offers[1].night.storm, em.storm, 'and it IS the scheduled emergency, not a seeded draw');
assertEq(offers[1].night.site, em.site);
assert(offers[1].night.emergency, 'the costume comes off night.emergency — one source of truth');
// ⚖️ DOUBLE RATES: DESIGN.md line 29 and ROADMAP arc 2, in those words.
const def = boardDefs[em.storm];
const plain = calloutFee(def, REP.START, []);
const emergency = calloutFee(def, REP.START, [], true);
assertEq(emergency, Math.round(plain * PAY.emergencyRate),
'an emergency pays the ruled multiple of the ordinary fee, through the SAME calloutFee()');
assertEq(PAY.emergencyRate, 2.0, 'and the multiple is the canon 2.0 — "double rates", DESIGN.md line 29');
// gate 0 × gate 1, the join: taking it stands up the booked client (it is a
// callout), and the emergency itself is NOT a booking anybody can be stood
// up over. Both halves, on one board.
assertEq(offers[0].booked, true, 'the wildnight is still a booking');
assertEq(offers[1].booked, false, 'nobody booked you for an emergency — the phone rang');
assertEq(offers[1].standsUp.length, 1, 'so taking it stands the booked client up');
assertEq(offers[1].standsUp[0].client, nightAt(em.night - 1).client);
});
t.test('SPRINT18 gate 1: there is NO QUOTE on an emergency, and the ledger runs UNCHANGED anyway', () => {
if (!boardDefs.storm_03_southerly) return 'SKIPPED — no server for storm defs';
const em = EMERGENCIES[0];
const def = boardDefs[em.storm];
const w = createWeek();
for (let i = 0; i < em.night - 1; i++) w.advance();
w.take(w.offers()[1]);
assert(w.job.emergency, 'the taken emergency IS the night — normaliseNight carried it');
assertEq(w.job.emergency.arriveAt, em.emergency.arriveAt, 'with its arrival clock intact');
const q = w.quote(def);
assert(q.emergency, 'the quote carries the emergency block the dispatch renders');
assertEq(q.emergency.rate, PAY.emergencyRate);
assertEq(q.base, calloutFee(def, REP.START, [], true), 'the dispatch prints the doubled fee');
// ⚠️ THE ACCEPTANCE CRITERION FOR THE WHOLE GATE (THREADS [A] §10): the ledger
// runs on an emergency night through the code it already had. If any of these
// needed a branch on "was this an emergency", the SHAPE is wrong.
const s = w.settle({ hp: 72, win: true, collateral: [], intactHardwareValue: 15,
rigRecord: [], inherited: [{ anchorId: 'cb1', hw: 'carabiner' }], cutAways: [] }, def, 0);
assertEq(q.base, s.fullFee, 'quote==settle holds on a night nobody quoted');
assertEq(s.pay, s.fee + s.bonus + s.refund + s.clean - s.collateral - s.warrantyTotal,
'the ledger adds up on an emergency exactly as it does on a scripted night');
assert(s.rep != null && s.grade != null && s.bankAfter != null, 'rep, grade and the bank all computed');
assertEq(s.emergency.rate, PAY.emergencyRate, 'and the invoice records that it was a callout, at what rate');
assertEq(s.inherited.length, 1, 'and names the corner that was already down');
});
t.test('SPRINT18 gate 1: a corner that ARRIVED broken bills nothing, wrecks nothing, books no warranty, costs no standing', () => {
// ⚖️ A's ruling, and the pin exists because the wrong version would have
// shipped looking correct: you turn up at somebody else's collapsing rig and
// the invoice charges you for the cowboy's carabiner, docks your standing for
// his corner, and books you a free warranty callout tomorrow to fix his work.
const theirs = new Set(['cb1']);
const corner = (id, broken) => ({
anchorId: id, hw: { name: 'carabiner', cost: 5, rating: 1200 },
anchor: { ratingHint: 0.22 }, broken,
});
const rec = rigRecordFor([corner('cb1', true), corner('q3', true), corner('tr1', false)],
new Map(), theirs);
assertEq(rec[0].theirs, true, 'the inherited corner is marked as theirs');
assertEq(rec[1].theirs, false, 'and yours is not');
assertEq(rec[0].brokenAtDawn, true, 'it is still honestly recorded as down — the record does not lie');
// The WARRANTY half: last dawn's inherited corner books nothing tonight.
const def = { baseCurve: [[0, 10], [10, 20]], gusts: {} };
const w = createWeek();
w.settle({ hp: 80, win: true, collateral: [], intactHardwareValue: 0, rigRecord: rec }, def, 0);
const q = w.quote(def);
assertEq(q.warranty.length, 1,
`only YOUR corner books a warranty — got ${JSON.stringify(q.warranty.map((i) => i.anchorId))}`);
assertEq(q.warranty[0].anchorId, 'q3', 'and it is the one you were answerable for');
// The STANDING half: two corners down at dawn, one of them theirs, so the rep
// hit is for ONE. Read off the settlement that produced the record above.
const s = w.log[0];
assertEq(s.repReasons.some((r) => r.includes('1 corner down at dawn')), true,
`standing moves for one corner, not two — got ${JSON.stringify(s.repReasons)}`);
assertEq(s.rep, REP.START + REP.WARRANTY, 'exactly one warranty step, not two');
});
t.test('SPRINT18 gate 1: ⚠️ THE ONE-VARIABLE LAW IS ENFORCED IN CODE, and it accuses out loud', () => {
// SPRINT18's spec: "A's ordering must make that impossible to get wrong by
// accident." A comment cannot do that. This walks the SHIPPED data...
assertEq(oneVariableOffences({ nights: NIGHTS }).length, 0,
`the shipped week breaks the law: ${oneVariableOffences({ nights: NIGHTS }).join(' | ')}`);
// ...and the shipped emergency's yard really is one an earlier night rigs,
// which is the fact the law is protecting rather than a restatement of it.
const em = EMERGENCIES[0];
const firstRig = NIGHTS.findIndex((n) => (n.site ?? 'backyard_01') === em.site);
assert(firstRig >= 0 && firstRig < em.night - 1,
`the emergency arrives night ${em.night} over ${em.site}, first rigged night ${firstRig + 1}`);
// ⚠️ THE VACUITY GUARD — three ways to break the law, three accusations. A
// law that cannot say no is decoration, and this repo has shipped one before.
const glass = { storm: 'storm_03_southerly', site: 'site_99_glasshouse', client: 'x', addr: 'y' };
const asEmergency = { ...glass, emergency: { arriveAt: 30, dispatch: 'x'.repeat(30), rig: [] } };
const o1 = oneVariableOffences({ nights: [...NIGHTS, asEmergency] });
assert(o1.some((m) => m.includes('site_99_glasshouse') && m.includes('debut')),
`a NIGHTS emergency over an unrigged yard must be an offence, got ${JSON.stringify(o1)}`);
const o2 = oneVariableOffences({ nights: NIGHTS, emergencies: [{ ...asEmergency, night: 2, id: 'too_early' }] });
assert(o2.some((m) => m.includes('too_early')),
`an EMERGENCIES entry over an unrigged yard must be an offence, got ${JSON.stringify(o2)}`);
const o3 = oneVariableOffences({ nights: NIGHTS, pool: [{ ...asEmergency, id: 'pool_glass' }] });
assert(o3.some((m) => m.includes('pool_glass') && m.includes('no NIGHTS')),
`a POOL emergency over a yard in no NIGHTS slot must be an offence — this is the rule that `
+ `protects E's glasshouse from becoming the emergency's debut stage. Got ${JSON.stringify(o3)}`);
// And an emergency over a yard the ladder DOES rig early is legal, or the law
// would be refusing the thing it exists to permit.
assertEq(oneVariableOffences({ nights: NIGHTS,
emergencies: [{ ...EMERGENCIES[0], night: 7 }] }).length, 0,
'the law permits an emergency over an already-rigged yard');
});
t.test('SPRINT18 gate 1: a malformed emergency fails LOUD at the board, not 30s into a storm', () => {
// validateConstraint's precedent. A content bug must be a crash on the morning
// card, never a runtime surprise inside a storm the player cannot leave.
const ok = { storm: 'storm_01_gentle', site: 'backyard_01', emergency: EMERGENCIES[0].emergency };
validateEmergency(ok, 'fixture'); // the shipped shape passes
for (const p of POOL) validateEmergency(p, p.id);
for (const e of EMERGENCIES) validateEmergency(e, e.id);
const bad = (em, needle) => {
let msg = '';
try { validateEmergency({ ...ok, emergency: { ...ok.emergency, ...em } }, 'fixture'); }
catch (err) { msg = err.message; }
assert(msg.includes(needle), `expected "${needle}" in the failure, got: ${msg || '(no throw)'}`);
};
bad({ arriveAt: 0 }, 'arriveAt'); // arriving at t=0 is a prep night wearing a siren
bad({ dispatch: 'too short' }, 'dispatch'); // no forecast, no quote — the call IS the brief
bad({ rig: [] }, 'rig'); // an emergency with no rig is not a rig you didn't build
bad({ rig: [{ hw: 'carabiner' }, {}, {}, {}] }, 'anchorId');
// A night with NO emergency block passes untouched — every scripted night.
for (let i = 0; i < NIGHTS.length; i++) validateEmergency(nightAt(i), `night ${i + 1}`);
});
t.test('SPRINT18 gate 1: the DISPATCH is not a job sheet — no schedule, no total, and it says why', () => {
if (typeof document === 'undefined') return 'SKIPPED — no DOM';
// The S12 lesson: both of that sprint's card bugs were invisible to a suite
// that never rendered a card. This card's whole design is what it REFUSES to
// print, and a refusal is only checkable by rendering it.
const def = { baseCurve: [[0, 10]], gusts: { powBase: 5, powRamp: 5 } };
const em = EMERGENCIES[0];
const w = createWeek();
for (let i = 0; i < em.night - 1; i++) w.advance();
w.take(w.offers()[1]);
const hud = createHud({ scene: new THREE.Scene() });
try {
hud.showDispatch({ job: w.job, quote: w.quote(def), site: null, read: null },
{ night: w.night, nights: w.nights, bank: w.bank, rep: w.rep }, () => {});
const qs = (sel) => document.querySelector(`#hud-card ${sel}`);
assertEq(qs('.letterhead .docket .kind')?.textContent, 'DISPATCH', 'the fourth paper in the chain');
assert((qs('.letterhead .docket .no')?.textContent ?? '').includes('CALL '),
'a dispatch is a CALL, not a JOB or an INV — one outfit, four papers');
assert((qs('.siren')?.textContent ?? '').includes('no quote'),
'it says out loud that nobody quoted this');
assert(qs('.call'), 'the phone call is the brief, because there is no other one');
// The REFUSALS, which are the design.
const body = qs('.jobcard')?.textContent ?? '';
assert(!/AMOUNT DUE|garden bonus|clean site/i.test(body),
'a card for a job nobody costed must not print a schedule or a total');
assert(!qs('.row.due'), 'and it must not conclude — you find out on the invoice');
// The one number it DOES print, and the rate it came from.
assert(body.includes(`${PAY.emergencyRate}\u00d7 the going rate`),
`the rate is the reason you get in the ute — not found in the card`);
assert(body.includes(`$${w.quote(def).base}`), 'and the fee it applies to is on the card');
// DESIGN.md's tell rule: the inherited rig is NAMED before you go, and the
// corner already on the ground is marked. Brutal is allowed; surprising is not.
const down = [...document.querySelectorAll('#hud-card .theirs.down')];
assertEq(down.length, em.emergency.rig.filter((c) => c.broken).length,
'every corner that arrives on the ground is marked as such on the dispatch');
assert(down[0].textContent.includes('ON THE GROUND'), 'in words, not by colour alone');
// No read from C yet ⇒ one honest line, not an empty section.
assert((qs('.noread')?.textContent ?? '').includes('Nobody briefed you'),
'with no mid-storm read available the card is honest about it rather than blank');
} finally { hud.dispose(); }
});
t.test('SPRINT18 gate 1: ⚖️ the answerability split — their corner is the JOB, yours is your failure', () => {
const c = (id, broken) => ({ anchorId: id, hw: { name: 'carabiner', cost: 5 }, broken });
const em = { rig: [{ anchorId: 'cb1', hw: 'carabiner', broken: true },
{ anchorId: 'q3', hw: 'carabiner' }, { anchorId: 'tr1', hw: 'carabiner' },
{ anchorId: 'tr1b', hw: 'carabiner' }] };
const a = answerableFor([c('cb1', true), c('q3', true), c('tr1', false), c('tr1b', false)], em);
assertEq(a.lost.length, 2, 'REPORTING stays honest — two corners are on the ground');
assertEq(a.yours.length, 1, 'but you are answerable for one of them');
assertEq(a.yours[0].anchorId, 'q3', 'the one that went on your watch');
assertEq(a.inherited.length, 1, 'and their corner is named, for the invoice');
assertEq(a.inherited[0].anchorId, 'cb1');
// The two facts the split protects, in the same shape scoreRun consumes them:
// the gnome needs TWO corners of YOURS (not two on the ground), and the
// collateral walk only visits `yours` — so the $180 carport under an inherited
// corner is not billed to a player who never chose it.
assert(a.yours.length < 2, 'one corner of yours cannot bill the gnome, even with two corners down');
assert(!a.yours.some((x) => x.anchorId === 'cb1'), 'the carport corner is not in the billable set');
// An ORDINARY night is untouched: no emergency ⇒ everything down is yours, which
// is the property that keeps 522 asserts of scripted week honest.
const plain = answerableFor([c('p1', true), c('p2', true)], null);
assertEq(plain.yours.length, 2, 'on a night you rigged, every corner down is yours');
assertEq(plain.inherited.length, 0, 'and nothing is inherited');
assertEq(answerableFor([c('p1', true)], { rig: [] }).yours.length, 1,
'an emergency whose rig arrived whole still makes every break yours');
});
t.test('SPRINT18 gate 1: the OFFER CARD prices an emergency at double — the live bug, pinned', () => {
if (!boardDefs.storm_03_southerly) return 'SKIPPED — no server for storm defs';
// ⚠️ THIS PIN EXISTS BECAUSE THE BUG SHIPPED AND EYES FOUND IT, not the suite:
// on night 5's real board the emergency advertised $57 while the dispatch one
// click later quoted $114. The pricing was an unreachable closure inside boot();
// priceOffer is that closure, extracted, so the board's money is assertable.
const def = boardDefs.storm_03_southerly;
const em = EMERGENCIES[0];
const emNight = normaliseNight(em);
const plainNight = normaliseNight({ storm: em.storm, site: em.site });
const carded = priceOffer(emNight, { def, rep: REP.START });
const plain = priceOffer(plainNight, { def, rep: REP.START });
assertEq(plain.fee, calloutFee(def, REP.START, []), 'an ordinary night is priced as it always was');
assertEq(carded.fee, calloutFee(def, REP.START, [], true), 'and an emergency is priced as an emergency');
assertEq(carded.fee, Math.round(plain.fee * PAY.emergencyRate),
`the card must print the doubled fee — got $${carded.fee} against $${plain.fee}`);
// And the card is the same document as the sheet: what priceOffer prints is what
// week.quote() quotes, which is the promise the whole board rests on.
const w = createWeek();
for (let i = 0; i < em.night - 1; i++) w.advance();
w.take(w.offers()[1]);
assertEq(w.quote(def).base, carded.fee, 'the dispatch quotes exactly what the card advertised');
// The weather and the risk still come from C's and board.js's own functions.
assert(Array.isArray(carded.band) && carded.band.length, "C's ordered band survives the extraction");
});
t.test('SPRINT18 gate 1: an emergency does NOT look like an ordinary callout on the board', () => {
if (typeof document === 'undefined') return 'SKIPPED — no DOM';
// ⚠️ ALSO FOUND BY EYES, NOT BY THE SUITE: the first cut wore the same grey
// "callout" tag as the Tuesday alternative beside it. Double rates, no prep, no
// quote, somebody else's rig — the biggest new thing in the game — and the card
// said nothing until one click later. That is the tell rule broken on the exact
// surface the tell rule is for.
const em = EMERGENCIES[0];
const w = createWeek();
for (let i = 0; i < em.night - 1; i++) w.advance();
// PRICED, through the same priceOffer() the real board uses — my first cut built
// an UNPRICED board and then asserted the fee line's contents, so the pin failed
// on a fixture that could not produce what it was demanding. A card assert has to
// be given the card the game renders.
const offers = w.offers((night) => priceOffer(night, { def: boardDefs[night.storm], rep: w.rep }));
const priced = offers[1].fee != null;
const hud = createHud({ scene: new THREE.Scene() });
try {
hud.showBoard(offers, { night: w.night, nights: w.nights, bank: 80, log: [], rep: 3.0 }, () => {});
const cards = [...document.querySelectorAll('#hud-card .offer')];
assertEq(cards.length, 2, 'an emergency morning still offers two jobs');
assert(!cards[0].classList.contains('emergency'), 'the booked wildnight is not an emergency');
assert(cards[1].classList.contains('emergency'),
'the emergency offer must be marked as one, not left to read as a Tuesday alternative');
const tag = cards[1].querySelector('.tag')?.textContent ?? '';
assert(/emergency/i.test(tag), `its tag must say so — got "${tag}"`);
const siren = cards[1].querySelector('.siren')?.textContent ?? '';
assert(siren.includes('no prep') && siren.includes('no quote'),
`and one line must name what is different about it — got "${siren}"`);
assert(siren.includes(`${em.emergency.arriveAt}s in`), 'including how late you arrive');
// The doubled number carries its reason, on the card, next to the number.
// (Guarded on the money being present at all: with no server there are no storm
// defs, so the fee line degrades away exactly as the job sheet's forecast does.)
if (priced) {
const fee = cards[1].querySelector('.fee')?.textContent ?? '';
assert(fee.includes(`${PAY.emergencyRate}\u00d7 emergency rate`),
`the rate is printed beside the fee — got "${fee}"`);
assert(fee.includes(`$${offers[1].fee}`), 'and the doubled number itself');
}
} finally { hud.dispose(); }
});
t.test('SPRINT18 gate 5: ⚠️ the SECOND-RUST-POST landmine is a tripwire now, not a silent free failure', () => {
// D's filing + the other D session's measured receipt. Every shipped yard is
// clean TODAY (the pool yard ships exactly one corroded post on purpose), so
// this walk is green — which is why the guard below is the part that matters.
for (const [name, json] of Object.entries(siteJsonForTest)) {
if (!json) continue;
assertEq(collateralKeyClashes(json).join(' | '), '',
`${name} ships clashing collateral keys — see the accusation for the numbers`);
}
// ⚠️ THE VACUITY GUARD, and it carries D's measured numbers so the next author
// reads the actual failure rather than a policy. A yard with TWO corroded posts:
// the offer card would read $90 and the failure route $45, so one post fails
// FREE and wrecks the other one.
const twoPosts = { name: 'the fixture yard', structures: [
{ id: 'c1', collateralKey: 'corroded_post', collateralValue: 45 },
{ id: 'c2', collateralKey: 'corroded_post', collateralValue: 45 },
] };
const clash = collateralKeyClashes(twoPosts);
assertEq(clash.length, 1, 'two structures on one key must be accused exactly once');
assert(clash[0].includes('$90') && clash[0].includes('$45'),
`the accusation must carry BOTH harnesses' numbers — got "${clash[0]}"`);
assert(clash[0].includes('c1') && clash[0].includes('c2'), 'and name the structures');
assert(/fail FREE/.test(clash[0]), 'and say what actually goes wrong, not just that something does');
// An UNKEYED structure resolves by its own id, which the editor generates unique,
// so it cannot clash — and reporting it would be a false alarm that teaches an
// author to ignore this check.
assertEq(collateralKeyClashes({ structures: [
{ id: 's1', collateralValue: 180 }, { id: 's2', collateralValue: 180 },
] }).length, 0, 'unkeyed structures resolve by unique id and must not be accused');
// And one post is fine — the shipped pool yard's own shape.
assertEq(collateralKeyClashes({ structures: [twoPosts.structures[0]] }).length, 0,
'one corroded post is the shipped shape and must stay legal');
});
// Gate 2 acceptance: both sites load from data, and the corner block is not
// the backyard with the furniture moved. Built up front (Suite.test() can't
// await — the guard I added last sprint enforces it, and just caught me).

View File

@ -71,13 +71,17 @@ async function buildYard() {
// the real backyard_01 keeps this suite's "read the yard, never copy it" rule
// intact — it's the same yard, from its source instead of from code.
const { loadSite } = await import('../world.js');
const world = createWorld(scene, { wind: calm, site: await loadSite('backyard_01') });
// Kept, not just consumed: SPRINT18 gate 2 flies the site's own pinned
// `separation` recipe, and reading it from the site the yard was built from is
// the same "never copy the yard" rule one level up.
const siteDef = await loadSite('backyard_01');
const world = createWorld(scene, { wind: calm, site: siteDef });
if (world.dress) { try { await world.dress(); } catch { /* graybox anchors are enough */ } }
const anchors = world.anchors.map((a) => {
const pos = { x: a.pos.x, y: a.pos.y, z: a.pos.z };
return { id: a.id, type: a.type, pos, sway: () => pos };
});
return { anchors, bed: world.gardenBed, heightAt: world.heightAt };
return { anchors, bed: world.gardenBed, heightAt: world.heightAt, siteDef };
}
/**
@ -393,6 +397,54 @@ async function gardenHailByPorosity(yard, stormName, burstT) {
return { cloth, membrane, lost: rig.corners.filter((c) => c.broken).length };
}
/**
* SPRINT18 GATE 2 fly the site's PINNED separation recipe at one stated
* porosity and report only what the steel did. Game-true: no calm settle (the
* real game's commitattachstorm is one keypress), no skyfx, no garden the
* question is which corners let go, and corners are decided by `rig.step` alone.
*
* Deliberately NOT `fly()`: that helper settles 12 s on the calm day first,
* which is the phantom-settle skew gardenfly was rewritten to delete. A peak
* quoted off a settled rig is not the number the player's night produces.
*
* The fabric goes on through `RiggingSession.commit(rig)` the same door that
* was missing from the shipped commit path, so this helper exercises the wire it
* is here to protect.
*/
async function flySeparationSteel(yard, porosity) {
const sep = yard.siteDef?.separation;
if (!sep) throw new Error('balance: backyard_01 lost its pinned separation block');
const def = await loadStorm(sep.stormKey);
const wind = createWind(def);
wind.setSheltersFromTrees(yard.anchors.filter((a) => a.type === 'tree'));
const s = new RiggingSession({ anchors: yard.anchors });
for (const l of sep.line) if (!s.rig(l.anchor).ok) throw new Error(`balance: cannot rig ${l.anchor}`);
for (const l of sep.line) {
const hw = HARDWARE.find((h) => h.name === l.hw);
if (!s.setHardware(l.anchor, hw).ok) throw new Error(`balance: cannot buy ${l.hw} for ${l.anchor}`);
}
s.setTension(sep.tension ?? 1.0);
s.setFabric(porosity === 0 ? 'membrane' : 'cloth');
const rig = s.commit(new SailRig({ anchors: yard.anchors, gridN: 10 }));
if (rig.porosity !== porosity) {
throw new Error(`balance: asked for porosity ${porosity}, the rig committed at ${rig.porosity}` +
'the fabric did not reach the sail, which is gate 2 all over again');
}
const order = [];
rig.events.on('break', (e) => order.push(e.anchorId));
for (let i = 0, n = Math.round(def.duration / FIXED_DT); i < n; i++) rig.step(FIXED_DT, wind, i * FIXED_DT);
return {
porosity: rig.porosity,
lost: rig.corners.filter((c) => c.broken).length,
order,
peak: Object.fromEntries(rig.corners.map((c) => [c.anchorId, c.peakLoad])),
eff: Object.fromEntries(rig.corners.map((c) => [c.anchorId, c.hw.rating * (c.anchor.ratingHint ?? 1)])),
};
}
/**
* @param {import('../testkit.js').Suite} t
*
@ -465,6 +517,38 @@ export default async function run(t) {
}
}
// SPRINT18 GATE 2 — the pinned recipe, flown at each fabric, nothing else
// changed. These two rows ARE the two-harness disagreement D reported, and the
// membrane row is D's cold play and the site's own `_playedReference`.
const sepCloth = await flySeparationSteel(yard, 0.30);
const sepMembrane = await flySeparationSteel(yard, 0);
// GATE 2's SEAM, pinned on the door the shipped game actually walks through.
// `RiggingSession.commit(rig)` was never reached in play — the rigging UI's
// commit() calls onCommit, so whatever onCommit fails to forward, the sim never
// learns. The fabric is the 4th argument now; this is the assert that keeps it
// there. Browser-only (the UI needs THREE, a scene and a canvas), which is why
// it lives in this suite and not in rigging.selftest.js's node-safe set.
let seam;
{
const { createRiggingUI } = await import('../rigging.js');
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(60, 1.6, 0.1, 400);
const canvas = document.createElement('canvas');
const seen = [];
const ui = await createRiggingUI({
scene, camera, domElement: canvas,
world: { anchors: yard.anchors },
onCommit: (ids, hw, tension, fabric) => seen.push({ ids, hw, tension, fabric }),
panel: false,
});
for (const id of COVER_QUAD) ui.session.rig(id);
const flipped = ui.session.setFabric('membrane').ok;
ui.commit();
ui.dispose();
seam = { seen: seen[0], flipped, sessionFabric: ui.session.fabric };
}
// --- then judge -----------------------------------------------------------
// SPRINT8 gate 0' — CLOSED. The harnesses agree; the disagreement was a miscount.
@ -555,6 +639,116 @@ export default async function run(t) {
`(hp ${membrane.hp}) — the fabric is the decision`;
});
// ⚠️ SPRINT18 GATE 2 — THE TWO HARNESSES, AND THE VARIABLE BETWEEN THEM.
//
// D flew backyard_01's pinned wildnight recipe by hand and lost p2 then p3;
// `gardenfly` holds the same recipe 0-lost. One variable, measured:
//
// shade cloth 0.30 0/4 lost p2 3.07 kN p3 1.78 p1 2.97 p5 5.11
// membrane 0 2/4 lost p2 3.48 p3 3.90 p1 3.50 p5 5.65
//
// The second row is D's cold play to the decimal, and it is also the site's own
// `_playedReference` (hp 55.7, p2 then p3, 2026-07-18). The bench was NOT
// lying: it flew the fabric every card in the game promised. The GAME was
// lying — `rig.setFabric` was never called on the sim's rig, so main.js's
// `new SailRig({anchors})` flew porosity 0 every night while the panel, the
// SCORE IT card, the HUD row and the invoice all said "shade cloth". Fixed by
// widening the commit seam (rigging.js) to carry the fabric.
//
// This assert is the tripwire that keeps the two rows apart. The break ORDER is
// pinned too, because p2-then-p3 is the physics: p2 goes at the change and p3
// catches its share. If these ever converge, either porosity has stopped
// reaching the wind load or the wildnight went soft.
t.test('gate 2: the wildnight pin holds on the fabric it was priced on, and only that one', () => {
if (sepCloth.lost !== 0) {
throw new Error(`the pinned recipe lost ${sepCloth.lost}/4 on the shade cloth it was measured on ` +
`(${sepCloth.order.join(' then ')}). The pin's heldMustExceed target assumes 0 lost, so either ` +
`storm_02 hardened or porosity stopped bleeding the pressure term.`);
}
if (sepMembrane.lost !== 2) {
throw new Error(`the same recipe on membrane lost ${sepMembrane.lost}/4, not 2. D's cold play and ` +
`the site's own _playedReference both lost exactly p2 then p3 — this is the number that made ` +
`gate 2 findable, and moving it silently loses the receipt.`);
}
if (sepMembrane.order.join(',') !== 'p2,p3') {
throw new Error(`membrane broke ${sepMembrane.order.join(' then ') || 'nothing'}, not p2 then p3. ` +
`The order is the mechanism: p2 goes at the southerly change and p3 catches the share it drops.`);
}
// and the mechanism itself, not just the verdict: full wind load is HEAVIER.
const gain = sepMembrane.peak.p2 / sepCloth.peak.p2;
if (!(gain > 1.05)) {
throw new Error(`membrane's p2 peak is only ${gain.toFixed(3)}x the cloth's. Porosity scales the ` +
`pressure coefficient by (1 - porosity), so 0.30 cloth must read materially lighter than a ` +
`membrane. A ratio near 1.0 means setFabric is no longer reaching _accumulateWind.`);
}
return `same recipe, one variable: cloth ${sepCloth.lost}/4 (p2 ${(sepCloth.peak.p2 / 1000).toFixed(2)} kN) · ` +
`membrane ${sepMembrane.lost}/4 lost ${sepMembrane.order.join(' then ')} ` +
`(p2 ${(sepMembrane.peak.p2 / 1000).toFixed(2)} kN, ${gain.toFixed(2)}x) — the variable is POROSITY`;
});
// ⚠️ SPRINT18 GATE 3 — WHY THE CLEAN BOUNDARY CANNOT BE A PERCENTAGE.
//
// One flight, two corners, identical $15 shackles, same 3.2 kN rating:
//
// p1 3.528 kN = 10.3% OVER -> HELD (over the rating for 0.150 s)
// p2 3.493 kN = 9.2% OVER -> BROKE (over the rating for 0.400 s)
//
// p1 peaks HIGHER than p2 and survives it. Every rule of the shape "peak within
// X% of the rating breaks" is therefore wrong at every X — 15% (the retired
// AUDIT.MARGIN) condemns p1's perfectly adequate steel and demands $30 for it;
// 9% blesses p2 and watches it let go. sail.js never used peak: it burns a
// 0.4 s fuse while a corner is over, and DWELL is what decides. This assert is
// the receipt, taken off the same flight gate 2 uses, so the audit's re-ruled
// boundary (tools/site_audit/sweep.js — the fuse replayed per tier) has a
// measured fact under it in the game's own suite rather than a tool's fixture.
t.test('gate 3: the same steel at a HIGHER peak survives — so peak cannot price a corner', () => {
const eff = sepMembrane.eff, peak = sepMembrane.peak;
if (eff.p1 !== eff.p2) {
throw new Error(`p1 and p2 are on different steel now (${eff.p1} vs ${eff.p2} N). This test needs two ` +
`corners at the SAME rating for the comparison to mean anything — re-pick the pair or re-measure.`);
}
if (!(peak.p1 > eff.p1 && peak.p2 > eff.p2)) {
throw new Error(`both corners must go OVER their rating for this to say anything: p1 ${peak.p1} and ` +
`p2 ${peak.p2} against ${eff.p1} N.`);
}
if (!(peak.p1 > peak.p2)) {
throw new Error(`p1 (${peak.p1} N) no longer peaks higher than p2 (${peak.p2} N) — the inversion that ` +
`kills percentage pricing has gone, so re-measure before trusting the dwell rule's justification.`);
}
if (sepMembrane.order.includes('p1')) {
throw new Error(`p1 broke as well. The whole point is that the HIGHER peak held: if both go, peak and ` +
`outcome agree again and a percentage rule would be defensible.`);
}
const overP1 = ((peak.p1 / eff.p1) - 1) * 100, overP2 = ((peak.p2 / eff.p2) - 1) * 100;
return `same ${(eff.p1 / 1000).toFixed(1)} kN steel: p1 ${overP1.toFixed(1)}% over HELD, ` +
`p2 ${overP2.toFixed(1)}% over BROKE — dwell decides, not peak`;
});
// GATE 2's WIRE, not its symptom. The bug was never in the physics: it was that
// the only function which tells a sail what it is made of sat on a path the
// player never walked. So assert the path, on the object the player's Enter key
// reaches — the fabric must come OUT of commit(), or the sim cannot learn it.
t.test('gate 2: the commit seam carries the FABRIC, not just the steel', () => {
if (!seam.seen) throw new Error('the rigging UI committed nothing — four corners were rigged');
if (!seam.flipped) throw new Error('setFabric refused the membrane; the control never changed');
if (!seam.seen.fabric) {
throw new Error(`commit() handed onCommit (ids, hw, tension) and NO fabric. That is exactly the ` +
`sprint-18 gate-2 bug: main.js's rigSail can only call rig.setFabric with something it was ` +
`given, so a dropped 4th argument means every night flies new SailRig's default porosity (0, ` +
`the membrane) while every card in the game says shade cloth.`);
}
if (seam.seen.fabric.id !== seam.sessionFabric.id) {
throw new Error(`commit forwarded fabric "${seam.seen.fabric.id}" while the session holds ` +
`"${seam.sessionFabric.id}" — the paperwork and the sim would disagree again, one layer down.`);
}
if (!Number.isFinite(seam.seen.fabric.porosity)) {
throw new Error('the forwarded fabric has no finite porosity — setFabric would clamp NaN and the ' +
'sail would fly a fabric nobody chose.');
}
return `commit() -> onCommit(ids, hw, tension, fabric) with the session's own ` +
`"${seam.seen.fabric.id}" (porosity ${seam.seen.fabric.porosity}) — the wire the game was missing`;
});
// The fabric's SECOND edge, through the REAL garden-hail chain, not a copy of it.
// `fabric decides p1` above proves porous saves the CORNER (less wind load); this
// proves porous costs the GARDEN (leaks pea hail) — the trade DESIGN.md promises

View File

@ -22,6 +22,9 @@ import {
// 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,
// SPRINT18 gate 1 — the dispatch: the read with no paper behind it, and the
// honesty gate extended to the storms nobody forecast.
midStormRead, dispatchHonest,
} from '../weather.js';
import { loadSite, createWorld } from '../world.js';
// SPRINT17 gate 1 — the board's weather side. NIGHTS POOL is every storm an
@ -29,6 +32,11 @@ import { loadSite, createWorld } from '../world.js';
// without a test edit the day D's pool yard lands.
import { NIGHTS, nightAt } from '../week.js';
import { POOL, exposureOf } from '../board.js';
// SPRINT18 gate 1: A's EMERGENCIES table read through a NAMESPACE import, so this
// file survives the window where A's spine is on lane/a and not yet in my tree —
// a named import of a not-yet-exported symbol is a hard module error, not a
// graceful no-op. A's own reasoning for `weather.midStormRead?.()`, applied back.
import * as board from '../board.js';
// GATE 2.3 — the GAME's own wind wiring, IMPORTED rather than re-typed. A pin
// that copied main.js's two lines would agree with a copy of the game forever,
// including on the day the game itself changed. "Two harnesses, one number"
@ -208,6 +216,138 @@ export default async function run(t) {
assert(gale.leafCount === 0, 'clear() left leaves flying into the aftermath card');
});
// -------------------------------------------------------------------------
// SPRINT18 gate 1 — THE MID-STORM ENTRY, debris half.
//
// The emergency callout arrives at t≈30. weather.selftest.js pins that the WIND
// is the same storm however you got to t0 (it is closed-form, measured
// order-independent). Debris was not, and it cost two determinism breaks that
// I measured before writing a line — both recorded in debris.js beside the fix:
//
// · the same storm flown twice through one module spawned DIFFERENT crates
// (wildnight crate height 1.582 m then 1.165 m). main.js builds one debris
// module at boot and clear()s it per phase change; clear() rewound the leaf
// accumulators and not the piece PRNG. Live on main today — night 2 of the
// seven-night week never flew night 1's crates, and no test looked.
// · an entry at t=45 (past the wildnight's t=38 event) gave every LATER crate
// the skipped crate's height, spin axis and phase — the stream sat 5 draws
// behind. Skipping history corrupted the FUTURE, not just the present.
//
// Both had one cause: a crate's jitter came off a shared stream whose POSITION
// encoded how much history had happened. It is now keyed on the event itself.
// These three cases are what stop it coming back.
// -------------------------------------------------------------------------
/** Every piece the storm spawns, snapshotted AT BIRTH. */
const crateLog = (dbg, from, to, dt = FIXED_DT) => {
const born = [];
const seen = new Set();
for (let i = Math.round(from / dt); i < Math.round(to / dt); i++) {
const time = i * dt;
dbg.step(dt, time, {});
for (const p of dbg.pieces) {
if (seen.has(p)) continue;
seen.add(p);
// y/sx/sy/sz/phase are the seeded jitter; x/z/vx come off the wind and
// were never in doubt. Round only for the message — compare exactly.
born.push({ model: p.model, y: p.y, sx: p.sx, sy: p.sy, sz: p.sz, phase: p.phase });
}
}
return born;
};
const sameCrates = (a, b) => a.length === b.length && a.every((p, i) =>
p.model === b[i].model && p.y === b[i].y && p.sx === b[i].sx
&& p.sy === b[i].sy && p.sz === b[i].sz && p.phase === b[i].phase);
const showCrates = (a) => a.map((p) => `${p.model}@y${p.y.toFixed(3)}/ph${p.phase.toFixed(3)}`).join(', ') || '(none)';
t.test('a crate is a pure function of its event, not of what came before it', () => {
for (const name of STORMS) {
const def = storms[name];
const dur = def.duration ?? 90;
// two independently built modules on the same seed
const one = crateLog(createDebris({ wind: createWind(def) }), 0, dur);
const two = crateLog(createDebris({ wind: createWind(def) }), 0, dur);
assert(sameCrates(one, two),
`${name}: two freshly built debris modules spawned different crates — ${showCrates(one)} vs ${showCrates(two)}`);
// and the same module flown twice, clear()ed between: night 1 vs night 2
const reused = createDebris({ wind: createWind(def) });
const night1 = crateLog(reused, 0, dur);
reused.clear();
const night2 = crateLog(reused, 0, dur);
assert(sameCrates(night1, night2),
`${name}: the same storm flown twice through one module spawned different crates — night 1 ${showCrates(night1)}, night 2 ${showCrates(night2)}. clear() has stopped rewinding something.`);
}
});
t.test('an entry at t0 spawns the crates a t=0 run spawns after t0 (every storm, every t0)', () => {
for (const name of STORMS) {
const def = storms[name];
const dur = def.duration ?? 90;
const mk = () => createDebris({ wind: createWind(def) });
// the reference: watch the whole storm, and count how many crates are born
// at or after each candidate entry time
const whole = [];
const seen = new Set();
const ref = mk();
for (let i = 0; i < Math.round(dur / FIXED_DT); i++) {
const time = i * FIXED_DT;
ref.step(FIXED_DT, time, {});
for (const p of ref.pieces) {
if (seen.has(p)) continue;
seen.add(p);
whole.push({ at: time, model: p.model, y: p.y, sx: p.sx, sy: p.sy, sz: p.sz, phase: p.phase });
}
}
for (const t0 of [10, 30, 45, 60]) {
const want = whole.filter((p) => p.at >= t0);
const got = crateLog(mk(), t0, dur);
assert(sameCrates(want, got),
`${name}: entering at t=${t0} spawned ${showCrates(got)} where a t=0 run spawns ${showCrates(want)} — the crates a callout flies depend on whether anybody watched the first ${t0} s`);
}
}
});
t.test('arriving mid-storm, the grass is already moving (and a cold entry proves it can fail)', () => {
// DESIGN.md line 149: a gust front is readable because a wave of motion
// crosses the grass a beat before it hits. The callout has NO forecast paper
// (gate 1), so that motion is not decoration — it is the briefing. `leafDist`
// and `leafEma` are genuine path integrals (a 2.5 s EMA off zero), so they are
// the one part of my modules a mid-storm entry cannot teleport: measured, a
// t=0 run has 6 leaves crossing at t=30 and a cold entry has ZERO, staying
// becalmed for ~7 s while the EMA climbs. debris.enterAt() runs them.
const def = storms.storm_02_wildnight;
const T0 = 30;
const mk = () => createDebris({ wind: createWind(def) });
const look = (d) => ({ n: d.leafCount, at: d.leaves.map((p) => [p.x, p.y, p.z]) });
const same = (a, b) => a.n === b.n && a.at.every((v, i) =>
v[0] === b.at[i][0] && v[1] === b.at[i][1] && v[2] === b.at[i][2]);
const watched = mk();
fixedLoop(T0, FIXED_DT, (dt, time) => watched.step(dt, time, {}));
const want = look(watched);
assert(want.n > 0, `the reference t=0 run has ${want.n} leaves flying at t=${T0} — pick a windier moment or this case proves nothing`);
const arrived = mk();
arrived.enterAt(T0, FIXED_DT);
const got = look(arrived);
assert(same(want, got),
`enterAt(${T0}) put ${got.n} leaves in the yard where a t=0 run has ${want.n}, or put them elsewhere — the arrival is not the storm the player would have been standing in`);
// NEGATIVE CONTROL. Without this the case above could be satisfied by a
// do-nothing enterAt on a layer that happened to warm instantly. The naive
// path — fresh module, first step lands at t0 — must be measurably wrong, and
// is: 0 leaves against 6. This is also the guard against somebody later
// "optimising" enterAt into a teleport.
const cold = mk();
cold.step(FIXED_DT, T0, {});
assert(!same(want, look(cold)),
`a cold module stepped straight to t=${T0} already matches the watched run — the ambient layer carries no history, so the case above cannot fail and must be rewritten`);
});
// SPRINT13, the third time this repo learned it: the venturi lives in the
// SITE json, not the storm def. B's site_audit flew site_02 funnel-OFF in
// Sprint 11 (their sweep.selftest tells it); C's garden_bench did it again
@ -1512,6 +1652,245 @@ export default async function run(t) {
+ 'nobody flew is the same offence in the other direction');
});
// ═══ SPRINT18 GATE 1 — THE DISPATCH: what an honest read is when no paper comes ═══
//
// `offerBand`'s sin was OMISSION. The dispatch's sin is INVENTION, and these
// cases are aimed at that inversion. The sharpest of them states the rule as an
// invariant rather than as a vocabulary check: A READ AT t MAY NOT MOVE WHEN THE
// STORM'S FUTURE BEYOND t MOVES. If it does, the card is printing something
// nobody standing in that yard could know.
//
// ⚠️ NOT pinned by diffing storm_03_southerly against storm_03b_earlybuster,
// which was my first instinct and is wrong: THREADS has them "identical in every
// number the band prints", but they carry DIFFERENT SEEDS (30717/30818), so their
// gust timelines differ and their instantaneous wind differs at every t. They are
// twins on the FORECAST's numbers, not on the sim's. Isolating the variable means
// constructing it, so these cases mutate a def's future and hold everything else.
const AT = 30; // A's authored arriveAt (§1), the number ROADMAP names
/** A copy of `def` whose FUTURE past `t` is different and whose present is not. */
const futureChanged = (def, t) => {
const d = JSON.parse(JSON.stringify(def));
// Curve points strictly after the first point at/after t cannot affect the
// value AT t (piecewise-linear interpolation there brackets t). Move those.
for (const key of ['dirCurve', 'baseCurve']) {
const c = d[key];
if (!Array.isArray(c)) continue;
let first = c.findIndex((p) => p[0] >= t);
if (first < 0) continue;
for (let i = first + 1; i < c.length; i++) c[i][1] = -(c[i][1] ?? 0) * 1.7 - 0.3;
}
// A hail burst that has not started by t cannot be falling at t.
if (d.hail && Array.isArray(d.hail.bursts)) {
d.hail.bursts = d.hail.bursts.filter((b) => b.t <= t);
d.hail.bursts.push({ t: t + 5, ramp: 0.5, hold: 9, fade: 1, intensity: 1 });
}
// Events are announcements; the read never reads them. Move them anyway.
if (Array.isArray(d.events)) d.events = d.events.filter((e) => e.t <= t);
return d;
};
t.test('GATE 1: the mid-storm read cannot know the future (change the future, the read holds)', () => {
const same = (a, b) => a.length === b.length
&& a.every((l, i) => l.key === b[i].key && l.text === b[i].text);
let futuresActuallyChanged = 0;
for (const name of STORMS) {
const def = storms[name];
for (const t of [8, 20, AT]) {
const now = midStormRead(def, t);
const altered = midStormRead(futureChanged(def, t), t);
assert(same(now, altered),
`${name}: the read at t=${t} MOVED when only the storm's future moved.\n was: `
+ `${now.map((l) => l.key + '=' + l.text).join(' | ')}\n now: `
+ `${altered.map((l) => l.key + '=' + l.text).join(' | ')}\n `
+ 'a dispatch that reads past t is inventing the paper it says it has not got');
// VACUITY GUARD: the mutation must actually alter the storm, or the case
// above is comparing a def to itself. Read them both LATER than t.
const later = Math.min((def.duration ?? 90) - 1, t + 12);
if (!same(midStormRead(def, later), midStormRead(futureChanged(def, t), later))) {
futuresActuallyChanged++;
}
}
}
assert(futuresActuallyChanged >= STORMS.length,
`only ${futuresActuallyChanged} of ${STORMS.length * 3} mutations changed the storm at all — `
+ 'the invariant above is being satisfied by a no-op mutation, not by an honest read');
});
t.test('GATE 1: the read never carries a forecast-only fact, by key or by number', () => {
for (const name of STORMS) {
const def = storms[name];
const s = stormStats(def);
for (const t of [8, AT, 60]) {
const read = midStormRead(def, t);
for (const l of read) {
// the offer band's forward-looking keys have no business on a dispatch
assert(l.key !== 'change' && l.key !== 'confidence',
`${name} @${t}: the read carries a '${l.key}' line — that is forecast paper`);
}
const all = read.map((l) => l.text).join(' ');
// the two numbers only a bureau could hand you
if (s.changeAt != null && s.changeAt > t) {
assert(!all.includes(String(Math.round(s.changeAt))),
`${name} @${t}: the read prints ${Math.round(s.changeAt)} and the change lands at `
+ `${s.changeAt} — nobody told you when it swings`);
}
if (s.hailSeconds > 0) {
assert(!all.includes(`${s.hailSeconds.toFixed(0)}s of it`),
`${name} @${t}: the read prints the storm's TOTAL hail duration — that is the forecast's number`);
}
}
}
});
t.test('GATE 1: the admission is unconditional — every storm, every t, same words', () => {
// The subtle half of the design. On an offer card a missing hail line means
// THE STORM DOES NOT HAIL. On a dispatch it means NOBODY KNOWS. Silence cannot
// tell those apart, so the read says so out loud — and it must say it in the
// SAME words every time, because a line that appears only when there is
// something to not-know becomes the very fact it claims not to have.
const texts = { nopaper: new Set(), unknown: new Set() };
let reads = 0;
for (const name of STORMS) {
for (let t = 1; t < (storms[name].duration ?? 90); t += 7) {
const read = midStormRead(storms[name], t);
reads++;
assert(read[0].key === 'nopaper',
`${name} @${t}: the read does not OPEN with the no-forecast line — it frames every line under it`);
assert(read[read.length - 1].key === 'unknown',
`${name} @${t}: the read does not CLOSE with what you cannot know`);
for (const l of read) if (texts[l.key]) texts[l.key].add(l.text);
}
}
assert(reads > 60, `only ${reads} reads sampled — widen the grid`);
for (const k of ['nopaper', 'unknown']) {
assert(texts[k].size === 1,
`the '${k}' line has ${texts[k].size} different wordings across the shipped storms — `
+ `it is conditional on something, and its presence is then information the observer `
+ `cannot have:\n ${[...texts[k]].join('\n ')}`);
}
});
t.test('GATE 1: the honesty gate covers emergency storms — a storm nobody can describe cannot be dispatched', () => {
// "A storm that cannot be described honestly cannot be DISPATCHED either."
// The storm-side gate applies unchanged; the arrival adds its own three.
for (const name of STORMS) {
const r = dispatchHonest(storms[name], AT, name);
assert(r.ok, `${name} cannot be honestly dispatched at t=${AT}:\n ${r.errors.join('\n ')}`);
}
// NEGATIVE CONTROLS — four, because a gate that only ever says yes is decoration.
const base = storms.storm_03_southerly;
assert(!dispatchHonest(base, 0, 'x').ok,
'arriveAt 0 was accepted — that is not a mid-storm arrival, it is an ordinary night');
assert(!dispatchHonest(base, base.duration ?? 90, 'x').ok,
'arriveAt at the storm\'s end was accepted — you would be dispatched to finished weather');
assert(!dispatchHonest(base, 500, 'x').ok, 'arriveAt past the end was accepted');
// THE S17 FILING, CORRECTED BY MEASUREMENT. I filed "stoneWord has no floor,
// size 0 words itself fine pea" — and that hole was already shut one layer up:
// validateStorm refuses size <= 0, so this is the case proving the gate above
// does the work, not a floor check in forecastHonest.
const zero = JSON.parse(JSON.stringify(storms.storm_06_soaker));
zero.hail.size = 0;
assert(!dispatchHonest(zero, AT, 'zero').ok, 'hail.size 0 was dispatched');
// ⚠️ THE REAL HOLE, found by probing the layer that DOES pass: the validator
// only checks a size that is PRESENT (`hd.size != null &&`), and hailSize reads
// `size ?? 1`. So a hailing storm with NO authored size prints "marble stones"
// — the middle of the vocabulary, stated as fact, authored by nobody. On the
// soaker that turns a real 0.45 "fine pea" into a stone 2.2x bigger, on the one
// axis the fabric bet is decided on.
const sizeless = JSON.parse(JSON.stringify(storms.storm_06_soaker));
delete sizeless.hail.size;
assert(stormStats(sizeless).hailSeconds > 1,
'the sizeless fixture does not actually hail — this case would prove nothing');
const sr = dispatchHonest(sizeless, AT, 'sizeless');
assert(!sr.ok, 'a storm that hails with no authored hail.size was dispatched — the card '
+ 'would state "marble stones" as fact for a stone nobody wrote');
assert(sr.errors.some((e) => /hail\.size is not authored/.test(e)),
`the refusal does not say WHY: ${sr.errors.join('; ')}`);
// and the ceiling still bites, so the new check did not displace it
const boulders = JSON.parse(JSON.stringify(storms.storm_06_soaker));
boulders.hail.size = 2.6;
assert(!dispatchHonest(boulders, AT, 'boulders').ok,
'a 2.6 stone was dispatched — the vocabulary ceiling stopped biting');
});
t.test('GATE 1: every EMERGENCY is a storm that can be honestly dispatched over that yard', () => {
// The honesty gate walking A's own table — NIGHTS POOL grew a third member
// this sprint, and an emergency is the member with the most to hide: no
// forecast card, no quote, and a storm entered at a t nobody walked up to.
//
// ⚠️ Read through a NAMESPACE import (`board.EMERGENCIES`), not a named one, so
// this file does not hard-error in the sprint-window where A's spine is on
// lane/a and not yet in my tree — the same reasoning A gave for calling
// `weather.midStormRead?.()` that way, applied back at them. And it walks a
// FIXTURE mirroring A's §6 as well as the real table, so the gate is exercised
// on known data TODAY rather than reading green off an absent import: the
// calm-day vacuity that has now bitten this repo in three separate gates.
const fixture = [{
id: 'fixture:corner_block_emergency', night: 5,
storm: 'storm_03_southerly', site: 'site_02_corner_block',
emergency: { arriveAt: 30 },
}];
const real = Array.isArray(board.EMERGENCIES) ? board.EMERGENCIES : [];
const walk = [...fixture, ...real];
assert(walk.length >= 1, 'the emergency walk found nothing at all');
for (const e of walk) {
const at = e.emergency?.arriveAt;
const def = storms[e.storm];
assert(def, `${e.id}: emergency storm '${e.storm}' is not in the loaded storm set — `
+ 'an emergency whose weather nobody can load cannot be dispatched');
assert(Number.isFinite(at), `${e.id}: no numeric emergency.arriveAt`);
// (1) the storm-and-instant gate
const r = dispatchHonest(def, at, e.id);
assert(r.ok, `${e.id} cannot be honestly dispatched at arriveAt=${at}:\n ${r.errors.join('\n ')}`);
// (2) THE PAIRING LAW, extended to the new job type. A read can be perfectly
// honest about a storm and still be an invitation to a night nobody can
// work — and that is worse on a callout, which the player cannot decline
// once they are standing in it.
const why = pairingRefusal(e.storm, e.site);
assert(why === null,
`${e.id}: dispatching ${e.storm} over ${e.site} is refused by measurement — ${why}`);
}
// VACUITY GUARDS — three, because a walk that only ever says yes is decoration.
assert(pairingRefusal('storm_06_soaker', 'backyard_01') !== null,
'the pairing arm of this walk cannot say no — an emergency on a refused yard would pass');
assert(!dispatchHonest(storms[fixture[0].storm], 95, 'late').ok,
'an emergency arriving after its storm ended was accepted');
const sizeless = JSON.parse(JSON.stringify(storms.storm_06_soaker));
delete sizeless.hail.size;
assert(!dispatchHonest(sizeless, 30, 'sizeless').ok, 'the storm arm of this walk cannot say no');
});
t.test('GATE 1: every line of the read is reachable, and each one is earned', () => {
// A vocabulary with an unreachable word is a vocabulary that has never been
// read. Walk the shipped storms and prove each key fires somewhere — and that
// the two conditional-on-weather keys do NOT fire where the weather is absent.
const where = {};
for (const name of STORMS) {
for (let t = 0.5; t < (storms[name].duration ?? 90); t += 0.25) {
for (const l of midStormRead(storms[name], t)) (where[l.key] ??= []).push(name);
}
}
for (const k of ['nopaper', 'wind', 'unknown', 'lull', 'front', 'rain', 'veer', 'hail', 'ground']) {
assert(where[k]?.length, `no shipped storm ever produces a '${k}' line — it is unreachable vocabulary`);
}
// storm_01_gentle is the calm-day preload: no hail, no change. It must not
// grow a hail line or a swing, and this is the case that would catch a read
// that manufactures weather to fill a card.
const gentle = new Set();
for (let t = 0.5; t < 90; t += 0.25) for (const l of midStormRead(storms.storm_01_gentle, t)) gentle.add(l.key);
assert(!gentle.has('hail') && !gentle.has('ground'),
'the gentle night reads hail or stones on the ground, and it has neither');
assert(!gentle.has('veer'), 'the gentle night reads a wind swing, and it has no change');
});
// ── 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.

View File

@ -23,14 +23,24 @@ import { loadStorm, createWind } from '../weather.js';
// SPRINT16 gate 4 / SPRINT17 gate 0.1: the week-law pins at the bottom of this
// file read the ladder. week.js is contracts-only underneath, so this stays
// headless-safe.
import { NIGHTS, nightAt } from '../week.js';
import { NIGHTS, nightAt, REP } 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 { POOL, exposureOf, EMERGENCIES } from '../board.js';
// SPRINT18 gate 1.3 — the knife's physics half, imported for ONE reason: to check that the doors
// main.js feature-detects on the emergency path exist. See that test.
import { SailRig } from '../sail.js';
import { loadSite } from '../world.js';
// SPRINT18 gate 1.3 — the knife. knife.js is headless-importable for the same reason ladder.js is
// (its GLB-free, and the panel guards on `typeof document`); the ledger halves come from main.js,
// which a.test and c.test already import, because scoreRun lives behind a WebGL context and the only
// way to assert its rulings is to have them extracted as pure functions.
import { createKnife, createVerbPanel, cuttable, KNIFE_TUNE } from '../knife.js';
import { answerableFor, collateralWalk, rigRecordFor, verdictFor } from '../main.js';
import { createWeek } from '../week.js';
const DT = FIXED_DT;
@ -1328,4 +1338,350 @@ export default async function run(t) {
assertEq(rustItem.cost, 45,
'the rust is the cheapest structure you can break — the stake E sized the gamble in');
});
// ================================================================ SPRINT18 gate 1.3 — THE KNIFE
//
// DESIGN.md line 165, and the signature decision under load: release, cut, or ride it out. B built
// `rig.cutAway`; these pin the VERB — where it reaches, what interrupts it, what the read promises,
// and (the half that would have shipped as a trap) what it costs on the paperwork.
//
// A note on the fixtures: they are the SHIPPED emergency's own geometry, not invented numbers.
// site_02's cowboy rig is q3/q1/tr1b/cb1, and cb1 is a carport BEAM — `work:'bracket'`, y=2.36,
// ratingHint 0.22, the corner that takes the $180 carport at t=35.9. It is the one corner the knife
// must refuse and the whole reason the verb is a whole-sail cut.
/** A rig shaped like sail.js's: corners with `broken`/`cut`, and a cutAway that behaves like B's. */
function fakeRig(corners) {
const cuts = [];
return {
rigged: true,
corners,
cuts,
cutAway(ids = null, reason = null) {
const want = ids ?? corners.map((c) => c.anchorId);
const cut = [];
for (const id of want) {
const c = corners.find((x) => x.anchorId === id);
if (!c || c.broken) continue;
c.broken = true; c.cut = true; cut.push(id);
}
if (!cut.length) return { ok: false, reason: 'the sail is already down' };
cuts.push({ corners: cut, reason });
return { ok: true, cut };
},
};
}
/** The corner block's real anchors, with the beam's mechanism as E baked it. */
const YARD = {
anchors: [
{ id: 'q1', type: 'post', work: 'cloth', pos: { x: -3.5, y: 3.95, z: -2.5 } },
{ id: 'q3', type: 'post', work: 'cloth', pos: { x: 2.0, y: 3.95, z: 4.0 } },
{ id: 'tr1b', type: 'tree', pos: { x: -6.0, y: 5.05, z: 3.0 } },
{ id: 'cb1', type: 'carport', work: 'bracket', pos: { x: -7.0, y: 2.36, z: -3.0 }, collateral: 'carport' },
],
gnome: { collateralValue: 25 },
anchor(id) { return this.anchors.find((a) => a.id === id) ?? null; },
collateralFor(key) { return key === 'carport' ? { cost: 180, label: 'the carport' } : null; },
};
const fourCorners = () => [
{ anchorId: 'q3', broken: false, hw: { name: 'shackle', cost: 15, rating: 3200 } },
{ anchorId: 'q1', broken: false, hw: { name: 'carabiner', cost: 5, rating: 1200 } },
{ anchorId: 'tr1b', broken: false, hw: { name: 'carabiner', cost: 5, rating: 1200 } },
{ anchorId: 'cb1', broken: false, hw: { name: 'shackle', cost: 15, rating: 3200 } },
];
const knifeAt = (p, rig, extra = {}) => createKnife({
player: p, getRig: () => rig, getWorld: () => YARD,
collateralAtRisk: collateralWalk, ...extra,
});
/** Stand on a cleat. The cleat is the anchor's x/z at ground level — see knife.js. */
const standAt = (p, id) => { const a = YARD.anchor(id); p.pos.x = a.pos.x; p.pos.z = a.pos.z; };
t.test('knife: it reaches a post cleat from the grass and NEVER the bracket', () => {
const p = new PlayerSim({ start: { x: 0, y: 0, z: 0 } });
const corners = fourCorners();
const K = knifeAt(p, fakeRig(corners));
standAt(p, 'q3');
assertEq(K.target()?.anchorId, 'q3', 'standing at the post cleat, the knife is offered');
standAt(p, 'tr1b');
assertEq(K.target()?.anchorId, 'tr1b', 'and at the tree strop');
// THE ONE THAT MATTERS. cb1 is the corner about to take the $180 carport, and it is a bracket at
// 2.36 m — the same rule that costs a fascia repair two trips and a ladder. You cannot cut there,
// which is why the verb releases the WHOLE sail from a corner you can reach.
standAt(p, 'cb1');
assertEq(K.target(), null,
'the carport BEAM is bracket work — a knife cannot reach it, and having no time to fetch the '
+ 'ladder is the emergency\'s whole premise');
assert(!cuttable(corners[3], YARD.anchor('cb1')), 'cuttable() states the same rule directly');
/**
* THE REFUSAL SAYS WHICH OF THE TWO REASONS IT IS, and this pin exists because playing it
* caught the version that did not: one constant that always blamed the bracket, so on a yard
* with no bracket in the quad the game told me to fetch a ladder for a corner that does not
* exist. `needsLadder`'s own SPRINT10 disease a rule keyed on the wrong thing, failing
* plausibly on the surface built to explain it.
*/
assert(/cleat|strop/.test(K.read().whyNot ?? ''),
`standing at the beam with two posts still up, the answer is WALK: ${K.read().whyNot}`);
const bracketOnly = fakeRig([
{ anchorId: 'cb1', broken: false, hw: { name: 'shackle', cost: 15, rating: 3200 } },
]);
const K2 = knifeAt(p, bracketOnly);
assert(/bracket/.test(K2.read().whyNot ?? ''),
`with every live corner up a wall, the answer is that the knife cannot reach it: ${K2.read().whyNot}`);
// out of range of everything: still a sail, still no cut
p.pos.x = 0; p.pos.z = 0;
assertEq(K.target(), null, 'mid-yard is not at a cleat');
// up the ladder: both hands are on the rungs
standAt(p, 'q3');
p.climbY = 2.35;
assertEq(K.target(), null, 'you cannot cut from up a ladder');
});
t.test('knife: hold X for the full 1.2 s and the WHOLE sail goes — once', () => {
const p = new PlayerSim({ start: { x: 0, y: 0, z: 0 } });
const rig = fakeRig(fourCorners());
const K = knifeAt(p, rig);
standAt(p, 'q3');
// half a hold is no cut, and that is the deliberation the verb is made of
fixedLoop(KNIFE_TUNE.cutSecs * 0.5, DT, (dt, tt) => K.step(dt, tt, true));
assertEq(rig.cuts.length, 0, 'half a hold cuts nothing');
assert(K.cutting, 'and the radial is running');
fixedLoop(KNIFE_TUNE.cutSecs * 0.6, DT, (dt, tt) => K.step(dt, 1 + tt, true));
assertEq(rig.cuts.length, 1, 'the full hold cuts');
assertEq(rig.cuts[0].corners.length, 4,
'the WHOLE sail — DESIGN.md line 165, and B\'s cutAway() with no argument');
assertEq(rig.cuts[0].reason, 'the carport',
'the paperwork carries what it was FOR: the most expensive thing those corners were holding up');
// one press, one cut. Without the latch the held key re-arms and the ticker fills with refusals.
fixedLoop(3, DT, (dt, tt) => K.step(dt, 5 + tt, true));
assertEq(rig.cuts.length, 1, 'a held key must not try again every 1.2 s');
assertEq(K.read().state, 'none', 'and with no live corner there is nothing left to offer');
});
t.test('knife: letting go, walking off, or being put down all abort the cut', () => {
const start = () => {
const p = new PlayerSim({ start: { x: 0, y: 0, z: 0 } });
const rig = fakeRig(fourCorners());
const K = knifeAt(p, rig);
standAt(p, 'q3');
fixedLoop(KNIFE_TUNE.cutSecs * 0.7, DT, (dt, tt) => K.step(dt, tt, true));
assert(K.progress > 0.5, 'mid-cut');
return { p, rig, K };
};
const a = start();
a.K.step(DT, 1, false); // let go
assertEq(a.K.progress, 0, 'releasing X abandons the cut');
const b = start();
b.p.pos.x += 4; // walk off the cleat
b.K.step(DT, 1, true);
assertEq(b.K.progress, 0, 'walking away abandons it too');
const c = start();
c.p.knockdown(1, 1, 0); // the storm votes
c.K.step(DT, 1, true);
assertEq(c.K.progress, 0, 'a gust that floors you mid-cut takes the cut with it');
assertEq(c.p.state, 'knocked',
'and the knife did NOT stomp the state the world just set — interact.js owns `busy`, '
+ 'nothing else writes it');
// ...nor can a cut START while the player is locked in something else
const d = start();
d.p.setState('busy', 2);
fixedLoop(2, DT, (dt, tt) => d.K.step(dt, 3 + tt, true));
assertEq(d.rig.cuts.length, 0, 'you cannot cut mid-crank or mid-poke');
});
t.test('knife: the read prints BOTH halves of the trade, and the saves side is priced', () => {
const p = new PlayerSim({ start: { x: 0, y: 0, z: 0 } });
const rig = fakeRig(fourCorners());
const K = knifeAt(p, rig, { getGarden: () => ({ hp: 84 }), secsLeft: () => 52.4 });
standAt(p, 'q3');
const r = K.read();
assertEq(r.state, 'ready');
// $205: the carport 180 + the gnome 25 on the two-corner rule. The number the OFFER CARD printed
// as "at risk" that morning — same walk, so it cannot disagree with the invoice.
assertEq(r.savesTotal, 205, 'the saves side is the collateral the live corners are still holding');
assertEq(r.hp, 84, 'and the costs side is the bed');
assertEq(r.secsLeft, 52, 'for however long is left of the storm');
// The trade must survive on the glass, not just in the object — this panel is the thing that was
// missing for five sprints, so it gets an assert rather than a screenshot.
const panel = createVerbPanel({});
panel.update({ target: { id: 'rerig_0' }, usable: true, label: 're-rig corner', holding: false, progress: 0 }, r);
const text = panel.el.textContent;
assert(text.includes('[E] re-rig corner'), `the hold-E prompt reaches the glass: ${text}`);
assert(/CUT THE SAIL LOOSE/.test(text), 'so does the knife');
assert(text.includes('$205') && text.includes('the carport'), 'with what it saves, named and priced');
assert(/84%/.test(text) && /52s/.test(text), 'and what it costs, in the same breath');
panel.dispose();
});
// ---------------------------------------------------------------- the money (D's own filing, fixed)
t.test('knife (the money): a corner you CUT is not a corner you are answerable for', () => {
// The bug this pins: `_releaseCorner` sets `broken` for a cut exactly as for an overload, and
// every ledger surface reads `broken`. So the shipped tree billed the knife on five surfaces at
// once — the hardware, the $180 carport you cut the sail TO SAVE, the gnome's two-corner rule,
// the clean bonus it voided, and (in week.js) warranty plus standing.
const cut = fourCorners();
cut.forEach((c) => { c.broken = true; c.cut = true; });
const a = answerableFor(cut, null);
assertEq(a.lost.length, 4, 'the record still says four corners are down — the sail IS on the lawn');
assertEq(a.yours.length, 0, 'and none of them is a failure of yours');
assertEq(a.cut.length, 4, 'they are named as cut, so the paper can say who put them there');
assertEq(collateralWalk(a.yours, YARD).total, 0,
'THE HEADLINE: cutting the sail to save the carport does not then bill you for the carport');
// The vacuity guard, and it is the whole reason this test can be trusted: the SAME four corners
// broken by the storm bill everything. If both cases read $0 the walk is simply not working.
const blown = fourCorners();
blown.forEach((c) => { c.broken = true; });
const b = answerableFor(blown, null);
assertEq(b.yours.length, 4, 'an overload is still yours');
assertEq(collateralWalk(b.yours, YARD).total, 205, 'and it still costs the carport and the gnome');
// one cut, one blown: the ledger splits them by name, not by count
const mixed = fourCorners();
mixed[3].broken = true; // cb1 blew — the carport goes, honestly
mixed[0].broken = true; mixed[0].cut = true; // q3 cut afterwards
const m = answerableFor(mixed, null);
assertEq(m.yours.map((c) => c.anchorId).join(','), 'cb1', 'the one that failed is yours');
assertEq(m.cut.map((c) => c.anchorId).join(','), 'q3', 'the one you cut is not');
assertEq(collateralWalk(m.yours, YARD).total, 180,
'the carport bills (its corner really did let go) and the gnome does not — one corner, not two');
});
t.test('knife (the money): the walk that prices the panel is the walk that bills the invoice', () => {
// Not "they agree today" — they are the SAME FUNCTION, injected. This asserts the wire, because a
// second copy of the arithmetic is how the board came to advertise $57 against a $114 dispatch.
const p = new PlayerSim({ start: { x: 0, y: 0, z: 0 } });
const rig = fakeRig(fourCorners());
const K = knifeAt(p, rig);
standAt(p, 'q3');
const promised = K.read();
K.step(KNIFE_TUNE.cutSecs + DT, 0, true);
assertEq(rig.cuts.length, 1, 'cut');
const billed = collateralWalk(answerableFor(rig.corners, null).yours, YARD);
assertEq(promised.savesTotal, 205, 'the panel promised $205 off the table');
assertEq(billed.total, 0, 'and the invoice honoured it to the cent');
// PURE: asking the question must not wreck the yard. (The wreck stays at scoreRun's call site.)
let wrecks = 0;
collateralWalk(fourCorners(), { ...YARD, anchor: YARD.anchor.bind(YARD),
collateralFor: YARD.collateralFor, wreckStructure: () => { wrecks++; } });
assertEq(wrecks, 0, 'the walk never swaps a structure for its wreck — the HUD asks it every frame');
});
t.test('knife: the verdict says you cut it — and stands aside when something of yours failed too', () => {
const dmg = { hail: 30, rain: 4 };
const cut = fourCorners();
cut.forEach((c) => { c.broken = true; c.cut = true; });
const a = answerableFor(cut, null);
// The bug: with `lost` empty and hp under the line, verdictFor reached `uncovered` and told a
// player who had just cut the sail down that "the hail fell where your sail wasn't". That is the
// one sentence the verdict must never say here — it blames placement for a decision.
const lostBed = verdictFor({ hp: 31, lost: a.yours, cut: a.cut, win: false, dmg, pondPeak: 0, pondDumped: 0 });
assertEq(lostBed.mode, 'cut', `a cut night is its own mode, not '${lostBed.mode}'`);
assert(/CUT IT LOOSE/.test(lostBed.verdict), 'and it says so');
assert(!/sail wasn/.test(lostBed.verdict), 'never "the hail fell where your sail wasn\'t"');
assert(/didn't/.test(lostBed.verdict) || /their property/.test(lostBed.verdict),
'it names what the bed bought: their property');
const heldBed = verdictFor({ hp: 62, lost: a.yours, cut: a.cut, win: true, dmg, pondPeak: 0, pondDumped: 0 });
assertEq(heldBed.mode, 'cut-held', 'cut early enough and the bed can still make it');
// And the composition rule: a corner of YOURS on the ground is the louder fact, so the failure
// modes keep speaking and the knife stays a line on the ledger.
const mixed = fourCorners();
mixed[3].broken = true;
mixed[0].broken = true; mixed[0].cut = true;
const m = answerableFor(mixed, null);
const both = verdictFor({ hp: 20, lost: m.yours, cut: m.cut, win: false, dmg, pondPeak: 0, pondDumped: 0 });
assertEq(both.mode, 'corner', 'one corner of yours went: that verdict is true and it wins');
assert(/CB1/.test(both.verdict), 'and it names the corner that actually let go');
// no knife, no change: every pre-existing mode is untouched by the new argument
const plain = verdictFor({ hp: 31, lost: [], cut: [], win: false, dmg, pondPeak: 0, pondDumped: 0 });
assertEq(plain.mode, 'uncovered', 'a night with no cut still reads exactly as it did');
});
t.test('knife: the shipped emergency has something to CUT FOR — B\'s authored rig, not the placeholder', () => {
// The night's whole premise, as DATA, because that is where it was wrong. On the merged tree the
// rig was still A's flagged first cut with `cb1` (the carport beam) ARRIVING ALREADY DOWN — which
// makes the $180 carport `theirs`, unbillable and unwreckable, and leaves DESIGN.md line 165's
// signature decision with no object. Found by playing the night and reading the dispatch card.
const em = EMERGENCIES.find((e) => e.id === 'corner_block_emergency');
assert(em, 'the shipped emergency is still on the board');
const by = (id) => em.emergency.rig.find((c) => c.anchorId === id);
assert(by('cb1'), 'the carport beam is in the cowboy\'s rig — it is the bait the client has been '
+ 'selling since night 3');
assertEq(by('cb1').hw, 'shackle',
'a SHACKLE on the 0.22 beam: 704 N holding their roof, over its ceiling t=35..42 (B, measured). '
+ 'A carabiner there fails in the first second and there is nothing left to decide');
assert(!by('cb1').broken,
'AND IT IS FLYING WHEN YOU ARRIVE. A carport corner already on the ground is not a knife '
+ 'decision, it is a receipt — this is the assert that stops the placeholder coming back');
const down = em.emergency.rig.filter((c) => c.broken).map((c) => c.anchorId);
assertEq(down.join(','), 'tr1b',
'exactly one corner arrives gone, and it is the 0.88 branch on the cheapest steel');
// B's ruling, which is also data in A's file and also had not landed.
assertEq(em.pay?.garden, 0,
'the garden BONUS is off on a callout (B): you cannot be paid per-HP for a bed you had no prep '
+ 'phase to protect. The garden WIN stays, which is what makes the knife cost something');
});
t.test('knife: every rig door the emergency path feature-detects actually EXISTS', () => {
// ⚠️ This is the pin for the bug that cost this sprint its arrived-broken corner: main.js called
// `rig.breakCorner?.(id)` and B had named the door `failCorner`, so an optional call to a
// nonexistent method ran silently, forever, looking exactly like a call. The repo has a name for
// it — D's own `rigging.setWorld` lesson, quoted in main.js — and it still happened, because
// `?.()` makes a rename invisible. A feature-detect is only safe if something checks the feature.
for (const door of ['failCorner', 'cutAway', 'seedClock', 'setFabric']) {
assertEq(typeof SailRig.prototype[door], 'function',
`SailRig has no ${door}() — main.js feature-detects this door on the emergency path, so a `
+ 'rename here is a silent no-op, not an error');
}
// and the pre-fly argument, which is how the cloth gets into the storm it is standing in
assert(/\{\s*at\s*=\s*0/.test(SailRig.prototype.attach.toString()),
'attach() no longer takes {at} — the emergency\'s sail would fly the first second of a storm '
+ 'it arrived 30s into (measured: clockSkew 30.02)');
});
t.test('knife: warranty and standing do not chase a corner you chose to let go', () => {
const def = { baseCurve: [[0, 10]], gusts: { powBase: 5, powRamp: 5 } };
const cutRec = () => {
const cs = fourCorners();
cs.forEach((c) => { c.broken = true; c.cut = true; });
return rigRecordFor(cs);
};
assert(cutRec().every((c) => c.cut && c.brokenAtDawn),
'the record is honest: cut AND down at dawn, both true, neither hidden');
// night 1: cut the lot. night 2: the sheet must book NO warranty off it.
const w = createWeek();
const s1 = w.settle({ hp: 40, win: false, collateral: [], intactHardwareValue: 0,
rigRecord: cutRec(), cutAways: [{ t: 33, corners: ['q3', 'q1', 'tr1b', 'cb1'], reason: 'the carport' }] }, def, 0);
assertEq(s1.warranty.length, 0, 'nothing to warranty on the night you cut');
assert(!s1.repReasons.some((r) => /down at dawn/.test(r)),
`standing is not docked for the cut: ${s1.repReasons.join(' · ')}`);
assertEq(s1.repDelta, REP.CLEAN,
'a night where nothing FAILED is a clean night — the knife\'s price is the garden, not the star');
w.advance();
const s2 = w.settle({ hp: 100, win: true, collateral: [], intactHardwareValue: 0, rigRecord: [] }, def, 0);
assertEq(s2.warranty.length, 0,
'and no free callout tomorrow to go and re-hang a sail you deliberately spent');
// the vacuity guard: the same four corners BLOWN book four warranty lines and cost the standing
const w2 = createWeek();
const blownRec = () => { const cs = fourCorners(); cs.forEach((c) => { c.broken = true; }); return rigRecordFor(cs); };
const b1 = w2.settle({ hp: 40, win: false, collateral: [], intactHardwareValue: 0, rigRecord: blownRec() }, def, 0);
assert(b1.repDelta < 0, 'four blown corners DO cost standing');
w2.advance();
const b2 = w2.settle({ hp: 100, win: true, collateral: [], intactHardwareValue: 0, rigRecord: [] }, def, 0);
assertEq(b2.warranty.length, 4, 'and they DO book four warranty lines — the pin is not vacuous');
});
}

View File

@ -113,6 +113,16 @@ const ASSETS = [
nodes: ['frame', 'panes', 'door', 'ridge_anchor'] },
{ name: 'glasshouse_01_wrecked', h: [2.15, 2.40],
nodes: ['frame', 'door', 'panes_left', 'shards'] },
// SPRINT18 gate 4 — the glasshouse tier's live half. The height floor is a
// design claim about the asset next to it: 2.70 sits ABOVE the glasshouse's
// 2.40 ceiling, because a net frame that does not clear the ridge is resting
// on the glass it exists to protect. The wreck shares the band — the +x
// portal stands, so a portal frame folds at one end rather than falling.
{ name: 'hail_net_frame_01', h: [2.70, 2.85],
nodes: ['footings', 'legs', 'top_frame', 'net_roll',
'net_anchor_01', 'net_anchor_02', 'net_anchor_03', 'net_anchor_04'] },
{ name: 'hail_net_frame_01_wrecked', h: [2.70, 2.85],
nodes: ['footings', 'legs', 'top_frame', 'net_down'] },
{ name: 'pool_kit_01', h: [1.12, 1.32],
nodes: ['pool_shell', 'pool_water', 'fence_rails', 'pool_gate',
'fence_post_01', 'fence_post_14'] },
@ -1484,5 +1494,313 @@ export default async function run(t) {
'vacuous: no node-less declared anchors found across the shipped sites — this pin checked nothing');
});
// -------------------------------------------------------------------------
// SPRINT18 gate 4 — THE GLASSHOUSE TIER. Sprint 16 shipped the glass and
// priced it at 320; the gate asked for the tier, and reading the gate found
// that the price could not be REACHED. The comment two hundred lines up says
// it in this file's own words: "nothing in the sim wrecks a glasshouse yet —
// the value is inert data". main.js bills off a broken rig corner's baked
// collateral string, and a glasshouse correctly offers no anchors, so the
// most expensive object in the game could not cost anybody a cent.
//
// DESIGN.md line 179 named the missing asset: hail nets rigged OVER glass.
// You tie to the frame that straddles it. The tier's teaching is new — the
// frame's numbers are GOOD, and it is the most dangerous anchor in the game
// because of where it stands. So the tell being pinned below is a POSITION,
// measured off two GLBs, not a defect painted on one.
// -------------------------------------------------------------------------
t.test('the hail-net frame BRACKETS the glass — the tell is a position, and it is measured', () => {
const frame = loaded.get('hail_net_frame_01');
const glass = loaded.get('glasshouse_01');
assert(frame && glass, 'hail_net_frame_01 or glasshouse_01 did not load');
const f = new THREE.Box3().setFromObject(frame.scene);
const g = new THREE.Box3().setFromObject(glass.scene);
// Both assets are built at the origin, so a shared-origin placement is the
// authored case and these comparisons are the real geometry.
assert(f.min.x < g.min.x && f.max.x > g.max.x,
`the frame spans x ${f.min.x.toFixed(2)}..${f.max.x.toFixed(2)} and the glasshouse `
+ `${g.min.x.toFixed(2)}..${g.max.x.toFixed(2)} — the frame must STRADDLE the glass on x. `
+ 'A frame that no longer brackets it is a frame whose anchors bill a building they are '
+ 'not standing over, and the $320 becomes a number with no geometry behind it');
assert(f.min.z < g.min.z && f.max.z > g.max.z,
`the frame spans z ${f.min.z.toFixed(2)}..${f.max.z.toFixed(2)} and the glasshouse `
+ `${g.min.z.toFixed(2)}..${g.max.z.toFixed(2)} — it must straddle on z as well; `
+ 'legs standing ON the glass would be the joke version of this asset');
assert(f.max.y > g.max.y + 0.35,
`the frame heads at y=${f.max.y.toFixed(2)} and the ridge is at ${g.max.y.toFixed(2)}`
+ 'a net frame built to protect glass must CLEAR the ridge with room for the net to '
+ 'drape, or it is resting on the thing it exists to keep hail off');
// Every eye is above the ridge: the load path arcs OVER the glass, which is
// the whole reason a failure here lands on panes.
for (const n of ['net_anchor_01', 'net_anchor_02', 'net_anchor_03', 'net_anchor_04']) {
const o = frame.scene.getObjectByName(n);
assert(o, `${n} missing`);
const p = new THREE.Vector3().setFromMatrixPosition(o.matrixWorld);
assert(p.y > g.max.y,
`${n} sits at y=${p.y.toFixed(2)}, below the ridge (${g.max.y.toFixed(2)}) — every corner `
+ 'of this frame must be above the glass or the tier is not about the glass');
}
});
t.test('the frame\'s 20 m read is METALNESS, not lightness — S17\'s lesson, applied on purpose', () => {
// S17 measured this the hard way on the corroded post: galvanised ships at
// metalness 0.90, and with no environment map a near-mirror has nothing to
// mirror and renders NEAR-BLACK (rgb 49,51,49 at 20 m). So every steel the
// player is allowed to trust is dark at yard distance. This asset's entire
// long-range tell is being the opposite of that, and the mechanism is
// metalness — pin the mechanism, or the docstring gets "fixed" the wrong
// way by whoever repaints it next.
const pick = (asset, node) => {
let m = null;
loaded.get(asset)?.scene.getObjectByName(node)?.traverse((o) => { if (o.material) m = o.material; });
return m;
};
const paint = pick('hail_net_frame_01', 'top_frame');
const gal = pick('sail_post', 'post');
assert(paint && gal, 'could not reach the frame paint or the honest post steel');
assert(paint.metalness <= 0.15,
`the frame paints at metalness ${paint.metalness} — powder-coat is PAINT, and above ~0.15 `
+ 'it starts mirroring an environment that does not exist and goes dark like every other '
+ 'steel. The tell dies silently: the frame is still white in the palette and black on screen');
assert(gal.metalness >= 0.6,
`the honest post's steel is at metalness ${gal.metalness} — the control for this comparison `
+ 'is that trusted steel IS a near-mirror; if that changed, re-measure the whole claim');
const lum = (c) => c.r * 0.2126 + c.g * 0.7152 + c.b * 0.0722;
assert(lum(paint.color) > lum(gal.color) * 1.3,
`frame paint luminance ${lum(paint.color).toFixed(3)} vs galvanised ${lum(gal.color).toFixed(3)}`
+ 'the powder-coat must also be the brighter base colour, so the two halves of the tell '
+ 'agree instead of fighting');
});
t.test('the hail-net frame is a NEW RUNG at 0.80: good steel, worst place, resolvable node-less', () => {
const T = FACTORY_ANCHOR_RATINGS.types;
const entry = T.hail_net_frame;
assert(entry, 'hail_net_frame is not in the ratings manifest — the type cannot resolve '
+ 'node-less, and a site declaring it plainly would play at the default hint (the '
+ 'sail_post bug shape, on the highest-collateral anchor in the game)');
assert(!FACTORY_ANCHOR_RATINGS.ambiguous.hail_net_frame,
'hail_net_frame went manifest-AMBIGUOUS — its four eyes disagree, and the runtime will '
+ 'refuse to guess; they are one frame and must bake one number');
// Pinned against the manifest, never a literal — a literal would stay green
// through a whole reshuffle of the ladder.
assert(entry.rating_hint > T.pergola.rating_hint,
`the frame rates ${entry.rating_hint} and the pergola ${T.pergola.rating_hint} — the frame `
+ 'must out-rate it by a distance: a deck screwed to a house flexes, this is an engineered '
+ 'portal on four footings. De-rating honest steel for looking dangerous is the corroded '
+ 'post\'s lie inverted');
assert(entry.rating_hint < T.post.rating_hint,
`the frame rates ${entry.rating_hint} against the honest post's ${T.post.rating_hint} — at or `
+ 'above the post there is no de-rate left for "32 mm sized for mesh, not for 25 m² of sail"');
// THE FIELD THAT MAKES IT A TIER: what it takes with it is the GLASS.
assert(entry.collateral === 'glasshouse',
`the manifest says this type bills ${JSON.stringify(entry.collateral)} — it must bill `
+ '"glasshouse". That string is the entire tier: the anchor is fine, the thing underneath '
+ 'it is $320, and a node-less declaration has to carry that or the yard is free to break');
assert(entry.sources.length === 4,
`the manifest cites ${entry.sources.length} eyes; the frame has four corners and a net `
+ 'ties at all of them — a missing source means a corner baked something different');
});
// ⚠️ THE GATE, AS ONE MEASUREMENT. Before this sprint this assert was RED by
// construction: no node anywhere in the palette named the glasshouse's key,
// so the collateral apex was unbillable and the 320 was decoration. It is the
// free-failure bug's mirror image — RULE 2 catches a key with no price, and
// this catches a price with no key that can reach it.
t.test('⚠️ the collateral APEX is reachable: some anchor in the palette can actually bill the glass', () => {
const g = loaded.get('glasshouse_01')?.scene.getObjectByName('glasshouse_01');
assert(g, 'glasshouse_01 root missing');
const key = g.userData?.collateral_key;
assert(typeof key === 'string', 'the glasshouse carries no collateral_key to reach');
const billers = [];
for (const [asset, o] of allNodes()) {
const u = o.userData ?? {};
if (u.collateral !== key) continue;
if (typeof u.rating_hint !== 'number' || u.tie_off === false) continue;
billers.push(`${asset}/${o.name}`);
}
assert(billers.length > 0,
`NOTHING in the palette can bill "${key}" ($${g.userData?.collateral_value}). The apex is `
+ 'priced and unreachable: main.js bills collateral off a BROKEN RIG CORNER\'s baked key, '
+ 'a glasshouse rightly offers no anchors, so the most expensive thing in the game costs '
+ 'nobody anything. It needs an adoptable anchor whose collateral names the glass — that '
+ 'is what the hail-net frame is for (SPRINT18 gate 4)');
// ...and the corollary that keeps gate 5 disarmed on my own asset: the
// frame POINTS at the key and must never CLAIM it. structFor() returns the
// FIRST structure matching a key, so a frame carrying collateral_key
// "glasshouse" would, in any yard authored frame-first, bill $320 for the
// panes and then wreck THE FRAME — the bill and the wreck agreeing with
// each other and both wrong, which is the only kind of wrong this repo
// cannot see from the outside.
const claimants = [];
for (const [asset, gltf] of loaded) {
const root = gltf.scene.getObjectByName(asset);
if (root?.userData?.collateral_key === key) claimants.push(asset);
}
for (const c of claimants) {
assert(c.startsWith('glasshouse_01'),
`${c} claims collateral_key "${key}", and it is not the glasshouse. Exactly one structure `
+ 'may OWN a key — the one that owns the price. structFor() takes the first match, so a '
+ 'second claimant means some yard bills the glass and wrecks something else');
}
assert(claimants.length > 0, `vacuous: nothing owns "${key}" at all`);
});
t.test('the net-frame wreck folds at ONE end: keeps its height, carries no glass, offers nothing', () => {
const w = loaded.get('hail_net_frame_01_wrecked');
const i = loaded.get('hail_net_frame_01');
assert(w && i, 'the net frame or its wreck did not load');
const bw = new THREE.Box3().setFromObject(w.scene);
const bi = new THREE.Box3().setFromObject(i.scene);
assert(Math.abs(bw.max.y - bi.max.y) < 0.10,
`the wreck tops out at y=${bw.max.y.toFixed(2)} vs the intact ${bi.max.y.toFixed(2)} — the +x `
+ 'portal stands and holds the eave rails, so a portal frame folds at one end. A wreck lying '
+ 'flat is claiming a failure mode this thing does not have');
assert((bw.max.x - bw.min.x) > (bi.max.x - bi.min.x) + 0.6,
`the wreck spans ${(bw.max.x - bw.min.x).toFixed(2)} m on x against the intact `
+ `${(bi.max.x - bi.min.x).toFixed(2)} — the net has to end up OUT past the footing, or your `
+ 'own debris is tidier than the storm\'s and the tier stops teaching what it is for');
// No glass here. The glasshouse has its own wreck variant and swaps
// independently; shards in both would draw twice the night both swap, and
// would quietly claim the glass's failure as this asset's.
const glassy = [];
w.scene.traverse((o) => {
const m = o.material;
if (m && m.transparent === true && m.opacity < 0.6) glassy.push(o.name);
});
assert(glassy.length === 0,
`the net-frame wreck ships translucent geometry on [${glassy.join(', ')}] — the glass belongs `
+ 'to glasshouse_01_wrecked, which swaps on its own; shards in both double-draw');
// No anchor survives the wreck (the fascia rule, sixth application).
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 still offers [${offers.join(', ')}] — you cannot clip to a folded frame, and an `
+ 'anchor that outlives its structure is the free-failure bug in a costume');
const wr = w.scene.getObjectByName('hail_net_frame_01_wrecked');
assert(wr?.userData?.broken_variant_of === 'hail_net_frame_01',
'the wreck must name its intact twin so A can pair the swap');
// UNPRICED BY RULING — the bike/jacaranda pattern. One broken corner can
// only name one collateral key, and the player is being taught about the
// glass. Splitting a bill between the frame and the panes with no mechanism
// to split it is exactly the guess the invoice exists to refuse.
assert(wr?.userData?.collateral_value === undefined,
'the net frame is UNPRICED BY RULING — build the mechanism that can bill two things off '
+ 'one broken corner before pricing the second one');
});
// -------------------------------------------------------------------------
// SPRINT18 gate 5 — THE SECOND-RUST-POST LANDMINE (D's filing, E+A).
//
// Two structures in one yard sharing a collateral key is currently ARMED and
// not firing: world.js's structFor() returns the FIRST structure whose key
// matches, main.js's billing loop dedupes by key, and C's scorecard envelope
// dedupes by key again — so the second one is invisible to the bill and to
// the wreck, while board.js's exposureOf() counts per STRUCTURE and prices
// both. D measured the split on a real two-post yard: $115 on the offer card
// against $45 on the failure route.
//
// The pool yard dodges it by shipping exactly one corroded post ON PURPOSE.
// That is a yard author remembering, which is not a defence — and gate 4 just
// made it likelier, because a glasshouse yard carries a structure that OWNS
// the key and a frame whose anchors POINT at it. Two glasshouses with a frame
// each trips this on the first night.
//
// So: the asset/manifest half is to make it impossible to SHIP quietly. The
// runtime half (a per-instance key a site can override) is A's and the exact
// wiring is filed in THREADS.
// -------------------------------------------------------------------------
const shippedSites = [];
for (const name of ['backyard_01', 'site_02_corner_block', 'site_03_swing_lawn',
'site_04_pool_yard']) {
shippedSites.push({ name, site: await loadSite(name) });
}
t.test('⚠️ no shipped yard gives two structures the same collateral key, and ids are unique', () => {
// Mirrors world.js structKey() exactly: site JSON wins, then the baked
// GLB key, then the structure's own id as a last resort.
const resolvedKey = (s) => {
if (s.collateralKey) return s.collateralKey;
const base = String(s.model ?? '').replace(/_v1$/, '');
return loaded.get(base)?.scene.getObjectByName(base)?.userData?.collateral_key ?? s.id;
};
let checked = 0;
for (const { name, site } of shippedSites) {
const structs = site.structures ?? [];
const ids = structs.map((s) => s.id);
const dupeIds = ids.filter((v, i) => ids.indexOf(v) !== i);
assert(dupeIds.length === 0,
`${name}: duplicate structure ids [${[...new Set(dupeIds)].join(', ')}]. world.js keys its `
+ 'structures Map by id, so the second one SILENTLY REPLACES the first — it never renders, '
+ 'never bills, and never wrecks, and nothing anywhere says a word');
const keys = structs.map(resolvedKey);
const dupeKeys = keys.filter((v, i) => keys.indexOf(v) !== i);
assert(dupeKeys.length === 0,
`${name}: two structures resolve to the same collateral key `
+ `"${[...new Set(dupeKeys)].join('", "')}" (keys: [${keys.join(', ')}]). structFor() `
+ 'returns the FIRST match, so the bill and the wreck would both land on the wrong one, '
+ 'agreeing with each other; meanwhile exposureOf() prices per structure and the offer '
+ 'card would quote a total the failure route can never reach (D measured $115 vs $45). '
+ 'Give each instance its own key in the site JSON — and until A lands the per-anchor '
+ 'override, the anchors bake the shared key, so the second one cannot be made to bill '
+ 'at all: see THREADS [E] SPRINT18 gate 5');
checked += keys.length;
}
assert(checked > 0, 'vacuous: no structures found across the shipped yards');
});
// The other half of the same seam, from the asset side: an anchor names the
// collateral it TAKES, and in a real yard something has to own that name. The
// hail-net frame is the first asset where those are different objects, so a
// frame placed in a yard with no glasshouse would bill a building that is not
// there — collateralFor() returns null and the corner is FREE. RULE 2 proves
// the key is priced SOMEWHERE in the palette; this proves it is owned in
// every yard that names it.
t.test('⚠️ every collateral an authored anchor names is OWNED by exactly one thing in that yard', () => {
const types = FACTORY_ANCHOR_RATINGS.types;
let checked = 0;
for (const { name, site } of shippedSites) {
const owners = new Map();
const own = (key, what) => {
if (typeof key !== 'string') return;
owners.set(key, (owners.get(key) ?? 0) + 1);
void what;
};
const houseBase = String(site.house?.model ?? '').replace(/_v1$/, '');
own(site.house?.collateralKey
?? loaded.get(houseBase)?.scene.getObjectByName(houseBase)?.userData?.collateral_key,
'house');
for (const s of site.structures ?? []) {
const base = String(s.model ?? '').replace(/_v1$/, '');
own(s.collateralKey
?? loaded.get(base)?.scene.getObjectByName(base)?.userData?.collateral_key ?? s.id, s.id);
}
// What every authored anchor on a structure will actually bill: the baked
// node's key when it names a node, else the manifest's key for its type.
for (const s of site.structures ?? []) {
const base = String(s.model ?? '').replace(/_v1$/, '');
for (const a of s.anchors ?? []) {
const baked = a.node
? loaded.get(base)?.scene.getObjectByName(a.node)?.userData?.collateral
: types[a.type]?.collateral;
if (typeof baked !== 'string') continue; // bills nothing, by design
assert(owners.get(baked) === 1,
`${name}/${a.id} will bill "${baked}" on failure, and ${owners.get(baked) ?? 0} things `
+ 'in this yard own that key. Zero means collateralFor() returns null and breaking '
+ 'this corner is FREE (the gutter bug, RULE 2\'s runtime twin); more than one means '
+ 'structFor() picks the first and the wreck lands on the wrong structure');
checked += 1;
}
}
}
assert(checked > 0,
'vacuous: no structure anchor in any shipped yard names collateral — this pin checked nothing');
});
for (const { world } of siteWorlds) world.dispose();
}

View File

@ -901,6 +901,95 @@ export function weatherCases(storms) {
}
});
// -------------------------------------------------------------------------
// SPRINT18 gate 1 — THE MID-STORM ENTRY SEAM.
//
// The emergency callout spawns the player at t≈30 in a storm nobody briefed
// them on. That makes a NON-ZERO START a first-class case for the storm clock,
// and "a number gathered from the wrong harness" is the exact class of bug this
// repo hunts. The whole callout stands on one property: an entry at t0 must see
// the storm a t=0 run sees at t0.
//
// For the WIND that property is structural — weather.core.js's header promises
// everything is a closed-form function of (pos, t) — but promised is not pinned,
// and the field is not as stateless as it looks: buildGustTimeline and the
// advected-drift table are both built at construction, uniformSpeed early-exits
// on a sorted scan, and stormStats memoises into a WeakMap. Any one of those
// growing a cursor would make the storm depend on the order it was watched in.
// Below: cold vs walked vs scrambled, and two independently built fields.
// -------------------------------------------------------------------------
test('the wind at t=30 does not care how you got there (the callout seam)', () => {
for (const [name, def] of Object.entries(storms)) {
const read = (f, t) => [f.uniformSpeed(t), f.gustOnly(t), f.dirAt(t), f.rainAt(t), f.hailAt(t)];
const T0 = 30;
const cold = createWindField(def);
const want = read(cold, T0);
// walked: every step a t=0 run would have taken, in order, first
const walked = createWindField(def);
for (let i = 0; i < Math.round(T0 / DT); i++) read(walked, i * DT);
const got = read(walked, T0);
for (let i = 0; i < want.length; i++) {
assert(want[i] === got[i],
`${name}: field #${i} at t=${T0} reads ${got[i]} after being walked 0→30 but ${want[i]} cold — the wind remembers being watched, so a mid-storm entry cannot reproduce a t=0 run`);
}
// scrambled: backwards, past the end, and repeated
const scrambled = createWindField(def);
for (const t of [90, 0, 45.5, 12, 88, T0, 1, T0, -5]) read(scrambled, t);
const sc = read(scrambled, T0);
for (let i = 0; i < want.length; i++) {
assert(want[i] === sc[i],
`${name}: field #${i} at t=${T0} moved after out-of-order sampling — ${sc[i]} vs ${want[i]}`);
}
// two independently constructed fields must agree: the gust timeline and
// the drift table are drawn from a PRNG at build, so this is the pin that
// says "built twice" is the same storm as "built once".
const twin = read(createWindField(def), T0);
for (let i = 0; i < want.length; i++) {
assert(want[i] === twin[i],
`${name}: field #${i} at t=${T0} differs between two freshly built fields — ${twin[i]} vs ${want[i]}`);
}
}
});
test('the storm the dispatch quotes is the storm a BACKWARDS scan finds', () => {
// Two harnesses, one number — the repo's oldest rule, aimed at the entry seam.
// stormStats scans FORWARD from 0 and everything worded (the dispatch, the
// offer band, the honesty gate) is built on its peaks. A mid-storm arrival is
// the first consumer that reads the field at a t nobody walked up to, so scan
// it in the one order no production code uses — downwards from the end — and
// insist on the same peak. A field that remembers being watched fails here and
// nowhere else: caught the resume-cursor mutation on uniformSpeed (14.6 m/s
// against 18.58 on the wildnight, a 21% understatement of the gust peak).
//
// Deliberately NOT `stormStats(def)` twice: it memoises per def in a WeakMap,
// so the second call hands back the SAME OBJECT and comparing them is
// comparing an object to itself. That version of this case could not fail.
const STEP = 0.05;
for (const [name, def] of Object.entries(storms)) {
const s = stormStats(def);
const f = createWindField(def);
const dur = def.duration ?? 90;
let gustPeak = 0, rainPeak = 0, hailPeak = 0;
for (let t = Math.floor(dur / STEP) * STEP; t >= 0; t -= STEP) {
gustPeak = Math.max(gustPeak, f.uniformSpeed(t));
rainPeak = Math.max(rainPeak, f.rainAt(t));
hailPeak = Math.max(hailPeak, f.hailAt(t));
}
// Same grid, opposite direction: this is an equality, not a tolerance.
assert(Math.abs(gustPeak - s.gustPeak) < 1e-9,
`${name}: scanned backwards the gust peak is ${gustPeak.toFixed(3)} m/s, but the dispatch quotes ${s.gustPeak.toFixed(3)} — the field depends on the order it was read`);
assert(Math.abs(rainPeak - s.rainPeak) < 1e-9,
`${name}: backwards rain peak ${rainPeak.toFixed(4)} vs quoted ${s.rainPeak.toFixed(4)}`);
assert(Math.abs(hailPeak - s.hailPeak) < 1e-9,
`${name}: backwards hail peak ${hailPeak.toFixed(4)} vs quoted ${s.hailPeak.toFixed(4)}`);
}
});
return { cases, metrics };
}

View File

@ -192,6 +192,33 @@ export function forecastHonest(def, name = def?.name ?? 'storm') {
+ `(${STONE_WORD_CEILING}) — "${stoneWord(s.hailSize)}" would undersell what falls; `
+ 'grow the word map before offering this storm');
}
// ⚠️ AND THE OTHER END. [SPRINT18 — the S17 filing, corrected by measuring it]
//
// I filed this forward in S17 as "stoneWord has no FLOOR: a def with
// hail.size 0 words itself 'fine pea stones'". **That hole was already shut and
// I had not checked one layer up**: validateStorm refuses `size <= 0` outright,
// forecastHonest runs the validator FIRST and returns early, so a floor check
// here would have been dead code. Recorded rather than quietly dropped —
// filing a hole without testing the gate above it is the same mistake as
// trusting a comment.
//
// Probing the layer that DOES pass turned up the real one, and it is worse:
// `hd.size != null &&` — the validator only checks a size that is PRESENT, and
// `hailSize` reads `hailDef.size ?? 1`. So a storm that hails with no authored
// size passes every gate and the card prints **"marble stones"**: the middle of
// the vocabulary, stated as fact, authored by nobody. Measured on the soaker —
// delete one field and its real 0.45 "fine pea" becomes a confident "marble",
// a stone 2.2× bigger, on the single axis the fabric bet is decided on. The
// validator makes this exact argument two lines above about a hail block with
// nothing to fire it: "that's a typo, not a design, so say so".
//
// It matters more now than in S17: the dispatch's stone line is the ONLY
// description an emergency night gets, with no band beside it to hedge in.
if (s.hailSeconds > 0 && def.hail?.size == null) {
errors.push(`${name}: hails for ${s.hailSeconds.toFixed(1)}s but hail.size is not authored — `
+ `it defaults to ${s.hailSize} and the card would state "${stoneWord(s.hailSize)} stones" `
+ 'as fact on the axis the fabric bet turns on; a hailing storm must declare its stone');
}
// Recompute the band promises from the def (structural today — see header).
const contains = (b, val, what, lead) => {
@ -404,6 +431,221 @@ export function pairingRefusals(pairings = []) {
return out;
}
// ─────────────────────────────────────────────────────────────────────────────
// THE DISPATCH — SPRINT18 gate 1, C's seam (A's §8).
//
// ⚖️ **THE CALLOUT HAS NO FORECAST, AND THAT IS A WORDING PROBLEM BEFORE IT IS
// A FEATURE.** Every other night in this game is sold off paper: `offerBand`
// prints a forecast, and its honesty rule is the one S17 settled — the band may
// be vague, it may not rule out what happens, and it may not omit a fact it
// holds. An emergency callout has no paper at all. You are standing in the
// weather at t=30 with a phone call and a set of eyes.
//
// ⚠️ **THE INVERSION, which is the whole design.** The forecast's sin is
// OMISSION — the board lied about night 7 by printing two of the fields it had.
// The dispatch's sin is the opposite: INVENTION. Standing in a yard you know the
// wind on your back to the km/h, better than any forecast ever told you — so the
// present tense is EXACT here where the card was banded. What you do not know is
// everything the bureau would have told you: how long this has to run, what the
// peak will be, whether a southerly is coming at t=48. Printing any of that
// would be inventing paper that does not exist.
//
// So the read is ruled by TENSE, and the permitted set is not "less than a
// forecast" — it is a different, sharper set:
// · **NOW** — exact, unhedged. You are standing in it.
// · **THE VISIBLE IMMINENT** — a gust front crossing the grass (DESIGN.md line
// 149: "readable — a wave of motion crosses the grass and trees a beat
// before it hits"). The telegraph window is 1.5 s and that is the whole
// horizon you have earned by looking.
// · **THE PAST, AS WRITTEN ON THE YARD** — and this is the one that makes the
// design work. You were not here for the first 30 seconds, so you have no
// memory of them; but hail lying in the grass and water standing on the flat
// are a RECORD of them, and reading a yard for what already went wrong is
// literally this game's other job type (DESIGN.md's cowboy-job line: "half
// the puzzle is finding what's about to fail"). Integrals over [0, t] are
// honest. Anything over (t, ∞) is not.
// · **THE FUTURE** — forbidden. Not hedged, not banded. Absent.
//
// ⚠️ **AND THE ADMISSION IS MANDATORY, which is the subtle half.** On a forecast
// card a missing hail line means THE STORM DOES NOT HAIL — `offerBand` rules it
// so, deliberately, and seven nights have taught the player to read it that way.
// On a dispatch a missing hail line means NOBODY KNOWS. Those two cannot look
// identical on a document, and silence cannot distinguish them: a player fresh
// off six forecast cards will read an absent line as a reassurance nobody gave.
// So the read SAYS it has no paper, first, and says what it cannot know, last.
// That is the honest answer to "what does honest mean when the honest answer is
// nobody told you" — you say the words "nobody told you", on the card, in the
// order a person reads.
//
// ⚠️ **THE IGNORANCE LINE IS UNCONDITIONAL, and it has to be.** A tempting
// version says "a change may still be coming" only when `def` HAS a change event
// — which would make the line's PRESENCE the very fact it claims not to have.
// The same trick in reverse to `offerBand`'s "hail line iff it hails": there,
// presence is information the forecast legitimately holds; here, presence would
// be information the observer cannot possibly hold. Constant text, every storm,
// every t. It leaks nothing because it says nothing about the weather — it is a
// statement about YOUR INFORMATION, and those you may always make.
// ─────────────────────────────────────────────────────────────────────────────
/** Below this, no gust is "on it" — the base wind is all you are feeling. */
const LULL_GUST = 0.4; // m/s of live gust envelope
/** Above this the wind is visibly swinging, rad/s, measured over ±0.5 s. */
const VEER_RATE = 0.06;
/** Hail-seconds already delivered before stones are lying where you can see them. */
const HAIL_ON_GROUND = 1.5;
/** Real mm of rain already delivered before water is standing on the flat. */
const WATER_STANDING = 6;
/**
* **WHAT THE SKY AND THE GRASS TELL YOU WHEN NO PAPER DOES.**
*
* The dispatch's weather half A owns `emergency.dispatch` (the phone call) and
* this is everything under it. Same construction as `offerBand`: an ORDERED list
* of `{key, text}`, so the card MAPS it and a fact added here cannot be dropped
* by a card that predates the fact. Reading order is the order a tradie takes the
* yard in what you haven't got, what's hitting you, what's coming, what has
* already happened, and what you still don't know.
*
* Reads ONLY the permitted tenses (see the divider above). It never touches
* `stormStats` that is the whole-storm truth, the thing the bureau would have
* measured, and `gustPeak`/`hailSeconds`/`changeAt` are precisely the facts a
* callout is not entitled to. Pinned two ways: `storm_03_southerly` and
* `storm_03b_earlybuster` are identical in every number except when the change
* lands (30 s against 18 s), so a read taken BEFORE both changes must be
* character-identical and one taken between them must DIFFER, because by then
* the swing is something you can feel.
*
* @param {object} def parsed storm JSON
* @param {number} t storm seconds at which you got out of the ute (`arriveAt`)
* @returns {{key:string, text:string}[]} nopaper, wind, veer?, front?|lull?,
* hail?, rain?, ground?, unknown
*/
export function midStormRead(def, t = 0) {
const f = createWindField(def);
const lines = [{ key: 'nopaper', text: 'no forecast on this one — nobody briefed it' }];
const i = (v) => v.toFixed(0);
// NOW, exact. Speed is the one number a body measures better than a bureau: it
// is on your back. Unhedged on purpose — the contrast with a forecast card is
// the point, not an oversight.
lines.push({ key: 'wind', text: `${i(kmh(f.uniformSpeed(t)))} km/h on it right now` });
// NOW: is it swinging? A wind backing round is felt and seen (the trees go
// first), so it is fair game — and it is the fact that matters most on an
// inherited rig, because a rig built for a westerly is loaded backwards by a
// southerly. Central difference over ±0.5 s: short enough to be "right now",
// wide enough not to read curve noise as a swing. It reports only that it IS
// swinging, never that one is COMING, and never a compass word — the def alone
// carries no reference frame the player shares, and inventing one would be the
// same offence as inventing the forecast.
const veer = f.dirAt(t + 0.5) - f.dirAt(t - 0.5);
if (Math.abs(veer) > VEER_RATE) {
lines.push({ key: 'veer', text: 'the wind is backing round on you as you stand there' });
}
// THE VISIBLE IMMINENT — the only future you have earned, and you earned it by
// looking at grass.
const tele = f.telegraph ? f.telegraph(t) : null;
if (tele && Number.isFinite(tele.eta)) {
lines.push({
key: 'front',
text: `a gust front crossing the grass — on you in about ${tele.eta.toFixed(1)}s`,
});
} else if (f.gustOnly(t) < LULL_GUST) {
// NOW: nothing on it this second. DESIGN.md line 149 — "lulls are repair
// windows" — and on a triage job that is the most actionable line on the
// card. Honest at an instant: it says nothing about how long the lull holds,
// because nothing tells you that.
lines.push({ key: 'lull', text: 'nothing on it this second — if you are moving, move now' });
}
// NOW: what is falling. Present tense only — no duration and no total, because
// those are the bureau's numbers.
if (f.hailAt(t) > 0) {
lines.push({ key: 'hail', text: `${stoneWord(f.hailSize)} stones coming through` });
}
const rain = f.rainMmPerHour(t);
if (rain > 0.5) lines.push({ key: 'rain', text: `rain at ${i(rain)} mm/hr` });
// THE PAST, AS WRITTEN ON THE YARD. You did not see the first t seconds; the
// yard did, and it kept the receipt. Integrals over [0, t] — never past t.
const ground = [];
let hailSoFar = 0;
for (let s = 0; s <= t; s += 0.05) hailSoFar += f.hailAt(s) * 0.05;
if (hailSoFar > HAIL_ON_GROUND) ground.push('stones lying in the grass');
if (f.rainDepthMm(0, t) > WATER_STANDING) ground.push('water standing on the flat');
if (ground.length) {
lines.push({ key: 'ground', text: `${ground.join(' and ')} — it has been through here a while` });
}
// THE ADMISSION. Unconditional, constant, and last, because it is what the
// player carries into the yard. It says nothing about the weather — only about
// what you were given, which is nothing.
lines.push({
key: 'unknown',
text: 'how long it runs, what it does next, whether it swings again — no paper, no quote. You find that out standing in it.',
});
return lines;
}
/**
* **THE HONESTY GATE, EXTENDED TO EMERGENCY STORMS.** A storm that cannot be
* described honestly cannot be DISPATCHED either an emergency night still gets
* worded, priced, invoiced, warrantied and rep'd through the surfaces it already
* had (A's §10 acceptance criterion), so `forecastHonest`'s storm-side gate
* applies unchanged. Having no forecast card is not a licence for a storm nobody
* can describe; it RAISES the bar, because the read is the only description there
* is and there is no band to hide vagueness in.
*
* On top of the storm gate, three things only an ARRIVAL can get wrong:
* 1. **the arrival must be inside a storm that is still running.** `arriveAt`
* past the end dispatches you to weather that has already finished.
* 2. **the read must not come out empty or mute.** A dispatch whose weather half
* renders nothing is the offer card picking two fields all over again.
* 3. **the stone must be real AT THE INSTANT you arrive** the S17 filing,
* corrected by measuring it. `size: 0` turned out to be refused by the
* validator a layer up, so the floor I filed for would have been dead code.
* The REACHABLE hole is `size` ABSENT: it passes every gate and defaults to
* the MIDDLE of the vocabulary, so the card states "marble stones" as fact for
* a stone nobody authored. See the note beside the check in `forecastHonest`.
*
* @param {object} def parsed storm JSON
* @param {number} t the authored `arriveAt`
* @param {string} [name]
* @returns {{ok:boolean, errors:string[]}}
*/
export function dispatchHonest(def, t, name = def?.name ?? 'storm') {
const base = forecastHonest(def, name);
const errors = [...base.errors];
if (!base.ok) return { ok: false, errors };
const dur = def.duration ?? 90;
if (!(Number.isFinite(t) && t > 0)) {
errors.push(`${name}: arriveAt ${t} is not a time inside a storm — a callout arrives MID-storm`);
} else if (t >= dur) {
errors.push(`${name}: arriveAt ${t} is at or past the storm's end (${dur}s) — `
+ 'you would be dispatched to weather that has already finished');
} else {
const read = midStormRead(def, t);
const keys = new Set(read.map((l) => l.key));
for (const must of ['nopaper', 'wind', 'unknown']) {
if (!keys.has(must)) {
errors.push(`${name}: the read at t=${t} has no '${must}' line — a dispatch that does not `
+ 'say it has no paper teaches that a missing line is a reassurance');
}
}
if (read.some((l) => !l.text || !l.text.trim())) {
errors.push(`${name}: the read at t=${t} contains an empty line`);
}
const f = createWindField(def);
if (f.hailAt(t) > 0 && !(f.hailSize > 0)) {
errors.push(`${name}: hail is falling at t=${t} with size ${f.hailSize}`
+ `the read would print "${stoneWord(f.hailSize)} stones" for stones that do not exist`);
}
}
return { ok: errors.length === 0, errors };
}
// Resolved against this module, not the server root: server.py serves the repo
// root (so the 2D prototype stays reachable), but the demo bench serves web/.
// import.meta.url is right under both, and under whatever Lane A does next.

View File

@ -231,6 +231,24 @@ export function normaliseNight(entry) {
* field is `[]` on all seven nights and nothing about the campaign moves.
*/
constraints: base.constraints ?? [],
/**
* 🚨 SPRINT18 gate 1 THE EMERGENCY CALLOUT, as data on the night.
*
* `null` by default, and null rather than `{}` on purpose the opposite
* ruling to `constraints` above, for the opposite reason. Constraints are
* ITERATED by four surfaces, so an empty array saves three of them a guard.
* An emergency is BRANCHED ON (is tonight a dispatch or a job sheet?), and a
* truthy empty object is the worst possible answer to that question: every
* caller would read "yes" and then find no arriveAt and no rig. `null` makes
* the branch honest.
*
* The whole point of it living HERE is that it rides `normaliseNight`, so an
* emergency taken off the board is the same SHAPE as an authored night and
* every downstream surface sheet, quote, invoice, ledger, warranty, rep,
* end card reads it through the path it already had. That is the same
* promise gate 1 of SPRINT17 made about the board, extended to a job type.
*/
emergency: base.emergency ?? null,
};
}
@ -259,12 +277,19 @@ export function nightAt(i) {
* 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.
*
* 🚨 SPRINT18 gate 1 the fourth term is EMERGENCY RATES, and it is deliberately
* a term on THIS function rather than a second fee function. The offer card, the
* dispatch and the invoice all price an emergency through here, so a night that
* pays double on the board cannot pay single on the paper.
*
* @param {object} def tonight's storm def
* @param {number} rep standing AS OF THIS MORNING
* @param {object[]} [constraints] tonight's client constraints
* @param {boolean} [emergency] mid-storm callout double rates (PAY.emergencyRate)
*/
export function calloutFee(def, rep, constraints = []) {
return Math.round(PAY.feeFor(gustPeakOf(def)) * REP.multiplier(rep) * (1 + premiumFor(constraints)));
export function calloutFee(def, rep, constraints = [], emergency = false) {
return Math.round(PAY.feeFor(gustPeakOf(def)) * REP.multiplier(rep)
* (1 + premiumFor(constraints)) * (emergency ? PAY.emergencyRate : 1));
}
/**
@ -341,6 +366,34 @@ export const PAY = {
* and cutting it costs nothing but a row on the invoice.
*/
noCollateralBonus: 20,
/**
* 🚨 **SPRINT18 gate 1 EMERGENCY RATES. RULED ×2.0, and this one is CANON
* rather than my taste.** DESIGN.md line 29 says *"mid-storm, right now, double
* rates"* and ROADMAP arc 2 says *"you arrive MID-STORM at double rates"* in
* those words, since line one of the design. I am not going to re-rule a number
* the document has been carrying for the whole project because it looks large
* next to `feeFor`.
*
* And it is not free money, which is the check I actually ran on it. What you
* give up for it:
* · **no prep.** No shop, no quote, no chance to have bought good steel.
* · **somebody else's hardware**, chosen by somebody cheaper than you, and NOT
* yours to unclip at dawn (see scoreRun's ruling in main.js — an emergency's
* intact hardware does not come home, so half the usual refund line is gone).
* · **the yard's whole collateral exposure already live** from the second you
* arrive, on corners you did not pick, with sixty seconds on the clock.
* A southerly pays $57 rig-for-tonight and $114 as a callout roughly one and
* a half good rigs, for a night where the rig is already wrong and the $180
* carport is the thing letting go.
*
* **UNMEASURED BY PLAY**, cut-first alongside `CONSTRAINT_PREMIUM` and REP,
* and it is the FIRST thing to look at if gate 6 says the emergency is the
* obviously-correct pick every time it appears. Per-job data would be the next
* move (a `pay.emergencyRate` override, exactly as `pay.garden` works), not a
* quiet trim of `feeFor`, which carries measured evidence and long reasons.
*/
emergencyRate: 2.0,
};
/**
@ -387,6 +440,31 @@ export const REP = {
WARRANTY: -0.5,
/** Per collateral event. Down harder: their property beats your hardware. */
COLLATERAL: -1.0,
/**
* **SPRINT18 gate 0 PER CLIENT YOU STOOD UP. JOHN'S RULING: declining a
* booked night costs you. "It adds pressure."**
*
* THE MIRROR OF `CLEAN`, exactly, and the full reasoning is in board.js beside
* `isBooked` (the structural half lives there because board.js cannot import
* this file). The short form: a stood-up client costs you what a flawless
* night earns one night of perfect work, cancelled by one phone call you
* didn't make. Bounded above by `COLLATERAL` on purpose; ranking a broken
* promise above a broken carport would make the invoice argue with itself.
*
* UNMEASURED, and it is the lever. If John's play says the price is wrong,
* move THIS number not `CLEAN`, which the rest of the ladder is measured
* against. a.test pins `STOOD_UP === -CLEAN` so the mirror cannot rot into a
* coincidence.
*
* WHEN it lands: at DAWN, with every other rep movement, and that ordering is
* load-bearing. You stand a client up in the morning; the reckoning is on the
* night's paper. Charging it inside take() would drop your standing between the
* board and the job sheet, and the fee is priced off standing so the offer
* card would advertise $57 and the sheet one click later would quote $56. That
* is the quote-vs-settle lie, moved one card earlier, which is the exact bug
* calloutFee() exists to make impossible.
*/
STOOD_UP: -0.5,
/** Callout-fee multiplier at rep r. Exact at 2dp for every half-step. */
multiplier: (r) => 1 + 0.04 * (r - 3),
};
@ -485,6 +563,10 @@ export function gustPeakOf(def) {
* last dawn's brokenAtDawn corners) {anchorId, hw, cost, night, site, addr, client}
* @property {number} warrantyTotal
* @property {number} feeMult rep multiplier the fee was priced at
* @property {object[]} stoodUp SPRINT18 gate 0 clients this night's take left
* waiting {night, client, addr, offerId, took}; `standUpCost` is the standing
* it cost, already multiplied.
* @property {number} standUpCost
* @property {number} rep after tonight's movement (repBefore/repDelta/repReasons too)
* @property {number} warrantiesToDate cumulative items this week, for the end card
*/
@ -524,6 +606,21 @@ export function createWeek(opts = {}) {
*/
const taken = new Map();
const jobAt = (i) => taken.get(i) ?? nightAt(i);
/**
* SPRINT18 gate 0 **THE CLIENTS YOU STOOD UP.** John's ruling's third
* clause: *"the stood-up client remembers."*
*
* Append-only, in night order, one entry per client stood up:
* `{night, client, addr, offerId, took}` `night` 1-based (it is printed),
* `offerId` the job you took instead, `took` the client you took instead.
*
* A LEDGER, not a flag, and that is the whole of "your work follows you": the
* board reads it to put the memory on that client's next offer card, the job
* sheet reads it if you go back to them, and settle() reads TONIGHT'S entries
* to move standing. One place, three surfaces, no surface deciding anything.
* @type {{night:number,client:string|null,addr:string|null,offerId:string|null,took:string|null}[]}
*/
const standUps = [];
/** @type {Settlement[]} */
const log = [];
@ -543,7 +640,19 @@ export function createWeek(opts = {}) {
function warrantyItems() {
const prev = log[log.length - 1];
if (!prev) return [];
return (prev.rig ?? []).filter((c) => c.brokenAtDawn).map((c) => ({
// 🚨 SPRINT18 gate 1 — `!c.theirs`: a corner that was already on the ground when
// you arrived at an emergency callout books NO warranty. Without this line the
// ledger sends you back, unpaid, to redo a cowboy's work — the game billing you
// for a failure that predates you, which is the exact opposite of what the
// warranty ledger was built to say. main.js's rigRecordFor sets the flag; the
// rep block below skips it for the same reason.
// 🚨 SPRINT18 gate 1.3 [Lane D] — `!c.cut`: THE KNIFE. B's ruling on `cutAway` in sail.js is
// that a cut is a DECISION and emits no `break`, precisely so "warranty must never chase a
// corner the player chose to let go" — but warranty is booked off `brokenAtDawn`, which a cut
// sets, so without this line the ledger sent you back unpaid tomorrow to re-hang a sail you
// deliberately spent to save the client's carport. Same shape as the `!c.theirs` line above it,
// one verb along.
return (prev.rig ?? []).filter((c) => c.brokenAtDawn && !c.theirs && !c.cut).map((c) => ({
anchorId: c.anchorId, hw: c.hw, cost: c.hwCost,
night: prev.night, site: prev.site, addr: prev.addr, client: prev.client,
}));
@ -587,7 +696,7 @@ export function createWeek(opts = {}) {
// 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),
base: calloutFee(def, rep, j.constraints, !!j.emergency),
garden: j.pay.garden ?? PAY.gardenBonusMax,
clean: j.pay.clean ?? PAY.noCollateralBonus,
feeMult,
@ -603,6 +712,23 @@ export function createWeek(opts = {}) {
*/
constraints: j.constraints ?? [],
premium: premiumFor(j.constraints),
/**
* 🚨 SPRINT18 gate 1 **THERE IS NO QUOTE ON AN EMERGENCY, and this block
* is how the paper says so.** The DISPATCH renders the RATE and the fee it
* applies to, because double rates are the reason you get in the ute and
* nothing else from this schedule. No garden bonus, no clean bonus, no
* total: nobody costed this job, and a card printing a total under a job
* nobody quoted would be inventing the one document the fiction says does
* not exist. You find out on the invoice. That IS the drama.
*
* It stays ONE money function all the same (`quote()` for both papers, off
* `calloutFee()`), because the alternative a second `dispatch()` that
* computes its own fee is the quote-vs-settle divergence with a new name.
* The dispatch is a VIEW of this object, never a rival to it.
*/
emergency: j.emergency
? { arriveAt: j.emergency.arriveAt, rate: PAY.emergencyRate, dispatch: j.emergency.dispatch }
: null,
/**
* 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.
@ -639,13 +765,29 @@ export function createWeek(opts = {}) {
offers(priced = null) {
return offersFor({
scripted: nightAt(index), weekSeed: seed, nightIndex: index, priced,
// SPRINT18 gate 1 — the ladder, so board.js can enforce the one-variable
// law at the door of the real game and not only in the suite. board.js
// cannot import this file (week imports board), so the law's input has to
// travel down rather than the law travelling up.
nights: NIGHTS,
});
},
/**
* 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).
* TAKE ONE. The other vanishes and **SPRINT18 gate 0: if the other one
* had a client expecting you, you have just stood them up.** John's ruling,
* landed here because this is the one door both offers go through.
*
* The charge itself is at DAWN (settle reads tonight's `standUps` entries and
* moves standing with the rest) see REP.STOOD_UP for why that ordering is
* not a detail. What happens HERE is the RECORD: who you left waiting, on
* which night, for what.
*
* `offer.standsUp` comes off board.js, so the cost the player read on the
* card is the cost that gets recorded. A hand-made offer with no board
* context (a.test does this deliberately, and so does any future scripted
* install) stands nobody up, which is the honest answer: nothing declined
* anybody.
*
* 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
@ -669,6 +811,17 @@ export function createWeek(opts = {}) {
const night = normaliseNight(offer.night);
Object.defineProperty(night, '_offerId', { value: offer.id, enumerable: false });
taken.set(index, night);
// ⚖️ gate 0 — the promise you just broke, written down before anything
// else can happen. Inside the `if` so the idempotence above covers it
// too: taking twice on one morning must not stand the same client up
// twice, and a double charge for one decision would be the worst kind of
// bug — invisible, and in the direction of punishment.
for (const s of offer.standsUp ?? []) {
standUps.push({
night: index + 1, client: s.client, addr: s.addr,
offerId: s.id, took: night.client ?? null,
});
}
}
return taken.get(index);
},
@ -676,6 +829,23 @@ export function createWeek(opts = {}) {
/** Tonight's taken offer id, or null if the player is on the spine. */
get takenOffer() { return taken.get(index)?._offerId ?? null; },
/**
* SPRINT18 gate 0 the stand-up ledger, read-only.
*
* A copy, because this is the one piece of week state a CARD reads directly
* and a card that could push onto it would be a HUD taking a decision
* (hud.js's own charter, first paragraph).
*/
get standUps() { return standUps.slice(); },
/**
* Has this client been stood up, and when? The memory, per client the
* board's offer card and the job sheet both ask this by name.
* @param {string|null} client
*/
remembers(client) {
return client ? standUps.filter((s) => s.client === client) : [];
},
/** SPRINT17 — the week's draw seed, for the board and for a.test's pins. */
get seed() { return seed; },
@ -693,6 +863,10 @@ export function createWeek(opts = {}) {
// 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();
// gate 0: and the clients forget. A new week that opened with last run's
// broken promises on the board would be the memory outliving the run that
// earned it — the same defect as an uncleared `taken`, one surface along.
standUps.length = 0;
return week;
},
@ -714,7 +888,7 @@ export function createWeek(opts = {}) {
// 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 fullFee = calloutFee(def, rep, jobAt(index).constraints, !!jobAt(index).emergency);
const fee = run.win ? fullFee : Math.round(fullFee * PAY.lostNightShare);
// Per-job override, PAY's default otherwise — and settle MUST read the same
// source the quote does, or the job sheet promises a number the invoice
@ -769,8 +943,15 @@ export function createWeek(opts = {}) {
// cannot cost it BY CONSTRUCTION — the flag needs no exemption here, and
// a.test pins that from both directions.
const rigRec = run.rigRecord ?? [];
const brokeCount = rigRec.filter((c) => c.broke).length;
const dawnBroken = rigRec.filter((c) => c.brokenAtDawn).length;
// SPRINT18 gate 1 — their corners are not your record. A rig you inherited
// mid-storm cannot cost you a clean night for breaking before you got there,
// and cannot cost you standing for still being down at dawn.
// SPRINT18 gate 1.3 — and a corner you CUT is not your record either. Standing is what the
// client thinks of your work; the knife is the work. Its price is the garden (and the fee the
// garden carries), ruled by B and measured by D — not a second, silent bite at the star the
// same night. `!c.cut` twice, for the same reason `!c.theirs` is there twice.
const brokeCount = rigRec.filter((c) => c.broke && !c.theirs && !c.cut).length;
const dawnBroken = rigRec.filter((c) => c.brokenAtDawn && !c.theirs && !c.cut).length;
const collateralEvents = (run.collateral ?? []).length;
const repBefore = rep;
let repDelta = 0;
@ -794,6 +975,18 @@ export function createWeek(opts = {}) {
repDelta += REP.COLLATERAL * collateralEvents;
repReasons.push(`broke ${collateralEvents > 1 ? 'things' : 'something'} of theirs`);
}
// ⚖️ SPRINT18 gate 0 — THE PRICE OF SAYING NO, settled with everything else
// tonight's work earned or cost. take() recorded it this morning; the
// invoice states it tonight, in the same repline that already explains
// every other movement. Note it is ADDITIVE, not exclusive: standing a
// client up and then having a clean night at the yard you went to instead
// is +0.5 0.5 = level, and that is exactly right — you did good work, for
// somebody else, on a night you were promised elsewhere.
const stoodUp = standUps.filter((s) => s.night === week.night);
if (stoodUp.length > 0) {
repDelta += REP.STOOD_UP * stoodUp.length;
repReasons.push(`stood up ${stoodUp.map((s) => s.client ?? 'a client').join(' · ')}`);
}
rep = Math.min(REP.MAX, Math.max(REP.MIN, rep + repDelta));
repDelta = rep - repBefore; // what actually moved, after the clamp
@ -815,6 +1008,13 @@ export function createWeek(opts = {}) {
constraints: job.constraints ?? [],
premium: premiumFor(job.constraints),
taken: taken.has(index) ? (taken.get(index)._offerId ?? true) : false,
// ⚖️ SPRINT18 gate 0, clause 4 — THE INVOICE STATES WHAT IT COST YOU TO
// TAKE THIS JOB. Rides the settlement, like `constraints` and for the
// identical reason: night 5's invoice must still name the client night 5
// stood up when you re-read it on night 7. `standUpCost` is pre-multiplied
// so no surface has to know the constant to print the number.
stoodUp,
standUpCost: REP.STOOD_UP * stoodUp.length,
// SPRINT16 — the ledger. The rig record IS the work history (the night
// computed all of it; the week just keeps it), the warranty items are
// tonight's sheet answered on the invoice, and the rep block is the
@ -822,6 +1022,13 @@ export function createWeek(opts = {}) {
// end card's rich-with-warranties reading without the HUD walking the
// log itself.
rig: rigRec,
// 🚨 SPRINT18 gate 1 — the emergency's own paperwork, carried on the
// settlement like `constraints` so the invoice stays true when re-read:
// whether tonight was a dispatch, what the rate was, whose corners were
// already down (named, unbilled), and whether you used the knife.
emergency: job.emergency ? { arriveAt: job.emergency.arriveAt, rate: PAY.emergencyRate } : null,
inherited: run.inherited ?? [],
cutAways: run.cutAways ?? [],
warranty, warrantyTotal, feeMult,
rep, repBefore, repDelta, repReasons,
warrantiesToDate: log.reduce((n, x) => n + (x.warranty?.length ?? 0), 0)

View File

@ -114,6 +114,27 @@ export function validateSite(site, name = site?.id ?? '?') {
if (!ids.length) bad.push('no anchors at all — nothing to rig to');
const dupes = ids.filter((id, i) => ids.indexOf(id) !== i);
if (dupes.length) bad.push(`duplicate anchor ids: ${[...new Set(dupes)].join(', ')}`);
// SPRINT18 gate 5 (E specified, A blessed): two author-time refusals for the
// second-rust-post landmine. A duplicate STRUCTURE id makes structures.set()
// silently replace the first, which then never renders, bills, or wrecks;
// two structures resolving to ONE collateral key make collateralFor bill and
// wreck whichever matched first. Both are quiet until a yard has two of the
// same thing, which is why they are refused here rather than measured later.
const structIds = (site.structures ?? []).map((s) => s.id);
const dupStructs = structIds.filter((id, i) => structIds.indexOf(id) !== i);
if (dupStructs.length) {
bad.push(`duplicate structure ids: ${[...new Set(dupStructs)].join(', ')}`);
}
const structKeys = (site.structures ?? [])
.filter((s) => s.collateral != null)
.map((s) => s.collateralKey ?? s.id);
const dupKeys = structKeys.filter((k, i) => structKeys.indexOf(k) !== i);
if (dupKeys.length) {
bad.push(
`two priced structures resolve to one collateral key: ${[...new Set(dupKeys)].join(', ')}`
+ ' — give each a distinct collateralKey and point its anchors at it',
);
}
// Lane D's field. `work` is the MECHANISM, not the anchor's type: a post is
// tensioned from a cleat at its base and a tree strop is thrown, but a
// bracket bolted up a bare wall is the one you cannot fake — so that, and
@ -542,6 +563,11 @@ export function createWorld(scene, opts = {}) {
makeStaticAnchor(a.id, a.type ?? 'post', new THREE.Vector3(st.x, heightAt(st.x, st.z) + 2.4, st.z)),
{ work: a.work },
a.node ? null : factoryExtras(a.type ?? 'post'),
// SPRINT18 gate 5 (E specified, A blessed): the site names its own
// collateral target, applied AFTER factoryExtras so the override wins.
// Without it, two same-type structures share one baked key and
// collateralFor bills or wrecks whichever matched first.
a.collateral ? { collateral: a.collateral } : null,
));
}
}
@ -723,7 +749,10 @@ export function createWorld(scene, opts = {}) {
if (!node || !anchor) return false;
anchor.pos.setFromMatrixPosition(node.matrixWorld);
anchor.ratingHint = node.userData?.rating_hint ?? 1;
anchor.collateral = node.userData?.collateral ?? null;
// SPRINT18 gate 5: the bake is the DEFAULT, never a clobber — a site that
// named its own target above keeps it. With no override the chain is
// identical, so every shipped site is byte-unchanged.
anchor.collateral = anchor.collateral ?? node.userData?.collateral ?? null;
// SPRINT14, E's gap 1: some baked anchor nodes are NOT tie-offs — a door
// step, a bench top, a broom grip, a lighting hint. They carry no
// `rating_hint`, and `?? 1` therefore rated them PERFECT: name one in a

Binary file not shown.

Binary file not shown.