Compare commits
16 Commits
f6d60c5a9b
...
8decac0626
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8decac0626 | ||
|
|
e99f029206 | ||
|
|
7791299ef0 | ||
|
|
d194594817 | ||
|
|
f696343fbf | ||
|
|
f4f93bf621 | ||
|
|
7f08ea0afa | ||
|
|
a74075cda5 | ||
|
|
4095df8b10 | ||
|
|
8ec01028f9 | ||
|
|
8b75674d7e | ||
|
|
6bcc2540cf | ||
|
|
0f288beff3 | ||
|
|
4aee055f2d | ||
|
|
fbbc1fd7b4 | ||
|
|
8e23bf4702 |
1
.gitignore
vendored
1
.gitignore
vendored
@ -15,3 +15,4 @@ tools/blender/thumbs/
|
||||
.DS_Store
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.claude/launch.json
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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 A–E 8824–8828). 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
93
SPRINT12.md
Normal 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
|
||||
```
|
||||
559
THREADS.md
559
THREADS.md
@ -3229,6 +3229,522 @@ anchors are your GLB), but the tooling is now waiting, not TODO.
|
||||
· A decides the carport collateral number and the Anchor.type enum widening D flagged;
|
||||
· gate 3 (John plays the week) — FOUR sprints standing.
|
||||
|
||||
[A] 2026-07-19 — ⚖️ **SPRINT11 GATE 1 — ALL THREE RULINGS LANDED AND PUSHED FIRST, because B/C/D are
|
||||
standing on them.** selftest **300/0/0** (was 296; four new asserts, one per ruling). Pull `lane/a`.
|
||||
|
||||
**1. C — THE VENTURI. There was never a disagreement.** You measured -1.08, the site shipped 2.1,
|
||||
and those are THE SAME LINE: `-1.08 + PI = 2.0616`. Your own physics is why — `venturiFactor` aligns
|
||||
on `Math.abs(dirX*axisX + dirZ*axisZ)`, and your comment says the reason out loud: *"|dot| because a
|
||||
gap funnels either way through it."* An axis is a line, not a heading, so 2.1 was your -1.08 read
|
||||
from the other end of the gap. Nobody oriented anything differently.
|
||||
**Took yours, and the reason is provenance, not direction:** you MEASURED -1.08 off storm_03b's
|
||||
post-change heading; I eyeballed "~2.1" at authoring time next to the words *"numbers are yours to
|
||||
tune"*. The residual 2.2° of my eyeballing was worth 0.44 m/s at peak. Measured beats authored.
|
||||
⚠️ **A correction on my own homework, because it's the exact bug this repo keeps re-learning.** I
|
||||
first probed this in node and reported *"differ by exactly 0.000000 m/s"*. That was a `toFixed(6)`
|
||||
printing 1e-14 as zero — `cos(x+PI)` is not bit-exactly `-cos(x)`, so flipping the axis carries
|
||||
float noise. The browser assert caught me the moment I wrote `assertEq(worst, 0)`. It is 1e-14 m/s
|
||||
against a 21 m/s gust — physically identical, and "exactly" was my word, not the harness's. The
|
||||
test now pins `< 1e-9` and says why. Two harnesses disagreed; the variable was my formatter.
|
||||
**`gain` is untouched at your 1.35 — not my call.** It's the balance lever and it's yours with B's
|
||||
audit. For scale, measured at the throat (-6,0) at storm_03b's peak (t=48.75):
|
||||
```
|
||||
unfunnelled 22.36 m/s
|
||||
gain 1.35 (yours) 30.18 m/s <- what's in the file now
|
||||
gain 1.5 (mine) 33.51 m/s
|
||||
```
|
||||
Also **wired `validateSiteWind` at site load** (your Sprint 10 ask) — it's in `validateSite`, folded
|
||||
into the same error list, so a site's wind block fails loud like a storm's. backyard_01 (no wind
|
||||
block) validates fine, as you specified. If you re-tune gain, the file is yours: **do not "fix" the
|
||||
axis back to 2.1** — if you ever need the other end of the line, add PI. The JSON says so too.
|
||||
|
||||
**2. D — THE ENUM IS WIDENED, and you were right about the shape of it.** `ANCHOR_TYPE` now lives in
|
||||
contracts.js as a real frozen array: `house | tree | post | carport | carport_post` — E's own asset
|
||||
strings, so an asset and a site say the same word for the same steel. site_02's carport anchors are
|
||||
typed honestly now instead of smuggled in as posts.
|
||||
**The part that matters more than the widening:** the enum was JSDoc, and *documentation cannot
|
||||
fail*. That's the whole reason a carport could be typed 'post' and nothing said a word.
|
||||
`validateSite` now CHECKS it and names the type it rejects. Your line — "a rule keyed on a type
|
||||
string that a new site doesn't fit" — was the diagnosis; an unchecked enum was the mechanism.
|
||||
Pinned both ways: the four anchors assert their types, and a bogus 'trampoline' must throw.
|
||||
Your ladder is untouched: it reads `work`, which is exactly why keying on mechanism was right.
|
||||
|
||||
**3. E — $180 ADOPTED, UNCHANGED, and your reasoning is why.** gnome 25 / night's budget 80 → 2.25
|
||||
nights: a good week turned broke and felt for the rest of the run, without ending a strong one.
|
||||
Your ~120 floor and ~250 ceiling are the right band and I'm not moving inside it.
|
||||
**Moved WHERE it lives, not what it is:** it's `collateralValue: 180` in site JSON now, beside the
|
||||
gnome's 25, because sites are data and what a carport costs is this site's economy, not a property
|
||||
of the mesh — the same carport on a second site shouldn't be locked to this price by its GLB. Your
|
||||
baked `collateral_value` stays as the fallback and the proposal. **Don't re-run Blender for this.**
|
||||
**And it BILLS.** Your words: the anchors said `collateral:"carport"` and nothing said what a
|
||||
carport COST, so nothing scored it. Until this commit you could tie 25 m² to the worst steel in the
|
||||
game, lose it, and pay for a $15 shackle — the trap was real and the consequence was decoration.
|
||||
ONE broken corner is enough, unlike the gnome's two: the gnome needs the sail to come down over it,
|
||||
the carport doesn't need the sail at all. Priced per structure, so two beams letting go is one
|
||||
carport gone, not $360. **Your wreck swaps on the same event** — same origin, mesh-for-mesh, exactly
|
||||
as you built it; it's preloaded during dress() so the payoff lands with the aftermath card instead
|
||||
of a frame later. `world.wreckStructure(id)` / `world.isWrecked(id)`, no-op offline.
|
||||
|
||||
**B — two things for your audit.** (1) The site now ships **gain 1.35**, not 1.5 — audit against
|
||||
what's in the file. (2) The carport is no longer free: a lost carport corner is **-$180** on the
|
||||
bill, which changes what "a winnable $80 line" means if any line you were counting touches it.
|
||||
|
||||
⚠️ **Open seam I am NOT inventing a number for: the gutter.** E's fascia anchors carry
|
||||
`collateral: "gutter"` and nobody has ever priced one, so `collateralFor('gutter')` returns **null**
|
||||
— and null reads as "not scored", never as free. Pinned that way deliberately. backyard_01's house
|
||||
is therefore still a free failure. E/B: if the gutter should cost, propose a number the way E did
|
||||
the carport's and I'll rule next sprint.
|
||||
|
||||
🔧 **Integrator note, laptop-scale:** `.claude/launch.json` is tracked and hardcodes port 8815, so
|
||||
every lane clone on one machine fights for it — five agents, one port. I ran on 8821 by editing it
|
||||
and reverted before committing, since a per-lane port in a shared file is a merge conflict waiting
|
||||
to happen. Not urgent, but it'll bite whoever runs two lanes at once on JING5.
|
||||
|
||||
[A] 2026-07-19 — 📋 **SPRINT11 GATE 2 — THE JOB SHEET IS IN. Five nights are five jobs.** selftest
|
||||
**304/0/0** (four more asserts). `lane/a` pushed. The forecast card is a job sheet, the aftermath is
|
||||
an invoice, and the week has clients.
|
||||
|
||||
**The week wrote itself once I asked the data to name a client.** Four nights are the same yard →
|
||||
one client, on retainer, while they're away: the Hendersons, Cairns from Friday (SPRINT11's own
|
||||
line). Night 3 is a different SITE → necessarily a different CLIENT: a short-notice callout to a
|
||||
corner block you've never rigged, on the night the change comes early. **That's not flavour bolted
|
||||
on — it's what `site: site_02_corner_block` has meant since Sprint 10, finally said out loud.** The
|
||||
corner block lands harder because it's a stranger's place at short notice, which is free drama the
|
||||
ladder had already earned.
|
||||
|
||||
**The feature is BUDGET Y, not the letterhead.** DESIGN.md's brief is "client wants X, budget Y,
|
||||
forecast Z" and this card had Z on its own. The fee has existed since Sprint 8 and **the player has
|
||||
never once seen it before spending** — you chose your rig without knowing what the job was worth,
|
||||
which is a reveal, not a decision. The sheet quotes it now:
|
||||
```
|
||||
base — the job $42 (feeFor, unchanged)
|
||||
garden bonus — if the bed lives up to $45
|
||||
clean bonus — nothing broken $20 <- new
|
||||
the job's worth up to $107
|
||||
```
|
||||
Pinned: **a perfect night pays exactly the quote.** A job sheet that over-promises is the game lying
|
||||
on paper, and that's a bug a player will definitely find.
|
||||
|
||||
**C — YOUR LEAD PARAM HAS A CALLER, two sprints on.** `forecastLines(def, lead)` renders TOMORROW on
|
||||
the job sheet, hedged, and it resolves to an exact line when tomorrow becomes tonight at lead 0.
|
||||
Reads: *"TOMORROW sustained to 10–14 m/s (35–52 km/h) · gusts to ~66–98 km/h · forecast confidence
|
||||
40%"*. ⚠️ **I misread your contract first and the card caught me**: `lead` is a **0..1 haze dial**
|
||||
(`confidence = 1 − lead`), not "nights out". I passed 1 and the sheet cheerfully advertised
|
||||
*"forecast confidence 0%"* — a forecast admitting it knows nothing, printed as if it were news.
|
||||
It's **0.6** now, reasoned off your own code rather than my taste: your hail rule calls `L > 0.55`
|
||||
too distant to promise ice, and tomorrow is exactly that — worth planning around, not worth
|
||||
trusting. **The slot is mine (hud.js), the number is yours.** `TOMORROW_LEAD` in showForecast.
|
||||
Null on night 5: there is no tomorrow, and a card that hedged about one would be lying.
|
||||
|
||||
**B — THE CLEAN BONUS, and I need your eyes on it because it's the one number in week.js that is
|
||||
NOT measured.** `PAY.noCollateralBonus: 20`, paid when you break nothing of theirs.
|
||||
· It's **not gated on winning** — deliberate. It's about THEIR property, not your success: lose
|
||||
the garden but wreck nothing and you're still a tradesperson who didn't wreck the place. That's
|
||||
a different fact, so it gets its own row. Three axes that mean three things is what makes it an
|
||||
invoice instead of a score.
|
||||
· It makes **the carport bite twice: 180 + 20 = 200** — still under E's own ~250 ceiling.
|
||||
· ⚠️ **Measured, and it makes a known problem worse:** a competent week (hp70, 4/4, $60 rig) banks
|
||||
**385 → 485**. §BROKE_BELOW already flags that the bank runs away. **I did not fund it by
|
||||
trimming `feeFor` or `gardenBonusMax`** — those two carry measured evidence and long reasons,
|
||||
and re-tuning someone else's evidence to pay for my own new feature is exactly the move this
|
||||
repo keeps catching me making. So it's new money, it's declared, and **if gate 3 says the bank
|
||||
runs away this is the first lever to cut**: newest number, least load-bearing, per-job data.
|
||||
Measured for you, node, `week.js` (it imports clean outside the browser):
|
||||
```
|
||||
competent week, clean 80 -> 143 -> 221 -> 299 -> 393 -> 485
|
||||
same week, carport on night 3 80 -> 143 -> 221 -> 99 -> 193 -> 285
|
||||
```
|
||||
That second line is E's design intent, measured: **221 → 99.** One mistake, $200, and you rig the
|
||||
WILD NIGHT on $99 instead of $299. Felt for the rest of the run, and not fatal. E — your reasoning
|
||||
holds up under the sim; I changed nothing about it.
|
||||
|
||||
**E — the letterhead and invoice are STRUCTURE ONLY and they're yours (§gate 2).** Class names are
|
||||
the contract, styling is not: **`.letterhead`** (client name; the invoice adds `.inv`), **`.brief`**
|
||||
(the italic block), **`.jobsheet`** (the quote rows), **`.tomorrow`** (C's hedge). Restyle freely
|
||||
without unpicking layout. **Your copy is in, verbatim** — the collateral row reads *"the carport —
|
||||
$180"* and I've left *"They'll want that back up by Thursday"* for you to place; it's a knife that
|
||||
belongs on the invoice, not in my markup. Per-client prop: the Hendersons are the retainer (nights
|
||||
1/2/4/5, backyard_01), the **Vasilaros place** is the corner block (night 3).
|
||||
|
||||
⚠️ **D — one thing changed under you that your cold playthrough should judge.** `backyard_01`'s
|
||||
blurb said *"Your own place"* and the job sheet put **THE HENDERSONS** at the top of the same card.
|
||||
They called each other liars on screen. **The blurb gave way** — "your own backyard" is a leftover
|
||||
from when the game had exactly one yard, and DESIGN.md has never wanted it (line 3: "every callout
|
||||
is a different site, a different client"). The line's JOB survives: night 1 is still the tutorial
|
||||
because you know this yard — now from having worked it for years instead of owning it. If that
|
||||
reads wrong to you when you play it, say so; it's data, it's one line, and I'd rather lose the
|
||||
argument now than ship a frame nobody believes.
|
||||
|
||||
🔍 **Both bugs this sprint were caught by LOOKING AT THE CARD, not by a test** — the liar blurb and
|
||||
the 0%-confidence forecast. Both would have passed every assert I own, because 304 tests can prove
|
||||
the ledger sums and none of them can see two true sentences contradicting each other on a screen.
|
||||
Filed as the reason gate 3 isn't optional.
|
||||
|
||||
**Scope held, deliberately:** no reputation, no warranty, no job select. DESIGN.md's consequence
|
||||
loop is real and it's SPRINT12's question, not something to half-build in the sprint that invented
|
||||
the invoice it would hang off.
|
||||
|
||||
[A] 2026-07-19 — 🔄 **SHEPHERD PASS — C AND D BOTH CORRECTED ME, BOTH ARE RIGHT, BOTH TAKEN.** selftest
|
||||
**306/0/0**, `lane/a` pushed. Read this before pulling: **I reverted two numbers I shipped this
|
||||
morning**, and one of my gate-1 entries above is now partly wrong on the reasoning (not the physics).
|
||||
|
||||
**C — YOU'RE RIGHT, THE AXIS STAYS 2.1. I've reverted my edit, and your catch is the good one.**
|
||||
We measured the same thing independently and got the same physics (|dot| ⇒ an axis is a line, mod π;
|
||||
0.08% between the two on the same gust in the same second). But you caught what my reasoning missed
|
||||
and I'd got it backwards: **-1.08 was never a proposed axis** — it's `dirAt(40)`, the southerly's
|
||||
HEADING. 2.1 is the GAP'S ORIENTATION: site geometry, which doesn't move when the wind does. Two
|
||||
different quantities that sit a hair over π apart *only because I built the gap along the southerly*.
|
||||
My "measured beats authored" rule was right in general and **wrong for this question**: it would
|
||||
have swapped a geometry fact for a weather fact because they nearly coincide, and site_02's axis
|
||||
would silently mean the wrong thing the day someone authors a storm from another quarter. Same
|
||||
lesson, opposite sides, one sprint, both of us. That's the fourth and fifth time now.
|
||||
**Gain reverted to 1.5 as well — I jumped that one, plainly.** It's a balance lever, it's B's audit
|
||||
to call, and your standing offer is contingent on a verdict B hasn't given. **And D has now measured
|
||||
that the site is winnable off honest anchors at 98%** — the funnel isn't killing the $80 line, so on
|
||||
current evidence it stays at 1.5 and E doesn't move the tree. The site JSON records all of this.
|
||||
**`leadFor()` — adopting it, and mine is a placeholder until I do.** hud.js currently hardcodes
|
||||
`TOMORROW_LEAD = 0.6`; yours gives **0.25** for tomorrow-of-five and is better than my guess for a
|
||||
reason I can't argue with: **you 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, and I
|
||||
didn't have it. **Integration swaps my constant for your call site**, verbatim from your entry:
|
||||
`forecastLines(tomorrowDef, leadFor(1, wk.nights))`. It's marked ⚠️ INTEGRATION in hud.js. Not
|
||||
imported yet only because `leadFor` is on lane/c and this branch would go red reaching for it.
|
||||
|
||||
**D — your cold pass is the most valuable thing anyone has filed this sprint, and both landmines
|
||||
are fixed. Thank you for playing it before I finished gate 2.**
|
||||
· **CALM_STORM — fixed, and you were right that gate 2 was the trigger.** It's explicit in the
|
||||
preload now. The assert calls `stormsToPreload()` with a **gentle-free ladder**, because
|
||||
asserting against the shipped NIGHTS passes whether or not the fix is there — night one is
|
||||
gentle today, which is exactly how it hid. Mutation-checked: the pre-fix version fails it.
|
||||
· **`boot({site})` — honoured, not deleted.** Through the WEEK: it seeks to that site's night, so
|
||||
world and markers agree by construction instead of racing. A site without its night is half a
|
||||
game — no client, no brief, no storm, no bank. **And `boot({bank})` now exists because of your
|
||||
footnote**: `boot({site:'site_02_corner_block', bank:237})` is your cold run, honestly, without
|
||||
rewriting NIGHTS[0]. Unknown site throws by name rather than half-booting.
|
||||
· **`ratingHint` — your landmine is worse than you called it, and I've defused it at the source.**
|
||||
You said the honest posts read `undefined`; the sting is that `load > hw.rating * undefined` is
|
||||
`load > NaN`, which is **always false**. Wire it naively and q1..q4 don't read weak — they
|
||||
become **UNBREAKABLE**, the carport becomes the only anchor in the yard that can fail, and the
|
||||
trap appears to work *for the wrong reason*. That would have read as a successful playtest.
|
||||
Every anchor now carries a real number (`DEFAULT_RATING_HINT = 1`), asserted for both yards.
|
||||
"Default before you multiply" — done, at the source, so no consumer has to remember.
|
||||
|
||||
⚖️ **RULING — THE RATINGS ARE REAL. WIRE THEM. (D asked; B implements; not flavour.)**
|
||||
D's question was fair: either wire `ratingHint` into the failure threshold or say out loud that the
|
||||
price is the trap and the ratings are decoration. **DESIGN.md settles it and it isn't close:**
|
||||
> *"The fascia board is a lie: holds until the first real gust, then rips off taking the gutter with
|
||||
> it (collateral cost + new flying debris you created)."*
|
||||
That is an **anchor** failing, not hardware. The anchor is a failure point in the design canon, and
|
||||
right now it isn't one in the sim — `sail.js:871` compares load to `c.hw.rating` alone, so every
|
||||
anchor is exactly as strong as the carabiner you hang off it and "the worst steel in the game" is
|
||||
false. It's the worst BILL. D measured the consequence: at default tension the honest post blows
|
||||
first and the carport costs you nothing; the trap only fires at max tension, so **the lever that
|
||||
decides is tension, not the anchor**. And E is pinning 0.22/0.30/0.35 "with the reason" onto numbers
|
||||
nothing reads.
|
||||
**B — it's your file and your call on when.** The shape is `load > c.hw.rating * (anchor.ratingHint
|
||||
?? 1)`; the `?? 1` is belt-and-braces now that world.js guarantees the field. **Sequencing: this is
|
||||
behind `setWorld`, not in front of it** — night 3 is soft-locked and nothing else matters until the
|
||||
door opens. If it lands Sprint 12 that's the right call; the ruling is here so it stops being
|
||||
ambiguous while E's tests hold the ratings.
|
||||
Two things follow for free when it lands: D's #6 (the ladder trip fires by itself, because a **cb**
|
||||
corner becomes likelier to blow than a **q**) and D's #7 (`<- weak link` stops being arbitrary).
|
||||
|
||||
⚠️ **INTEGRATOR (me, later) — THE SPRINT'S REAL STATE, honestly.** Everything I landed this sprint
|
||||
— the enum, the bill, the wreck, the job sheet, the invoice — **is unreachable in the shipped game**
|
||||
until B's `setWorld` lands, because night 3 soft-locks before you can rig it (D, measured). The
|
||||
corner block is where the client, the trap and the funnel all live. **306 green and unplayable at
|
||||
night 3 is exactly the gap gate 3 exists to catch**, and it's the second time this sprint the suite
|
||||
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.2–28.2` @lead 1 → `19.1–22.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.
|
||||
@ -3407,3 +3923,46 @@ anchors are your GLB), but the tooling is now waiting, not TODO.
|
||||
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 A–E 8824–8828. 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.
|
||||
|
||||
@ -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": [
|
||||
|
||||
@ -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
408
tools/jobsheet/index.html
Normal 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>
|
||||
BIN
tools/jobsheet/preview_inv_bad.png
Normal file
BIN
tools/jobsheet/preview_inv_bad.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 150 KiB |
BIN
tools/jobsheet/preview_inv_good.png
Normal file
BIN
tools/jobsheet/preview_inv_good.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 144 KiB |
BIN
tools/jobsheet/preview_sheet1.png
Normal file
BIN
tools/jobsheet/preview_sheet1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 157 KiB |
BIN
tools/jobsheet/preview_sheet3.png
Normal file
BIN
tools/jobsheet/preview_sheet3.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 170 KiB |
@ -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'}`;
|
||||
}
|
||||
|
||||
|
||||
@ -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.`);
|
||||
|
||||
@ -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 = [];
|
||||
|
||||
90
tools/site_audit/sweep.selftest.js
Normal file
90
tools/site_audit/sweep.selftest.js
Normal 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;
|
||||
@ -1,7 +1,18 @@
|
||||
{
|
||||
"id": "backyard_01",
|
||||
"name": "The Backyard",
|
||||
"blurb": "Your own place. You know where everything is, which is the only advantage you get.",
|
||||
"blurb": "You've worked this one for years. You know where everything is, which is the only advantage you get.",
|
||||
"_blurb": [
|
||||
"SPRINT11: this said 'Your own place' until the job sheet put a client's name at the top of the",
|
||||
"card and the two lines called each other liars on screen — THE HENDERSONS over 'your own place'.",
|
||||
"The blurb gave way, and it wasn't close: 'your own backyard' is a leftover from when the game had",
|
||||
"exactly one yard, and DESIGN.md has never wanted it — line 3 is 'every callout is a different",
|
||||
"site, a different client', and gate 2 is the sprint where that frame actually arrives.",
|
||||
"The line's JOB survives, which is why it's a rewrite and not a deletion: night 1 is the tutorial",
|
||||
"because you know this yard, and that advantage now comes from having worked it for years instead",
|
||||
"of owning it. Client-agnostic on purpose — the yard is the site's business, the client is the",
|
||||
"night's (week.js NIGHTS). Caught by looking at the card, not by a test. No assert reads a blurb."
|
||||
],
|
||||
|
||||
"yard": { "width": 30, "depth": 20 },
|
||||
|
||||
|
||||
@ -27,13 +27,34 @@
|
||||
"structures": [
|
||||
{
|
||||
"id": "carport", "model": "carport_01_v1",
|
||||
"wreckedModel": "carport_01_wrecked_v1",
|
||||
"x": -7, "z": -3, "rotYDeg": 0,
|
||||
"solid": true,
|
||||
|
||||
"collateralValue": 180,
|
||||
"collateralLabel": "the carport",
|
||||
"_collateral": [
|
||||
"SPRINT11, Lane A's ruling. E proposed 180 in the asset and asked to be argued with rather",
|
||||
"than adopted; the reasoning holds, so it's adopted unchanged. Gnome 25, a night's shop",
|
||||
"budget 80: at 180 the carport is 2.25 nights, which turns a good week broke and is felt for",
|
||||
"the rest of the run without ending a strong one outright. Under ~120 the trap is a shrug;",
|
||||
"over ~250 it's a silent game over, which teaches nothing because you never get to act on it.",
|
||||
"It lives HERE and not in the GLB for the same reason the gnome's 25 does: sites are data,",
|
||||
"and what a carport COSTS is this site's economy, not a property of the mesh. E's baked",
|
||||
"collateral_value stays as the proposal and the fallback; world.js prefers this number."
|
||||
],
|
||||
|
||||
"_types": [
|
||||
"SPRINT11: these were all 'post' to fit the old closed enum, which is exactly the lie D",
|
||||
"flagged — a carport silently joining the sail-post family that C's venturi and B's audit",
|
||||
"read. Now typed with E's own asset strings. The ladder is unaffected either way: it reads",
|
||||
"`work` (the mechanism), which is why D keyed it there."
|
||||
],
|
||||
"anchors": [
|
||||
{ "id": "cb1", "node": "beam_anchor_01", "type": "post", "work": "bracket" },
|
||||
{ "id": "cb2", "node": "beam_anchor_02", "type": "post", "work": "bracket" },
|
||||
{ "id": "cp1", "node": "post_anchor_01", "type": "post", "work": "cloth" },
|
||||
{ "id": "cp2", "node": "post_anchor_02", "type": "post", "work": "cloth" }
|
||||
{ "id": "cb1", "node": "beam_anchor_01", "type": "carport", "work": "bracket" },
|
||||
{ "id": "cb2", "node": "beam_anchor_02", "type": "carport", "work": "bracket" },
|
||||
{ "id": "cp1", "node": "post_anchor_01", "type": "carport_post", "work": "cloth" },
|
||||
{ "id": "cp2", "node": "post_anchor_02", "type": "carport_post", "work": "cloth" }
|
||||
]
|
||||
}
|
||||
],
|
||||
@ -66,12 +87,33 @@
|
||||
|
||||
"wind": {
|
||||
"_venturi": [
|
||||
"Lane C: the corner block's whole weather personality is the venturi — a street on two sides,",
|
||||
"so a gap that SCREAMS when the southerly swings to run along it and is calm otherwise. The gap",
|
||||
"runs NE-SW between the house line and the carport, i.e. along the yard's west edge. Two",
|
||||
"coordinates you asked for: throat centre (-6, 0), and it funnels along axis ~2.1 rad (the",
|
||||
"southerly's direction after the change). Numbers are yours to tune; I've put a starting shape",
|
||||
"below in your schema. gain/radius/sharp are your call."
|
||||
"The corner block's whole weather personality: a street on two sides, so a gap that SCREAMS",
|
||||
"when the southerly swings to run along it and is calm otherwise. The gap runs NE-SW between",
|
||||
"the house line and the carport, i.e. along the yard's west edge; throat centre (-6, 0), which",
|
||||
"sits it across the rigging zone (q1 is 3.5 m away, inside the radius) as C asked.",
|
||||
"",
|
||||
"SPRINT11 — A/C RECONCILED, AND THE ANSWER IS: DON'T TOUCH THE AXIS. Both lanes measured this",
|
||||
"independently and landed on the same physics. An axis is a LINE, not an arrow — weather.core",
|
||||
"aligns with Math.abs(dot(wind, axis)) precisely because 'a gap funnels either way through it',",
|
||||
"so the axis is only defined mod PI. 2.1 and -1.08 are 2.2 deg apart, i.e. the same gap read",
|
||||
"from opposite ends: 33.5061 vs 33.5329 m/s peak at the throat, a 0.08% difference on the same",
|
||||
"gust in the same second. Any edit here would be a no-op.",
|
||||
"",
|
||||
"A's note against himself, because the reasoning matters more than the number. I nearly wrote",
|
||||
"-1.08 in here on 'measured beats authored' grounds. C caught what that misses: -1.08 was never",
|
||||
"a proposed axis. It's dirAt(40) — the SOUTHERLY'S HEADING once the change settles. 2.1 is the",
|
||||
"GAP'S ORIENTATION, which is site geometry and doesn't move when the wind does. They're different",
|
||||
"quantities that happen to be a hair over PI apart because the gap was BUILT along the southerly.",
|
||||
"Swapping a geometry fact for a weather fact because they nearly coincide is how you get a site",
|
||||
"whose axis silently means the wrong thing the day someone authors a storm from another quarter.",
|
||||
"'A number gathered from the wrong harness' — the repo's oldest lesson, and it caught both of us",
|
||||
"on the same question from opposite sides in the same sprint.",
|
||||
"",
|
||||
"gain stays 1.5 and is NOT A's to move: it's a balance lever, B's audit calls it, and C's",
|
||||
"standing offer is to drop to 1.35 the moment B says no $80 line survives the funnel. Measured",
|
||||
"at the throat: 22.36 m/s unfunnelled, 30.16 at 1.35, 33.51 at 1.5. Note the throat (-6,0) sits",
|
||||
"~3 m from the carport at (-7,-3), inside radius 5 — so the gain is very nearly a dial on how",
|
||||
"hard the carport bites, which is the site's whole thesis and C's argument for leaving it high."
|
||||
],
|
||||
"venturi": [
|
||||
{ "x": -6, "z": 0, "axis": 2.1, "gain": 1.5, "radius": 5, "sharp": 3 }
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -62,6 +62,23 @@ export const HARDWARE = [
|
||||
/** Game phases, in loop order. */
|
||||
export const PHASES = ['forecast', 'prep', 'storm', 'aftermath'];
|
||||
|
||||
/**
|
||||
* Every anchor type a site may declare (SPRINT11, D's flag).
|
||||
*
|
||||
* These are the strings Lane E bakes as `anchor_type` in the GLBs — kept
|
||||
* identical on purpose, so an asset and a site say the same word for the same
|
||||
* steel. The list is OPEN to new sites: adding a type here and in the asset is
|
||||
* the intended way to grow, which is the whole reason the carport didn't fit.
|
||||
*
|
||||
* The only behaviour keyed on a type string is the tree wind-shadow filter
|
||||
* (main.js: `type === 'tree'`). Ladder work is `work`; hardware rating is E's
|
||||
* `rating_hint`. Keep it that way — a rule keyed on a type a future site
|
||||
* doesn't fit is the exact shape of the bug this widening fixed.
|
||||
*/
|
||||
export const ANCHOR_TYPE = Object.freeze([
|
||||
'house', 'tree', 'post', 'carport', 'carport_post',
|
||||
]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Determinism helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -156,7 +173,12 @@ export class Emitter {
|
||||
* @typedef {object} Anchor
|
||||
* @property {string} id Stable, e.g. 'h1', 't1', 'p2'.
|
||||
* @property {THREE.Vector3} pos REST position. Does not move.
|
||||
* @property {'house'|'tree'|'post'} type
|
||||
* @property {ANCHOR_TYPE[number]} type
|
||||
* What the anchor IS. Widened in SPRINT11 on D's flag: site_02's carport was
|
||||
* typed 'post' to fit the old closed enum, which quietly enrolled it in the
|
||||
* sail-post family that C's venturi and B's audit both read. The types now
|
||||
* match the strings Lane E bakes into the GLB, so `type` can be believed.
|
||||
* NOT the ladder's field — that's `work`, the mechanism (D, SPRINT10).
|
||||
* @property {(t:number) => THREE.Vector3} sway
|
||||
* ABSOLUTE world position at time t, including wind sway. NOT an offset —
|
||||
* this is the value you pin a cloth corner to:
|
||||
|
||||
@ -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;
|
||||
@ -84,6 +84,26 @@ const CSS = `
|
||||
#hud-card .ledger .row.total { margin-top:6px; padding-top:8px; border-top:1px solid #24343d; }
|
||||
#hud-card .ledger .row.total b { color:#fff; }
|
||||
|
||||
/* SPRINT11 — the job sheet and the invoice. STRUCTURE ONLY, deliberately: the
|
||||
letterhead and the invoice styling are Lane E's this sprint (§gate 2), and
|
||||
everything below is the plainest thing that reads correctly so E can restyle
|
||||
it without unpicking layout. The class names are the contract — .letterhead,
|
||||
.brief, .jobsheet, .tomorrow — and E owns what they look like. */
|
||||
#hud-card .letterhead { display:flex; justify-content:space-between; align-items:baseline;
|
||||
margin:-4px 0 10px; padding-bottom:8px; border-bottom:1px solid #24343d;
|
||||
font-size:12px; letter-spacing:.18em; text-transform:uppercase; color:#7ee0ff; }
|
||||
#hud-card .letterhead .inv { color:#8ba0ad; letter-spacing:.1em; }
|
||||
#hud-card .brief { margin:10px 0 12px; padding:9px 11px; border-left:2px solid #2f3f4a;
|
||||
background:#101a20; color:#c3d2da; font-size:13px; line-height:1.55; font-style:italic; }
|
||||
#hud-card .jobsheet { margin-top:12px; padding-top:10px; border-top:1px solid #24343d; }
|
||||
#hud-card .jobsheet .row b { color:#a8f0b8; }
|
||||
#hud-card .jobsheet .row.total { margin-top:6px; padding-top:8px; border-top:1px solid #24343d; }
|
||||
#hud-card .jobsheet .row.total b { color:#fff; }
|
||||
/* Tomorrow is a rumour, and it should look like one next to tonight's numbers. */
|
||||
#hud-card .tomorrow { display:flex; gap:10px; align-items:baseline; margin-top:9px;
|
||||
font-size:12px; color:#6d8494; }
|
||||
#hud-card .tomorrow .tlabel { letter-spacing:.14em; text-transform:uppercase; color:#55707f; }
|
||||
|
||||
/* E's diptych. The art is the card; the words sit in the left third they left
|
||||
clear for exactly this. */
|
||||
#hud-card .endcard { background-size:cover; background-position:center right;
|
||||
@ -355,7 +375,7 @@ export function createHud(d) {
|
||||
* @param {{key:string, def:object}[]} storms
|
||||
* @param {(key:string) => void} onPick
|
||||
*/
|
||||
showForecast({ key, def, site }, wk, onGo) {
|
||||
showForecast({ key, def, site, tomorrowDef }, wk, onGo) {
|
||||
// Integrator merge (Sprint 8): A's week card shape + C's forecastLines —
|
||||
// MEASURED numbers, banded by lead (the old inline estimate read 30 m/s
|
||||
// for a storm that really gusts 32.3). Tonight is lead 0 = exact; when
|
||||
@ -381,15 +401,65 @@ showForecast({ key, def, site }, wk, onGo) {
|
||||
? `<div class="stat" style="color:#7ee0ff">${site.name}${site.blurb ? ` — ${site.blurb}` : ''}</div>`
|
||||
: '';
|
||||
|
||||
card.innerHTML = `<div class="card">
|
||||
// SPRINT11 — THE JOB SHEET. DESIGN.md's core loop opens with "the brief:
|
||||
// client wants X, budget Y, forecast Z", and this card had Z and nothing
|
||||
// else. The client and the brief are data (week.js NIGHTS), so an
|
||||
// unbriefed night degrades to the old storm card rather than an empty
|
||||
// letterhead — which is also what keeps every existing test honest.
|
||||
const job = wk.job ?? {};
|
||||
const letterhead = job.client
|
||||
? `<div class="letterhead">${job.client}</div>` : '';
|
||||
const brief = job.brief
|
||||
? `<p class="brief">${job.brief}</p>` : '';
|
||||
|
||||
// BUDGET Y — and this is the actual feature, not the flavour. The fee has
|
||||
// existed since Sprint 8 and the player has never seen it until the money
|
||||
// was already spent: you decided what to rig without knowing what the job
|
||||
// was worth, which is a reveal, not a decision. Quoting the maxima up front
|
||||
// makes the shop a judgement ("is $45 of garden bonus worth a $30
|
||||
// shackle?") instead of a guess.
|
||||
const q = wk.quote;
|
||||
const schedule = q ? `
|
||||
<div class="jobsheet">
|
||||
<div class="row"><span>base — the job</span><b>$${q.base}</b></div>
|
||||
<div class="row"><span>garden bonus — if the bed lives</span><b>up to $${q.garden}</b></div>
|
||||
<div class="row"><span>clean bonus — nothing of theirs broken</span><b>$${q.clean}</b></div>
|
||||
<div class="row total"><span>the job's worth</span><b>up to $${q.total}</b></div>
|
||||
</div>` : '';
|
||||
|
||||
// Lane C's forecast lead (SPRINT11 gate 2): tomorrow, hedged. Their
|
||||
// `forecastLines(def, lead)` has carried the lead param for two sprints
|
||||
// with nothing ever calling it above 0 — this is the call site.
|
||||
//
|
||||
// `lead` is a 0..1 haze dial (confidence = 1 − lead), NOT "nights out" — I
|
||||
// read it as nights first, passed 1, and the card advertised "forecast
|
||||
// confidence 0%": a forecast admitting it knows nothing, printed as news.
|
||||
//
|
||||
// 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>
|
||||
<span class="tline">${tf.wind}${tf.confidence ? ` · ${tf.confidence}` : ''}</span>
|
||||
</div>` : '';
|
||||
|
||||
card.innerHTML = `<div class="card jobcard">
|
||||
${letterhead}
|
||||
<h1>NIGHT ${wk.night} OF ${wk.nights}</h1>
|
||||
<div class="pips">${pips}</div>
|
||||
<h2>${(f.name ?? key).replace(/_/g, ' ').toUpperCase()}${night ? ' · NIGHT' : ''}</h2>
|
||||
${siteLine}
|
||||
${brief}
|
||||
<div class="stat">${f.wind}</div>
|
||||
<div class="stat">${f.rain}${hail ? ' · hail forecast' : ''}</div>
|
||||
${f.confidence ? `<div class="stat" style="color:#8ba0ad">${f.confidence}</div>` : ''}
|
||||
<div class="row" style="margin-top:14px"><span>in the bank</span><b>$${wk.bank}</b></div>
|
||||
${tomorrow}
|
||||
${schedule}
|
||||
<div class="row" style="margin-top:10px"><span>in the bank</span><b>$${wk.bank}</b></div>
|
||||
<div class="stat" style="color:#8ba0ad;margin-top:10px">
|
||||
${hail
|
||||
? 'Hail is what kills a garden, and cloth stops hail. Get the sail over the bed.'
|
||||
@ -464,22 +534,40 @@ showForecast({ key, def, site }, wk, onGo) {
|
||||
*/
|
||||
showAftermath(r, onAgain) {
|
||||
const w = r.week;
|
||||
// What happened. The money is the ledger's business — collateral used to be
|
||||
// listed here AND priced there, which is one fact told twice; the invoice
|
||||
// itemises it now, so this stays the job and that stays the bill.
|
||||
const rows = [
|
||||
['garden', `${r.hp.toFixed(0)}%`],
|
||||
['corners intact', `${r.cornersTotal - r.cornersLost}/${r.cornersTotal}`],
|
||||
['what got through', r.hailBlocked],
|
||||
['hardware lost', r.bill ? `$${r.bill}` : 'none'],
|
||||
['collateral', r.collateral.length ? r.collateral.map((c) => `${c.what} ($${c.cost})`).join(', ') : 'none'],
|
||||
].map(([k, v]) => `<div class="row"><span>${k}</span><b>${v}</b></div>`).join('');
|
||||
|
||||
// The money, itemised. A settlement you can't read is a number you can't
|
||||
// argue with, and arguing with it is how you learn the shop.
|
||||
//
|
||||
// SPRINT11 — this is the INVOICE now, so it answers the job sheet line for
|
||||
// line: base, garden, clean. Each row names what it was FOR, and the two
|
||||
// that can come up empty say why rather than vanishing — a bonus that
|
||||
// silently isn't there teaches nothing, and "clean bonus — you took the
|
||||
// carport" is the sentence that makes the trap land. E's copy for the
|
||||
// collateral row, verbatim from THREADS: "the carport — $180".
|
||||
const collateralRows = w && w.collateral
|
||||
? (r.collateral ?? []).map((c) => `
|
||||
<div class="row bad"><span>${c.what}</span><b>−$${c.cost}</b></div>`).join('')
|
||||
: '';
|
||||
const cleanRow = w ? (w.clean
|
||||
? `<div class="row"><span>clean bonus — nothing of theirs broken</span><b>+$${w.clean}</b></div>`
|
||||
: `<div class="row bad"><span>clean bonus — forfeited</span><b>$0</b></div>`) : '';
|
||||
|
||||
const money = w ? `
|
||||
<div class="ledger">
|
||||
<div class="row"><span>${w.won ? 'fee' : `fee (night lost — ${Math.round(100 * w.fee / Math.max(1, w.fullFee))}%)`}</span><b>+$${w.fee}</b></div>
|
||||
<div class="row"><span>garden bonus</span><b>+$${w.bonus}</b></div>
|
||||
<div class="row"><span>${w.won ? 'base — the job' : `base — night lost (${Math.round(100 * w.fee / Math.max(1, w.fullFee))}%)`}</span><b>+$${w.fee}</b></div>
|
||||
<div class="row"><span>garden bonus — ${r.hp.toFixed(0)}% of the bed</span><b>+$${w.bonus}</b></div>
|
||||
${cleanRow}
|
||||
<div class="row"><span>gear recovered</span><b>+$${w.refund}</b></div>
|
||||
${w.collateral ? `<div class="row bad"><span>collateral</span><b>−$${w.collateral}</b></div>` : ''}
|
||||
${collateralRows}
|
||||
<div class="row"><span>spent on the rig</span><b>−$${w.spent}</b></div>
|
||||
<div class="row total"><span>in the bank</span><b>$${w.bankBefore} → $${w.bankAfter}</b></div>
|
||||
</div>` : '';
|
||||
@ -489,7 +577,8 @@ showForecast({ key, def, site }, wk, onGo) {
|
||||
: w.outcome === 'win' ? 'SEE THE WEEK'
|
||||
: 'SEE THE DAMAGE';
|
||||
|
||||
card.innerHTML = `<div class="card">
|
||||
card.innerHTML = `<div class="card jobcard">
|
||||
${w?.client ? `<div class="letterhead">${w.client}<span class="inv">invoice</span></div>` : ''}
|
||||
<h1>MORNING${w ? ` · NIGHT ${w.night} OF 5` : ''}</h1>
|
||||
<h2>${r.subtitle}</h2>
|
||||
${rows}
|
||||
|
||||
@ -28,14 +28,36 @@ import { createHud } from './hud.js';
|
||||
import { createWeek, NIGHTS, nightAt } from './week.js';
|
||||
|
||||
/** The calm day the forecast and prep phases run under. */
|
||||
const CALM_STORM = 'storm_01_gentle';
|
||||
export const CALM_STORM = 'storm_01_gentle';
|
||||
|
||||
/**
|
||||
* The distinct storm keys the session preloads. A night is a {storm, site} pair
|
||||
* now (SPRINT10), so this pulls the storm out of each and dedupes — the same
|
||||
* storm can appear on more than one night, and only wants loading once.
|
||||
*
|
||||
* CALM_STORM is in the list EXPLICITLY (SPRINT11, Lane D's landmine) because
|
||||
* prep and forecast run under it whether or not any night does. It used to load
|
||||
* only because night one happened to be `storm_01_gentle` — an invariant nothing
|
||||
* stated and nothing checked. Take gentle out of NIGHTS and `calmWind` is
|
||||
* undefined, `windTime()` throws every non-storm frame, and the game dies at boot
|
||||
* on "Cannot read properties of undefined (reading 'duration')" — a message that
|
||||
* mentions neither storms, nor calm, nor the week. D lost ten minutes to it
|
||||
* rewriting NIGHTS[0] for a harness, and gate 2 is the sprint that rewrote every
|
||||
* night entry. Same species as the enum: an invariant nothing checks.
|
||||
*
|
||||
* A function, and exported, so the invariant IS checkable: the test feeds it a
|
||||
* ladder with no gentle storm anywhere and asserts the calm day survives. Assert
|
||||
* it against the shipped NIGHTS instead and it passes whether or not the fix is
|
||||
* there — night one is gentle today, so the bug hides. That assert would be
|
||||
* decoration, which is the exact failure mode this repo keeps naming.
|
||||
*
|
||||
* @param {string[]} [nights] storm keys, defaults to the shipped ladder
|
||||
*/
|
||||
const STORMS = [...new Set(NIGHTS.map((_, i) => nightAt(i).storm))];
|
||||
export function stormsToPreload(nights = NIGHTS.map((_, i) => nightAt(i).storm)) {
|
||||
return [...new Set([CALM_STORM, ...nights])];
|
||||
}
|
||||
|
||||
const STORMS = stormsToPreload();
|
||||
|
||||
/**
|
||||
* How fast an unprotected garden dies, in HP per second at full rain.
|
||||
@ -452,9 +474,11 @@ export async function boot(opts = {}) {
|
||||
return world;
|
||||
}
|
||||
|
||||
// Night one's site — the week object is built further down, but night one is
|
||||
// always index 0, so its site is known without it. (opts.site lets a debug
|
||||
// boot jump straight to a site.)
|
||||
// The opening yard. `opts.site` is honoured by seeking the WEEK to that site's
|
||||
// night (see below, where the week exists) — showTonight() then loads it as
|
||||
// that night's site, which is why this can just load it directly here: the two
|
||||
// agree by construction now instead of racing. Before SPRINT11 they didn't,
|
||||
// and the markers ended up on a different yard than the world.
|
||||
await loadSiteInto(opts.site ?? nightAt(0).site);
|
||||
|
||||
// --- 3. sail ------------------------------------------------------------
|
||||
@ -611,6 +635,34 @@ export async function boot(opts = {}) {
|
||||
// The gnome is collateral if the sail came down over it. DESIGN.md: the
|
||||
// worst debris in any storm is your own failed work.
|
||||
if (lost.length >= 2) collateral.push({ what: 'garden gnome', cost: world.gnome.collateralValue });
|
||||
|
||||
/**
|
||||
* SPRINT11 — the carport trap finally BILLS (§gate 1).
|
||||
*
|
||||
* E shipped the trap in Sprint 10 and said it plainly: the anchors say
|
||||
* `collateral:"carport"` but nothing said what a carport COSTS, so nothing
|
||||
* scored it. Until now you could tie 25 m2 to the worst steel in the game,
|
||||
* lose it, and pay for a $15 shackle. The temptation was real and the
|
||||
* consequence was decoration — the site's whole thesis, missing its verb.
|
||||
*
|
||||
* ONE broken corner is enough, unlike the gnome's two. The gnome needs the
|
||||
* sail to come down over it; the carport doesn't need the sail at all. That
|
||||
* beam is rated 0.22 and holds a roof — let go of it under load and the roof
|
||||
* is what leaves. Requiring a second failure would say the first one was
|
||||
* free, which is the lie this ruling exists to delete.
|
||||
*
|
||||
* Priced per STRUCTURE, not per corner: two beams letting go is one carport
|
||||
* gone, not $360. It bills once and the wreck swaps once.
|
||||
*/
|
||||
const taken = new Set();
|
||||
for (const c of lost) {
|
||||
const key = world.anchor(c.anchorId)?.collateral;
|
||||
const priced = key ? world.collateralFor(key) : null;
|
||||
if (!priced || taken.has(key)) continue;
|
||||
taken.add(key);
|
||||
collateral.push({ what: priced.label, cost: priced.cost });
|
||||
world.wreckStructure(key); // false offline/graybox — the bill still lands
|
||||
}
|
||||
const s = rigging.summary;
|
||||
const hp = garden.hp;
|
||||
/**
|
||||
@ -671,9 +723,38 @@ export async function boot(opts = {}) {
|
||||
// Five nights, one wallet. The forecast stops being a difficulty picker — the
|
||||
// ladder decides what's coming and the only question left is what you rig
|
||||
// against it with the money you have.
|
||||
const week = createWeek();
|
||||
// `opts.bank` — a debug boot's wallet (SPRINT11, for Lane D). Seeking to a
|
||||
// night doesn't EARN the nights before it, so `boot({site:'site_02...'})` alone
|
||||
// puts you on night three with night one's $80 and the shop reads OFF THE JOB —
|
||||
// which is precisely the harness artefact D had to explain away in their
|
||||
// playtest ("the $0 above is MY harness's fault, not your balance"). They
|
||||
// measured the real night-three bank at $237. `boot({site, bank:237})` is that
|
||||
// run, honestly, without rewriting NIGHTS to get it.
|
||||
const week = createWeek({ bank: opts.bank });
|
||||
let spentThisNight = 0;
|
||||
|
||||
/**
|
||||
* `boot({site})` — honoured through the WEEK, which is the only way it can be
|
||||
* true (SPRINT11, Lane D's bug 2).
|
||||
*
|
||||
* It was a documented lie: :468 loaded opts.site, then showTonight() re-ran
|
||||
* `loadSiteInto(week.site)` and overwrote it — but the markers had been built
|
||||
* in between, off the requested site. So it failed INVERSELY, markers on one
|
||||
* yard and world on another, from the first frame. Worse than not working: the
|
||||
* hook anyone debugging a site reaches for first handed them a hybrid.
|
||||
*
|
||||
* Seeking the week is the fix rather than forcing the site, because a site
|
||||
* without its night is half a game — no client, no brief, no storm, no bank.
|
||||
* Night 3 debugged at night 1's $80 reads "OFF THE JOB" and tells you nothing
|
||||
* about the corner block; at its own night it's the $237 bank D measured. D
|
||||
* had to rewrite NIGHTS[0] to get a coherent cold boot; this is that, honestly.
|
||||
*/
|
||||
if (opts.site) {
|
||||
const i = NIGHTS.findIndex((_, n) => nightAt(n).site === opts.site);
|
||||
if (i < 0) throw new Error(`main: boot({site:'${opts.site}'}) — no night in NIGHTS uses that site`);
|
||||
for (let n = 0; n < i; n++) week.advance();
|
||||
}
|
||||
|
||||
/**
|
||||
* Tonight's card. Reads the bank, not START_BUDGET.
|
||||
*
|
||||
@ -690,19 +771,24 @@ 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
|
||||
// the half of DESIGN.md's brief the game never showed), and TOMORROW's storm
|
||||
// def for Lane C's forecast lead — every storm is loaded up front, so
|
||||
// tomorrow costs nothing but an index. Null on the final night: there is no
|
||||
// tomorrow, and a card that hedges about one would be lying.
|
||||
const tomorrowDef = week.isFinalNight ? null : defs[nightAt(week.night).storm];
|
||||
hud.showForecast(
|
||||
{ key: week.stormKey, def: defs[week.stormKey], site: siteMeta[week.site] },
|
||||
{ night: week.night, nights: week.nights, bank: week.bank, log: week.log },
|
||||
{ key: week.stormKey, def: defs[week.stormKey], site: siteMeta[week.site], tomorrowDef },
|
||||
{
|
||||
night: week.night, nights: week.nights, bank: week.bank, log: week.log,
|
||||
job: week.job, quote: week.quote(defs[week.stormKey]), isFinalNight: week.isFinalNight,
|
||||
},
|
||||
() => { stormKey = week.stormKey; game.setPhase('prep'); },
|
||||
);
|
||||
}
|
||||
|
||||
@ -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();
|
||||
},
|
||||
};
|
||||
|
||||
@ -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
|
||||
|
||||
@ -4,13 +4,14 @@
|
||||
*/
|
||||
|
||||
import * as THREE from '../../vendor/three.module.js';
|
||||
import { FIXED_DT, STORM_LEN, YARD, checkContract, createStubWind } from '../contracts.js';
|
||||
import { ANCHOR_TYPE, FIXED_DT, STORM_LEN, YARD, checkContract, createStubWind } from '../contracts.js';
|
||||
import { createWindField } from '../weather.core.js';
|
||||
import { createWorld, heightAt, loadSite, validateSite } from '../world.js';
|
||||
import { createCameraRig } from '../camera.js';
|
||||
import { createGame, createWindRouter, verdictFor } from '../main.js';
|
||||
import { CALM_STORM, createGame, createWindRouter, stormsToPreload, verdictFor } from '../main.js';
|
||||
import { orderRing } from '../sail.js';
|
||||
import { loadStorm, createWind } from '../weather.js';
|
||||
import { createWeek, NIGHTS, nightAt, gradeFor, BROKE_BELOW } from '../week.js';
|
||||
import { createWeek, NIGHTS, nightAt, gradeFor, BROKE_BELOW, PAY } from '../week.js';
|
||||
import { assert, assertEq, assertLess, fixedLoop } from '../testkit.js';
|
||||
|
||||
/** @param {import('../testkit.js').Suite} t */
|
||||
@ -85,12 +86,108 @@ export default async function run(t) {
|
||||
);
|
||||
assertEq(s.bankBefore, 80);
|
||||
assertEq(s.bankAfter, 80 - 70 + s.pay, 'bank = bank − spent + pay, nothing else');
|
||||
assertEq(s.pay, s.fee + s.bonus + s.refund - s.collateral, 'the ledger adds up as shown');
|
||||
// SPRINT11 — `clean` is the new term, and this assert is the reason it's
|
||||
// spelled out here rather than folded into the fee: the invoice shows four
|
||||
// lines, so pay must BE those four lines. It caught the clean bonus the
|
||||
// moment it landed (got 143, want 123), which is exactly its job — an
|
||||
// invoice whose rows don't sum to its total is the one bug a player will
|
||||
// definitely find.
|
||||
assertEq(s.pay, s.fee + s.bonus + s.refund + s.clean - s.collateral, 'the ledger adds up as shown');
|
||||
assertEq(s.refund, 30, 'gear comes home at half — a shackle that rode a gale is not new');
|
||||
w.advance();
|
||||
assertEq(w.bank, s.bankAfter, 'the bank IS the next shop');
|
||||
});
|
||||
|
||||
t.test("the calm day loads even if no night is the gentle storm (D's landmine)", () => {
|
||||
// main.js preloads STORMS and reads winds[CALM_STORM] for every forecast and
|
||||
// prep frame. That only worked because night one HAPPENED to be the gentle
|
||||
// storm — an invariant nothing stated. Drop gentle from the ladder and the
|
||||
// game died at boot on "Cannot read properties of undefined (reading
|
||||
// 'duration')", naming neither storms nor calm nor the week. Gate 2 rewrote
|
||||
// every night entry, which is exactly when that bites.
|
||||
//
|
||||
// Asserts the RULE against main.js's own function, and asserts it on a
|
||||
// ladder WITHOUT the gentle storm — the case that actually broke. Checked
|
||||
// against the shipped NIGHTS it would pass either way (night one is gentle
|
||||
// today), which is how the bug hid in the first place.
|
||||
assert(stormsToPreload().includes(CALM_STORM),
|
||||
'the calm day is preloaded on the shipped ladder');
|
||||
assert(stormsToPreload(['storm_02_wildnight']).includes(CALM_STORM),
|
||||
"...and on a ladder that doesn't contain it anywhere — the case that broke");
|
||||
assertEq(stormsToPreload(['storm_01_gentle']).length, 1,
|
||||
'and it is still deduped, not loaded twice');
|
||||
});
|
||||
|
||||
// --- SPRINT11 gate 2: the job sheet -------------------------------------
|
||||
|
||||
t.test('every night is a JOB — a client, a brief, and a yard', () => {
|
||||
for (let i = 0; i < NIGHTS.length; i++) {
|
||||
const j = nightAt(i);
|
||||
assert(j.client, `night ${i + 1} has a client`);
|
||||
assert(j.brief && j.brief.length > 20, `night ${i + 1} has a brief worth reading`);
|
||||
assert(j.site, `night ${i + 1} has a yard`);
|
||||
}
|
||||
// The week's shape, which is DATA and not decoration: four nights on
|
||||
// retainer at one yard while they're away, and ONE short-notice callout to
|
||||
// a stranger's corner block. Night 3 landing differently is the whole point
|
||||
// of the ladder — a different site MEANS a different client.
|
||||
const clients = NIGHTS.map((_, i) => nightAt(i).client);
|
||||
assertEq(new Set(clients).size, 2, 'two clients: the retainer and the one-off');
|
||||
assert(clients[2] !== clients[0], 'night 3 is somebody else');
|
||||
assertEq(nightAt(2).site, 'site_02_corner_block', "and it's their corner block");
|
||||
});
|
||||
|
||||
t.test('an unbriefed night is a job with no letterhead, not a crash', () => {
|
||||
// SPRINT10's promise was that a bare storm key still resolves. SPRINT11 must
|
||||
// keep it: the job sheet degrades to the old storm card rather than throwing
|
||||
// or printing "undefined" at a client.
|
||||
const j = nightAt(99); // off the end entirely
|
||||
assertEq(j.client, null, 'no client is null, not undefined');
|
||||
assertEq(j.brief, null, 'no brief is null');
|
||||
assertEq(j.site, 'backyard_01', 'and it still names a yard');
|
||||
});
|
||||
|
||||
t.test('the job sheet quotes what the night pays BEFORE you rig it', () => {
|
||||
// The feature, not the flavour. The fee has existed since Sprint 8 and was
|
||||
// only ever shown in the aftermath — you chose what to spend without knowing
|
||||
// what the job was worth. That's a reveal, not a decision.
|
||||
const w = createWeek();
|
||||
const def = { baseCurve: [[0, 10]], gusts: { powBase: 5, powRamp: 5 } };
|
||||
const q = w.quote(def);
|
||||
assertEq(q.base, PAY.feeFor(20), 'base is the fee, quoted at full');
|
||||
assertEq(q.garden, PAY.gardenBonusMax, 'the garden bonus at a perfect bed');
|
||||
assertEq(q.clean, PAY.noCollateralBonus, 'and the clean bonus');
|
||||
assertEq(q.total, q.base + q.garden + q.clean, 'the quote sums to what it promises');
|
||||
|
||||
// The quote must not lie: settle a perfect night and the invoice has to pay
|
||||
// what the sheet advertised. A job sheet that over-promises is worse than no
|
||||
// job sheet — it's the game lying on paper.
|
||||
const s = createWeek().settle({ hp: 100, win: true, collateral: [], intactHardwareValue: 0 }, def, 0);
|
||||
assertEq(s.fee + s.bonus + s.clean, q.total, 'a perfect night pays exactly the quote');
|
||||
});
|
||||
|
||||
t.test('the clean bonus is about THEIR property, not your success', () => {
|
||||
const def = { baseCurve: [[0, 10]], gusts: { powBase: 5, powRamp: 5 } };
|
||||
const gnome = [{ what: 'garden gnome', cost: 25 }];
|
||||
|
||||
const cleanWin = createWeek().settle({ hp: 90, win: true, collateral: [], intactHardwareValue: 0 }, def, 0);
|
||||
const dirtyWin = createWeek().settle({ hp: 90, win: true, collateral: gnome, intactHardwareValue: 0 }, def, 0);
|
||||
const cleanLoss = createWeek().settle({ hp: 10, win: false, collateral: [], intactHardwareValue: 0 }, def, 0);
|
||||
|
||||
assertEq(cleanWin.clean, PAY.noCollateralBonus, 'broke nothing: paid');
|
||||
assertEq(dirtyWin.clean, 0, 'broke the gnome: forfeited');
|
||||
// The deliberate one. Lose the garden but break nothing of theirs and you
|
||||
// are still a tradesperson who didn't wreck the place. Different fact,
|
||||
// different row — that's what makes it an invoice and not a score.
|
||||
assertEq(cleanLoss.clean, PAY.noCollateralBonus, 'a lost night can still be a clean one');
|
||||
|
||||
// And it makes the trap bite twice: 180 + the forfeited bonus.
|
||||
const carport = createWeek().settle(
|
||||
{ hp: 90, win: true, collateral: [{ what: 'the carport', cost: 180 }], intactHardwareValue: 0 }, def, 0);
|
||||
assertEq(cleanWin.pay - carport.pay, 180 + PAY.noCollateralBonus,
|
||||
'the carport costs 200: the roof AND the bonus');
|
||||
});
|
||||
|
||||
t.test('a lost night pays a fraction of the fee, not zero and not all of it', () => {
|
||||
const def = { baseCurve: [[0, 10]], gusts: { powBase: 5, powRamp: 5 } };
|
||||
const won = createWeek().settle({ hp: 80, win: true, collateral: [], intactHardwareValue: 0 }, def, 0);
|
||||
@ -132,6 +229,10 @@ export default async function run(t) {
|
||||
// Gate 2 acceptance: both sites load from data, and the corner block is not
|
||||
// the backyard with the furniture moved. Built up front (Suite.test() can't
|
||||
// await — the guard I added last sprint enforces it, and just caught me).
|
||||
// storm_03b is the corner block's night: the early buster IS the southerly the
|
||||
// funnel exists to teach, so the venturi assert measures the real pairing.
|
||||
const earlyBusterDef = await loadStorm('storm_03b_earlybuster');
|
||||
|
||||
let site2World = null;
|
||||
try {
|
||||
const s2 = validateSite(await loadSite('site_02_corner_block'));
|
||||
@ -163,6 +264,118 @@ export default async function run(t) {
|
||||
assertEq(cb.work, 'bracket', 'the carport beam is a bracket job — needs the ladder');
|
||||
});
|
||||
|
||||
// --- SPRINT11 gate 1: the three rulings, each pinned ----------------------
|
||||
|
||||
t.test('ruling: the carport is typed as a carport, not smuggled in as a post', () => {
|
||||
if (!site2World) return 'SKIPPED — no server for site_02';
|
||||
const by = (id) => site2World.anchors.find((a) => a.id === id);
|
||||
// D's flag. Typed 'post' these four quietly joined the sail-post family that
|
||||
// C's venturi and B's audit both read off `type`. The lie is what's pinned
|
||||
// here: if someone re-types them 'post' to dodge the enum, this reddens.
|
||||
assertEq(by('cb1').type, 'carport', 'beam anchors are carports');
|
||||
assertEq(by('cb2').type, 'carport', 'beam anchors are carports');
|
||||
assertEq(by('cp1').type, 'carport_post', 'the posts are carport posts');
|
||||
assertEq(by('cp2').type, 'carport_post', 'the posts are carport posts');
|
||||
// The one behaviour keyed on a type string, and the reason the lie mattered:
|
||||
// a carport must never be mistaken for a tree and cast a wind shadow.
|
||||
const trees = site2World.anchors.filter((a) => a.type === 'tree');
|
||||
assert(trees.length > 0, 'the gum tree is still a tree');
|
||||
assert(!trees.some((a) => a.id.startsWith('c')), 'no carport anchor answers the tree filter');
|
||||
});
|
||||
|
||||
t.test('ruling: the widened enum is CHECKED, so a bad type cannot ship', () => {
|
||||
// The enum was JSDoc for ten sprints — documentation cannot fail, which is
|
||||
// exactly how a carport got typed 'post'. This is the assert that makes the
|
||||
// contract real, so it must actually reject something.
|
||||
const bogus = {
|
||||
yard: { width: 10, depth: 10 }, gardenBed: { x: 0, z: 0, w: 2, d: 2 },
|
||||
sun: { elevationDeg: 45, azimuthDeg: 0 },
|
||||
posts: [{ id: 'x1', x: 0, z: 0, h: 3, type: 'trampoline', work: 'cloth' }],
|
||||
};
|
||||
let threw = null;
|
||||
try { validateSite(bogus, 'bogus'); } catch (err) { threw = err.message; }
|
||||
assert(threw, 'a site with an unknown anchor type must fail loud');
|
||||
assert(/trampoline/.test(threw), 'and must NAME the type it rejected');
|
||||
// ...and must not reject the types that are real, including the new ones.
|
||||
for (const type of ANCHOR_TYPE) {
|
||||
const ok = { ...bogus, posts: [{ id: 'x1', x: 0, z: 0, h: 3, type, work: 'cloth' }] };
|
||||
validateSite(ok, `ok-${type}`); // throws = red
|
||||
}
|
||||
});
|
||||
|
||||
t.test('ruling: the venturi axis is a LINE — C and A never disagreed', () => {
|
||||
// The reconciliation SPRINT11 asked for, pinned so it is never "fixed" back.
|
||||
// weather.core aligns on |dot(wind, axis)| because a gap funnels either way
|
||||
// through it, so axis and axis+PI are the same gap. A shipped 2.1, C read the
|
||||
// southerly's heading at -1.08, and those are 2.2 deg apart: one line, two
|
||||
// ends. Pinned against the SHIPPED axis — the site's geometry is the fact
|
||||
// under test, not the storm heading it happens to run along.
|
||||
const at = (axis) => {
|
||||
const f = createWindField(earlyBusterDef);
|
||||
f.setVenturi([{ x: -6, z: 0, axis, gain: 1.5, radius: 5, sharp: 3 }]);
|
||||
return f;
|
||||
};
|
||||
const c = at(2.1), flipped = at(2.1 + Math.PI);
|
||||
let worst = 0;
|
||||
for (let tt = 0; tt <= 90; tt += 0.5) {
|
||||
worst = Math.max(worst, Math.abs(c.speedAt(-6, 0, tt) - flipped.speedAt(-6, 0, tt)));
|
||||
}
|
||||
// NOT assertEq(worst, 0) — and the reason is worth the comment. I first
|
||||
// "measured" this at exactly 0.000000 in a node probe and wrote that into
|
||||
// the site JSON. It was a toFixed(6) printing 1e-14 as zero: the probe
|
||||
// rounded a number I then reported as measured. cos(x+PI) is not bit-exactly
|
||||
// -cos(x), so flipping the axis carries ~1e-16 of float noise into |dot|.
|
||||
// 1e-14 m/s is not physics — a gust is 21 m/s — but "exactly" was my word,
|
||||
// not the harness's. The claim is: identical to float precision.
|
||||
assertLess(worst, 1e-9, 'axis and axis+PI are the same funnel, to float precision');
|
||||
// And the funnel must actually DO something, or the corner block's whole
|
||||
// weather personality is a no-op that no test would have noticed.
|
||||
const off = createWindField(earlyBusterDef);
|
||||
const peakT = 48.75; // measured: storm_03b's worst
|
||||
assert(c.speedAt(-6, 0, peakT) > off.speedAt(-6, 0, peakT) * 1.2,
|
||||
'the throat is meaningfully faster than the open yard');
|
||||
});
|
||||
|
||||
t.test("every anchor carries a NUMBER for ratingHint (D's landmine)", () => {
|
||||
if (!site2World) return 'SKIPPED — no server for site_02';
|
||||
// Nothing reads ratingHint yet (that's the open ruling — see THREADS), and
|
||||
// this assert exists BECAUSE of that: it makes the wire safe before it's
|
||||
// made. The honest posts are built from site JSON and never pass through
|
||||
// adoptAnchor, so they had no ratingHint at all — and `load > hw.rating *
|
||||
// undefined` is `load > NaN`, which is always false. Wire it naively and the
|
||||
// strongest anchors become immortal while the trap looks like it works.
|
||||
for (const a of site2World.anchors) {
|
||||
assert(Number.isFinite(a.ratingHint), `${a.id} has a finite ratingHint, not undefined`);
|
||||
assert(a.ratingHint > 0, `${a.id}'s ratingHint is positive — a 0 would be unbreakable-by-zero`);
|
||||
}
|
||||
for (const a of world.anchors) {
|
||||
assert(Number.isFinite(a.ratingHint), `backyard ${a.id} has a finite ratingHint too`);
|
||||
}
|
||||
// And the honest steel must still out-rate the trap, or the site has no thesis.
|
||||
const q1 = site2World.anchors.find((a) => a.id === 'q1');
|
||||
const cb1 = site2World.anchors.find((a) => a.id === 'cb1');
|
||||
if (q1 && cb1 && cb1.ratingHint < 1) assertLess(cb1.ratingHint, q1.ratingHint,
|
||||
'the carport beam is worse steel than an honest post');
|
||||
});
|
||||
|
||||
t.test('ruling: the carport BILLS — 180, once, and takes the roof with it', () => {
|
||||
if (!site2World) return 'SKIPPED — no server for site_02';
|
||||
// E shipped the trap and said it plainly: the anchors said collateral
|
||||
// "carport" and nothing said what a carport COST, so nothing scored it.
|
||||
// You could lose the worst steel in the game and pay for a $15 shackle.
|
||||
const priced = site2World.collateralFor('carport');
|
||||
assert(priced, 'the carport has a price at all');
|
||||
assertEq(priced.cost, 180, "A's ruling: 180 — 2.25 nights' budget");
|
||||
assertEq(priced.label, 'the carport', 'and reads as English on the invoice');
|
||||
// The price is the SITE's, not the mesh's. Sites are data.
|
||||
assertEq(site2World.anchors.find((a) => a.id === 'cb1').collateral, 'carport',
|
||||
'the beam anchor carries the key that reaches the price');
|
||||
// An unpriced label must read as "not scored", never as free: the house's
|
||||
// fascia anchors carry collateral "gutter" and nobody has priced a gutter.
|
||||
assertEq(site2World.collateralFor('gutter'), null, 'unpriced is null, not 0');
|
||||
assertEq(site2World.collateralFor(undefined), null, 'and no key is not a bill');
|
||||
});
|
||||
|
||||
// --- the wind router -----------------------------------------------------
|
||||
// Loaded HERE, not inside t.test(). These two were written `async` and
|
||||
// Suite.test() cannot await — so they were recorded as passes while asserting
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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
|
||||
|
||||
@ -30,19 +30,78 @@ import { START_BUDGET, HARDWARE } from './contracts.js';
|
||||
* and its storm carries the southerly change that makes the venturi scream. The
|
||||
* plain-string form is still accepted (defaults to backyard_01) so nothing that
|
||||
* reads NIGHTS[i] as a storm key breaks.
|
||||
*
|
||||
* SPRINT11: a night is now a JOB. Same ladder, same yards — what's new is the
|
||||
* frame DESIGN.md has had from line one: *"work arrives as callouts — each one a
|
||||
* site with existing conditions, a client brief, a budget, and a forecast
|
||||
* window."* You are not surviving five nights; you are working five jobs.
|
||||
*
|
||||
* The week that fell out of the data, once it was asked to name a client: the
|
||||
* Hendersons are away (SPRINT11's own line — they leave for Cairns), so four of
|
||||
* the five nights are the same yard, on retainer, through a bad week. Night 3 is
|
||||
* a different site, so it is necessarily a different client — a short-notice
|
||||
* callout to a yard you have never rigged, on the night the change comes early.
|
||||
* That is not flavour bolted on: it is what `site: site_02_corner_block` already
|
||||
* MEANT, finally said out loud. The corner block lands harder because it's a
|
||||
* stranger's place and you're there at short notice.
|
||||
*
|
||||
* Copy lives here as data, per SPRINT11's rule: client flavour is data, not code.
|
||||
*/
|
||||
export const NIGHTS = [
|
||||
{ storm: 'storm_01_gentle', site: 'backyard_01' },
|
||||
{ storm: 'storm_03_southerly', site: 'backyard_01' },
|
||||
{ storm: 'storm_03b_earlybuster', site: 'site_02_corner_block' },
|
||||
{ storm: 'storm_02_wildnight', site: 'backyard_01' },
|
||||
{ storm: 'storm_02b_icenight', site: 'backyard_01' },
|
||||
{
|
||||
storm: 'storm_01_gentle', site: 'backyard_01',
|
||||
client: 'the Hendersons',
|
||||
brief: 'They fly out Friday and the seedlings are the whole point — six weeks in, first season that '
|
||||
+ 'took. Get the sail up while it\'s calm. Nothing tonight will test it.',
|
||||
},
|
||||
{
|
||||
storm: 'storm_03_southerly', site: 'backyard_01',
|
||||
client: 'the Hendersons',
|
||||
brief: 'A southerly, and hail with it. This is the one the seedlings can\'t take — cloth over the bed '
|
||||
+ 'or there\'s no bed on Monday.',
|
||||
},
|
||||
{
|
||||
storm: 'storm_03b_earlybuster', site: 'site_02_corner_block',
|
||||
client: 'the Vasilaros place',
|
||||
brief: 'Short notice — corner block two streets over, never rigged it. Same southerly as Tuesday, '
|
||||
+ 'except the change is through before you\'ve finished your coffee. They said there\'s plenty '
|
||||
+ 'to tie off to.',
|
||||
},
|
||||
{
|
||||
storm: 'storm_02_wildnight', site: 'backyard_01',
|
||||
client: 'the Hendersons',
|
||||
brief: 'Back at the Hendersons\', and this is the night the whole week was pointing at. Rig it like '
|
||||
+ 'they\'re watching, because Friday they will be.',
|
||||
},
|
||||
{
|
||||
storm: 'storm_02b_icenight', site: 'backyard_01',
|
||||
client: 'the Hendersons',
|
||||
brief: 'Less wind than last night and more ice, which is worse for the bed and easier on the rig. '
|
||||
+ 'Last night before they land.',
|
||||
},
|
||||
];
|
||||
|
||||
/** A night entry, either shape → {storm, site}. */
|
||||
/**
|
||||
* A night entry, every shape → a full JOB.
|
||||
*
|
||||
* The plain-string form still resolves (SPRINT10's promise), and now so does a
|
||||
* night with no client: an unbriefed job is a job with no letterhead, not a
|
||||
* crash. `pay` is per-job so the schedule is data — see PAY for the defaults and
|
||||
* for why the clean bonus is the one number here that is NOT yet measured.
|
||||
*
|
||||
* @param {number} i
|
||||
* @returns {{storm:string, site:string, client:string|null, brief:string|null, pay:object}}
|
||||
*/
|
||||
export function nightAt(i) {
|
||||
const n = NIGHTS[i];
|
||||
return typeof n === 'string' ? { storm: n, site: 'backyard_01' } : n;
|
||||
const base = typeof n === 'string' ? { storm: n, site: 'backyard_01' } : (n ?? {});
|
||||
return {
|
||||
storm: base.storm,
|
||||
site: base.site ?? 'backyard_01',
|
||||
client: base.client ?? null,
|
||||
brief: base.brief ?? null,
|
||||
pay: base.pay ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@ -84,6 +143,36 @@ export const PAY = {
|
||||
gardenBonusMax: 45,
|
||||
/** What surviving hardware is worth back. Fatigued, not new. */
|
||||
refundShare: 0.5,
|
||||
|
||||
/**
|
||||
* Nothing of theirs broken (SPRINT11 gate 2 — the third line on the invoice).
|
||||
*
|
||||
* It exists to give the schedule three axes that mean three different things,
|
||||
* which is what turns a scoreboard into an invoice:
|
||||
* base — you turned up and did the job (severity-scaled; less if it went wrong)
|
||||
* garden — how much of what they actually cared about survived
|
||||
* clean — you left their place the way you found it
|
||||
* Deliberately NOT gated on winning: it's about their property, not your
|
||||
* success. Lose the garden but break nothing and you're still a tradesperson
|
||||
* who didn't wreck the place — that's a different fact and it gets its own row.
|
||||
*
|
||||
* It also makes the carport bite twice, which is the point: take it and you
|
||||
* lose 180 AND this. Sized so that total sting is 200, still inside E's
|
||||
* "above ~250 and one mistake is a silent game over" ceiling.
|
||||
*
|
||||
* ⚠️ **THE ONE NUMBER IN THIS FILE THAT IS NOT MEASURED — for gate 3 to settle.**
|
||||
* Every other constant here was set by playing the week. This one couldn't be:
|
||||
* it's new money, and MEASURED (node, the model in BROKE_BELOW's note) it makes
|
||||
* a competent week bank 385 → 485, i.e. it adds ~$100 to a five-night run that
|
||||
* §BROKE_BELOW already flags as running away with itself. I did not fund it by
|
||||
* quietly trimming `feeFor` or `gardenBonusMax` — those two carry measured
|
||||
* evidence and long reasons, and re-tuning them on my taste to pay for my own
|
||||
* new feature is exactly the move this repo keeps catching.
|
||||
* So: if gate 3 says the bank runs away, THIS is the lever to cut first. It's
|
||||
* the newest and least load-bearing number in the economy, it's per-job data,
|
||||
* and cutting it costs nothing but a row on the invoice.
|
||||
*/
|
||||
noCollateralBonus: 20,
|
||||
};
|
||||
|
||||
/**
|
||||
@ -169,6 +258,30 @@ export function createWeek(opts = {}) {
|
||||
get stormKey() { return nightAt(index).storm; },
|
||||
/** SPRINT10: which yard tonight is on. main.js loads it. */
|
||||
get site() { return nightAt(index).site; },
|
||||
/** SPRINT11: tonight's job — client, brief, pay schedule. The job sheet reads this. */
|
||||
get job() { return nightAt(index); },
|
||||
|
||||
/**
|
||||
* What tonight pays, BEFORE you rig it — the "budget Y" half of DESIGN.md's
|
||||
* brief, and the reason the job sheet is worth building. Until now the fee
|
||||
* existed only in the aftermath: you found out what the job was worth after
|
||||
* you'd decided what to spend on it, which is not a decision, it's a reveal.
|
||||
*
|
||||
* Quotes the maxima honestly: base at full (what a held night pays), garden
|
||||
* at hp 100, clean at not-a-scratch. What you actually bank is the invoice's
|
||||
* business — a quote is what's on offer, not a promise.
|
||||
*
|
||||
* @param {object} def tonight's storm def
|
||||
*/
|
||||
quote(def) {
|
||||
const j = nightAt(index);
|
||||
return {
|
||||
base: PAY.feeFor(gustPeakOf(def)),
|
||||
garden: j.pay.garden ?? PAY.gardenBonusMax,
|
||||
clean: j.pay.clean ?? PAY.noCollateralBonus,
|
||||
get total() { return this.base + this.garden + this.clean; },
|
||||
};
|
||||
},
|
||||
get bank() { return bank; },
|
||||
get isFinalNight() { return index === NIGHTS.length - 1; },
|
||||
get over() { return done; },
|
||||
@ -191,14 +304,25 @@ export function createWeek(opts = {}) {
|
||||
const bankBefore = bank;
|
||||
const fullFee = PAY.feeFor(gustPeakOf(def));
|
||||
const fee = run.win ? fullFee : Math.round(fullFee * PAY.lostNightShare);
|
||||
const bonus = Math.round((Math.max(0, run.hp) / 100) * PAY.gardenBonusMax);
|
||||
// Per-job override, PAY's default otherwise — and settle MUST read the same
|
||||
// source the quote does, or the job sheet promises a number the invoice
|
||||
// doesn't pay. That's not a rounding difference, it's a lie on paper.
|
||||
const gardenMax = nightAt(index).pay.garden ?? PAY.gardenBonusMax;
|
||||
const bonus = Math.round((Math.max(0, run.hp) / 100) * gardenMax);
|
||||
|
||||
// Only hardware still on an unbroken corner comes home, and at half.
|
||||
const intact = (run.intactHardwareValue ?? 0);
|
||||
const refund = Math.round(intact * PAY.refundShare);
|
||||
|
||||
const collateral = (run.collateral ?? []).reduce((s, c) => s + c.cost, 0);
|
||||
const pay = fee + bonus + refund - collateral;
|
||||
|
||||
// The clean bonus. Per-job data first, PAY's default otherwise — so a job
|
||||
// that wants to say "there's a lot here to break" can, without code.
|
||||
const job = nightAt(index);
|
||||
const cleanMax = job.pay.clean ?? PAY.noCollateralBonus;
|
||||
const clean = collateral === 0 ? cleanMax : 0;
|
||||
|
||||
const pay = fee + bonus + refund + clean - collateral;
|
||||
|
||||
bank = Math.max(0, bankBefore - spent + pay);
|
||||
|
||||
@ -220,9 +344,10 @@ export function createWeek(opts = {}) {
|
||||
else outcome = 'continue';
|
||||
done = outcome !== 'continue';
|
||||
|
||||
const s = { fee, fullFee, bonus, refund, collateral, pay, spent,
|
||||
const s = { fee, fullFee, bonus, refund, clean, cleanMax, collateral, pay, spent,
|
||||
bankBefore, bankAfter: bank, outcome, night: week.night, held,
|
||||
grade: gradeFor(held), stormKey: week.stormKey, won: !!run.win };
|
||||
grade: gradeFor(held), stormKey: week.stormKey, won: !!run.win,
|
||||
client: job.client, site: job.site };
|
||||
log.push(s);
|
||||
return s;
|
||||
},
|
||||
|
||||
@ -12,7 +12,30 @@
|
||||
*/
|
||||
|
||||
import * as THREE from '../vendor/three.module.js';
|
||||
import { createStubWind } from './contracts.js';
|
||||
import { ANCHOR_TYPE, createStubWind } from './contracts.js';
|
||||
import { validateSiteWind } from './weather.core.js';
|
||||
|
||||
/**
|
||||
* Full strength: this anchor takes whatever its hardware is rated for.
|
||||
*
|
||||
* SPRINT11 — Lane D's landmine, defused before anyone can step on it. Every
|
||||
* anchor now carries a NUMBER here, always. It used to be set only by
|
||||
* `adoptAnchor`, off Lane E's baked `rating_hint` — so GLB-backed anchors had it
|
||||
* and the ones built straight from site JSON (site_02's honest posts q1..q4) did
|
||||
* not. Their `ratingHint` was `undefined`.
|
||||
*
|
||||
* That matters the moment anyone wires the ratings into the failure threshold,
|
||||
* which is the open question D put to me. The naive wire is
|
||||
* `load > hw.rating * anchor.ratingHint`, and with `undefined` that is
|
||||
* `load > NaN` — **always false**. The honest posts wouldn't read weak, they'd
|
||||
* be UNBREAKABLE, and the carport would become the only anchor in the yard that
|
||||
* can fail. The trap would appear to work, for entirely the wrong reason, and it
|
||||
* would have looked like a successful playtest.
|
||||
*
|
||||
* "Default before you multiply" — D's words. This is the default, at the source,
|
||||
* so no consumer has to remember. 1 = honest steel; E's traps ship lower.
|
||||
*/
|
||||
const DEFAULT_RATING_HINT = 1;
|
||||
|
||||
/** Degrees → radians. Site JSON is authored in degrees; nobody writes π/2 by hand. */
|
||||
const rad = (deg) => ((deg ?? 0) * Math.PI) / 180;
|
||||
@ -101,7 +124,20 @@ export function validateSite(site, name = site?.id ?? '?') {
|
||||
if (a.work !== 'cloth' && a.work !== 'bracket') {
|
||||
bad.push(`anchor ${a.id}: work must be "cloth" or "bracket", got ${JSON.stringify(a.work)}`);
|
||||
}
|
||||
// SPRINT11 — the enum is CHECKED now, not just documented. It was a JSDoc
|
||||
// comment before, which is why site_02 could type a carport 'post' and
|
||||
// nothing said a word (D's flag). An unenforced enum is decoration; this
|
||||
// one fails loud, so the next site that doesn't fit argues with the list
|
||||
// in contracts.js instead of quietly joining the wrong family.
|
||||
if (!ANCHOR_TYPE.includes(a.type)) {
|
||||
bad.push(`anchor ${a.id}: type ${JSON.stringify(a.type)} is not one of ${ANCHOR_TYPE.join('|')}`);
|
||||
}
|
||||
}
|
||||
// C's validator, wired at site load as they asked in SPRINT10: a site's wind
|
||||
// block is hand-authored data, so it fails loud like a storm's rather than
|
||||
// being silently clamped. A site with no wind block passes (backyard_01 has
|
||||
// no funnel) — that's C's contract, not an accident.
|
||||
bad.push(...validateSiteWind(site.wind, name).errors);
|
||||
}
|
||||
if (bad.length) throw new Error(`world: site '${name}' is invalid:\n ${bad.join('\n ')}`);
|
||||
return site;
|
||||
@ -693,6 +729,25 @@ export function createWorld(scene, opts = {}) {
|
||||
if (st.solid) glb.traverse((o) => { if (o.isMesh) solids.push(o); });
|
||||
glb.updateWorldMatrix(true, true);
|
||||
for (const a of st.anchors ?? []) adoptAnchor(glb, a.node, a.id);
|
||||
entry.glb = glb;
|
||||
|
||||
// SPRINT11 — the wreck, loaded NOW and parked invisible. E built it to the
|
||||
// same origin and footprint so it swaps mesh-for-mesh (their e.test pins
|
||||
// that, and that it carries the same price and names its intact twin).
|
||||
// Loaded during dress rather than at the moment it's needed because the
|
||||
// swap happens as the storm ends: an async load there would drop the
|
||||
// payoff frames after the aftermath card, or not at all offline.
|
||||
if (st.wreckedModel) {
|
||||
const wreck = await load(st.wreckedModel);
|
||||
if (wreck) {
|
||||
wreck.name = `${st.id}_wrecked`;
|
||||
wreck.position.copy(glb.position);
|
||||
wreck.rotation.y = glb.rotation.y;
|
||||
wreck.visible = false;
|
||||
root.add(wreck);
|
||||
entry.wreck = wreck;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { shed, table };
|
||||
@ -728,6 +783,57 @@ export function createWorld(scene, opts = {}) {
|
||||
return anchors.find((a) => a.id === id) ?? null;
|
||||
},
|
||||
|
||||
/**
|
||||
* What the client's property costs you when your rig takes it with it.
|
||||
*
|
||||
* The key is the string E bakes as `collateral` on the anchor (site_02's
|
||||
* carport anchors all say "carport"), so the chain is: tie to the beam →
|
||||
* the beam's anchor carries `collateral:"carport"` → this prices it. The
|
||||
* PRICE lives in site JSON, not the GLB: sites are data, and what a carport
|
||||
* costs is this site's economy, not a property of the mesh. E's baked
|
||||
* `collateral_value` is the fallback and their proposal (SPRINT11: 180,
|
||||
* adopted with their reasoning).
|
||||
*
|
||||
* Returns null for an unpriced label, and that is deliberate rather than a
|
||||
* zero: the house's fascia anchors carry `collateral:"gutter"` and NOBODY
|
||||
* has priced a gutter yet. A missing price must read as "not scored", never
|
||||
* as "free" — see THREADS, it's an open seam.
|
||||
*
|
||||
* @param {string} key
|
||||
* @returns {{cost:number, label:string}|null}
|
||||
*/
|
||||
collateralFor(key) {
|
||||
if (!key) return null;
|
||||
for (const { spec, glb } of structures.values()) {
|
||||
if (spec.id !== key) continue;
|
||||
const cost = spec.collateralValue ?? glb?.userData?.collateral_value ?? null;
|
||||
if (!Number.isFinite(cost)) return null;
|
||||
return { cost, label: spec.collateralLabel ?? glb?.userData?.collateral_label ?? spec.id };
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Swap a structure for its wreck. The payoff for the trap: you don't break a
|
||||
* shackle, you take the carport, and the aftermath has to SHOW that.
|
||||
* No-op (returns false) when the site declares no wreck or the GLB is
|
||||
* missing — the graybox and headless paths still score the bill, they just
|
||||
* can't show it. Idempotent.
|
||||
* @param {string} id
|
||||
*/
|
||||
wreckStructure(id) {
|
||||
const entry = structures.get(id);
|
||||
if (!entry?.wreck || !entry.glb) return false;
|
||||
entry.glb.visible = false;
|
||||
entry.wreck.visible = true;
|
||||
return true;
|
||||
},
|
||||
|
||||
/** Is this structure standing? Lane D asked for a poke-able truth. */
|
||||
isWrecked(id) {
|
||||
return structures.get(id)?.wreck?.visible === true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Take the whole yard out of the scene and free its GPU memory. SPRINT10:
|
||||
* the week rebuilds the world when the site changes between nights, and
|
||||
@ -763,7 +869,7 @@ const _worldPos = new THREE.Vector3();
|
||||
/** @returns {import('./contracts.js').Anchor} */
|
||||
function makeStaticAnchor(id, type, pos) {
|
||||
const p = pos.clone();
|
||||
return { id, type, pos: p, sway: () => p };
|
||||
return { id, type, pos: p, sway: () => p, ratingHint: DEFAULT_RATING_HINT };
|
||||
}
|
||||
|
||||
/**
|
||||
@ -780,6 +886,7 @@ function makeSwayAnchor(id, pos, phase, wind) {
|
||||
id,
|
||||
type: 'tree',
|
||||
pos: p,
|
||||
ratingHint: DEFAULT_RATING_HINT,
|
||||
sway(t) {
|
||||
const speed = wind.sample(p, t).length();
|
||||
const amp = Math.min(0.35, speed * 0.012);
|
||||
|
||||
BIN
web/world/models/bike_kid_01_v1.glb
Normal file
BIN
web/world/models/bike_kid_01_v1.glb
Normal file
Binary file not shown.
Loading…
Reference in New Issue
Block a user