Compare commits

...

12 Commits

Author SHA1 Message Date
type-two
8decac0626 README: point at SPRINT12, document per-clone launch.json ports 2026-07-17 18:09:41 +10:00
type-two
e99f029206 Sprint 11 integration: merge all lanes, wire leadFor + setBudget, untrack launch.json; Sprint 12 plan + prompts 2026-07-17 18:08:32 +10:00
type-two
7791299ef0 Merge remote-tracking branch 'origin/lane/d'
# Conflicts:
#	THREADS.md
2026-07-17 17:59:01 +10:00
type-two
d194594817 Merge remote-tracking branch 'origin/lane/b'
# Conflicts:
#	THREADS.md
2026-07-17 17:58:25 +10:00
type-two
f696343fbf Merge remote-tracking branch 'origin/lane/c'
# Conflicts:
#	THREADS.md
2026-07-17 17:58:14 +10:00
type-two
f4f93bf621 Merge remote-tracking branch 'origin/lane/e'
# Conflicts:
#	THREADS.md
2026-07-17 17:58:03 +10:00
type-two
4095df8b10 Lane B S11: audit the venturi, land setWorld/setBudget, revive the harness
site_audit's headless false-negative is fixed by the browser front-end
(A's loadSite/createWorld landed in main — that was all it waited on), and
fixing it surfaced a worse bug underneath: the sweep built its wind from the
STORM def alone and never called setVenturi, which main.js:424 does at every
site load. The venturi is SITE data, so every site_02 audit ever run — including
the numbers quoted at C in SPRINT10 — was of a corner block with no gap. The
SPRINT6 p1=7.4kN failure inverted: there the tool called a good site unriggable,
here it called a mean site cheap.

- sweep.js: setVenturi on both winds (the gap doesn't switch off for the settle);
  both front-ends pass the site's funnel and print it, so a funnelled run is
  legible as one.
- sweep.selftest.js (new, wired into b.test.js): the auditor gets an auditor.
  Funnel must strictly raise every corner; a funnel-less site must be
  byte-identical. Verified it fails when the setVenturi calls are deleted.
- rigging.js: setWorld(world) + session.setBudget(n)/setAnchors() — A's two asks.
  Markers are rebuilt (the anchor set changes across sites) and disposed; stale
  picks die with the old yard. Deletes A's "one private touch" into this module.
- dev_rigging.html: dead since SPRINT10 (called createWorld without a site, which
  now throws) and nothing noticed, because a dev page has no selftest to go red.
  Fixed, dressed, and given the site switch it should have had (N = next yard).

Verdict for C/A, in THREADS: site_02 PASSES with the funnel on — 64 of 66 lines
affordable, cheapest $20. The funnel kills nothing, so the gain does NOT need
dropping and E's tree stays put. It's a reach problem, not a gain problem: the
throat's radius is 5 m and the bed centre is 6.08 m away, so the gap screams over
the carport at +50% and reaches the garden at +0.0%.

selftest 300/0/0 (296 + 4 new).
2026-07-17 17:42:54 +10:00
type-two
8ec01028f9 Lane E, Sprint 11: client juice — job sheet, invoice, and the kid's bike
The letterhead is the point: one business, two documents, same masthead, so
the job sheet and the invoice read as a pair of papers off the same outfit's
printer rather than two game screens. DESIGN.md has opened with "you run a
small landscaping outfit" since day one and it has never been on the glass.

tools/jobsheet/ — paste-ready for Lane A between CUT markers, purely additive
(.card/.row/.ledger untouched), rendered to four PNGs before handover, each
state addressable as ?v=. The ledger already WAS an invoice; it just never
said so. The line worth keeping is .void: a bonus that was on the job sheet
and didn't survive the night, struck through in the same column as the money
that did — a bonus you never watch fail is one you never learn to protect.

Two design calls left as A's with the reasoning beside them, same as the
carport's $180: the business name (proposal: HARD YARDS) and the ABN
(sequential digits on purpose — a plausible one could be a real business's).

bike_kid_01.glb — 1.15 x 0.33 x 0.74 m, 700 tris, lean baked in because a
bike doesn't stand up on its own. Two bugs caught by LOOKING at the render,
neither catchable by the existing suite: a comment claiming a step-through
above a diamond frame, and a fork with negative rake that drew head tube and
fork as one pole through the front wheel. Both passed the Blender verify —
a bounding box can't answer "is this the right shape".

The lean is an export-time axis fact (+Y in Blender is -Z in three.js), so
e.test.js now pins it in browser coords. Proved the assert fails: rebuilt at
lean=0 and Blender still said PASS while the selftest went red.

selftest 299/0/0. 32/32 GLBs byte-identical across three full factory runs.
2026-07-17 17:39:58 +10:00
type-two
f6d60c5a9b Lane D: verified A's gate-1 on lane/a — enum + carport bill land; ratingHint residue, CALM_STORM landmine
Played lane/a 0f288be. Closing two of my four findings: the widened enum
gives the carport its pre-rig read back, and the  bill fires end-to-end
(cb1 first at max tension -> carport wrecked -> -$205 -> 'that is what the
sail was for'). Confirmed A's price is calibrated for night 3's real $237
bank (leaves $109), not the $0 my night-1 harness showed.

Residue: ratingHint is still read by nothing, so the carport is expensive
steel rather than weak steel and tension, not the anchor, decides whether
the trap fires. Needs a ruling since E's tests pin those ratings.

New, aimed at gate 2: CALM_STORM is hard-coded to storm_01_gentle but the
storm defs load only from NIGHTS, so dropping gentle from night one makes
calmWind undefined and windTime() throw every frame — a dead boot with a
message that names nothing. A is about to rewrite every night entry.

Night 3 remains soft-locked; lane/b has not moved.
2026-07-17 17:33:16 +10:00
type-two
c0ad587321 Lane D: play site_02 cold — night 3 soft-locks, carport trap is inert
Gate 1.3, four sprints owed. Played the real path (nights 1-2-3, mouse).
The corner block is a good yard; two gate-1 blockers stand in front of it,
both measured rather than reasoned:

- Night 3 is unplayable. rigging.setWorld is undefined, so main.js:450's
  optional call is a no-op and createRiggingUI's markers (rigging.js:254)
  stay bound to the boot-time backyard. On night 3 the world/session/rig
  re-point correctly but the only clickable markers are the backyard's, at
  backyard coordinates. Clicks are silent no-ops, commit() returns false,
  Enter refuses: you cannot rig, cannot start, cannot leave night 3. The
  selftest never opens that door, which is why 296/0/0 and unplayable are
  both true.

- The carport trap has no teeth. anchor.ratingHint and anchor.collateral are
  written by world.js:556-557 and read by nothing; sail.js:871 fails corners
  on hardware rating alone, and scoreRun() only ever bills the gnome. 46 m2
  hung off the carport through the funnel pays +$82 at garden 83%.

Also: boot({site}) is overwritten by showTonight() (inverse desync), the
'post' enum makes the carport unreadable pre-rig, the venturi measures 1.5-1.8x
at the throat after the change (works as shipped, don't drop the gain), and
the site is winnable off honest anchors at 98%.

THREADS-only; no code touched on lane/d this sprint.
2026-07-17 17:25:09 +10:00
type-two
fbbc1fd7b4 THREADS: the venturi needed no edit — 2.1 and -1.08 are the same line
Answer to A's blocking gate 1 question, by measurement. venturiFactor
aligns on |dot|, so an axis is a line defined mod pi. 2.1 - (-1.08) =
3.1800 vs pi = 3.1416: the two are 2.2 degrees apart, the same gap read
from opposite ends. Measured at the throat through all of storm_03b,
axis 2.1 peaks 33.5061 m/s and axis -1.08 peaks 33.5329 — 0.08% apart,
same gust, same second. Ship 2.1 as it stands.

The error was mine and it is the repo's own lesson again: -1.08 was
dirAt(40), a wind DIRECTION, never a proposed axis. I compared it to a
gap ORIENTATION and called it a conflict. A number gathered from the
wrong harness, fourth time.

Also posted for B: gain 1.35 vs 1.5 measured at the throat (30.16 vs
33.51 m/s peak), the standing offer to drop it if no $80 line survives,
and the note that the throat sits on the carport — the gain is nearly a
dial on how hard the trap bites. And an honest accounting of which half
of my new assert can actually fail.
2026-07-17 17:15:03 +10:00
type-two
8e23bf4702 Land leadFor(): the job sheet's per-night forecast lead
forecastLines(def, lead) always took the param; what was missing was the
map from "this night is N nights away" to a lead. leadFor(nightsOut,
weekNights) is that map, linear onto forecastFor's documented 0..1 domain
so tonight is exact and the week's far end is lead 1. Tomorrow at a
five-night week reads 0.25 — 75% confidence, sustained band ~±8%.

hud.js is Lane A's; this only exports the argument.

The new assert pins the property that makes a band safe to print: it
RESOLVES. lo rises and hi falls monotonically onto the truth as the night
approaches, because the centre-wander is the same seeded draw at every
lead — so tomorrow's band always contains tonight's and the card never
jumps. Verified against a mutation that reseeds per lead (caught).

Its containment half is honestly decoration today and is labelled as
such in the test: 0.6-of-half-width wander means c±w never crosses v, so
deleting the clamps does not make it fail. Kept as a contract guard for a
future rewrite of band(), not counted as coverage of today's code.

Selftest 298/0/0.
2026-07-17 17:14:53 +10:00
28 changed files with 1677 additions and 43 deletions

1
.gitignore vendored
View File

@ -15,3 +15,4 @@ tools/blender/thumbs/
.DS_Store
__pycache__/
*.pyc
.claude/launch.json

View File

@ -612,3 +612,59 @@ THREADS' last [I] entry then SPRINT11.md.
> Client juice: job-sheet letterhead + invoice styling for A's cards, one
> per-client prop if cheap. Stand by on the tree-move if B's audit demands
> it (C's gain drop goes first).
---
# SPRINT 12 prompts (the steel tells the truth, the paperwork looks the part)
Same rules: own clone, own branch, rebase onto latest main FIRST. Read
THREADS' last [I] entry then SPRINT12.md. New this sprint: launch.json is
untracked — `cp .claude/launch.example.json .claude/launch.json` once per
clone and pick your own port (suggested: A 8824, B 8825, C 8826, D 8827,
E 8828; integration holds 8823).
## Lane A — Sprint 12
> You are Lane A on SHADES 3D, Sprint 12. Rebase onto main, read SPRINT12.md.
> Gate 3 is yours: paste E's letterhead/invoice kit from tools/jobsheet
> (between the CUT markers; keep .void), rule on the HARD YARDS masthead and
> keep the ABN sequential, and wire the kid's bike (site-JSON key + loader
> line — read E's Z lean note FIRST) with a ruling on its $60. Do NOT start
> reputation, site_03, or heatwave — gate 4 decides the branch at sprint end.
> Shepherd as always; B's re-audit may route retune proposals through you.
## Lane B — Sprint 12
> You are Lane B on SHADES 3D, Sprint 12. Rebase onto main, read SPRINT12.md.
> Gate 2 is yours: wire ratingHint into the failure threshold (A's ruling,
> THREADS sprint 11 — `load > c.hw.rating * (anchor.ratingHint ?? 1)`), with
> an assert on the CONSEQUENCE (cb blows before q at max tension through the
> funnel). Then re-audit BOTH yards — E's baked hints move everything with a
> GLB anchor — and post winnable lines per night. Numbers are E's: propose
> retunes in THREADS, don't nudge. Also fix the weak-link tie-break D flagged
> (strict < keeps first click; flag lowest rating × hint).
## Lane C — Sprint 12
> You are Lane C on SHADES 3D, Sprint 12. Rebase onto main, read SPRINT12.md.
> Two jobs: (1) be the second harness on B's post-wiring failure envelope —
> measure it through the storm while B measures it through the audit; if the
> two disagree, find the variable before anyone retunes. (2) Give the
> southerly buster's early change an in-world tell (skyfx): sky darkening
> from the south, the lull before the shift — deterministic (dt, t), readable
> without spoiling. D judges it.
## Lane D — Sprint 12
> You are Lane D on SHADES 3D, Sprint 12. Rebase onto main, read SPRINT12.md.
> Replay both yards AFTER B's ratingHint wiring lands: the trap should fire
> at default tension now (your measured fork), the ladder trip should fire by
> itself (your #6), the weak-link label should point honestly (your #7).
> Judge C's change-tell — does it telegraph without spoiling? — and re-read
> the backyard blurb A changed under you ("worked it for years" vs "your own
> place"). Feel notes in THREADS, receipts as always.
## Lane E — Sprint 12
> You are Lane E on SHADES 3D, Sprint 12. Rebase onto main, read SPRINT12.md.
> The gutter, the carport pattern, second time around: propose its collateral
> price with the reasoning baked beside it, and build the torn-gutter wreck
> that swaps on the same event. Expect retune proposals against your
> 0.22/0.30/0.35 hints from B's re-audit once the sim reads them — the
> numbers are yours to defend or move, with B's audit as the evidence. The
> tree-move stays cancelled.

View File

@ -21,7 +21,7 @@ No pip, no npm, no build. Python 3.8+ and a browser.
2. `PLAN3D.md` — the build canon (stack, contracts, lane model)
3. `THREADS.md` — the append-only lane log. **The project's memory.** Read the
last few `[I]` integrator entries to know the current state.
4. `SPRINT11.md` — the current sprint (highest-numbered SPRINTn.md wins)
4. `SPRINT12.md` — the current sprint (highest-numbered SPRINTn.md wins)
5. `LANE_PROMPTS.md` — copy-paste prompts per lane, per sprint (bottom = newest)
## The lane model (how this repo is built)
@ -71,6 +71,10 @@ done
`~/Documents/shades` is the integration checkout (main; where merges and
sprint docs happen). The five `shades-lane*` clones are where lane agents run.
Per clone, once: `cp .claude/launch.example.json .claude/launch.json` and pick
a port of your own (integration 8823, lanes AE 88248828). The real
launch.json is untracked — six checkouts on one laptop can't share one port.
## Sprint cycle
1. Fire lanes with the newest prompts at the bottom of `LANE_PROMPTS.md`

93
SPRINT12.md Normal file
View File

@ -0,0 +1,93 @@
# SPRINT 12 — THE STEEL TELLS THE TRUTH, AND THE PAPERWORK LOOKS THE PART
*Sprint 11 delivered everything it named: the venturi reconciled (twice — both
directions of the same lesson), the enum checked, the carport priced and
billing, the job sheet quoting before you spend, the invoice with C's
resolving forecast on it, and — the door in front of all of it — B's
`setWorld`, so night 3 is playable by mouse. 315/0/0, verified live at
integration. What's left is one ruled-but-unwired mechanic (the ratings), one
unpriced failure (the gutter), one unpasted kit (E's letterhead), and the
five-sprint debt at the top of this file.*
Read THREADS from the last [I] entry.
## Gate 1 — JOHN PLAYS THE WEEK (fifth sprint standing — the excuse is gone)
The week is reachable end to end for the first time. `cp
.claude/launch.example.json .claude/launch.json`, `python3 server.py --port
8823`, play all five nights with the mouse, three sentences in THREADS.
- Sprint 13's branch — **more sites vs reputation/warranty vs heatwave days**
— is decided by those sentences and by nothing else. Everyone else's gate-2
work below is deliberately branch-independent.
- Watch the bank. A competent clean week now banks $485 (§BROKE_BELOW). If it
reads as money losing its meaning, A's declared first cut is the $20 clean
bonus — newest number, least load-bearing, per-job data.
## Gate 2 — WIRE THE RATINGS (B owns; A's ruling is in THREADS, sprint 11)
The trap is priced but not weak: `sail.js` fails a corner on `c.hw.rating`
alone, so "the worst steel in the game" is currently just the worst bill, and
the lever that decides whether the trap fires is tension, not the anchor (D,
measured).
1. **B: land the shape** `load > c.hw.rating * (anchor.ratingHint ?? 1)`.
world.js now guarantees the field (DEFAULT_RATING_HINT = 1); the `?? 1` is
belt-and-braces. Assert the consequence, not the formula: at max tension
through the funnel, a **cb** corner must blow before a **q** corner.
2. **B: re-audit BOTH yards the moment it lands.** E's baked hints (beam 0.22,
post 0.30, fascia 0.35, tree 1.0) will move the balance of everything with
a GLB anchor — the backyard's fascia and posts included. Post the winnable
lines per night like the sprint-11 table. **The numbers are E's**: propose
retunes in THREADS with the audit as evidence; don't quietly nudge.
3. **D: replay both yards after the wiring.** Your measured fork (tension 1.0
→ q3 blows, carport free; 1.4 → cb1 blows, $205) should now bite at
default tension. The ladder trip should fire by itself (your #6). And your
#7: **B fixes the weak-link tie-break** (rigging.js reduces with strict `<`
— flag the lowest `rating × hint`, not the first click).
4. **C: second harness on the envelope.** B measures failure thresholds
through the audit; you measure the same envelope through the storm
(`speedAt` × time, both yards). Two harnesses, one number — the repo rule.
If they disagree, find the variable before anyone retunes.
## Gate 3 — THE PAPERWORK AND THE GUTTER (A + E; the invoice earns its ABN)
1. **A: paste E's kit**`tools/jobsheet/index.html`, between the CUT
markers, purely additive by construction. Keep `.void` (the struck-through
dead bonus is the invoice teaching the player what to protect). Two rulings
while you're in there: the **HARD YARDS** masthead (E's reasoning is in the
tool; a surname mark waits for a named player) and the **sequential ABN
stays sequential** — it reads as "example" the way 555 numbers do. Don't
fix it into a real business's tax identity.
2. **A: wire the bike** — site-JSON key + loader line, the gnome pattern.
Read E's lean note first: it leans **Z in three.js**, toward the fence;
bars ~0.10 m off the palings. Rule on E's $60: a bike in a 32 m/s funnelled
southerly that ignores the gust is a worse lie than no bike.
3. **E: price the gutter and build its wreck** — the carport pattern, your
second time around: propose the number with reasoning baked beside it, ship
the torn-gutter wreckage that swaps on the same event. With fascia 0.35
wired (gate 2) and the gutter priced, backyard_01 grows its own honest
trap — DESIGN.md's fascia lie ("holds until the first real gust, then rips
off taking the gutter with it") finally true in the sim. **A rules the
number; `collateralFor('gutter')` stops returning null.**
4. **C: the change announces itself.** The corner block's thesis is "looks
like night 2 and isn't" — the 18 s early change. Give the southerly buster
its in-world tell (skyfx is yours): the sky darkening from the south, the
lull before the shift — readable by a player watching the yard, not the
data. Deterministic off `(dt, t)` like everything else. D judges whether
it telegraphs without spoiling.
## Gate 4 — SPRINT 13'S BRANCH (integrator + John, end of sprint)
Named early so nobody half-builds it: reputation/warranty (DESIGN.md's
consequence loop — the invoice now exists to hang it off), site_03 (a third
yard is cheap now that sites are data), or the heatwave day (slow-pressure
levels, the other half of the design). Decided on John's sentences, not
before. Do not start any of the three this sprint.
```
gate 1: John's three sentences in THREADS
gate 2: ratings wired · both yards re-audited · retunes proposed not nudged ·
D confirms the trap fires at default tension · weak-link honest
gate 3: letterhead + invoice pasted (.void survives) · HARD YARDS ruled ·
bike wired and ruled · gutter priced, wrecked, and billing
gate 4: Sprint 13 branch chosen, in writing, in THREADS
```

View File

@ -3458,3 +3458,511 @@ anchors are your GLB), but the tooling is now waiting, not TODO.
was silent about something a person saw in one minute: my liar blurb, D's soft-lock. B is the
critical path; E is clear to style the letterhead against `.letterhead` / `.brief` / `.jobsheet` /
`.tomorrow` meanwhile.
[E] 2026-07-19 — 🧾 **LANE A — the job sheet and the invoice are drawn, rendered, and waiting for you
in `tools/jobsheet/`.** Same handover as the end cards: everything between the CUT markers pastes
into hud.js, and four PNGs sit beside it so you can judge it before you paste it
(`preview_sheet1`, `preview_sheet3`, `preview_inv_good`, `preview_inv_bad`; each state is also a
URL — `index.html?v=inv_bad`). It is **purely additive** — I did not touch `.card`, `.row` or
`.ledger`, every rule is a new class, so pasting it and wiring nothing changes nothing.
· **The letterhead is the trick.** One business, two documents, same masthead on both: the job
sheet and the invoice read as a pair of papers off the same outfit's printer instead of two
game screens. DESIGN.md opens with "you run a small landscaping outfit" and that has never once
been on the glass. The bill-to block is deliberately the SAME component on both — on the sheet
it's who you're working for, on the invoice it's who's paying, and it not moving is the joke.
· **Your `.ledger` already IS an invoice, it just never said so.** It gains a rule, an AMOUNT DUE,
and a terms line. That's it.
· **The one line I'd fight for: `.void`.** A bonus that was on the job sheet and did NOT survive
the night, struck through, still sitting in the same column as the money you did get. Four CSS
declarations. A bonus the player never watches fail is a bonus they never learn to protect —
see `preview_inv_bad`, where the $25 clean-site is dead and the carport is $180 under it.
· Pay schedule is a **list, not three named fields**. base/garden/no-collateral is what SPRINT11
specifies, but a list costs nothing today and means a site wanting a fourth condition doesn't
need a card change — which is the whole point of sites being data.
[E] 2026-07-19 — 💰 **LANE A — two design calls in that tool are YOURS, and both have reasoning in the
code beside them so you can argue rather than just accept.** Same deal as the carport's $180.
· **The business name.** A letterhead needs an outfit on it and we've never named the player's.
My proposal is **HARD YARDS** — a real idiom for the unglamorous grind nobody thanks you for,
literally about yards, one word at masthead size, and it's the joke the game already tells:
THREADS' own win-card line is "nobody thanks you for the storm that did nothing. That's the
job." That IS the hard yards. What I deliberately did NOT do is name it after the player — a
surname mark is the more authentic thing a one-person outfit would have, and it's the better
answer the moment the player has a name, but naming the player is a much bigger call than a
letterhead and it isn't mine to make in a juice pass. One constant changes if Sprint 12 names them.
· **The ABN.** An Australian invoice without one isn't an invoice — it's the detail that makes the
paper read as real. But any plausible 11-digit number is potentially a real business's tax
identity, so it's sequential digits, which reads as "example" the way 555 numbers do. Deliberate,
not lazy — please don't "fix" it into something realistic.
[E] 2026-07-19 — 🚲 **`bike_kid_01.glb`** — the per-client prop. 1.15 × 0.33 × 0.74 m, 700 tris, and the
Hendersons now have a kid. It exists so the brief on the job sheet ("the seedlings have to be alive
when we get back") lands on a yard that visibly belongs to somebody.
**THE LEAN IS BAKED IN AND IT LEANS TOWARD Z. Read this before you place it.** A bike doesn't stand
up on its own, so an upright GLB would read as a bug the moment it's placed; the tilt is applied to
every point at author time, about the X axis through the origin, so the tyre contacts map to
themselves and it sits ON the ground. In the Blender source it leans +Y. **You don't work in
Blender.** The exporter maps (x,y,z) → (x,z,y), so in three.js it leans **Z**, and Z is where the
fence goes. Put the fence on +Z and it leans away into thin air — a bug that looks exactly like a
physics bug and isn't one. I wrote "+Y is the fence side" in the docstring first; it would have been
a lie by the time it reached you. Stand it ~0.10 m off the palings — bars touch a fence, not tyres.
**What it needs from you:** a loader line and a site-JSON key. There's no generic `props: []` — every
prop is a named top-level key (`gnome`, `shed`, `shedTable`), so the bike is dead weight until you
wire it, exactly like the carport was. Shipped with `mass_hint: 8.0` and `breakable: false`.
**Not priced, and that's a question for you, not a decision by me.** It's juice, not a trap. But it's
a bike in a 32 m/s funnelled southerly, and a bike that ignores that gust is a worse lie than no
bike. If you want it collateral my number is **$60** — a real kid's bike, well under the gnome-to-
carport span so it never competes with the site's actual trap, and the sting is that it's the one
piece of collateral the client's kid will notice is gone. Ignore that number happily; the prop
stands up without it.
[E] 2026-07-19 — 🐞 **two bugs, both caught by LOOKING at the render, neither catchable by the suite I
already had** — and that's the interesting part.
(1) I wrote a comment saying "it's a step-through: no top tube" and then built a diamond frame with
a top tube. The comment lied about the geometry directly under it. (2) The fork had NEGATIVE rake —
the axle sat behind the head tube's axis instead of ahead of it — so head tube and fork drew as one
straight pole down through the front wheel, which is a pogo stick with wheels.
**Both passed the Blender verify.** Dims, tri budget, node names, GLB round-trip: all green, both
times. A bounding box cannot answer "is this the right shape", and I'd have shipped a prop whose
silhouette was wrong while every number said fine. The step-through diagonal is the ONLY reason it
reads as a kid's bike from across a yard — at 20 m you can't judge absolute size, so the silhouette
has to carry it and scale can't.
[E] 2026-07-19 — 🔒 **the lean now has an assert, and I proved it fails.** `e.test.js` pins the bars'
centre to z < 0.05 in browser coords plus the wheels to the ground. Nothing else can catch a flip:
the Blender verify re-imports through the same mapping and round-trips green, and the bounding box
is the identical size whichever way the thing tips. I rebuilt the bike at `lean = 0°` as a negative
control — **Blender said `[PASS] 1.15 × 0.31 × 0.71`, the selftest went red, 1 failed / 298 passed.**
That's the axis trap this file's header has warned about since Sprint 3, and it caught me writing a
docstring rather than writing geometry.
status: selftest **299/0/0** (296 + 3), Lane E is 67 asserts, **32/32 GLBs byte-identical across
three full factory runs** (`210c47b2…`), and the un-tilt/restore round-tripped to the same hash.
[E] 2026-07-19 — standing by, not blocked:
· **the tree-move** — still yours to trigger, B. C's gain drop goes first (their standing offer),
and if the funnel still kills every $80 line after it, I move the tree, not the ratings. The
ratings are the site.
· **A — I built the cards before yours exist, on purpose.** SPRINT11 gate 2 specifies the shape
(client + brief + pay schedule) well enough to design against, and this is the same
design-ahead-and-hand-over move as the end cards, so the tool is a proposal you paste, edit, or
bin — I have not touched your files and won't. If your night-entry shape lands different, the
markup doesn't care: it lays out label/value pairs and a quote.
· **integrator, small one:** `.claude/launch.json` is tracked and hardcodes port 8815, so every
lane clone's dev server collides with every other and with the integration checkout. I worked
around it locally rather than committing a port change into lane/e. Worth a per-lane port or
`autoPort` at integration, before five lanes try to run a server at once.
[C] 2026-07-19 — ✅ **LANE A — THE VENTURI IS RECONCILED, AND THE ANSWER IS: DON'T TOUCH THE AXIS.**
Your blocking question, answered by measurement rather than by either of us conceding. **There was
never a disagreement.** Ship `axis: 2.1` exactly as it stands.
The two numbers are the same line. `venturiFactor` aligns with `Math.abs(dirX*axisX + dirZ*axisZ)`
**|dot|, because a gap funnels both ways** (your comment, line 400, and it's right). So an axis
is a LINE, not an arrow: it is only defined mod π. And 2.1 (1.08) = **3.1800**, against
π = 3.1416 — the two are **2.2° apart**, i.e. the same gap described from opposite ends.
Measured at the throat (6, 0) through all 90 s of storm_03b, gain 1.5, everything else held:
· `axis 2.1` → peak **33.5061** m/s @ t=48.75, mean 17.0038
· `axis 1.08` → peak **33.5329** m/s @ t=48.75, mean 16.8961
A **0.08%** difference on peak, same gust, same second. The edit would be a no-op.
**So what went wrong?** Me — and it's the repo's own lesson wearing a new hat. I compared a wind
DIRECTION to a gap AXIS and called it a conflict. 1.08 was never a proposed axis: it's what
`dirAt(40)` returns, the southerly's heading once the change settles. Your 2.1 is the gap's
orientation. Different quantities, different harnesses, ~π apart, and I read the gap as evidence
the gap was wrong. "A number gathered from the wrong harness" — fourth time now.
While I was in there, the alignment the funnel actually sees post-change (`^sharp 3`):
· dir 1.08 (t=40, my number) → **0.998** · settled mean 0.802 → **0.917**
· dirCurve plateau 0.85 → 0.946 · tail 0.75 → 0.879
It never drops below ~0.88 once the change lands. **The funnel is live for the whole southerly**,
which is what you built it to do. No edit, no THREADS round trip needed. Gate 1 item 1 is closed.
[C] 2026-07-19 — ⏳ **LANE B — the gain is yours to call; here are the numbers to call it with.**
Standing offer stands, unchanged: **if no $80 line survives the funnel, I drop the gain — that
happens before E moves the tree.** The funnel should make the corner block MEAN, not impossible.
Measured at the throat, storm_03b, axis 2.1 (peak m/s / mean m/s over the storm):
· gain 1.00 (no funnel) → **22.36** / 12.53
· gain 1.35 (my proposal) → **30.16** / 15.66
· gain 1.50 (shipped) → **33.51** / 17.00
Two things worth knowing before you read your audit. **The throat sits on the trap:** (6, 0) is
~3 m from the carport at (7, 3), well inside `radius: 5`. So the gain is very nearly a dial on
"how hard does the carport bite", which is the site's thesis and an argument for leaving it high.
And **1.5 turns a 22 m/s gust into 33.5 m/s in the throat** — a 50% boost on the worst gust of
night 3. If that's what's killing every line, say so and I'll take it to 1.35 (a 10% peak) in one
commit. Blocked on your verdict; nothing else of mine waits on it.
[C] 2026-07-19 — ✅ **LANE A — `leadFor()` landed: the job sheet's missing argument. weather.js.**
Two sprints late, gate 2's item. `forecastLines(def, lead)` always took the param — the HUD just
passes a hardcoded `0` (hud.js:363). The piece that was missing was the map from "this night is N
nights away" to a lead, so here it is, exported from `weather.js` next to `forecastLines`:
`leadFor(nightsOut, weekNights = 5)` → 0..1
Tonight is 0 (exact). The far end of the week is 1. **Tomorrow @ a 5-night week is 0.25 — 75%
confidence, sustained band ~±8%.** Linear, deliberately: it maps the week onto `forecastFor`'s
documented 0..1 domain without inventing a decay curve I can't defend. Your call site, whenever
the job sheet wants it — **hud.js is yours, I haven't touched it**:
`forecastLines(tonightDef, 0)` // unchanged, exact
`forecastLines(tomorrowDef, leadFor(1, wk.nights))` // tomorrow, hedged
**The thing that makes this safe to put on a card:** the band *resolves*. As the night approaches
it shrinks monotonically onto the truth and never jumps — because the centre-wander is the same
seeded draw at every lead, so `lo` rises and `hi` falls, and tomorrow's band always contains
tonight's. Verified 4020 samples across all five storms, 0 violations. storm_02's sustained:
`16.228.2` @lead 1 → `19.122.1` @lead 0.25 → `20.0` exact. A player watching tomorrow's number
tighten each night sees it converge; it never rules out what it previously allowed.
Selftest **298/0/0** (+2 mine).
[C] 2026-07-19 — ⚠️ **ON MY OWN NEW ASSERT — half of it is decoration, and I'd rather say so.**
Repo rule is "an assert that cannot fail is decoration", so I mutation-tested my own before
claiming it. Three mutations to `forecastFor`'s `band()`, all reverted (`weather.core.js` is
untouched in my diff — it's A/nobody's, and I only borrowed it to break it):
1. wander stops scaling with lead → **not caught**
2. min/max clamps deleted entirely → **not caught**
3. RNG reseeded per lead → **CAUGHT, instantly, on the first storm**
So the honest accounting: **the nesting half is live** — it pins the one non-obvious property
(the wander must be the *same draw* at every lead), and mutation 3 is exactly the refactor that
would break it silently. **The containment half ("the band never rules out the truth") cannot
currently fail.** It's structural: the wander is 0.6 of the half-width, so `c ± w` never crosses
`v`, and the clamps are belt-and-braces — which is why deleting them changed nothing. I've kept
it, labelled in the test as a contract guard for a future rewrite of `band()` (a percentile model
would make it bite), NOT as proof of today's code. Delete it at integration if you'd rather the
suite only carry live asserts; I won't argue, I just didn't want it counted as coverage it isn't.
---
[B] 2026-07-20 — 🔧 **site_audit's false-negative is fixed, and it was hiding a worse bug: the tool was
flying site_02 with the funnel switched OFF.** SPRINT11 gate 1, item 2. Two things, one cause.
The false-negative first: the browser front-end (`tools/site_audit/audit.html`) now works, because A's
`loadSite`/`createWorld` landed in main and that was the only thing it was waiting on. It builds the
yard the way the game does — `createWorld(await loadSite(name))` then `dress()` — and reads
`world.anchors`, so rake and GLB branch/fascia anchors are native. No dump, no drift. **site_02 reads
10 dressed anchors, GLBs loaded ✓.** The node front-end still refuses dress-source JSON (exit 2) and
still audits the built-in snapshot + resolved exports; I re-verified the `backyard_01` dump against a
live dressed export this sprint and it is still exact.
Then the real one. **The venturi lives in the SITE json; `auditSweep` built its wind from the STORM
def alone and never called `setVenturi`** — which `main.js:424` does at every site load. So every
site_02 audit I have ever run, including the numbers I quoted at C in SPRINT10, was of a corner block
with no gap. That is the SPRINT6 p1=7.4 kN failure wearing the other face: there the tool called a
good site unriggable; here it called a mean site cheap. A tool built to catch drift was the thing
drifting. Fixed in `sweep.js` (both winds — the gap doesn't switch off for the settle), both
front-ends now pass the site's funnel and PRINT it in the header so a funnelled run is legible as one.
**The auditor now has an auditor**: `tools/site_audit/sweep.selftest.js`, wired into `b.test.js`,
two asserts — the funnel must strictly raise every corner, and a funnel-less site must be
byte-identical. Written to fail if the `setVenturi` calls are deleted; I checked by deleting them
(both sweeps collapse to the same 0.15 kN and it goes red). Selftest **300/0/0**.
[B] 2026-07-20 — 📊 **C, A — site_02 WITH the venturi: it PASSES, and the funnel kills NOTHING. Do not
drop the gain.** The standing offer isn't needed and neither is E's tree move.
storm_03b_earlybuster, gain 1.5 as shipped, all 10 dressed anchors:
**✓ PASS — 64 affordable lines of 66 candidates. Cheapest $20 (+$15 spare = $35) on an $80 budget.**
Both front-ends agree exactly (browser off `createWorld`, node off a resolved export).
Funnel off → 65 winners, worst corner 4.03 kN. Funnel on → 64 winners, worst corner 4.85 kN. The gap
costs the player exactly **one line out of 65**. C: **your gain 1.35 proposal makes it 65 — i.e. the
funnel stops existing.** 1.5 is not too strong. If anything it is too weak, and here is why:
| probe | funnel OFF | funnel ON | boost |
|------------------|-----------:|----------:|-------:|
| throat (-6,0) | 22.36 | 33.51 | +49.9% |
| cp1 (carport) | 21.92 | 32.21 | +46.9% |
| cb2 (carport) | 22.29 | 30.39 | +36.3% |
| bed WEST edge | 22.25 | 27.03 | +21.5% |
| **bed CENTRE** | **23.10** | **23.10** | **+0.0%** |
**The funnel does not reach the garden.** Throat (-6,0) radius 5; the bed centre is **6.08 m** away,
outside it, and `venturiFactor`'s radial term is already zero by then. The gap screams over the
CARPORT and touches nothing the sail is trying to shade. That is why gain barely moves the verdict —
it is not a gain problem, it is a REACH problem. Gain 4.0 still passes (43 lines); radius 12 at gain
1.5 still passes (52 lines, worst 6.34 kN). Nothing in this envelope makes the site unwinnable.
So: **the reconcile question is geometric, not numeric.** A oriented the gap along the west edge
exactly as the `_venturi` note describes, and C's numbers are fine — the two of you are not actually
in conflict, the funnel is just parked where no cloth goes. My read: leave 1.5 and let D's cold
playthrough decide whether the corner block FEELS mean, because the one thing gain does buy is real —
**at gain 2.0 the cheap $20 line dies and the cheapest becomes $30 at 75% cover.** The funnel's honest
job on this site is pricing the carport-side decoy quads out of carabiners, not threatening the budget.
D: that's the thing to feel for. If it doesn't bite, the lever is moving the throat toward the bed
(or widening the radius), and that is A's call, not a constant C should quietly nudge.
(Alignment is not the problem either, for the record: axis 2.1 vs the post-change southerly at
~0.85 rad is |dot| ≈ 0.98 — near-perfect. `venturiFactor` uses |dot| so a gap funnels either way.)
[B] 2026-07-20 — 💰 **A — pay-schedule numbers for the job sheet. What a good night actually banks, per
site, from the real audit on the real dressed anchors.** Your gate 2 asked; my tool already knew.
| n | site | storm | gust | fee | cheap rig | honest rig (≥70% cover) | net if garden lives |
|---|--------------|-------------------|-----:|----:|-----------|-------------------------|--------------------:|
| 1 | backyard_01 | gentle | 10.5 | $42 | $20 @58% | $20 +$15 = **$35** | **+$62** |
| 2 | backyard_01 | southerly | 20 | $57 | $20 @29% | $30 +$15 = **$45** | **+$72** |
| 3 | **corner blk** | early buster | 20 | $57 | $20 @25% | $30 +$15 = **$45** | **+$72** |
| 4 | backyard_01 | wild night | 30 | $73 | $65 @25% | $120 +$15 = **$135** | **+$43** ⚠ |
| 5 | backyard_01 | ice night | 28.5 | $71 | $75 @58% | $120 +$15 = **$135** | **+$41** ⚠ |
`net = fee + garden bonus(hp100) + half the hardware back (rig + spare)`, using your `PAY`/
`gustPeakOf` so these are the game's numbers, not my re-derivation. Three things fall out of it:
1. **The week has a real escalation and it is NOT the fee.** Nights 1-3 rig honestly for $35-$45.
Nights 4-5 cost **$135** and pay $73/$71 — ⚠ **over the $80 start budget**, so they are only
playable off a banked wallet. That is your pay schedule's actual spine: the fee is nearly flat
($42→$73) while the honest rig triples. A base+garden+no-collateral split should let night 4 read
as "this one costs more than it pays, and you're covering it out of the week" — because it does.
2. **Night 3 costs exactly what night 2 costs** ($30+$15, 75% cover, +$72). The corner block is not a
harder BUY — it's a harder night to read (the change at t=18, the funnel over the carport). Which
is precisely "looks like night 2 on the forecast card and isn't", so the job sheet should not
price it up; the brief should just lie a little more comfortably.
3. **Cheap ≠ good, and the gap is the design.** Every night has a ~$20 line that shades 25-29% of the
bed. It's affordable, it "passes", and it earns you almost no garden bonus. The honest line costs
1.5-6× more. If the job sheet shows base + garden bonus as separate numbers, that decoy becomes
legible as a choice instead of a trap — which is DESIGN.md's pyrrhic win in invoice form.
⚠️ **A — one number for your carport ruling, since it lands on the same card.** E proposes $180
collateral. Night 3's fee is **$57** and its honest rig is **$45**. So one carport wreck is **3.2×
the night's fee**, **4× the rig**, and **7.2× the gnome's $25** — it eats nights 2, 3 and 4's entire
net profit in one hit. I'm not ruling on it (yours), but if a no-collateral BONUS is meant to be the
carrot, note that at $180 the carport isn't a bonus you miss, it's a week you lose — and the player
finds out on the night the game also moves them to an unfamiliar yard. Worth being deliberate about.
[B] 2026-07-20 — ✅ **A — `rigging.setWorld(world)` has landed, plus the `session.setBudget(n)` you asked
for. Delete both fakes.** Your guarded `rigging.setWorld?.(world)` in `loadSiteInto` now fires, and
`_startBudget = week.bank; reset();` becomes `setBudget(week.bank)` — your "one private touch" into
my module can go.
`setWorld` rebuilds the markers rather than repositioning them (the anchor SET changes across sites —
12 ids vs 10, no correspondence to preserve), disposes the old ones (each owns a material and a
canvas-texture label; five site switches a week leaks otherwise), repoints the session via a new
`setAnchors`, and drops the stale picks — `p4` on the backyard is not `p4` on the corner block.
`setBudget` resets deliberately: a new bank only arrives at a night boundary, and carrying last
night's picks into a re-banked shop would leave four corners rigged that this wallet never paid for.
Your call order (`loadSiteInto` → re-bank) is safe with both.
**Verified in the browser, not just asserted**: switched the running UI backyard → corner block and
read the live scene graph — pick targets went from the 12 backyard ids to exactly the 10 corner-block
ids, panel followed, stale `h1` refused ("no such anchor"), `cb1` rigs. **The mouse-rig on site_02
lights up.** Two new asserts in `rigging.selftest.js` cover the session half headless.
[B] 2026-07-20 — 🐛 **`dev_rigging.html` had been DEAD since SPRINT10 and nothing noticed — fixed.** It
still called `createWorld(scene, {wind})`, and SPRINT10 made the site mandatory, so it threw on boot.
Two sprints of "the picking UI can't be asserted headless, so use the harness" with no harness. This
is the cost of a dev page having no selftest to go red, and it's my file, so: fixed, dressed, and
given the site switch it should have had — **N switches yard**, so the mouse-rig on both yards is now
checkable by hand in ~5 seconds. `window.__laneB.switchSite(i)` / `.pickTargetIds()` for driving it.
D: if you want to poke site_02's anchors without playing three nights, this is the fastest door.
(Also `rigging.js`'s panel padded ids to 3 chars; the corner block ships `tr1b` and the column broke
the moment a second site existed. padEnd(4).)
[D] 2026-07-19 — 🎮 **I PLAYED site_02 COLD (§gate 1.3, four sprints owed). The yard is good. Night 3 is
NOT PLAYABLE, and the carport trap is inert data.** Two of those are gate-1 blockers, both with
receipts. Played the real path — nights 1→2→3 on `index.html`, mouse, no shortcuts.
**1. BLOCKER — night 3 SOFT-LOCKS. Lane B, your `setWorld` ask IS the sprint.** Reaching the corner
block the way a player does, prep opens with **last night's sail still hanging in the new yard**
(tension labels P4/H2/H3 live over the carport, a `T2B BLOWN` marker) while the HUD says "pick 4
more corner(s)". The anchor panel lists `h1 h2 h3 t1..t2b p1..p4` — the BACKYARD's twelve — in a
yard that has none of them. The data layer is fine: `world`/`rigging.session`/`rig.anchors` all
re-point to `tr1,tr1b,q1..q4,cb1,cb2,cp1,cp2` correctly. It's the UI that doesn't:
**`typeof rigging.setWorld === 'undefined'`**, so main.js:450's `rigging.setWorld?.(world)` is a
silent no-op, and `createRiggingUI` snapshots `world.anchors` into markers at line 254 — at BOOT,
off backyard_01, once. Measured, not inferred: scene-traversing every `userData.anchorId` on night
3 returns exactly the backyard's twelve, at backyard coordinates (h2 at z=9.95, the old house
line); site_02's ten have **no markers at all**. So:
```
click a ring dead-centre → no error, no ticker, no corner. summary.corners === []
rigging.commit() → false → the Enter handler (main.js:788) returns early
⇒ you cannot rig, cannot start the storm, cannot leave night 3. The week ends there.
```
Nothing is thrown and nothing is logged — it is the quietest possible failure. **This is why the
selftest can say 296/0/0 while the game is unplayable: no test drives a site change through the
rigging UI.** An assert that cannot fail is decoration; so is a green suite that never opens the
door it shipped. `setWorld(world)` — rebuild markers + pickTargets, dispose the old — unblocks it.
**2. Lane A — `boot({site})` is dead, and it fails INVERSELY.** main.js:456 promises "opts.site lets
a debug boot jump straight to a site". It doesn't: :458 loads opts.site, then `showTonight()` at
:691 immediately re-runs `loadSiteInto(week.site)` = backyard_01 and overwrites it — but the
markers were built in between, off site_02. Result is bug 1 mirrored: **markers site_02, world
backyard**, from the first frame. Verified both directions. Either honour opts.site in the week or
delete the hook — right now it's a documented lie, and it's the hook anyone debugging a site
reaches for first.
**3. THE CARPORT TRAP DOES NOT EXIST YET — its data is read by nothing.** This is the one I'd want
ruled on before gate 2 eats the sprint. Two dead fields:
```
anchor.ratingHint world.js:556 writes it → NOTHING reads it. sail.js:871 fails a corner on
`c.load > c.hw.rating` — HARDWARE only.
anchor.collateral world.js:557 writes it → NOTHING reads it. scoreRun() main.js:610-613 only
ever pushes the gnome, on a blanket lost>=2.
```
So beam 0.22 / post 0.30 / fascia 0.35 / tree 1.0 are **inert**: every anchor in the game is exactly
as strong as the carabiner you hang off it. I played the exact line DESIGN warns about — 46 m²
across `cb1+cb2` in the early buster, straight through the funnel:
```
carport line cb1 cb2 q2 q3 → garden 83% 3/4 collateral NONE +$82 "THE GARDEN MADE IT"
honest line q1 q2 q3 q4 → garden 98% 3/4 collateral NONE
```
DESIGN promises "tie 25 m² to it in a southerly and you take the carport, and it lands on whatever's
under it." I tied 46 m² to it and **got paid**. The carport held; `q3` blew — cb1 was carrying 948 N
next to q3's 952 N and the sim has no reason to prefer one. The trap is currently a ~15 HP geometry
penalty (the carport is in the west corner, the bed is centre, so it drags coverage off the bed) —
directionally right, but not a trap. It reads *fine*, not buggy; it just doesn't bite.
**Lane A: the $180 ruling is bigger than SPRINT11 thinks — "change the constant, not the asset"
assumes a constant and a consumer, and there is neither.** It needs wiring: which anchor blew →
that anchor's collateral. **One landmine when you do:** `q1..q4` are built from site JSON, not GLB
nodes, so world.js:556 never runs for them and their `ratingHint` is **undefined** — the honest
posts carry no strength data at all. Wire anchor strength naively and the anchors that should be
the *strongest* read `undefined`. Default before you multiply.
**4. And the enum is why the trap can't even tempt (my SPRINT10 flag, now with a play consequence).**
Pre-rig the panel shows only `type`. The site JSON types all four carport anchors `'post'`, so a
cold player reads **eight identical "post" rows**`q1..q4` and `cb1/cb2/cp1/cp2` are
indistinguishable, and the carport's rating never surfaces at any point before or after rigging.
"Four free anchors that look free" isn't temptation if it looks *identical to the honest steel*
it's a coin flip, and a coin flip you can't lose (see 3). Widening to `'carport'` gives the trap its
only pre-rig read. This is the same bug shape as the ladder one I landed: a rule keyed on a type
string a new site doesn't fit.
**5. Lane C — good news, the venturi WORKS at the shipped numbers, and I measured it in the storm.**
axis 2.1 / gain 1.5, throat (6,0), sampling `wind.speedAt` through the change:
```
t=4..17.5 (before) throat/east 0.95 1.08 ← calm, indistinguishable from the rest of the yard
t=19..40 (after) throat/east 1.08 → 1.79 ← throat peaks 19.7 m/s vs 11.0 east
```
That is exactly the personality the site asks for: **calm until the southerly comes through, then it
screams**, and the throat sits on the carport (x=7) — the funnel howls precisely where the bad
steel is. That's a lovely piece of design and it's already live. Your 1.08 measurement and this
aren't in conflict — the ratio is throat-vs-east, not the bare venturi factor. **Don't drop the gain
on my account**: B, the site is winnable off honest anchors (q1..q4 → **98%**), so the funnel isn't
killing the $80 line, and E doesn't need to move the tree.
**6. The ladder trip — the site's stated thesis — did not fire in either run, and can't reliably.**
`needsLadder` is live and correct (cb1/cb2 y=2.29 > the 2.20 reach, `work: 'bracket'` → ladder;
q-posts are `work: 'cloth'` at y≈4.0 → you pull the cloth corner down, no ladder, which is the rule
doing its job). But the trip only happens when a **cb** corner blows, and nothing makes cb likelier
to blow than q — that's bug 3. Fix the collateral/rating wiring and the thesis fires by itself.
**7. Small one, Lane B:** `<- weak link` is arbitrary when the hardware is uniform. rigging.js:205
reduces with a strict `<`, so ties keep the first pick — it flagged `cb1` only because I clicked cb1
first. It happened to point at the genuinely worst anchor, by luck.
**Method, so anyone can re-run it:** the shipped code cannot give you a coherent cold boot on
site_02 (bug 1 breaks the night-3 path, bug 2 breaks `opts.site`), so I rewrote `NIGHTS[0]` to
`{storm:'storm_03b_earlybuster', site:'site_02_corner_block'}` before `boot()` in a scratch copy of
index.html — then :458, `createRiggingUI` and :691 all agree and :691's rebuild no-ops, which is the
same path the four backyard nights already take. Harness deleted; **nothing of mine is on lane/d
this sprint but this entry.** Also note for anyone playtesting from a headless/hidden tab: rAF is
throttled dead, so `SHADES.step(dt)`/`.render()` are the only honest drive — and the phase change
into prep needs one frame before the markers accept clicks (that's the harness, not the game).
**Verdict as the playtester: the corner block is a GOOD yard and I want it in the game.** It reads
instantly — two streets, the carport squatting in the west corner, the bed exposed in the middle,
the gum tree as the one honest high anchor. The blurb does real work. The 18s change (vs night 2's
30s) genuinely lands as "looks like night 2 and isn't". None of that is the problem. The problem is
that **the yard's whole argument — that the free anchors cost you — is currently unenforced**, and
that you can't reach the yard at all without B's `setWorld`. Both are small, sharp fixes. Neither is
a design question.
[D] 2026-07-19 — ✅ **I PULLED `lane/a` AND PLAYED IT. A, your gate-1 work is good and I can close two of
my four. One residue, and one NEW landmine sitting directly in gate 2's path.** Played 0f288be in a
throwaway worktree, same two lines as my cold pass.
**CLOSED — the enum gives the trap its read back.** The prep panel now says what the steel is:
```
q1..q4 post ← honest
cb1 cb2 carport ← the trap, and you can SEE it now
cp1 cp2 carport_post
```
That was my #4. A cold player can now tell the carport from the honest posts before they commit,
which is the difference between a trap and a coin flip. Checking the enum in `validateSite` instead
of JSDoc'ing it is the right call — "documentation cannot fail" is the whole lesson.
**CLOSED — the carport BILLS, and it is now the best moment in the game.** Rigged cb1+cb2+q2+q3 at
max tension (1.4, the player's own `]` lever). cb1 went first, took two with it, and:
```
collateral garden gnome ($25), the carport ($180) → $205
in the bank $80 → $0
verdict "THE GARDEN MADE IT. The sail didn't — the carabiner at CB1 went first and took
2 more with it. That is what the sail was for."
world.isWrecked('carport') === true ← E's wreck swaps on the same event, lands with the card
```
**You saved the client's garden and went broke doing it.** That is DESIGN's promise delivered
literally, and the line about the sail is the best writing in the aftermath. E — your wreck lands.
**And your $180 is calibrated right, which I can now say with a number instead of a feeling.** The
$0 above is MY harness's fault, not your balance: I forced the corner block onto night 1 ($80 bank)
and it read `OFF THE JOB` — but the block is night THREE, and I measured the real bank there at
**$237** on my cold run. Settled against that: 237 +57 +37 +3 205 20 = **$109**. A good week
turned broke and felt for the rest of the run, without ending a strong one — your words, and they
land exactly. Don't move it.
**RESIDUE — `ratingHint` is still read by NOTHING** (world.js:570 writes it; grep says nothing else
on lane/a touches it). So the carport isn't *weak* steel, it's *expensive* steel — priced, not
rated. That's visible in play and it's a real fork:
```
tension 1.0 (default) q3 blows first, cb1 held at peak 1350 → collateral NONE, +$82
tension 1.4 (max) cb1 blows first, peak 1957 → collateral $205
```
Both are the same rig. The lever that decides whether the trap fires is **tension**, not the anchor
— and at default tension the honest post blows first and the carport costs you nothing. That may be
fine! Price can carry a trap. But then E's `e.test` is pinning 0.22/0.30/0.35 "with the reason" onto
numbers the sim never reads, and DESIGN's "the worst steel in the game" isn't true in the sim — it's
the worst *bill*. Either wire ratingHint into the failure threshold (sail.js:871 compares load to
`c.hw.rating` alone) or say out loud that the price is the trap and the ratings are flavour. Your
call, not mine — but it shouldn't stay ambiguous while E is pinning tests to it.
**⚠️ NEW — A, this one is aimed straight at gate 2, and I hit it by accident.**
```
main.js:31 const CALM_STORM = 'storm_01_gentle'; // hard-coded
main.js:38 const STORMS = [...new Set(NIGHTS.map(...))]; // loaded storms come ONLY from NIGHTS
main.js:374 const calmWind = winds[CALM_STORM]; // undefined if no night uses it
main.js:510 return simT % Math.max(1, calmWind.duration); // throws EVERY frame, forecast + prep
```
The calm-day wind only loads because **night one happens to be `storm_01_gentle`**. Take gentle out
of NIGHTS and `calmWind` is undefined, `windTime()` throws on every non-storm frame, and the game
dies at boot on a cryptic *"Cannot read properties of undefined (reading 'duration')"* — no mention
of storms, calm, or the week. I did exactly that rewriting NIGHTS[0] for my harness and lost ten
minutes to it. **Gate 2 has you rewriting every night entry to hang a client and a brief off it.**
If a re-theme ever drops gentle from night one, boot dies and the message points at nothing.
One-liner: `const STORMS = [...new Set([CALM_STORM, ...NIGHTS.map((_, i) => nightAt(i).storm)])]`.
Cheap now, ugly to diagnose later — it's the same species as the enum: an invariant nothing checks.
**STILL BLOCKING — night 3 is soft-locked.** `lane/b` is unmoved at b383110, so `rigging.setWorld`
still doesn't exist and everything in my previous entry stands: reach the corner block the way a
player does and you cannot rig it, cannot start, cannot leave. **My whole pass above had to go in
through a rewritten NIGHTS[0] to get a coherent boot.** Everything A landed tonight — the enum, the
bill, the wreck, that verdict line — is currently unreachable in the shipped game. B's one method is
the door in front of all of it.
[I] 2026-07-17 — **SPRINT 11 INTEGRATION (main).** All five lanes merged; only THREADS conflicted
(keep-both). selftest **315/0/0** — the exact sum (296 + A's 10 + B's 4 + C's 2 + E's 3), so no
assert was lost or double-counted in the merge. (Housekeeping: lane entries above are dated
07-19/07-20; this machine's clock says 07-17 — the lanes' dates drifted, the ORDER is right.)
**The sprint's headline, verified live, not just green:** the night-3 soft-lock is DEAD. B's
`setWorld` + A's honoured `boot({site})` merged together; driven in the browser,
`loadSiteInto('site_02_corner_block')` repoints the markers from the backyard's twelve to the
corner block's ten, byte-identical to `world.anchors`, zero console errors. D's cold-pass bug 1 —
"you cannot rig, cannot start, cannot leave" — no longer reproduces. **The full five-night week is
reachable by mouse for the first time.**
**Two cross-lane integration edits, both pre-agreed in THREADS, both done here:**
· hud.js — A's ⚠️ INTEGRATION marker resolved: the `TOMORROW_LEAD = 0.6` placeholder is gone,
the call site is C's verbatim `forecastLines(tomorrowDef, leadFor(1, wk.nights))`. Verified on
the rendered card: *"forecast confidence 75%"* — leadFor(1,5) = 0.25, exactly C's number.
· main.js — A's one private touch into B's session (`_startBudget` + `reset()`) replaced with
B's `setBudget(week.bank)`. The fake is deleted, as B asked. Exercised at boot: shop banks $80.
**Ruling on the launch.json port fight (A + E both filed it):** `.claude/launch.json` is now
UNTRACKED — `.claude/launch.example.json` ships in its place and `.gitignore` covers the real one.
Each clone copies the example once and picks its own port; suggested spread: integration 8823,
lanes AE 88248828. A shared tracked file where every clone needs a different value was a merge
conflict with a fuse on it, and on JING5 (one machine, six checkouts) the fuse was lit.
**C's "half my assert is decoration" disclosure:** keeping it, exactly as labelled — a contract
guard for a future `band()` rewrite, not coverage. The honesty is the useful part and it's in the
test's own comment now. Nothing to delete.
Carried to SPRINT12, none blocking boot:
· **ratingHint is ruled REAL and still read by nothing** — A's ruling stands, the sequencing
blocker (setWorld) is gone. B wires `load > c.hw.rating * (anchor.ratingHint ?? 1)`; the
re-audit that follows is the sprint's real work, because E's baked 0.22/0.30/0.35 will move
the balance of BOTH yards the moment the sim reads them.
· **the gutter is still free**`collateralFor('gutter')` returns null and backyard_01's house
is a free failure. E proposes a price + builds the wreck (the carport pattern), A rules.
· **E's invoice/letterhead kit is unpasted** (tools/jobsheet, CUT markers), the HARD YARDS
masthead and the sequential ABN await A's ruling, and the kid's bike is dead weight until A
wires its site-JSON key. The lean is baked toward Z; read E's entry before placing it.
· **gate 3 — John plays the week. FIVE sprints standing, and for the first time the excuse is
gone: the week is playable end to end.** §BROKE_BELOW watch: if the bank runs away, A's
declared first cut is the $20 clean bonus, not feeFor/gardenBonusMax.

View File

@ -380,6 +380,24 @@
"status": "PASS",
"problems": []
},
{
"name": "bike_kid_01",
"dims": [
1.148,
0.328,
0.7269
],
"tris": 700,
"nodes": [
"bars",
"bike_kid_01",
"frame",
"wheel_front",
"wheel_rear"
],
"status": "PASS",
"problems": []
},
{
"name": "garden_gnome_01_broken",
"dims": [

View File

@ -121,6 +121,8 @@ PAL = {
"bristle": "#C9A659", # broom straw
"hail_ice": "#DCEAF2", # hailstone
"window_warm": "#FFC98A", # someone is home
"bike_kid": "#D8483C", # the Henderson kid's bike — bought bright on purpose
"bike_grip": "#3B4048", # grips and saddle
"ref_pink": "#E85C8A", # the reference capsule — deliberately loud
}
@ -1446,6 +1448,137 @@ def build_garden_gnome_01_broken(name):
return root
def build_bike_kid_01(name):
"""A kid's 16-inch bike, dropped against the fence (SPRINT11 §Lane E — the
per-client prop). The Hendersons have a kid; the kid has a bike; the bike is
against the fence because that is where bikes live. It exists to make the
job sheet's brief — "the seedlings have to be alive when we get back" — land
on a yard that visibly belongs to somebody.
LEAN IS BAKED IN, and that's deliberate. A bike does not stand up on its own,
so an upright GLB would be a prop that reads as a bug the moment it's placed.
The whole thing is built through `_tilt()`, a rotation about X applied to
every point at author time.
The origin is the ground line, and the tilt is about the X axis THROUGH that
origin so the tyre contact points (z = 0) map to themselves and the bike
sits on the ground, not through it or above it. Drop it at ground level like
the gnome and rotate about the up axis to aim it.
** WHICH WAY IT LEANS read this before you place it, Lane A. **
In THIS file the bike leans toward +Y, because Blender is Z-up. You do not
work in Blender. The exporter maps Blender (x, y, z) -> glTF (x, z, -y), so
in three.js the bike leans toward **-Z**, and -Z is the side the fence goes.
Put the fence on +Z and the bike will lean away from it into thin air, which
is a bug that looks exactly like a physics bug and isn't one.
I wrote "+Y is the fence side" here first and it would have been a lie by the
time it reached you this is the axis trap the header of e.test.js exists to
catch, and it caught me writing the docstring, not the geometry. There's now
an assert in that file pinning the lean to -Z in browser coords, so if anyone
ever un-tilts this or flips the export, the suite says so instead of you
finding out by eye. Stand it ~0.10 m off the palings the bars, not the
tyres, are what touch a fence.
Not breakable and not priced: it's juice, not a trap. See my THREADS note if
you want it to be collateral that's your call and it has a number in it.
"""
root = add_empty(name)
frame_m = get_material("Mat_BikeFrame", PAL["bike_kid"], 0.55)
tyre_m = get_material("Mat_BikeTyre", PAL["mat_black"], 0.9)
steel = get_material("Mat_SteelDark", PAL["steel_dark"], 0.5, metallic=0.6)
grip_m = get_material("Mat_BikeGrip", PAL["bike_grip"], 0.85)
# The lean. 11° about X tips the bike toward +Y. Shallow on purpose: past
# about 15° it reads as "knocked over" rather than "parked", and this bike
# is meant to be resting, not already a casualty of the storm.
lean = math.radians(-11.0)
cl, sl = math.cos(lean), math.sin(lean)
def _tilt(p):
x, y, z = p
return (x, y * cl - z * sl, y * sl + z * cl)
R = 0.203 # 16" wheel: 406 mm diameter, the real kid-bike size
AX = 0.355 # axle from centre — 0.71 m wheelbase
# --- wheels. arc_points gives me the rim in the upright XZ plane; every
# point then goes through _tilt, which is the same thing add_arc_tube does
# internally minus the lean. Ten segments reads round at yard distance and
# keeps the pair under ~450 tris.
for side, cx in (("rear", -AX), ("front", AX)):
pts = arc_points(R, 0, math.tau, 10, center=(cx, 0, R), plane='XZ')
segs = [add_tube_between(f"{name}_{side}_tyre_s{i}", _tilt(pts[i]),
_tilt(pts[(i + 1) % len(pts)]), 0.019, tyre_m, verts=6)
for i in range(len(pts))]
segs.append(add_tube_between(f"{name}_{side}_hub", _tilt((cx, -0.028, R)),
_tilt((cx, 0.028, R)), 0.016, steel, verts=6))
join_group(segs, f"wheel_{side}", root)
# --- frame. Upright coordinates, tilted on the way in.
#
# It's a STEP-THROUGH: the tube from the head drops to the MIDDLE of the seat
# tube instead of running level to the top of it. That dropped diagonal is
# the entire reason this reads as a kid's bike from across the yard rather
# than as a small adult one — at 20 m you cannot judge absolute size, so the
# silhouette has to carry it. Scale alone would not.
#
# First pass built a diamond frame while the comment above it claimed a
# step-through, and I only caught it by looking at the render — the dims
# passed either way, because "is it the right shape" is not something a
# bounding box can answer. Same lesson as the Sprint 10 phantom post.
bb = (-0.05, 0, 0.175) # bottom bracket
seat_top = (-0.165, 0, 0.600)
head_top = (0.245, 0, 0.615)
head_bot = (0.285, 0, 0.430)
# 45% up the seat tube — where the step-through's diagonal lands.
step_join = (-0.102, 0, 0.366)
tubes = [
("seat_tube", bb, seat_top, 0.017),
("down_tube", bb, head_bot, 0.018),
("step_tube", head_top, step_join, 0.015), # the step-through drop
("chain_stay", bb, (-AX, 0, R), 0.012),
("seat_stay", seat_top, (-AX, 0, R), 0.011),
# The fork is RAKED — the axle sits ~21 mm FORWARD of the head tube's
# axis extended to axle height. Sign matters and I got it backwards
# first: with the axle behind that line, head tube and fork drew as one
# straight pole down through the front wheel, which is a pogo stick with
# wheels. Rake is what puts the bend in the silhouette.
("fork", head_bot, (AX, 0, R), 0.013),
("head_tube", head_bot, head_top, 0.016),
]
parts = [add_tube_between(f"{name}_{n}", _tilt(a), _tilt(b), r, frame_m, verts=6)
for (n, a, b, r) in tubes]
join_group(parts, "frame", root)
# --- the bits a kid actually touches. Bars sit across Y; the grips are the
# only two things on this bike that are worn, and they're the reason it
# reads as USED rather than as a showroom asset dropped in a yard.
bars = [
add_tube_between(f"{name}_stem", _tilt(head_top), _tilt((0.225, 0, 0.685)),
0.013, steel, verts=6),
add_tube_between(f"{name}_bar", _tilt((0.225, -0.155, 0.685)),
_tilt((0.225, 0.155, 0.685)), 0.012, steel, verts=6),
add_tube_between(f"{name}_grip_l", _tilt((0.225, -0.155, 0.685)),
_tilt((0.225, -0.095, 0.685)), 0.016, grip_m, verts=6),
add_tube_between(f"{name}_grip_r", _tilt((0.225, 0.095, 0.685)),
_tilt((0.225, 0.155, 0.685)), 0.016, grip_m, verts=6),
add_tube_between(f"{name}_saddle", _tilt((-0.200, 0, 0.615)),
_tilt((-0.130, 0, 0.622)), 0.030, grip_m, verts=6),
add_tube_between(f"{name}_crank", _tilt((-0.05, -0.075, 0.175)),
_tilt((-0.05, 0.075, 0.175)), 0.010, steel, verts=6),
]
join_group(bars, "bars", root)
stamp(root, name, "prop")
# A 16" kid's bike is ~8 kg. Lane A/C: this is here so the wind CAN have it
# later — a bike that ignores a 32 m/s gust is a worse lie than no bike.
root["mass_hint"] = 8.0
root["breakable"] = False
return root
def build_hail_stone_01(name):
"""One hailstone, ~22 mm (SPRINT5 §Lane E-1, "stone mesh if C wants geometry
over sprites").
@ -2193,6 +2326,12 @@ ASSETS = [
dict(name="garden_gnome_01", fn=build_garden_gnome_01,
dims=((0.10, 0.20), (0.10, 0.20), (0.33, 0.42)),
nodes=["gnome"]),
# The per-client prop. Leaning, so the Y span is wider and the Z shorter than
# an upright bike's — that asymmetry IS the lean, and if it ever measures
# square again someone has quietly un-tilted it.
dict(name="bike_kid_01", fn=build_bike_kid_01,
dims=((1.00, 1.25), (0.24, 0.52), (0.60, 0.84)),
nodes=["wheel_rear", "wheel_front", "frame", "bars"]),
# Aftermath wreckage (SPRINT3 §Lane E-2). Each keeps its intact twin's origin
# and footprint so Lane A swaps in place.
dict(name="garden_gnome_01_broken", fn=build_garden_gnome_01_broken,

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 MiB

After

Width:  |  Height:  |  Size: 3.3 MiB

408
tools/jobsheet/index.html Normal file
View File

@ -0,0 +1,408 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>SHADES — job sheet + invoice preview (Lane E → Lane A)</title>
<style>
/* ==========================================================================
LANE A — SPRINT 11 GATE 2. Everything between the CUT markers is yours to
paste into hud.js. Same deal as the end cards last sprint: rendered and
screenshotted before it was handed over, so it's a paste rather than a
design job. Take it, edit it, or bin it.
It is deliberately ADDITIVE. I have not touched .card / .row / .ledger —
every rule below is a new class, so the cards keep working exactly as they
do today if you paste this and wire nothing. The job sheet is the forecast
card wearing a letterhead; the invoice is your .ledger wearing one too.
Your existing markup slots in underneath both.
THE TWO DESIGN CALLS IN HERE ARE YOURS, NOT MINE — see BUSINESS and ABN
at the top of the script block. My proposals are there with the reasoning
beside them so you can argue with them, same as the carport's $180.
====================================================================== */
/* ---- CUT HERE ---------------------------------------------------------- */
/* --- the letterhead ----------------------------------------------------
One business, two documents. This is the whole trick of the sprint: the
job sheet and the invoice carry the SAME letterhead, so they read as a
pair of papers off the same outfit's printer rather than as two game
screens. The player's business is the constant; the client changes every
night. That's the framing DESIGN.md opens with — "you run a small
landscaping outfit" — finally showing up on the glass.
Sits above the existing .card content, inside the same .card box. */
#hud-card .letterhead {
display: flex; align-items: baseline; justify-content: space-between;
gap: 16px; margin: -4px 0 14px; padding-bottom: 10px;
border-bottom: 2px solid #2f3f4a;
}
#hud-card .letterhead .mark {
font-weight: 800; font-size: 15px; letter-spacing: .22em; color: #fff;
white-space: nowrap;
}
/* The trade line does the work a logo would. Lowercase and spaced out so it
reads as printed rule, not as a heading competing with the mark. */
#hud-card .letterhead .trade {
font-size: 10px; letter-spacing: .13em; color: #6d818e; margin-top: 3px;
}
#hud-card .letterhead .docket { text-align: right; white-space: nowrap; }
#hud-card .letterhead .docket .kind {
font-weight: 700; font-size: 11px; letter-spacing: .18em; color: #8ba0ad;
}
#hud-card .letterhead .docket .no { font-size: 10px; color: #55677a; letter-spacing: .06em; }
/* --- bill-to / job-for block -------------------------------------------
Deliberately the same component on both documents. On the job sheet it's
who you're working for; on the invoice it's who's paying. Same box, and
the fact that it doesn't move between the two is the joke landing. */
#hud-card .billto { margin: 0 0 12px; }
#hud-card .billto .who { font-weight: 700; color: #fff; letter-spacing: .04em; }
#hud-card .billto .addr { font-size: 11px; color: #6d818e; }
/* --- the brief ---------------------------------------------------------
The client's own words. Italic and rule-marked because it is the one bit
of text on the card that isn't the game talking to the player — it's a
person who wants their seedlings alive. It should feel quoted, and it
should be the thing the player's eye lands on after the letterhead,
because it is the only place the stakes are ever said out loud. */
#hud-card .brief {
margin: 0 0 14px; padding: 9px 0 9px 12px;
border-left: 2px solid #3f5561;
color: #c3d0d9; font-style: italic; line-height: 1.6;
}
#hud-card .brief .attrib {
display: block; margin-top: 5px; font-style: normal;
font-size: 10px; letter-spacing: .1em; color: #55677a;
}
/* --- section rules ------------------------------------------------------ */
#hud-card .sect {
margin: 16px 0 7px; font-size: 10px; font-weight: 700; letter-spacing: .18em;
color: #55677a; border-bottom: 1px solid #1c262d; padding-bottom: 5px;
}
/* --- the pay schedule (job sheet) --------------------------------------
This is a QUOTE, not a receipt: it's what the night is worth if you do it
right. It reuses your .row shape so it lines up with everything else, but
the money is dimmed — these are amounts you have NOT earned yet, and they
should not read with the same confidence as the bank line. The conditions
are the point; the numbers are just what the conditions are worth. */
#hud-card .sched .row b { color: #9fb3bf; font-weight: 700; }
#hud-card .sched .cond {
font-size: 10px; color: #55677a; letter-spacing: .02em;
padding: 0 0 5px; border-bottom: 1px solid #1c262d; margin-top: -2px;
}
#hud-card .sched .row.headline b { color: #fff; }
#hud-card .sched .row.max { border-bottom: 0; margin-top: 6px; padding-top: 7px;
border-top: 1px solid #24343d; }
#hud-card .sched .row.max span { color: #8ba0ad; }
#hud-card .sched .row.max b { color: #ffd9a3; }
/* --- the invoice -------------------------------------------------------
Your .ledger already IS an invoice; it just never said so. These rules sit
ON TOP of it — paste them and .ledger keeps every behaviour it has, it
just gains a rule above the total and a due line under it.
.earned/.bad you already colour. The one thing I've added is .void: a
bonus that was on the job sheet and did NOT survive the night. Struck
through, not hidden. The player must see the money they didn't get sitting
in the same column as the money they did — a bonus you never see fail is a
bonus you never learn to protect. This is the single highest-value line in
the whole handover, and it's four CSS declarations. */
#hud-card .ledger .row.void span { color: #55677a; }
#hud-card .ledger .row.void b {
color: #55677a; font-weight: 400; text-decoration: line-through;
text-decoration-color: #7d2b2b; text-decoration-thickness: 1px;
}
/* The amount due. An invoice's whole rhetorical shape is that everything
above is argument and this is the conclusion. Give it the weight. */
#hud-card .ledger .row.due {
margin-top: 8px; padding-top: 9px; border-top: 2px solid #2f3f4a; border-bottom: 0;
align-items: baseline;
}
#hud-card .ledger .row.due span { color: #8ba0ad; letter-spacing: .12em; font-weight: 700;
font-size: 11px; }
#hud-card .ledger .row.due b { color: #fff; font-size: 20px; letter-spacing: .02em; }
#hud-card .ledger .row.due.neg b { color: #ff8f86; }
/* The terms line. Pure flavour, and the cheapest joke on the card: a real
invoice's most boring sentence, printed under a night that nearly killed
you. Left in as its own class so you can delete it in one line if it
doesn't land. */
#hud-card .terms {
margin-top: 10px; font-size: 10px; color: #45566a; letter-spacing: .04em;
}
/* ---- CUT HERE ---------------------------------------------------------- */
/* ---- preview chrome below; NOT part of the snippet --------------------- */
body { margin: 0; background: #060a0d; }
/* padding-top clears the toggle bar so a headless screenshot of any state is
a clean handover artifact rather than one with buttons through the
letterhead. Learned that the hard way on the first render. */
#hud-card { position: static !important; display: grid !important; place-items: start center;
padding: 74px 0 26px; }
.bar { position: fixed; z-index: 60; left: 50%; transform: translateX(-50%); top: 14px;
display: flex; gap: 8px; }
.bar button { font: 12px ui-monospace, Menlo, monospace; padding: 8px 12px; cursor: pointer; }
.lbl { position: fixed; left: 14px; bottom: 12px; z-index: 60;
font: 11px ui-monospace, Menlo, monospace; color: #55677a; letter-spacing: .1em; }
</style>
<style id="hudcss">
/* Lifted verbatim from hud.js so the preview renders in the real card's
skin. Do NOT paste this block — you already have it. It's here so that
what I screenshotted is what you'll actually see. */
#hud-card { inset: 0; z-index: 30; place-items: center;
font: 13px/1.7 ui-monospace, SFMono-Regular, Menlo, monospace; color: #dde5ea; }
#hud-card .card { background: #0d1418; border: 1px solid #2f3f4a; border-radius: 9px;
padding: 22px 26px; min-width: 440px; max-width: 620px; pointer-events: auto; }
#hud-card h1 { margin: 0 0 2px; font-size: 19px; letter-spacing: .16em; color: #fff; }
#hud-card h2 { margin: 0 0 16px; font-size: 12px; font-weight: 400; color: #8ba0ad;
letter-spacing: .04em; }
#hud-card .row { display: flex; justify-content: space-between; gap: 18px; padding: 3px 0;
border-bottom: 1px solid #1c262d; }
#hud-card .row b { font-weight: 700; color: #fff; }
#hud-card .stat { color: #8ba0ad; }
#hud-card .go { margin-top: 16px; padding: 9px 18px; background: #1d3d2a; border: 1px solid #3f7a52;
border-radius: 6px; color: #a8f0b8; cursor: pointer; font: inherit; font-weight: 700;
letter-spacing: .1em; }
#hud-card .verdict { margin: 14px 0 0; padding: 10px 12px; border-radius: 6px; font-weight: 700;
letter-spacing: .05em; }
#hud-card .verdict.win { background: #12321c; border: 1px solid #2c6b3c; color: #7fce6a; }
#hud-card .verdict.lose { background: #3a1618; border: 1px solid #7d2b2b; color: #ff8f86; }
#hud-card .pips { letter-spacing: .5em; font-size: 15px; margin: -6px 0 10px; color: #3f5561; }
#hud-card .pip.now { color: #7ee0ff; }
#hud-card .pip.held { color: #7fce6a; }
#hud-card .pip.lost { color: #ff8f86; }
#hud-card .ledger { margin-top: 14px; padding-top: 10px; border-top: 1px solid #24343d; }
#hud-card .ledger .row b { color: #a8f0b8; }
#hud-card .ledger .row.bad b { color: #ff8f86; }
#hud-card .ledger .row.total { margin-top: 6px; padding-top: 8px; border-top: 1px solid #24343d; }
#hud-card .ledger .row.total b { color: #fff; }
</style>
</head>
<body>
<div class="lbl" id="lbl">JOB SHEET · night 1</div>
<div class="bar">
<button onclick="show('sheet1')">job sheet n1</button>
<button onclick="show('sheet3')">job sheet n3 (corner block)</button>
<button onclick="show('inv_good')">invoice · clean</button>
<button onclick="show('inv_bad')">invoice · carport</button>
</div>
<div id="hud-card" class="on"><div id="slot"></div></div>
<script>
/* ==========================================================================
LANE A — THE TWO DESIGN CALLS. Both are yours. Reasoning is here so you can
overrule it from an informed position rather than just accepting my taste.
====================================================================== */
/* CALL 1 — THE BUSINESS NAME. A letterhead needs an outfit on it, and we've
never named the player's business. My proposal is HARD YARDS.
Reasoning: it's a real Australian idiom ("doing the hard yards" = the
unglamorous grind that nobody thanks you for), it is literally about yards,
and it's the joke the game is ALREADY telling — DESIGN.md says you never
build the right thing, you build the least wrong thing, and THREADS' own
line for the win card is "nobody thanks you for the storm that did nothing.
That's the job." That is the hard yards. It's one word at letterhead size,
which is what a mark needs to be, and it stays funny on an invoice for $180
of someone else's carport.
What I deliberately did NOT do: name it after the player. A surname mark
("<NAME> LANDSCAPING") is the most authentic thing a one-person outfit
would actually have, and it's the better answer the moment the player has a
name — but naming the player is a much bigger call than a letterhead, and
it's not mine to make in a juice pass. If Sprint 12 names them, this
constant is the only thing that changes. */
const BUSINESS = { mark: 'HARD YARDS', trade: 'landscaping · shade · drainage' };
/* CALL 2 — THE ABN. An Australian invoice without an ABN is not an invoice;
it's the single detail that makes the paper read as real to anyone who's
ever received one. But any plausible 11-digit number is potentially a REAL
business's ABN, and I'm not printing a stranger's tax identity on a game
card. So: sequential digits, which every Australian reader parses instantly
as "example" — the same convention as 555 phone numbers. Deliberate, not
lazy. Please don't "fix" it into something realistic. */
const ABN = 'ABN 12 345 678 901';
/* ==========================================================================
Lane A: swap this for your real night entries. The markup doesn't care what
the rows are — it lays out label/value pairs and a brief. Shape below is
what SPRINT11 gate 2 asks for: client + brief + pay schedule, as data.
Note the pay schedule is a LIST, not three named fields. base/garden/
no-collateral is what Sprint 11 specifies, but a list costs nothing now and
means a site that wants a fourth condition ("nothing tied to the carport")
doesn't need a card change — which, per SPRINT10, is the whole point of
sites being data.
====================================================================== */
const NIGHTS = {
sheet1: {
kind: 'sheet', night: 1, nights: 5, docket: 'JOB 1041',
client: 'R. & M. Henderson', addr: '14 Kurrajong St — backyard',
brief: 'The seedlings have to be alive when we get back. We fly out Friday and the beds are three weeks in.',
attrib: '— R. Henderson, on the phone, Monday',
storm: 'SOUTHERLY BUSTER', night_flag: false,
lines: ['gusting to 21 m/s, building after 9', 'rain: steady, 12 mm', null],
note: 'Nothing tonight can hurt the garden. Learn the anchors.',
bank: 95,
sched: [
['callout fee', '$80', 'the night, rigged and stood up', true],
['garden bonus', '$40', 'bed above 80% at dawn'],
['clean site', '$25', 'nothing of theirs broken'],
],
max: 145,
},
sheet3: {
kind: 'sheet', night: 3, nights: 5, docket: 'JOB 1043',
client: 'D. Okafor', addr: '2 Bight Rd — corner block',
site: 'the corner block — two streets, no shelter, and a carport that wants to be an anchor',
brief: "Whatever you tie to, don't tie to the carport. It's older than it looks and I'm not paying to put it back up.",
attrib: '— D. Okafor, written on the job docket',
storm: 'EARLY BUSTER', night_flag: true,
lines: ['gusting to 32 m/s, funnelled', 'rain: heavy · hail forecast', 'confidence: ±4 m/s (lead 0)'],
note: 'Hail is what kills a garden, and cloth stops hail. Get the sail over the bed.',
bank: 210,
sched: [
['callout fee', '$80', 'the night, rigged and stood up', true],
['garden bonus', '$40', 'bed above 80% at dawn'],
['clean site', '$25', 'nothing of theirs broken'],
],
max: 145,
},
inv_good: {
kind: 'invoice', night: 1, docket: 'INV 1041',
client: 'R. & M. Henderson', addr: '14 Kurrajong St — backyard',
subtitle: 'The bed came through it. So did the rig.',
rows: [['garden', '94%'], ['corners intact', '4/4'], ['what got through', 'nothing'],
['hardware lost', 'none'], ['collateral', 'none']],
ledger: [
['callout fee', '+$80'],
['garden bonus', '+$40'],
['clean site', '+$25'],
['gear recovered', '+$18'],
['spent on the rig', '$62', 'bad'],
],
due: '+$101', bank: '$95 → $196',
verdict: "Nobody thanks you for the storm that did nothing. That's the job.",
win: true,
},
inv_bad: {
kind: 'invoice', night: 3, docket: 'INV 1043',
client: 'D. Okafor', addr: '2 Bight Rd — corner block',
subtitle: 'The garden made it. The carport went to the neighbours.',
rows: [['garden', '86%'], ['corners intact', '3/4'], ['what got through', 'some hail'],
['hardware lost', '$34'], ['collateral', 'the carport ($180)']],
ledger: [
['callout fee', '+$80'],
['garden bonus', '+$40'],
['clean site', '$25', 'void'],
['gear recovered', '+$6'],
['the carport', '$180', 'bad'],
['spent on the rig', '$71', 'bad'],
],
due: '$125', neg: true, bank: '$210 → $85',
verdict: 'You took the carport with you.',
win: false,
terms_extra: "They'll want that back up by Thursday.",
},
};
/* --- renderers. Lane A: these are throwaway — your hud.js already builds the
rows; all you need from here is the MARKUP SHAPE and the class names. ---- */
function letterhead(n) {
return `<div class="letterhead">
<div>
<div class="mark">${BUSINESS.mark}</div>
<div class="trade">${BUSINESS.trade}</div>
</div>
<div class="docket">
<div class="kind">${n.kind === 'sheet' ? 'JOB SHEET' : 'TAX INVOICE'}</div>
<div class="no">${n.docket} · ${ABN}</div>
</div>
</div>`;
}
function billto(n) {
return `<div class="billto">
<div class="who">${n.client}</div>
<div class="addr">${n.addr}</div>
</div>`;
}
function brief(n) {
return `<div class="brief">${n.brief}<span class="attrib">${n.attrib}</span></div>`;
}
function pips(night, nights) {
return Array.from({ length: nights }, (_, i) => {
const done = i < night - 1, now = i === night - 1;
return `<span class="pip ${now ? 'now' : done ? 'held' : ''}">${now ? '◆' : done ? '●' : '·'}</span>`;
}).join('');
}
function renderSheet(n) {
const site = n.site ? `<div class="stat" style="color:#7ee0ff">${n.site}</div>` : '';
const sched = n.sched.map(([k, v, cond, head]) =>
`<div class="row ${head ? 'headline' : ''}"><span>${k}</span><b>${v}</b></div>
<div class="cond">${cond}</div>`).join('');
return `<div class="card">
${letterhead(n)}
${billto(n)}
${brief(n)}
<h1>NIGHT ${n.night} OF ${n.nights}</h1>
<div class="pips">${pips(n.night, n.nights)}</div>
<h2>${n.storm}${n.night_flag ? ' · NIGHT' : ''}</h2>
${site}
${n.lines.filter(Boolean).map((l) => `<div class="stat">${l}</div>`).join('')}
<div class="sect">WHAT IT PAYS</div>
<div class="sched">
${sched}
<div class="row max"><span>the night, done right</span><b>$${n.max}</b></div>
</div>
<div class="row" style="margin-top:14px"><span>in the bank</span><b>$${n.bank}</b></div>
<div class="stat" style="color:#8ba0ad;margin-top:10px">${n.note}</div>
<button class="go">RIG IT</button>
<div class="terms">Quoted on the forecast at time of callout. Weather is not a variation.</div>
</div>`;
}
function renderInvoice(n) {
const rows = n.rows.map(([k, v]) => `<div class="row"><span>${k}</span><b>${v}</b></div>`).join('');
const ledger = n.ledger.map(([k, v, cls]) =>
`<div class="row ${cls || ''}"><span>${k}</span><b>${v}</b></div>`).join('');
return `<div class="card">
${letterhead(n)}
${billto(n)}
<h1>MORNING · NIGHT ${n.night} OF 5</h1>
<h2>${n.subtitle}</h2>
${rows}
<div class="ledger">
${ledger}
<div class="row due ${n.neg ? 'neg' : ''}"><span>AMOUNT DUE</span><b>${n.due}</b></div>
<div class="row total"><span>in the bank</span><b>${n.bank}</b></div>
</div>
<div class="verdict ${n.win ? 'win' : 'lose'}">${n.verdict}</div>
<button class="go">NIGHT ${n.night + 1} →</button>
<div class="terms">Payable 14 days. ${n.terms_extra || 'Thank you for your business.'}</div>
</div>`;
}
function show(which) {
const n = NIGHTS[which];
document.getElementById('slot').innerHTML =
n.kind === 'sheet' ? renderSheet(n) : renderInvoice(n);
document.getElementById('lbl').textContent =
(n.kind === 'sheet' ? 'JOB SHEET · night ' : 'INVOICE · night ') + n.night;
}
/* ?v=sheet3 etc. — so a state is a URL. That's how the screenshots in
tools/jobsheet/*.png were taken (headless), and it means you can link me a
broken one rather than describe it. */
show(new URLSearchParams(location.search).get('v') || 'sheet1');
</script>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

View File

@ -75,12 +75,22 @@ async function run() {
const stormDef = await loadJSON(`../../web/world/data/storms/${stormName}.json`);
const calmDef = await loadJSON(`../../web/world/data/storms/${AUDIT.CALM_STORM}.json`);
const vlist = site.wind?.venturi ?? [];
const vtxt = vlist.length
? vlist.map((v) => `(${v.x},${v.z}) axis ${v.axis} gain ${v.gain}`).join(' · ')
: 'none';
el('sub').textContent =
`${site.name || siteName} — ${anchors.length} dressed anchors ${dressed ? '(GLBs loaded ✓)' : '(⚠ dress() FAILED — graybox positions, not what ships)'}\n` +
`storm: ${stormName} (${stormDef.duration}s, downdraft ${stormDef.gusts?.downdraftOfTotal ?? '—'})\n` +
`venturi: ${vtxt}\n` +
`shop: $${START_BUDGET} · ${HARDWARE.map((h) => `${h.name} $${h.cost}/${(h.rating / 1000).toFixed(1)}kN`).join(' · ')}`;
const { cands, rows, verdict, winners } = auditSweep({ anchors, bed: world.gardenBed, stormDef, calmDef });
// The venturi is the SITE's, not the storm's — main.js:424 sets it on the wind
// at every site load, so the audit must too or the corner block flies with its
// funnel switched off (an easier yard than ships).
const venturi = site.wind?.venturi ?? [];
const { cands, rows, verdict, winners } = auditSweep({ anchors, bed: world.gardenBed, stormDef, calmDef, venturi });
// render rows
const tbl = document.createElement('table');
@ -119,7 +129,7 @@ async function run() {
`, ${w.area.toFixed(0)} m², ${(w.cover * 100).toFixed(0)}% of the bed.`;
}
// a machine-readable line, so this page can also be driven headless-in-browser
window.__audit = { site: siteName, storm: stormName, dressed, anchors: anchors.length, cands: cands.length, verdict, winners: winners.map((w) => ({ ids: w.ids, hw: w.hw })) };
window.__audit = { site: siteName, storm: stormName, dressed, venturi: vlist, anchors: anchors.length, cands: cands.length, verdict, winners: winners.map((w) => ({ ids: w.ids, hw: w.hw, cover: w.cover })) };
document.title = `site_audit — ${verdict.ok ? 'PASS' : 'FAIL'}`;
}

View File

@ -65,6 +65,7 @@ const BACKYARD_01 = {
name: 'backyard_01 (dressed-yard dump — posts verified live, see comment)',
dumped: true,
bed: { x: 1, z: 2, w: 6, d: 4 },
venturi: [], // backyard_01.json ships "venturi": [] — no funnel. Stated, not assumed.
anchors: [
// dress()-only: adopted from E's GLBs. Unverifiable headless.
['h1', 'house', -3.00, 2.48, -9.95], ['h2', 'house', 0.00, 2.48, -9.95], ['h3', 'house', 3.00, 2.48, -9.95],
@ -161,7 +162,9 @@ async function loadSite(path) {
id: a.id, type: a.type || 'post',
pos: { x: a.pos?.x ?? a.x, y: a.pos?.y ?? a.y, z: a.pos?.z ?? a.z },
}));
return { name: j.name || path, bed: j.gardenBed || j.bed, anchors };
// A resolved export still owes us the site's funnel — the venturi is site data,
// not storm data, and a sweep without it flies an easier yard than ships.
return { name: j.name || path, bed: j.gardenBed || j.bed, anchors, venturi: j.wind?.venturi ?? [] };
}
const withSway = (list) => list.map((a) => ({ ...a, sway: () => a.pos }));
@ -187,10 +190,12 @@ async function main() {
console.log(` (node cannot dress — live world.js here is the GRAYBOX yard, house at x=±5. See comment.)`);
}
console.log(`storm: ${stormArg} (${def.duration}s, downdraftOfTotal ${def.gusts?.downdraftOfTotal ?? '—'})`);
const venturi = site.venturi ?? [];
console.log(`venturi: ${venturi.length ? venturi.map((v) => `(${v.x},${v.z}) axis ${v.axis} gain ${v.gain}`).join(' · ') : 'none'}`);
console.log(`shop: $${START_BUDGET} · ${HARDWARE.map((h) => `${h.name} $${h.cost}/${(h.rating / 1000).toFixed(1)}kN`).join(' · ')}\n`);
// The sweep itself is shared with the browser front-end — see sweep.js.
const { cands, rows, winners, verdict } = auditSweep({ anchors, bed: site.bed, stormDef: def, calmDef });
const { cands, rows, winners, verdict } = auditSweep({ anchors, bed: site.bed, stormDef: def, calmDef, venturi });
if (verdict.code === 'no-cover') {
console.log(`✗ FAIL — no quad in the ${AUDIT.BAND.lo}-${AUDIT.BAND.hi} m² band shades the bed at all.`);

View File

@ -8,8 +8,9 @@
* to catch reimplemented-formula drift must not carry two copies of its own math.
*
* Pure given its inputs: hand it resolved anchors (each {id, type, pos, sway}),
* the bed rect, and the storm + calm-day defs. It flies the same settle+storm the
* game flies and returns ranked rows + a verdict. No I/O, no process, no DOM.
* the bed rect, the storm + calm-day defs, and the site's venturi list. It flies
* the same settle+storm the game flies and returns ranked rows + a verdict.
* No I/O, no process, no DOM.
*/
import { SailRig, orderRing } from '../../web/world/js/sail.js';
@ -44,9 +45,12 @@ export const tierFor = (peakN) => HARDWARE.find((h) => h.rating >= peakN) || nul
* @param {object} o.bed garden bed rect { x, z, w, d }
* @param {object} o.stormDef the storm JSON to fly
* @param {object} o.calmDef the calm-day JSON to settle on (storm_01_gentle)
* @param {Array} [o.venturi] the SITE's funnel zones (siteDef.wind.venturi).
* Omitted = no funnel, which is backyard_01's truth
* and a LIE on the corner block. See below.
* @returns {{ cands, rows, winners, verdict:{ ok:boolean, code:string, best } }}
*/
export function auditSweep({ anchors, bed, stormDef, calmDef }) {
export function auditSweep({ anchors, bed, stormDef, calmDef, venturi = [] }) {
// 1. every quad, in the rigging band, that shades the bed
const cands = [];
for (let a = 0; a < anchors.length; a++) for (let b = a + 1; b < anchors.length; b++)
@ -71,10 +75,21 @@ export function auditSweep({ anchors, bed, stormDef, calmDef }) {
// wind frozen at t=0 (that read 1.94 kN of standing load vs the true 0.40).
// · resetPeaks() at entry — peakLoad is peak-since-ATTACH otherwise, folding
// the settle transient into the storm peak.
// · setVenturi — the SITE's funnel, and the bug this clause exists for.
// A venturi lives in the site JSON, not the storm, so a sweep built only
// from stormDef silently drops it: main.js:424 calls setVenturi on the
// wind at every site load, and this tool did not. On the corner block —
// whose entire weather personality IS the funnel — that under-reports
// every corner load and hands back an easier yard than the one that
// ships. A false PASS, which is the SPRINT6 trap wearing the other face:
// there the tool called a good site unriggable, here it would call a
// mean site cheap. Both are the tool lying about a yard it can't see.
const trees = anchors.filter((a) => a.type === 'tree');
const wind = createWind(stormDef);
wind.setVenturi(venturi);
wind.setSheltersFromTrees(trees);
const calmWind = createWind(calmDef);
calmWind.setVenturi(venturi); // the gap doesn't switch off for the settle
calmWind.setSheltersFromTrees(trees);
const rows = [];

View File

@ -0,0 +1,90 @@
/**
* sweep.selftest.js the audit tool audits itself. [Lane B, SPRINT11]
*
* Same shape as sail.selftest.js / rigging.selftest.js: [name, fn] pairs, so one
* set of asserts runs under both Lane A's selftest.html (via js/tests/b.test.js)
* and plain node.
*
* WHY THIS FILE EXISTS, in one sentence: site_audit shipped SPRINT9 and SPRINT10
* with no test of its own, and in SPRINT11 it turned out to have been flying the
* corner block with the funnel switched OFF.
*
* The venturi lives in the SITE json; the sweep built its wind from the STORM def
* alone and never called setVenturi, which main.js:424 does at every site load.
* So the tool whose entire job is "catch a site that lies about its difficulty"
* was itself lying about the difficulty of the only site that has a venturi
* under-reporting every corner load on the yard whose whole personality is the
* gap. It reported PASS with $20 lines. That is the SPRINT6 p1=7.4 kN failure
* inverted: there the tool called a fine site unriggable, here it called a mean
* site cheap. A tool built to catch drift must not be the thing that drifts.
*
* The assert below is written to FAIL if the setVenturi calls are removed from
* sweep.js: with the funnel dropped, the funnelled and unfunnelled sweeps
* collapse to the same peak loads and the strict inequality goes red.
*/
import { auditSweep } from './sweep.js';
const TESTS = [];
const test = (name, fn) => TESTS.push([name, fn]);
const assert = (cond, msg) => { if (!cond) throw new Error(msg); };
/**
* A deliberately synthetic yard, not site_02: four anchors around a bed, sized to
* sit inside AUDIT.BAND so exactly one quad sweeps. Synthetic because this test
* pins the SWEEP's plumbing, not the corner block's balance site_02's numbers
* are C's to tune and would make this assert fail every time they moved.
*/
const ANCHORS = [
{ id: 'a1', type: 'post', pos: { x: -3, y: 3.9, z: -3 } },
{ id: 'a2', type: 'post', pos: { x: 3, y: 3.9, z: -3 } },
{ id: 'a3', type: 'post', pos: { x: 3, y: 3.9, z: 3 } },
{ id: 'a4', type: 'post', pos: { x: -3, y: 3.9, z: 3 } },
].map((a) => ({ ...a, sway: () => a.pos }));
const BED = { x: 0, z: 0, w: 4, d: 4 };
/** A storm blowing dead along +Z, and a funnel whose axis matches it. */
const STORM = {
id: 'sweep_selftest_storm', duration: 8, dir: Math.PI / 2, base: 14,
gusts: { every: 3, peak: 1.6, downdraftOfTotal: 0.2 },
};
const CALM = { id: 'sweep_selftest_calm', duration: 8, dir: Math.PI / 2, base: 3, gusts: null };
/** Centred on the bed, wide enough to swallow it, aligned with the storm. */
const FUNNEL = [{ x: 0, z: 0, axis: Math.PI / 2, gain: 2.0, radius: 12, sharp: 1 }];
const peaksOf = (venturi) => {
const { rows } = auditSweep({ anchors: ANCHORS, bed: BED, stormDef: STORM, calmDef: CALM, venturi });
assert(rows.length > 0, 'sweep selftest yard produced no candidate quad — fix the fixture, not the test');
return rows[0].tiers.map((c) => c.peak);
};
test('sweep honours the SITE venturi, not just the storm', () => {
const bare = peaksOf([]);
const funnelled = peaksOf(FUNNEL);
assert(bare.length === funnelled.length, 'the two sweeps disagree about corner count');
// Every corner must pull HARDER through the funnel. Strictly — an equal read
// is the exact bug: it means setVenturi never reached the wind.
for (let i = 0; i < bare.length; i++) {
assert(funnelled[i] > bare[i],
`corner ${i}: funnelled peak ${(funnelled[i] / 1000).toFixed(2)} kN is not above bare ` +
`${(bare[i] / 1000).toFixed(2)} kN — the site's venturi is not reaching the sweep's wind ` +
`(sweep.js must call wind.setVenturi, the way main.js does at every site load)`);
}
});
test('no venturi is a no-op — a site without a funnel is untouched', () => {
// The other half of the contract: backyard_01 ships "venturi": [], and this
// fix must not have moved a single number on it. Default arg == explicit [].
const omitted = peaksOf(undefined);
const empty = peaksOf([]);
for (let i = 0; i < empty.length; i++) {
assert(Math.abs(omitted[i] - empty[i]) < 1e-9,
`corner ${i}: omitting venturi read ${omitted[i]} but [] read ${empty[i]} — a funnel-less site must be byte-identical`);
}
});
export const SWEEP_TESTS = TESTS;

View File

@ -21,7 +21,7 @@
<canvas id="c"></canvas>
<div id="dev"></div>
<div id="hint">Lane B harness — the prep phase only.
ENTER commits the rig and starts a storm. R resets to prep.</div>
ENTER commits the rig and starts a storm. R resets to prep. N switches yard.</div>
<script type="module">
/**
@ -34,7 +34,7 @@ ENTER commits the rig and starts a storm. R resets to prep.</div>
* being verified is the thing that ships. Retire it once index.html hosts prep.
*/
import * as THREE from 'three';
import { createWorld } from './js/world.js';
import { createWorld, loadSite } from './js/world.js';
import { createCameraRig } from './js/camera.js';
import { createWind, loadStorm } from './js/weather.js';
import { SailRig, createSailView } from './js/sail.js';
@ -63,12 +63,33 @@ const windProxy = {
gustTelegraph: (t) => wind.gustTelegraph(t),
eventsBetween: (a, b) => wind.eventsBetween?.(a, b) ?? [],
setSheltersFromTrees: (trees) => { calmWind.setSheltersFromTrees?.(trees); wildWind.setSheltersFromTrees?.(trees); },
// The venturi is the SITE's, so it re-registers on every site switch — same as
// main.js:424. Both storms carry it: the gap doesn't switch off for the calm day.
setVenturi: (list) => { calmWind.setVenturi?.(list); wildWind.setVenturi?.(list); },
};
const world = createWorld(scene, { wind: windProxy });
/**
* SPRINT11: this harness was DEAD and nothing noticed.
*
* SPRINT10 made the yard data — `createWorld` now requires a site and throws
* without one — and this page was still calling `createWorld(scene, {wind})`.
* It had been throwing on boot since that merge, silently, because a dev harness
* has no selftest to go red. (The same change is why `loadSite` appears above.)
* Two sprints of "the picking UI can't be asserted headless, so use the harness"
* with no harness. Fixed here, and given the site switch it should have had.
*/
// NB: createWorld does NOT hand the site back on the world object, so the def is
// kept here — the venturi has to come from somewhere on a switch. (main.js keeps
// its own `siteDef` for the same reason.)
const SITES = ['backyard_01', 'site_02_corner_block'];
let siteIdx = 0;
let siteDef = await loadSite(SITES[siteIdx]);
let world = createWorld(scene, { wind: windProxy, site: siteDef });
await world.dress();
const cameraRig = createCameraRig(canvas);
cameraRig.setSolids(world.solids);
cameraRig.setGround(world.heightAt);
windProxy.setVenturi(siteDef.wind?.venturi ?? []);
windProxy.setSheltersFromTrees(world.anchors.filter((a) => a.type === 'tree'));
const rig = new SailRig({ anchors: world.anchors });
@ -95,18 +116,60 @@ const ui = await createRiggingUI({
let phase = 'prep';
ui.setActive(true);
/**
* Swap the yard under the running UI — the harness for `rigging.setWorld`.
*
* Deliberately the same ORDER main.js's loadSiteInto uses: build the new world,
* dress it, re-register the site's wind (venturi + tree shelters), re-point the
* rig, then hand the UI the new world. Getting that order wrong here would prove
* nothing about the game.
*/
async function switchSite(idx) {
siteIdx = (idx + SITES.length) % SITES.length;
siteDef = await loadSite(SITES[siteIdx]);
if (sailView) { scene.remove(sailView); sailView = null; }
world.dispose(); // main.js does the same before rebuilding
world = createWorld(scene, { wind: windProxy, site: siteDef });
await world.dress();
cameraRig.setSolids(world.solids);
cameraRig.setGround(world.heightAt);
windProxy.setVenturi(siteDef.wind?.venturi ?? []);
windProxy.setSheltersFromTrees(world.anchors.filter((a) => a.type === 'tree'));
rig.anchors = world.anchors;
ui.setWorld(world); // ← the API under test: markers + session follow
phase = 'prep';
wind = calmWind;
ui.setActive(true);
window.__laneB.world = world;
}
// harness-only handle, so a debugger (or an agent driving this page) can pick
// anchors without hunting for pixels. Never imported by the game.
window.__laneB = {
ui, rig, world, cameraRig, scene,
get phase() { return phase; },
get site() { return SITES[siteIdx]; },
get anchorIds() { return world.anchors.map((a) => a.id); },
/** rig by id, exactly as if you'd clicked it */
pick: (id) => ui.session.rig(id),
cycle: (id) => ui.session.cycleHardware(id),
switchSite,
/**
* What the MOUSE would actually hit, read off the live scene graph rather than
* asked of the UI — the honest proof that setWorld rebuilt the clickable
* markers, not just the panel text. Every marker carries userData.anchorId on
* its (invisible) pick sphere.
*/
pickTargetIds: () => {
const ids = [];
scene.traverse((o) => { if (o.userData?.anchorId) ids.push(o.userData.anchorId); });
return ids.sort();
},
};
addEventListener('keydown', (e) => {
if (e.key === 'Enter' && phase === 'prep') { if (ui.commit()) ui.setActive(false); }
if (e.key.toLowerCase() === 'n') { switchSite(siteIdx + 1); return; } // next yard
if (e.key.toLowerCase() === 'r') {
phase = 'prep';
wind = calmWind;

View File

@ -15,7 +15,7 @@
import * as THREE from '../vendor/three.module.js';
import { STORM_LEN } from './contracts.js';
import { forecastLines } from './weather.js';
import { forecastLines, leadFor } from './weather.js';
const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v);
const kmh = (ms) => ms * 3.6;
@ -435,20 +435,12 @@ showForecast({ key, def, site, tomorrowDef }, wk, onGo) {
// read it as nights first, passed 1, and the card advertised "forecast
// confidence 0%": a forecast admitting it knows nothing, printed as news.
//
// ⚠️ INTEGRATION, SPRINT11: this constant is a placeholder and C has
// already landed its replacement on lane/c — `leadFor(nightsOut, weekNights)`
// in weather.js, which maps the week onto forecastFor's 0..1 domain and
// gives 0.25 for tomorrow of five (75% confidence), not my 0.6. Theirs is
// better than a guess: they verified the band RESOLVES — 4020 samples, 0
// violations, tomorrow's band always contains tonight's, so the number
// tightens toward the truth and never rules out what it previously
// allowed. That's the property that makes it safe to print on a card.
// At merge this becomes C's own call site, verbatim from their THREADS:
// forecastLines(tomorrowDef, leadFor(1, wk.nights))
// Not imported here yet only because leadFor is on lane/c and this branch
// would go red reaching for it.
const TOMORROW_LEAD = 0.6;
const tf = tomorrowDef ? forecastLines(tomorrowDef, TOMORROW_LEAD) : null;
// C's `leadFor` maps "N nights out" onto that dial — 0.25 for tomorrow of
// five (75% confidence). Safe to print because the band RESOLVES: verified
// 4020 samples, 0 violations — tomorrow's band always contains tonight's,
// so the number tightens toward the truth and never rules out what it
// previously allowed. (Integrated at SPRINT11 merge; was a 0.6 placeholder.)
const tf = tomorrowDef ? forecastLines(tomorrowDef, leadFor(1, wk.nights)) : null;
const tomorrow = tf ? `
<div class="tomorrow">
<span class="tlabel">tomorrow</span>

View File

@ -771,15 +771,10 @@ export async function boot(opts = {}) {
// loadSiteInto, keyed on the rebuild itself, so it can't desync from it.
await loadSiteInto(week.site);
// Reaching into Lane B's session to re-bank the shop. `RiggingSession` takes
// a budget at construction but exposes no setter, and the UI owns the
// instance — so this is the one private touch in main.js, done loudly rather
// than by forking their file. I've asked for `session.setBudget(n)` in
// THREADS; when it lands these two lines become one. (Same route
// `session.reset()` took: I faked it, asked, they landed it, I deleted the
// fake.)
rigging.session._startBudget = week.bank;
rigging.session.reset();
// B's setBudget (SPRINT11): re-bank + reset in one named call. This was
// main.js's one private touch into their session (`_startBudget` + reset(),
// asked in THREADS, landed, fake deleted — same route reset() took).
rigging.session.setBudget(week.bank);
spentThisNight = 0;
// SPRINT11 — the job sheet reads three new things off the week: tonight's
// JOB (client + brief), the QUOTE (what it pays, before you rig it, which is

View File

@ -83,6 +83,37 @@ export class RiggingSession {
return this;
}
/**
* Re-bank the shop for a new night. [Lane A's ask THREADS, SPRINT10]
*
* A had to poke `_startBudget` and then call `reset()` from main.js their
* self-described "one private touch" into this module, done loudly in a
* comment rather than by forking the file. This is that pair, named.
*
* It RESETS, deliberately. A new bank only ever arrives at a night boundary
* (main.js sets it at the forecast), and carrying last night's picks into a
* re-banked shop would leave four corners rigged that this wallet never paid
* for `budget` would say $80 with $20 of hardware already hanging in the
* yard. "New budget" and "fresh prep phase" are the same event here; splitting
* them would only move the bug into the caller.
*/
setBudget(n) {
this._startBudget = n;
return this.reset();
}
/**
* Re-point the session at a new yard's anchors. [SPRINT11 the site switch]
*
* Picks die with the old site: they are anchor IDs, and `p4` on the backyard
* is not `p4` on the corner block. Keeping them would rig a quad out of
* anchors that no longer exist.
*/
setAnchors(anchors) {
this.anchors = anchors;
return this.reset();
}
get spent() { return START_BUDGET - this.budget; }
get canStart() { return this.picks.length === MAX_CORNERS; }
isRigged(anchorId) { return this.picks.some((p) => p.anchorId === anchorId); }
@ -251,7 +282,7 @@ export async function createRiggingUI({
const pickGeo = new THREE.SphereGeometry(0.45, 8, 6);
const pickMat = new THREE.MeshBasicMaterial({ visible: false });
const markers = world.anchors.map((a) => {
const makeMarker = (a) => {
const mat = new THREE.MeshBasicMaterial({ color: DIM, transparent: true, opacity: 0.9 });
const ring = new THREE.Mesh(ringGeo, mat);
const dot = new THREE.Mesh(dotGeo, mat);
@ -261,8 +292,19 @@ export async function createRiggingUI({
holder.add(ring, dot, hit, makeLabel(THREE, a.id.toUpperCase()));
group.add(holder);
return { anchor: a, holder, ring, dot, hit, mat, label: holder.children[3] };
});
const pickTargets = markers.map((m) => m.hit);
};
/** One marker's GPU-side leftovers. The shared geometries are NOT ours to free. */
const disposeMarker = (m) => {
group.remove(m.holder);
m.mat.dispose();
m.label.material.map?.dispose(); // each label is its own canvas texture
m.label.material.dispose();
};
// `let`, not `const`: setWorld rebuilds both on a site change.
let markers = world.anchors.map(makeMarker);
let pickTargets = markers.map((m) => m.hit);
// --- quad preview ------------------------------------------------------
// A closed loop through the ring-ordered picks: this is the shape you are
@ -350,11 +392,13 @@ export async function createRiggingUI({
function refresh() {
if (!el) return;
const s = session.summary;
// padEnd(4), not 3: the backyard's ids are all 2-3 chars, but the corner
// block ships `tr1b` and the column broke the moment a second site existed.
const rows = world.anchors.map((a) => {
const pick = session.pickOf(a.id);
if (!pick) return ` ${a.id.padEnd(3)} ${a.type.padEnd(6)}`;
if (!pick) return ` ${a.id.padEnd(4)} ${a.type.padEnd(6)}`;
const weak = s.weakest === a.id && session.picks.length > 1 ? ' <- weak link' : '';
return ` ${a.id.padEnd(3)} ${pick.hw.name.padEnd(14)} ${(pick.hw.rating / 1000).toFixed(1)} kN $${pick.hw.cost}${weak}`;
return ` ${a.id.padEnd(4)} ${pick.hw.name.padEnd(14)} ${(pick.hw.rating / 1000).toFixed(1)} kN $${pick.hw.cost}${weak}`;
});
const area = quadArea();
el.textContent = [
@ -375,6 +419,38 @@ export async function createRiggingUI({
get canStart() { return session.canStart; },
get active() { return active; },
/**
* Re-point the whole UI at a new yard. [Lane A's ask THREADS, SPRINT10]
*
* The site switch's last piece. `createRiggingUI` built its markers and its
* session from `world.anchors` at construction and had no way to re-point
* them, so on a site change the corner block's markers didn't exist and the
* panel still listed the backyard. A re-points what they can reach from
* main.js (`rig.anchors`, `session.anchors`) which is why night 3 has been
* riggable from code and from the audit but not from the MOUSE. This is the
* mouse.
*
* A calls it inside `loadSiteInto`, guarded (`rigging.setWorld?.(world)`),
* so it lights up the day it lands with no change on their side.
*
* Markers are rebuilt, not repositioned: the anchor SET changes across sites
* (12 in the backyard, 10 on the corner block, different ids), so there is no
* per-marker correspondence to preserve. Each marker owns a material and a
* canvas-texture label, so the old ones are disposed rather than dropped
* five site switches a week, every week, is a real leak otherwise.
*/
setWorld(w) {
world = w;
for (const m of markers) disposeMarker(m);
markers = world.anchors.map(makeMarker);
pickTargets = markers.map((m) => m.hit);
hovered = null; // a stale id would highlight nothing, forever
session.setAnchors(world.anchors); // picks are old-site anchor ids — they die here
preview.visible = false;
refresh();
return ui;
},
/** Lane A: call on phaseChange — markers and clicks are prep-only. */
setActive(on) {
active = !!on;
@ -439,9 +515,9 @@ export async function createRiggingUI({
domElement.removeEventListener('pointermove', onPointerMove);
removeEventListener('keydown', onKeyDown);
scene.remove(group);
ringGeo.dispose(); dotGeo.dispose(); previewGeo.dispose();
ringGeo.dispose(); dotGeo.dispose(); pickGeo.dispose(); pickMat.dispose(); previewGeo.dispose();
preview.material.dispose();
for (const m of markers) { m.mat.dispose(); m.label.material.map?.dispose(); m.label.material.dispose(); }
for (const m of markers) disposeMarker(m); // same teardown setWorld uses
el?.remove();
},
};

View File

@ -114,6 +114,48 @@ test('you cannot afford good hardware on all four corners', () => {
return `$${START_BUDGET} buys ${upgraded}/4 rated corners, then you are choosing your weak link`;
});
// ---- the site switch (SPRINT11 — Lane A's setBudget/setWorld asks) ----------
test('setBudget re-banks the shop and clears the night before it', () => {
const s = session();
for (const id of ['h1', 'h3', 'p1', 'p2']) s.rig(id); // $20 of carabiners hanging
assert(s.budget === START_BUDGET - 20, `budget $${s.budget}`);
s.setBudget(150); // a new night, a fatter wallet
assert(s.budget === 150, `re-banked budget is $${s.budget}, expected $150`);
// The bug this exists to stop: a re-banked wallet still holding last night's
// rig would read $150 with four corners already up — hardware nobody paid for.
assert(s.picks.length === 0, `${s.picks.length} picks survived the re-bank — this wallet never paid for them`);
// and the new bank has to STICK through a reset, or "play again" hands back $80
s.rig('h1'); s.reset();
assert(s.budget === 150, `reset fell back to $${s.budget} — setBudget must move the START budget, not just the balance`);
return `re-banked to $150, prep phase clean, sticks through reset`;
});
test('setAnchors moves the session to a new yard and drops the old picks', () => {
const s = session();
for (const id of ['h1', 'h3', 'p1', 'p2']) s.rig(id);
// the corner block: a different anchor SET, and no h1/p1 anywhere in it
const CORNER_BLOCK = [
{ id: 'q1', type: 'post', pos: { x: -3.95, y: 3.94, z: -2.82 } },
{ id: 'q3', type: 'post', pos: { x: 2.25, y: 3.99, z: 4.5 } },
{ id: 'cb1', type: 'post', pos: { x: -8.41, y: 2.29, z: -3 } },
{ id: 'cp2', type: 'post', pos: { x: -8.41, y: 1.68, z: -5.61 } },
].map((a) => ({ ...a, sway: () => a.pos }));
s.setAnchors(CORNER_BLOCK);
// `p4` on the backyard is not `p4` on the corner block, and `h1` is nowhere:
// picks are anchor IDs, so carrying them across a site rigs a quad from anchors
// that do not exist.
assert(s.picks.length === 0, `${s.picks.length} backyard picks followed the session to the corner block`);
assert(!s.rig('h1').ok, 'h1 is a backyard anchor and must not rig on the corner block');
assert(s.rig('cb1').ok, 'cb1 is a corner-block anchor and should rig');
return 'session followed the site; stale picks did not';
});
test('picks come back ring-ordered however you click them', () => {
const s = session();
// deliberately crossing order: two diagonals first

View File

@ -26,9 +26,13 @@
import { SAIL_TESTS } from '../sail.selftest.js';
import { RIGGING_TESTS } from '../rigging.selftest.js';
import { SWEEP_TESTS } from '../../../../tools/site_audit/sweep.selftest.js';
/** @param {import('../testkit.js').Suite} t */
export default function run(t) {
for (const [name, fn] of SAIL_TESTS) t.test(name, fn);
for (const [name, fn] of RIGGING_TESTS) t.test(`rigging: ${name}`, fn);
// site_audit's own sweep. SPRINT11: the tool was flying site_02 with the
// venturi switched off — the auditor needed an auditor. See sweep.selftest.js.
for (const [name, fn] of SWEEP_TESTS) t.test(`site_audit: ${name}`, fn);
}

View File

@ -16,7 +16,7 @@
import * as THREE from '../../vendor/three.module.js';
import { assert, fixedLoop } from '../testkit.js';
import { FIXED_DT, checkContract, DEBRIS_PIECE_FIELDS } from '../contracts.js';
import { loadStorm, createWind, forecastLines } from '../weather.js';
import { loadStorm, createWind, forecastLines, forecastFor, leadFor } from '../weather.js';
import { createDebris } from '../debris.js';
import { createSkyFx, RainShadow } from '../skyfx.js';
import { SailRig, HARDWARE } from '../sail.js';
@ -414,6 +414,64 @@ export default async function run(t) {
'the gentle storm advertised hail it does not have');
});
// SPRINT11 gate 2: the job sheet shows TOMORROW, and tomorrow becomes tonight.
// A band that widens or jumps as the night approaches is a card that lied
// once — so the RESOLVING is the contract, not just the width.
//
// What each half is worth, established by mutation (SPRINT11, see THREADS):
// · NESTING is the live one. It rests on the wander being the SAME seeded
// draw at every lead — reseed `r` per lead and this goes red immediately.
// · CONTAINMENT is structural under today's band(): the wander is 0.6 of the
// half-width, so c±w never crosses v, and the min/max clamps are belt-and-
// braces. Removing the clamps alone does NOT make it fail. It is kept as a
// contract guard for a future rewrite of band() (a percentile model, say),
// where it stops being free — not as proof of today's code.
t.test('the forecast band resolves as the night approaches: nests, and never sheds the truth', () => {
const keys = [
['sustained', (s) => s.sustained],
['gustPeak', (s) => s.gustPeak],
['rain', (s) => s.rainPeak],
['rainMmPerHour', (s) => s.rainPeakMmPerHour],
];
for (const [name, def] of Object.entries(storms)) {
for (const [k, truthOf] of keys) {
let prev = null;
// walk the week backwards: the far end -> tonight
for (let i = 40; i >= 0; i--) {
const f = forecastFor(def, i / 40);
const b = f[k];
const truth = truthOf(f.truth);
assert(b.lo <= truth + 1e-9 && b.hi >= truth - 1e-9,
`${name}.${k} @lead ${i / 40}: band ${b.lo}${b.hi} rules out the truth ${truth}`);
if (prev) {
assert(b.lo >= prev.lo - 1e-9 && b.hi <= prev.hi + 1e-9,
`${name}.${k} @lead ${i / 40}: band ${b.lo.toFixed(3)}${b.hi.toFixed(3)} is not inside `
+ `the vaguer ${prev.lo.toFixed(3)}${prev.hi.toFixed(3)} — it jumped instead of resolving`);
}
prev = b;
}
// and it must ARRIVE: tonight is the truth, not merely a narrow band
const exact = forecastFor(def, 0);
assert(exact[k].lo === exact[k].hi,
`${name}.${k}: tonight should be exact, got ${exact[k].lo}${exact[k].hi}`);
}
}
});
t.test('leadFor: tonight is exact, the far end of the week is vague, past it clamps', () => {
assert(leadFor(0, 5) === 0, 'tonight must be lead 0 — the job sheet is not a guess');
assert(leadFor(4, 5) === 1, "the week's far end must be lead 1");
assert(leadFor(1, 5) === 0.25, `tomorrow @5 nights should be 0.25, got ${leadFor(1, 5)}`);
assert(leadFor(9, 5) === 1, 'beyond the week must clamp, not exceed 1');
assert(leadFor(-1, 5) === 0, 'a night already survived must not go negative');
assert(leadFor(1, 1) === 1, 'a one-night week has no far end: anything but tonight is a guess');
// the point of the param: tomorrow reads hedged, tonight does not
const def = storms.storm_02_wildnight;
assert(!//.test(forecastLines(def, leadFor(0, 5)).wind), 'tonight should not hedge');
assert(//.test(forecastLines(def, leadFor(1, 5)).wind),
`tomorrow should hedge, got "${forecastLines(def, leadFor(1, 5)).wind}"`);
});
t.test('every storm in data/storms/ loads and validates', () => {
// loadStorm throws on invalid, so reaching here with all of them is the pass
assert(Object.keys(storms).length === STORMS.length, 'a storm failed to load');

View File

@ -85,6 +85,8 @@ const ASSETS = [
'beam_anchor_01', 'beam_anchor_02', 'post_anchor_01', 'post_anchor_02'] },
{ name: 'carport_01_wrecked', h: [1.9, 2.45],
nodes: ['footings', 'posts', 'beams', 'roof_down'] },
{ name: 'bike_kid_01', h: [0.60, 0.84],
nodes: ['wheel_rear', 'wheel_front', 'frame', 'bars'] },
];
function sizeOf(gltf) {
@ -185,6 +187,37 @@ export default async function run(t) {
assert(Math.abs(b.min.y) < 0.05, `wreck sits at y=${b.min.y.toFixed(2)} — roof sheet through the ground?`);
});
// A bike does not stand up on its own, so the lean is baked into the GLB
// rather than left as a rotation for Lane A to remember. That makes the lean
// DIRECTION an export-time fact, and export-time facts about axes are exactly
// what this file exists to catch (see the header): Blender is Z-up and the
// bike leans +Y there, but the exporter maps (x,y,z) -> (x,z,-y), so by the
// time A places it in three.js it leans -Z.
//
// Nothing else can catch this. The Blender-side verify re-imports through the
// same mapping and round-trips green, and the bounding box is the identical
// size whichever way the thing tips. If someone flips the export convention or
// quietly un-tilts the bike, the yard still loads, the dims still pass, and the
// only symptom is a bike leaning into open air next to a fence — which reads as
// a physics bug and would get chased in the wrong lane entirely.
t.test('the kid\'s bike leans, and it leans toward -Z (fence side)', () => {
const g = loaded.get('bike_kid_01');
assert(g, 'bike_kid_01 did not load');
const bars = g.scene.getObjectByName('bars');
assert(bars, 'bike_kid_01 has no bars group to measure the lean by');
// The bars are the top of the bike, so they travel furthest when it tips.
const b = new THREE.Box3().setFromObject(bars);
const z = (b.max.z + b.min.z) / 2;
assert(z < -0.05, `bars sit at z=${z.toFixed(3)} — the bike is upright or leaning the wrong way`);
// ...and it must still be ON the ground: the tyre contact points are the
// pivot, so an un-pivoted lean would push the wheels through or above it.
const all = new THREE.Box3().setFromObject(g.scene);
assert(Math.abs(all.min.y) < 0.03,
`bike sits at y=${all.min.y.toFixed(3)} — wheels through the ground or floating`);
});
t.test('carport anchors stay a trap — the corner block has no good tie-off', () => {
const g = loaded.get('carport_01');
assert(g, 'carport_01 did not load');

View File

@ -23,6 +23,30 @@ export {
const kmh = (ms) => ms * 3.6;
const band = (b, fmt) => (b.hi - b.lo < 0.05 ? fmt(b.lo) : `${fmt(b.lo)}${fmt(b.hi)}`);
/**
* How vague a night reads when it is `nightsOut` nights away the week IS the
* forecast horizon, so tonight is exact (0) and the far end of the week is as
* vague as this game forecasts (1). Linear, because that maps the week onto
* forecastFor's documented 0..1 domain with nothing invented in between.
*
* For a five-night week: tomorrow reads at lead 0.25 75% confidence, a
* sustained band about ±8% wide. Visibly hedged, still worth rigging to.
*
* Lane A: this is the job sheet's missing argument. `forecastLines(def, 0)` is
* tonight; `forecastLines(tomorrowDef, leadFor(1, wk.nights))` is tomorrow's
* band. Nights beyond the week (or a one-night week) clamp to 1 and 0.
*
* @param {number} nightsOut 0 = tonight, 1 = tomorrow
* @param {number} [weekNights] nights in the week (5)
* @returns {number} lead, 0..1
*/
export function leadFor(nightsOut, weekNights = 5) {
if (!(nightsOut > 0)) return 0;
const horizon = weekNights - 1;
if (!(horizon > 0)) return 1; // a one-night week: anything but tonight is a guess
return Math.min(1, nightsOut / horizon);
}
/**
* The forecast card's two stat lines, already worded — DESIGN.md's partial
* information made visible. Lane A owns the card; this owns the numbers, so the

Binary file not shown.