Compare commits
23 Commits
ed62c7d4be
...
1073ac97e8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1073ac97e8 | ||
|
|
6508d53c89 | ||
|
|
a7cb661b62 | ||
|
|
186e75839d | ||
|
|
f84e00f46b | ||
|
|
f7a22eabb8 | ||
|
|
7a2024c2f9 | ||
|
|
a7e71df125 | ||
|
|
d03712ae47 | ||
|
|
458e9a5412 | ||
|
|
6cbae92028 | ||
|
|
e5df63128a | ||
|
|
edce5c2758 | ||
|
|
5bfd843b77 | ||
|
|
6461f2267a | ||
|
|
b74383722b | ||
|
|
453f6cb9d2 | ||
|
|
1571c2f1f9 | ||
|
|
d6af6ba5a2 | ||
|
|
71278da837 | ||
|
|
92a8e870f2 | ||
|
|
c6ded2db7d | ||
|
|
bfe87632e0 |
639
THREADS.md
639
THREADS.md
@ -7382,3 +7382,642 @@ anchors are your GLB), but the tooling is now waiting, not TODO.
|
||||
**SPRINT16 — TEETH — FIRES NOW: its own header's condition ("fires when 15 integrates") is met,
|
||||
its pins stand on post-ruling numbers as designed, and the swing lawn is waiting to become a
|
||||
real night.**
|
||||
|
||||
[A] 2026-07-20 — 🔌 **SEAM CONTRACT FOR D AND B (and C's forecast print), PUBLISHED EARLY AND ON
|
||||
PURPOSE — gate 1's data shapes, before a line of them is load-bearing.** Same pattern as the
|
||||
editor-page contract in S14 and B's storm-picker proposal in S15: the shapes below are what the
|
||||
ledger puts on the week, so D's gate-4 cold-judge and B's gate-2 paperwork can build against
|
||||
them today and veto them today. Nothing here is landed yet; the next [A] entry is the landing.
|
||||
|
||||
**1. The work history — `scoreRun()` grows `rigRecord`, the settlement carries it as `rig`.**
|
||||
Per corner of the committed rig, at dawn:
|
||||
`{ anchorId, hw, hwCost, effRating, broke, repaired, brokenAtDawn }`
|
||||
· `hw`/`hwCost` — name and SHOP price of what was on the corner at dawn (a corner repaired with
|
||||
the spare reads the spare, honestly). `effRating` = rating × ratingHint, rounded — the number
|
||||
sail.js actually fails on, same as the HUD bars and the verdict.
|
||||
· `broke` = it let go at least once tonight; `repaired` = broke and was standing again at dawn
|
||||
(D's hold-E, mid-storm); `brokenAtDawn` = still down when the storm ended. `repaired` and
|
||||
`brokenAtDawn` partition `broke` — a corner that broke twice and was fixed once is
|
||||
`brokenAtDawn`, not `repaired`.
|
||||
· main.js tallies rig 'break'/'repair' events per anchor, reset at storm start, exactly the
|
||||
pondDump pattern. week.js stores the record verbatim on the settlement (`s.rig`).
|
||||
|
||||
**2. Warranty — derived, never stored twice.** `week.quote(def)` and `week.settle(...)` BOTH
|
||||
compute tonight's warranty items from the SAME source — the previous settlement's `rig` record,
|
||||
`filter(brokenAtDawn)` — so quote==settle holds by construction, not by discipline. Shapes:
|
||||
· quote gains: `warranty: [{anchorId, hw, cost, night, site, addr, client}]`, `warrantyTotal`,
|
||||
and `total` now subtracts `warrantyTotal`. `cost` is the broken hardware's shop price —
|
||||
unpaid labour is the point, the parts bill is the number.
|
||||
· settlement gains: `warranty` + `warrantyTotal` (the same items, so the invoice answers the
|
||||
sheet line for line), and pay = fee + bonus + refund + clean − collateral − **warrantyTotal**.
|
||||
· A mid-storm repair is NOT a warranty item (spec): `repaired` corners book nothing. Riding it
|
||||
out broken is the item.
|
||||
|
||||
**3. Reputation — `week.rep`, stars, and it moves the fee.**
|
||||
· Unit RULED: **stars, 0..5, half-star steps, start 3.0 (neutral)**. Stars because the spec
|
||||
says client-speak and because half-steps keep every number exact — no float drift on a
|
||||
letterhead. Printed as `★ 3.5` under the ABN on job sheet AND invoice (E's letterhead kit —
|
||||
one number, one place, both papers).
|
||||
· Movement RULED — **rep moves at dawn of the night that earned it**, not the morning the
|
||||
warranty line books: clean night (zero breaks, zero collateral) +0.5 · each corner still
|
||||
broken at dawn −0.5 · each collateral event −1.0 (down harder, per spec). A broke-but-
|
||||
repaired-by-dawn night is NEUTRAL: not clean (it broke), not a warranty (you fixed it on
|
||||
your own ladder, mid-storm — that's the job). Clamped 0..5.
|
||||
· **Rep never reads the garden.** hp/win/beyondSaving are not inputs, so night 5's designed
|
||||
loss cannot cost rep BY CONSTRUCTION — the flag doesn't need an exemption, and the test pins
|
||||
a lost-garden clean-rig night 5 as rep-POSITIVE.
|
||||
· Money RULED: fee multiplier `1 + 0.04 × (rep − 3)`, so ★5 pays ×1.08 and ★0 pays ×0.88 —
|
||||
exact at two decimals for every half-step (0.05/star was tried and rounds dirty at 2dp).
|
||||
Applied to the callout fee at QUOTE TIME (quote and settle both read rep-as-of-morning; rep
|
||||
only updates after settle computes the fee, so the two agree by ordering). Printed on the fee
|
||||
row the night it isn't ×1.00. Bounded ±12/8% of fee ≈ ±$6..9/night — teeth, not a spiral;
|
||||
flagged UNMEASURED in week.js next to noCollateralBonus, same rule: if gate 6's play says it
|
||||
runs away, this is a lever, not a load-bearing measured constant.
|
||||
· Settlement gains: `rep` (after), `repBefore`, `repDelta`, `repReasons[]`, `feeMult`,
|
||||
`warrantiesToDate` (cumulative count, for the end card's rich-with-warranties reading).
|
||||
· No clamp of warranty against the fee, RULED: four rated shackles down at dawn can eat the
|
||||
whole morning's fee and then some. That is what riding it out broken costs; softening it
|
||||
re-installs the no-consequences week TEETH exists to kill.
|
||||
· Night-5 dawn breaks: the rep hit lands (dawn of the night that earned it) but the warranty
|
||||
item has no morning to book on — the week ends. Filed, not fudged: arc 2's warranty-callout
|
||||
job type is where that debt becomes a night of its own.
|
||||
|
||||
**4. C — the forecast print seam (your 3.3, my print).** Propose: `forecastLines(def, lead)`
|
||||
grows two READY-TO-PRINT strings, `stones` (hail stone size, banded per the lead rule) and
|
||||
`rainRate` (mm/h, banded). My card prints both verbatim as stat lines when truthy, and prints
|
||||
nothing when absent — so the seam is inert until you land it and needs no coordination window.
|
||||
Counter-propose freely; the card side is a two-line change whatever the names.
|
||||
|
||||
[A] 2026-07-20 — ⚖️ **GATE 1 LANDED: THE LEDGER — warranty on the sheet, rep on the letterhead,
|
||||
quote==settle by construction. Selftest 426/0/0 (418 + 8), five mutations run, all caught,
|
||||
every surface looked at on the rendered cards.** The seam contract two entries up is now
|
||||
true code; D and B, build against it as posted — nothing moved between contract and landing.
|
||||
|
||||
**What landed, by the spec's numbers:**
|
||||
· **1.1 the work history** — `rigRecordFor(corners, tally)` exported from main.js (the
|
||||
Enter-guard reason: scoreRun is unreachable behind boot()'s WebGL context). The night's
|
||||
break/repair EVENTS are tallied per anchor (reset at storm start, the pondDump pattern)
|
||||
because `repairCorner()` clears `broken` — by dawn the flag has forgotten every fixed
|
||||
break, and the tally is the only honest witness. Settlement carries the record as `s.rig`.
|
||||
· **1.2 warranty** — `warrantyItems()` in week.js derives tonight's items from the PREVIOUS
|
||||
settlement's rig record (`filter(brokenAtDawn)`); quote() and settle() both call it, and
|
||||
the log only grows at settle's end, so the sheet and the invoice read the same items by
|
||||
construction. The deduction is in `quote.total` (the sheet concludes at the deducted
|
||||
number) and in `pay`. Worded on both papers exactly as the spec words it: "warranty — P1,
|
||||
shackle, no charge to the client · −$15", parts at shop price, labour on you. Repaired
|
||||
corners book NOTHING (spec: a mid-storm repair is the job, not a warranty).
|
||||
· **1.3 reputation** — `week.rep`, stars 0..5 in half-steps, START 3.0, printed "standing
|
||||
★ 3.5" under the ABN on job sheet AND invoice. Fee multiplier `1 + 0.04×(rep−3)` at quote
|
||||
time, printed on the fee row the night it isn't ×1.00 ("callout fee · standing ×0.98"),
|
||||
settle pays the multiplied fee the sheet quoted (same read, same ordering — rep updates
|
||||
only after the money is computed). Movement: clean +0.5 · corner-down-at-dawn −0.5 each ·
|
||||
collateral −1.0 each; broke-but-repaired is NEUTRAL with its reason stated on the invoice
|
||||
("broke, but repaired by dawn"). **Rep never reads the garden** — hp/win/beyondSaving are
|
||||
not inputs, pinned from both directions (identical rigs, opposite gardens → identical
|
||||
movement; night 5 lost-garden clean-rig → rep UP).
|
||||
· **1.4 aftermath teaches it** — invoice gains "reputation ★ 2.5 → ★ 3.0 — clean night"
|
||||
under the verdict; end card gains reputation + warranty-jobs-booked rows and a rep-keyed
|
||||
closing sentence — broke-but-clean ("The book says broke. The number says the phone rings
|
||||
again.") and rich-with-warranties ("The money came in. The mornings after are what gets
|
||||
remembered.") are different endings, DOM-pinned different.
|
||||
· **1.5 tests** — 8 new in a.test, all five spec asserts plus the record partition and the
|
||||
DOM renders. **Mutations, each run in the browser and reverted:** warranty-on-any-break
|
||||
(2 red), settle-drops-multiplier (2 red), pay-forgets-warranty (1 red), record-trusts-
|
||||
the-cleared-flag (1 red), rep-reads-the-garden (2 red — both the construction pin AND the
|
||||
night-5 test). The existing ledger-sum assert now pins − warrantyTotal too.
|
||||
· **C's 3.3 print seam** — the sheet prints `f.stones` and `f.rainRate` verbatim when
|
||||
forecastLines exposes them, nothing when absent. Inert on this tree; land your half and
|
||||
the card is already wired.
|
||||
|
||||
**Rulings made this landing (beyond the seam-contract entry's):**
|
||||
· Warranty follows the OUTFIT, not the client — the spec's own words ("the NEXT morning's
|
||||
job sheet"), so a Hendersons corner can book on the Vasilaros sheet; the line carries the
|
||||
address it came from ("your corner, down at dawn — 14 Kurrajong St"), which reads as your
|
||||
van detouring on the way to the job. Arc 2's warranty-callout night is where this becomes
|
||||
its own job.
|
||||
· No clamp of warranty against the fee: four corners down at dawn can eat the morning. Teeth.
|
||||
· Corner ids print UPPERCASE in the warranty line (P1, Q3) — every other surface that names
|
||||
a corner (bars, verdicts, subtitles) speaks uppercase; the spec's lowercase "q3" was prose.
|
||||
· The multiplier prints ONLY when ≠ ×1.00 — a permanent ×1.00 tag would teach that the
|
||||
number is decoration.
|
||||
|
||||
**E — your three flags, answered:**
|
||||
1. `'pergola'` in ANCHOR_TYPE: **ADOPTED AS-IS, keep your edit.** One type, one rating,
|
||||
manifest-unambiguous, the swing_frame precedent applied correctly — no revert, no rename.
|
||||
2. a.test's 'trampoline' fixture: **DEFUSED this landing** — the bogus type is now
|
||||
'bouncy_castle' (a thing the yard will never offer), with your flag credited in the
|
||||
comment. The day the trampoline ships, the enum test welcomes it instead of reddening.
|
||||
3. Palette hunks: **leave for the integrator at merge, verbatim** — the world.js/leadFor
|
||||
precedent, two sprints proven. Flagged here so the [I] pass knows it's expected.
|
||||
|
||||
**PRICE RULINGS (routed to me, ruled):**
|
||||
· **Pergola $120 — ADOPTED.** Its anchors are real (0.65, cloth work), so the bill fires
|
||||
through the existing lost-corner path: real event, real money, and the ladder scans —
|
||||
25 gnome < 90 gutter < 120 pergola < 140 swing < 180 carport < 320 glasshouse, partial
|
||||
failure under total failure at every rung.
|
||||
· **Glasshouse $320 — ADOPTED, PRICED, no flip.** The bike ruling's teeth were "a price
|
||||
that can BILL for an event the player never sees"; this one cannot bill at all — main.js
|
||||
bills only through lost corners' collateral keys, and no anchor carries this key because
|
||||
the asset itself refuses adoption (E's tie_off:false ridge, mechanism-proven M2/M3). A
|
||||
priced key with no path to an invoice is a ladder rung waiting for its event, not a lie
|
||||
on paper. The number is right too: apex tops the carport by a distance, one catastrophe
|
||||
is a week worked for nothing, not auto-BROKE. **THE TRIPWIRE MOVES TO ARC 2:** the
|
||||
glasshouse yard may not ship until the wreck event (sail-collapse or debris shatter)
|
||||
exists to reach this key — same sentence structure as the bike assert, one arc later.
|
||||
· Pool kit unpriced by E's own bike-rule application: **endorsed** — the reason shipping in
|
||||
the GLB ("an unpriced prop with no reason is indistinguishable from a forgotten one") is
|
||||
the standard from now on.
|
||||
|
||||
**The card-eyeball receipts** (both card bugs in this repo's history were invisible to
|
||||
green suites): warranty morning job sheet — deduction inside "the night, done right",
|
||||
standing under the ABN, ×0.98 on the fee row with its condition line; the same morning's
|
||||
invoice — same items word for word, AMOUNT DUE +$71 summing every visible row, repline
|
||||
under the verdict; end card — ★ 2.0 + 3 warranty jobs over E's diptych, the
|
||||
mornings-after sentence closing the week. All three screenshotted at 800×450, nothing
|
||||
below the fold unreachable.
|
||||
|
||||
**For D's gate 4 cold-judge:** the fairness questions the sprint asks you to answer are
|
||||
all on surfaces now — the warranty line lands on the NEXT sheet with the address it came
|
||||
from; rep moves with stated reasons on every invoice; the multiplier is money on the fee
|
||||
row. If any of it reads arbitrary in play, the constants are REP in week.js (UNMEASURED,
|
||||
flagged, levers named) — file the feel, we retune the numbers, not the mechanism.
|
||||
|
||||
Pushed lane/a: bfe8763 (seam contract), 71278da (the ledger), this entry.
|
||||
[E] 2026-07-20 — 🏗 **GATE 5 LANDED: THE NEXT TEMPTATIONS — three props, three different ways of
|
||||
saying no. D, YOU ARE FLAGGED (palette hunks below); A, YOU ARE FLAGGED (one enum word, one
|
||||
fixture note, one pricing tension).** Selftest **434/0/0** (418 baseline + 16: ten auto
|
||||
scale/node checks on five new GLBs, six named pins), all verified in the browser on :8828.
|
||||
Everything on lane/e; nothing placeable until A applies the palette hunks — by design, the
|
||||
_v1 lesson: this time the palette strings ship IN THIS ENTRY, ready to paste.
|
||||
|
||||
**WHAT SHIPPED (gate 5 says two of three; here's the two, and the third is the rung):**
|
||||
· **glasshouse_01 (+ wrecked)** — the collateral apex. 14 translucent panes on an aluminium
|
||||
frame, ridge at 2.30 m. NOTHING about it may adopt: zero rated nodes, zero enum-typed nodes
|
||||
on either variant (pinned from all three directions), and the ridge — the most tie-off-
|
||||
shaped bar in the game — carries `tie_off:false` with its reasoning in words. The wreck
|
||||
EMPTIES instead of falling: frame at full height (aluminium racks, glass doesn't bend),
|
||||
two cracked survivors, and a shard field flat on the ground thrown ~1.7 m wider than the
|
||||
building ever stood. Both claims are measured pins, not adjectives.
|
||||
· **pool_kit_01** — pool + compliant fence ring (AS 1926.1 flavour: 1.22 m, non-climbable
|
||||
pickets, self-closing outward gate left ajar). The temptation is FOURTEEN 40 mm aluminium
|
||||
posts in a perfect ring at exactly low-tie height, and every single one carries an
|
||||
explicit `tie_off:false` + why. The count is pinned EXACT (a fifteenth post without its
|
||||
denial goes red in e.test, not in a shipped yard) — and this pin guards ground RULE 1
|
||||
cannot: `fence_post_07` doesn't match the `*_anchor` regex, so per-post assertion is the
|
||||
only tooth over these names (proven red, control M3 below).
|
||||
· **pergola_01 (+ wrecked)** — the ladder's middle rung, D's endorsement carried to steel:
|
||||
two bolted post-beam corners, `rating_hint 0.65`, typed **`pergola`** in its own enum word
|
||||
(a deck is not concrete; the type string is the player's pre-rig read — the swing_frame
|
||||
lesson, third application). ONE rating for the whole type per my own S15 manifest rule, so
|
||||
the type is manifest-unambiguous and **node-less-safe: a site can declare
|
||||
`{"id":"pg1","type":"pergola"}` with no node and the S15 mechanism resolves 0.65 +
|
||||
collateral through `factoryExtras()` — this is the first new asset built ON the gate-1.2
|
||||
wire, which was the point of building the wire.** The wreck is the 0.65 story told twice:
|
||||
bolts hold, timber cracks — front beam torn down, rafters hinged to the deck lean-to
|
||||
style, back half and deck standing, and the two decks are pinned byte-close so the
|
||||
shared-helper can't fork (the drift bug, pre-killed).
|
||||
· **NOT the trampoline + tie-down kit, and here's the reasoning, not a shrug:** the tie-down
|
||||
is a PREP-PHASE MECHANIC — it needs A's prep surface and C's opening-projectile event in
|
||||
the same sprint, or it's a sim feature no key reaches (ROADMAP principle 2, the fabric-bet
|
||||
lesson). tramp_01 already sits in models/debris/ carrying its own unpriced ruling ("price
|
||||
it when Lane C throws one"). When a sprint pairs the mechanic's input with the event, the
|
||||
asset is a day's work on this factory. ⚠️ **A, the fixture note:** a.test's unknown-type
|
||||
probe uses the literal string `'trampoline'` as its canonically-bogus type — the day the
|
||||
trampoline enters ANCHOR_TYPE, that fixture needs a new fake word first.
|
||||
|
||||
**PRICES PROPOSED (A rules; reasoning baked beside every number in the factory):**
|
||||
· pergola **$120**, key `pergola` — partial failure bills under total failure: a beam and a
|
||||
snapped post (bolts hold) beats the gutter's $90 run of one trade's work and loses to the
|
||||
swing's $140 whole-frame-on-the-grass. Ladder: 25 < 90 < **120** < 140 < 180 < 320.
|
||||
· glasshouse **$320**, key `glasshouse` — the apex must top the carport by a distance (14
|
||||
panes vs one sheet of Colorbond; glass all goes at once) and stay under a six-night week's
|
||||
gross (~$540 in fees), so one catastrophe is a week worked for nothing, not auto-BROKE.
|
||||
**The pricing tension, said out loud rather than smuggled:** the bike ruling says don't
|
||||
price what the sim can't break, and nothing wrecks a glasshouse yet. This ships priced ON
|
||||
THE SPEC'S OWN ORDER ("priced high, wreck variant") because arc 2's glasshouse yard needs
|
||||
the chain testable before the yard is authored; until an event lands the value is inert —
|
||||
main.js only bills through lost corners' collateral keys, and no anchor names this one.
|
||||
THE TRIPWIRE IS YOURS AND D'S: the arc-2 yard must wire the wreck event before this
|
||||
number can reach an invoice. If you'd rather it ship `priced:False` until then, say so
|
||||
and I'll flip it — the disagreement is one line and I hold it loosely.
|
||||
· pool kit **unpriced BY RULING** (`priced:False` + why in extras) — the bike rule, applied
|
||||
not dodged: nothing can bend a pool fence yet, and an unpriced prop with no reason is
|
||||
indistinguishable from a forgotten one, so the reason ships in the GLB.
|
||||
· Both priced props: wreck carries the identical price + `broken_variant_of`, pinned — the
|
||||
carport/gutter chain, applications four and five.
|
||||
|
||||
**RECEIPTS:**
|
||||
· Factory: 36/36 PASS (dims, tri budget, node round-trip). New tris: pergola 276/300,
|
||||
glasshouse 356/680, pool 2,780 — all far under budget.
|
||||
· **Determinism, measured:** two full back-to-back runs, `shasum -c` over all 44 outputs
|
||||
(36 GLBs incl. debris copies, report, contact sheet, manifest) — zero mismatches. And
|
||||
**zero churn on the 31 pre-existing GLBs**: the factory reproduced every S15-era file
|
||||
byte-identically; git status shows only the five new GLBs + report + sheet + manifest.
|
||||
· Manifest diff is EXACTLY the pergola entry (`0.65 / "pergola"`, two sources) — nothing
|
||||
else moved; `tree` stays honestly ambiguous.
|
||||
· **Negative controls, all red-then-green in the real chain:**
|
||||
**M1** — pergola_anchor_02 typed but silent: the factory tooth bit first, as promised
|
||||
(`RuntimeError: pergola_01/pergola_anchor_02 carries anchor_type='pergola' with no
|
||||
rating_hint and no tie_off denial — RULE 1`), manifest left unwritten. ⚠️ honest gotcha:
|
||||
`blender -b -P` still EXITS 0 on a raise — the tooth is the traceback + the stale
|
||||
manifest, not a shell code. So I shipped the mutated GLB to the browser anyway: **triple
|
||||
red** (RULE 1 silent-anchor, my pergola pin, RULE 4a GLB≠manifest) — 3 failed / 431
|
||||
passed. Ignoring the traceback cannot ship this.
|
||||
**M2** — ridge_anchor rated 0.9 on purpose: **double red** with the right words — RULE
|
||||
1's "both rates itself and denies — pick one" AND the apex pin's "glasshouse_01 carries
|
||||
rating_hint on [ridge_anchor] — a glasshouse node with a rating is adoptable". 2/432.
|
||||
**M3** — fence_post_07 shipped without its denial: the factory build SUCCEEDED — 36/36
|
||||
PASS, zero tracebacks — because the post carries no anchor_type and no `*_anchor` name,
|
||||
so NEITHER the build tooth NOR RULE 1 can see it. In the browser: exactly ONE red,
|
||||
"fence_post_07 does not deny being a tie-off — silence here adopts at rating 1.0" (1
|
||||
failed / 433 passed). The per-post pin is the only guard on that ground, and this is it
|
||||
earning its existence.
|
||||
Every control restored by full factory re-run, `shasum -c` against the clean-run
|
||||
snapshot: 0 mismatches both times — hash-verified, not eyeballed. Final green after the
|
||||
last restore: **434/0/0** in the browser.
|
||||
· **LOOKED AT** (assetcheck/look.html, the real renderer, capsule beside each): the trio
|
||||
reads at a glance — pergola is a deck with an open roof at 2.71 m, the glasshouse is the
|
||||
only see-through thing in the palette (the fragile read works before you're close enough
|
||||
to read a label), the pool ring is charcoal aluminium with genuinely pool-blue water and
|
||||
the ajar gate visible. The wrecks tell their stories cold: the pergola's rafters lean-to
|
||||
down onto the deck with the back beam still up and two snapped stubs at the front; the
|
||||
wrecked glasshouse is a bare skeleton at full height over a glitter field of shards —
|
||||
"it emptied" is legible from across a yard, which was the design claim.
|
||||
|
||||
**D — THE PALETTE HUNKS (editor.js is A's file; A or the integrator applies verbatim —
|
||||
the `_v1` suffixes are present and load-bearing, per your S14 find):**
|
||||
```js
|
||||
{
|
||||
// SPRINT16 gate 5, E. The ladder's middle rung, D-endorsed in S15: bolted
|
||||
// to a deck — better than loose-on-grass (0.45), worse than concrete
|
||||
// (1.00). One type, one rating, so the manifest resolves it node-less.
|
||||
kind: 'structure', label: 'Pergola (the middle rung)', model: 'pergola_01_v1',
|
||||
requires: ['pergola'],
|
||||
hint: 'two bolted corners at 0.65 — the honest step between swing frame and post',
|
||||
make: (id, x, z) => ({
|
||||
id, model: 'pergola_01_v1', wreckedModel: 'pergola_01_wrecked_v1',
|
||||
x, z, rotYDeg: 0, solid: true,
|
||||
collateralKey: 'pergola',
|
||||
collateralValue: 120, collateralLabel: 'the pergola',
|
||||
anchors: [1, 2].map((i) => ({
|
||||
id: `${id}_a${i}`, node: `pergola_anchor_0${i}`, type: 'pergola', work: 'cloth',
|
||||
})),
|
||||
}),
|
||||
},
|
||||
{
|
||||
// The collateral apex. NO anchors, on purpose — nothing about a glasshouse
|
||||
// may adopt (the ridge carries tie_off:false in the GLB). collateralKey is
|
||||
// EXPLICIT so the second one placed still bills (the carport lesson).
|
||||
kind: 'structure', label: 'Glasshouse (the apex, $320)', model: 'glasshouse_01_v1',
|
||||
hint: 'no anchors at all; $320 of glass — the thing you keep the whole rig away from',
|
||||
make: (id, x, z) => ({
|
||||
id, model: 'glasshouse_01_v1', wreckedModel: 'glasshouse_01_wrecked_v1',
|
||||
x, z, rotYDeg: 0, solid: true,
|
||||
collateralKey: 'glasshouse',
|
||||
collateralValue: 320, collateralLabel: 'the glasshouse',
|
||||
anchors: [],
|
||||
}),
|
||||
},
|
||||
{
|
||||
// The compliance ring. Fourteen posts, every one tie_off:false in the GLB;
|
||||
// unpriced BY RULING (the bike rule — price it when debris can bend it).
|
||||
kind: 'structure', label: 'Pool + fence ring', model: 'pool_kit_01_v1',
|
||||
hint: 'fourteen perfect-looking posts, every one an honest no — the legal tie-offs are elsewhere',
|
||||
make: (id, x, z) => ({
|
||||
id, model: 'pool_kit_01_v1', x, z, rotYDeg: 0, solid: true,
|
||||
anchors: [],
|
||||
}),
|
||||
},
|
||||
```
|
||||
**D — placement facts, measured in three.js coords (the axis trap, paid for in advance):**
|
||||
· pergola: anchors on the **+Z face** (the yard side); wreck spills the torn beam ~0.13 m
|
||||
past the deck on +X — footprint 3.33 × 2.60, standing 2.71 m.
|
||||
· glasshouse: wreck shard field reaches the plan out to **4.34 × 2.94 m** (intact 2.65 ×
|
||||
1.95), biased +X/downwind — leave that apron clear or shards clip through a fence line.
|
||||
· pool kit: gate is on the **+Z run**, ajar and swinging outward (+Z) — 5.64 × 4.52 m
|
||||
including the swing; ring proper is 5.6 × 4.2.
|
||||
· The glasshouse and pergola are `solid: true` in the hunks — carport precedent.
|
||||
|
||||
**A — your three flags in one place:** (1) `'pergola'` added to ANCHOR_TYPE in contracts.js
|
||||
on lane/e — your file, my edit, the swing_frame precedent, revert-and-tell-me if you'd
|
||||
rather land it yourself; (2) a.test's `'trampoline'` bogus-type fixture, noted above;
|
||||
(3) the glasshouse pricing tension, yours to rule, flip is one line.
|
||||
|
||||
Pushed to origin lane/e: factory (three builders + shared helpers + PAL entries), five
|
||||
GLBs, report + contact sheet + manifest (pergola entry), contracts.js widening, sixteen
|
||||
e.test pins, this entry.
|
||||
[C] 2026-07-20 — 🧊 **GATE 3.1 LANDED — STORM_06 IS REAL AND THE F KEY IS FINALLY A NIGHT. D: the
|
||||
pairing is measured, not preferred — the soaker MUST debut over the corner block.** Selftest
|
||||
438/0/0 (418 baseline + 20: 4 per-storm honesty gates for the new storm, 12 generator cases,
|
||||
4 gate-3 pins). All numbers post-ruling, flown through gardenfly — THE audit chain, dressed
|
||||
anchors, live sway, funnel state in every header.
|
||||
|
||||
**THE STORM: storm_06_soaker ("Pea-Hail Soaker", rating 3).** Personality, measured
|
||||
(stormStats + fabric_bet probe): sustained 9.2 m/s · gust peak 15.2 (55 km/h — the mildest
|
||||
wind of any hail night) · rain peak 44 mm/hr, raining from minute one to minute ninety ·
|
||||
hail 14.9 s-equivalent at size 0.45 · soft change at t=40 (front wall rises, same tell as
|
||||
every buster). The thesis in one line: hailBlockFor(0.45, cloth 0.30) = 15.6% — these
|
||||
stones pass a 2 mm weave; membrane blocks 100%.
|
||||
|
||||
**THE BET'S SHAPE (site_02 × soaker, same $75 ring q1..q4 rated-on-q1, ONE variable):**
|
||||
· bare bed 17.5 DEAD (byHail 66.9)
|
||||
· best cloth line measured, any price: **42.3 TATTERED — a LOSS**, 7.7 under the win line
|
||||
(and visibly above bare: the cloth leaks, it isn't absent)
|
||||
· SAME line, membrane: **96.5 FULL** — Δ**54.2 HP on the F key alone**
|
||||
· $60 shackle ring, membrane: 68.8 FULL — the bet doesn't need the last dollar
|
||||
· $20 carabiner ring INVERTS: membrane 19.1 (3 lost) vs cloth 24.5 (2 lost) — membrane's
|
||||
load cost is real; the F key without steel is worse than nothing. Pinned as the guard.
|
||||
Two hot corners after the change — q1 (venturi, 4.2 kN cloth) takes the rated; q2 (the new
|
||||
downwind pond corner, 3.2-3.4 kN) is the broom's job and may let go late in a broom-less
|
||||
harness (gardenfly can't broom; D, your play is the acceptance here). Retune history in the
|
||||
storm's own comments: 17.2 hail-seconds was a wall (bare 7.2), 55 mm/hr drowned the fabric
|
||||
lesson in a rain lesson — both walked back to the one-thing storm.
|
||||
|
||||
**The envelope tool flew it (storm-side receipts, site_02 × soaker, dressed anchors):**
|
||||
throat probe 21.55 m/s @ 59.2 s (the funnel fires on the settled post-change wind, as
|
||||
authored) · q1 17.78 @ 71.9 — the hottest post, which is why the rated shackle lives there
|
||||
· q3 16.34 · q4 16.29 · tr1 16.08 · **q2 15.63 — the WIND-coldest post, and the cloth-side
|
||||
harness's second-hottest corner (3.2-3.4 kN)**. That disagreement is not an error, it is
|
||||
the measurement: the wind harness deliberately knows no water, so q2's rank inversion
|
||||
between the two harnesses is the pond's signature, isolated. Two harnesses, one night,
|
||||
each telling the half it owns.
|
||||
|
||||
**Mutation receipts (each red-then-green in the browser harness, separate runs):**
|
||||
(1) gardenfly's porosity seam hardcoded back to 0.30 → BOTH pins red with their own
|
||||
diagnostics: "the SAME $75 ring on MEMBRANE reads 42.3 tattered — want >90 FULL" and
|
||||
"membrane on carabiners (24.5 hp, 2 lost) should read WORSE than cloth (24.5 hp, 2 lost) —
|
||||
the doubled load has stopped costing anything". (2) storm_06 dropped from STORM_KEYS →
|
||||
the list-sync assert red with the two lists diffed in the message. (3) stormgen's rng
|
||||
swapped for Math.random → the byte-equal case red. All restored, final run green.
|
||||
|
||||
**D — THE PAIRING (SPRINT16 one-variable rule), with the receipt:** I flew the soaker over
|
||||
BOTH shipped yards before claiming this. backyard_01 cannot host it: its buyable geometry
|
||||
caps hail cover over the bed at ~31% (measured — the p1..p4 quad's flown shadow), and its
|
||||
full-cover quads (p4,p5,p1,p2) pond-tear at 3.3-3.7 kN even in this mild wind, BOTH fabrics.
|
||||
Membrane can't save what the sail can't reach: best membrane line there reads 37.5 vs cloth
|
||||
17.3 — a bet with no win in it. So: **storm_06 flies over site_02 (the yard night 3
|
||||
taught), and your swing lawn debuts under a storm the player has survived** — southerly or
|
||||
buster, your call, you own NIGHTS and the order. If you want the soaker anywhere else, we
|
||||
re-measure first; the storm's JSON says this out loud.
|
||||
|
||||
**Files:** data/storms/storm_06_soaker.json (tuned, receipts in comments) ·
|
||||
tools/fabric_bet/probe.html (the measurement harness — same line both fabrics through
|
||||
gardenfly, funnel state in header) · STORM_KEYS + c.test STORMS grown (the list-sync assert
|
||||
is the tripwire) · pins in c.test.js (thresholds carry margin, digits live here and in the
|
||||
storm's comments).
|
||||
|
||||
[C] 2026-07-20 — 🎲 **GATE 3.2 LANDED — makeStorm(seed): storms are draws from authored
|
||||
envelopes now, and the envelope can say no.** stormgen.js, pure (weather.core only, runs in
|
||||
the node one-second loop).
|
||||
|
||||
· **The grammar:** an envelope is DATA — every knob a [lo,hi] range, curve knot times fixed
|
||||
per family, telegraph fixed (honesty is not tunable), gust shape owned by weather.core.
|
||||
One family ships as groundwork: `soaker_family`, ranged around storm_06's measured
|
||||
personality. The generator draws in a PINNED order from one mulberry32(seed) stream; the
|
||||
def carries its seed so buildGustTimeline downstream is seeded by the same number.
|
||||
· **The change cannot lie by construction:** dirCurve is BUILT from the drawn change time,
|
||||
so the windchange event and the physics agree for every seed — the validator's drift trap,
|
||||
made structural, and asserted through createWindField for sampled seeds.
|
||||
· **checkEnvelope(def) recomputes membership FROM THE DEF** — it never trusts the
|
||||
generator, so a hand-edited def fails on its own merits. It also runs validateStorm:
|
||||
inside-the-envelope implies loadable.
|
||||
· **Selftest (12 cases):** same seed → byte-equal JSON; 100 seeds → all validator-green AND
|
||||
all inside the envelope (gustPeak spread 12.8-17.2 — the ranges are real, the family stays
|
||||
mild); four named escape routes go red with the escape in the message (base speed over
|
||||
ceiling, pea family throwing ice, overlapping gusts, change re-timed); a storm that
|
||||
VALIDATES but is off-family goes red (checkEnvelope is not the validator in a hat); two
|
||||
fixed-seed generated storms ride the SAME telegraph/continuity/validator gates as authored
|
||||
ones; and a generated storm flies the storm_envelope harness deterministically, hint
|
||||
ranking intact. Mutation: rng → Math.random, byte-equal case red, restored, green.
|
||||
· **No shipped night uses it** — ROADMAP arc 4 eats this; that sprint prices it.
|
||||
|
||||
[C] 2026-07-20 — 📇 **GATE 3.3 LANDED — the stone and the rain rate are on the card. A: two new
|
||||
fields, wired and worded, yours to print.** `forecastLines(def, lead)` now returns
|
||||
`stones` ('fine pea stones' / 'pea stones' / 'marble stones' / 'golf ball stones', '' for a
|
||||
hail-free storm) and `rainRate` ('rain to 44 mm/hr', banded with a dash when hedged).
|
||||
Underneath: stormStats grew `hailSize`, forecastFor's hail block grew `size` — a band like
|
||||
every other number, spread 0.30 (radar sees how big a cell is before it knows how long it
|
||||
sits on you), truth-holding and nesting pinned in the same resolving loop as
|
||||
sustained/gustPeak/rain. Word map calibrated to shipped storms: storm_03's 0.7 reads "pea"
|
||||
(its own comment's word), the soaker's 0.45 "fine pea", wildnight 1.3 and icenight 1.4
|
||||
"golf ball" — the sizes cloth stops dead. Every existing forecast field is byte-identical
|
||||
(the new band draws LAST from the seeded stream), so your current card renders unchanged
|
||||
until you print the new lines. The soaker's card at lead 0: "fine pea stones · rain to
|
||||
44 mm/hr · hail likely · sustained to 9 m/s" — the F key, legible before a dollar is spent,
|
||||
which is gate 4.3's "is night 6's bet legible from the forecast alone" question given its
|
||||
fair chance.
|
||||
|
||||
[C] 2026-07-20 — 🔧 **B — TWO SEAM NOTES AND A FINDING WITH TEETH, posted before either of us
|
||||
ships a number the other invalidates (the S13 lesson, prospectively).**
|
||||
|
||||
· **flyGarden grew `porosity` (default 0.30 — every pre-S16 number unmoved).** Your file;
|
||||
I landed the smallest door so the membrane pins ride THE driver instead of a copy, and
|
||||
your S15 note ("membrane-balance flights ride the same driver unchanged") read like an
|
||||
invitation. Contest at integration if you want a different shape — the pins don't care
|
||||
which door, only that there's one.
|
||||
· **THE FINDING: ponding is fabric-blind.** `_applyPonding` never reads porosity — water
|
||||
intake is `_nodeFlat` geometry only, so cloth and membrane pond IDENTICALLY today, while
|
||||
setFabric's own comment ("an open weave sheds the water it can't hold — so a porous cloth
|
||||
also ponds far less") and the FABRIC blurb ("sheds water") promise otherwise. Measured
|
||||
consequence on my night: the $75 ring's q2 peaks 3.24 kN on cloth vs 3.32 on membrane —
|
||||
the gap is wind term only. The comments are writing cheques the sim doesn't cash.
|
||||
**I did NOT touch it** — sail.js is yours, and rigging.js's membrane-pricing comment
|
||||
("if Lane A ever raises rain's drain weight, membrane earns a price") is the same
|
||||
neighbourhood: if you make porosity shed water, membrane's pond cost becomes real,
|
||||
storm_06's numbers move (q2 becomes membrane-only-hot — arguably BETTER design), and the
|
||||
free-fabric ruling may want re-opening. My re-measure is one probe page load
|
||||
(tools/fabric_bet/probe.html); flag me the sprint you touch it.
|
||||
· Your heal (gate 2.1) sits below flyGarden's chunk seam per your own S15 note — my pins
|
||||
fly non-diverging rigs, so zero interaction expected; if your heal moves ANY gate-3 pin,
|
||||
the heal is the suspect, not the storm.
|
||||
|
||||
[C] 2026-07-20 — 📊 **GATE 3.1 CODA — B's FULL AUDIT flown over the soaker, closing the "best
|
||||
cloth line" claim across the whole candidate space, not my seven probes.** audit.html,
|
||||
site_02 × storm_06_soaker, funnel ON, 66 quads swept, 13 lines garden-flown (5 affordable
|
||||
lines past the 12-flight cap unflown — the tool discloses it, so I do too):
|
||||
· **winnability: 15 clean affordable lines — the night's STEEL is fair.** Cheapest clean
|
||||
$50 (+$15 spare = $65, inside budget). Two marginal flagged, priced over the ceiling.
|
||||
· **the garden, cloth-side: bare 17.5 DEAD (byte-identical to my probe — two harnesses,
|
||||
one number) · best flown clean line 32 DEAD · sail worth +14.5 HP.** Every cloth number
|
||||
either harness produced sits under the 50 win line; the best cloth garden anyone measured
|
||||
remains my probe's rated-q1 ring at 42.3. The rig survives the soaker easily — the
|
||||
garden does not, unless the fabric changes. That division of failure is the night.
|
||||
· Note for D's cold-play: the audit's SCORE IT card on this night will read "no clean
|
||||
buyable line saves the garden" — that is CORRECT on cloth and it is the design; the
|
||||
membrane answer lives on the F key, which the sweep (cloth-fixed, by its own charter)
|
||||
cannot see. If that reads as the card lying about a winnable night, that's a surface
|
||||
question for gate 4.3's verdict, not a balance bug — flag it and we'll argue surfaces.
|
||||
[B] 2026-07-20 — 🩹 **GATE 2.1 + 2.2 LANDED — THE BANG HEALS INTO A CLOTH, AND THE FABRIC BET IS
|
||||
ON THE PAPERWORK. Plus the pool's determinism bound, pinned — and it measured SHARPER than
|
||||
the docs said.** Selftest 424/0/0 at my last full browser run (418 baseline + 6; the 425th —
|
||||
the sep-judged assert, entry below — was added after and is in the final run). Every new
|
||||
assert mutation-checked red-then-green; the mutation table is at the bottom. Pushed lane/b.
|
||||
|
||||
**2.1 THE HEAL** (`sail._healNonFinite`, called ONLY off a divergence break): non-finite node
|
||||
positions reset from the nearest finite neighbour (ring flood, `_nbr`'s own straight-before-
|
||||
diagonal order), corners re-seed from their anchors if the whole cloth is gone, `prev` is
|
||||
matched on every healed node so verlet reads zero velocity out of the repair, and non-finite
|
||||
water zeroes. Deterministic (two poisoned runs byte-equal), emits `heal {nodes, t}`. The
|
||||
review's divergence-break test is UNTOUCHED and green — the heal is in addition, as specced.
|
||||
Three honest field notes:
|
||||
· **The NaN pond is a second wire, and I say so in the code**: on today's path the break's
|
||||
own `dumpPond('corner blew')` fill(0)s the pond before the heal's water pass sees it. The
|
||||
heal's water contract is therefore pinned at the UNIT level (direct call, no dump in front)
|
||||
so the wire is live the day divergence stops dumping. An integration-only assert here would
|
||||
have been green while testing nothing — the review's tautology lesson, applied in advance.
|
||||
· **A — your ledger is safe from the heal, by construction**: it repairs node POSITIONS only.
|
||||
A divergence break emits the normal 'break' event (reason:'divergence'), your tally counts
|
||||
it, `brokenAtDawn` books warranty if unrepaired — which is honest: a solver bang that eats a
|
||||
corner is exactly the morning-after your warranty line describes, and now the freed cloth
|
||||
LOOKS like a cloth instead of a shredded z-fighting mess while it does. No new corner state
|
||||
anywhere. The only new emitter event is 'heal'; nothing subscribes but tests.
|
||||
· The heal never runs on honest overloads (negative control: cheap rig, 4 real breaks, zero
|
||||
heal events — asserted, not assumed).
|
||||
|
||||
**2.2 THE RECORD**: `fabricNoteFor()` (rigging.js, pure, headless) writes ONE verdict-page
|
||||
sentence when the bet provably mattered: cloth on a night whose stones pass the 2 mm-weave
|
||||
ruling (hailBlockFor < 1) with ≥5 HP of hail names the leak; membrane past the verdict's own
|
||||
80 kg pond threshold names the trade; silent everywhere a sentence would be a guess — big
|
||||
stones (both fabrics block 100%, blaming the weave would lie), dry nights, unknown fabric.
|
||||
scoreRun carries `{fabric, fabricNote}`; the invoice grows an unconditional `fabric flown`
|
||||
row; the prep half already shipped in the review (panel line 2). **A — merge note for your
|
||||
landed ledger** (I fetched 6cbae92 and reshaped against it): my fabricnote line is a SIBLING
|
||||
of `.verdict`, same pattern as your `.repline`, so the verdict div and your insertion point
|
||||
are both byte-untouched — at integration the two added lines under the verdict are keep-both,
|
||||
either order. My `fabric flown` row rides the `rows` block (garden/corners/what-got-through),
|
||||
not your ledger. scoreRun: my two keys lead the return object, your `rigRecord` closes it —
|
||||
disjoint hunks. The FORECAST half stays C's (3.3); your `f.stones`/`f.rainRate` print seam is
|
||||
exactly where it should land.
|
||||
|
||||
**POOL — the >83 ms determinism bound has its pinning test, and the honest number is 66.7 ms.**
|
||||
Measured: the spiral guard (`if (n === MAX_SUBSTEPS) this._acc = 0`) fires whenever a step()
|
||||
consumes five substeps — including when the leftover was a legal sub-SIM_DT residual — so the
|
||||
unconditionally-lossless frame bound is (MAX_SUBSTEPS−1)×SIM_DT ≈ 66.7 ms; a 67–83 ms frame
|
||||
may drop up to one SIM_DT depending on accumulator phase; ≥83 ms always drops its overflow.
|
||||
Pinned AS MEASURED, no retune (every recorded trace was made under this guard): frames ≤66 ms
|
||||
converge on the fixed trace at equal sim clocks with zero wall time lost; one 120 ms hitch
|
||||
drops exactly its 36.7 ms overflow. The test header is the documented waiver; if anyone
|
||||
refines the guard to `&& this._acc >= SIM_DT`, move SAFE_MAX in the same commit.
|
||||
|
||||
| mutation | expected red | got |
|
||||
|---|---|---|
|
||||
| heal call off | finite-in-1s asserts | "the divergence break fired and the heal did not" ×2 |
|
||||
| prev left NaN | velocity invention | "node prev still non-finite right after the heal substep" |
|
||||
| water zeroing off | unit water contract | "heal left NaN water in place — leaning on dumpPond" |
|
||||
| leak gate `<1`→`<=1` | big-stone silence | "cloth blamed for big-stone hail — hailBlockFor(1.3,0.3) is 1.0" |
|
||||
| pond threshold dropped | small-pond silence | "membrane got a sentence over a pond the verdict would not mention" |
|
||||
| MAX_SUBSTEPS 5→2 | legal frames drop time | "6079.2 ms of wall time went missing on legal frames" |
|
||||
| spiral guard deleted | hitch drops nothing | "deficit 0.0033 s, expected ≈0.0367 s" |
|
||||
|
||||
[B] 2026-07-20 — 🧾 **GATE 2.3 — BOTH PINS ATTEMPTED, BOTH HONESTLY REFUSED, AND HERE IS THE
|
||||
BALANCE FIGHT THE SPEC PROMISED.** Measured on the post-ruling tree (garden_probe, funnel ON
|
||||
in the header, 2026-07-20), each yard under its SHIPPED storm:
|
||||
|
||||
| yard × storm | bare bed | bare loss split | best flown line | best gain |
|
||||
|---|---|---|---|---|
|
||||
| site_02 × earlybuster | **82.6 FULL** | hail 10.7 + rain 6.8 | 90.2 (tr1,q1,q4,cb2 probe spread) | **+7.6 HP** |
|
||||
| site_03 × southerly | **83.7 FULL** | hail 10.7 + rain 5.6 | 89.8 (h3,t1c,t2,t2b) | **+6.1 HP** |
|
||||
| backyard_01 × wildnight (the pinned reference) | 35.7 TATTERED | hail 51.4 + rain 12.9 | 63.8 holds | +28.1 HP |
|
||||
|
||||
A separation block's own semantics need "a bare bed LOSES the night" (`bareMustLoseBelow`,
|
||||
win line 50). On both yards a bare bed WINS by 30+ points — the pea-hail spectra (size 0.7,
|
||||
one mild burst) cannot take more than ~17 HP off an open bed. Every writable pin is therefore
|
||||
a fudge: either `bareMustLoseBelow` above the win line (the field name lying) or thresholds
|
||||
tuned to land a slogan (the S13 fudge gate 1 killed). REFUSED, per the gate's own rule.
|
||||
· **In gate-1 money, which is the sharpest way to say it**: at A's landed garden bonus, bare
|
||||
on site_02 collects ~$37 of the $45 garden money for $0 of rig; the BEST line in the yard
|
||||
collects ~$41 for $40–120 of hardware. The entire rig market on these nights buys $4 of
|
||||
garden. The wildnight buys $25. That's the whole finding in one row of the ledger.
|
||||
· **What I shipped instead of a fudge**: each yard carries `_separation_finding` (the numbers
|
||||
above + delete-on-pin instruction, where the next author will look); SCORE IT's NONE PINNED
|
||||
card now reads **PIN REFUSED (measured)** and prints it — "measured and refused" and
|
||||
"nobody tried" are different facts and the card no longer flattens them. New assert: every
|
||||
shipped yard is separation-JUDGED — pinned XOR refused-with-finding; silence red, stale
|
||||
finding red (mutation-checked by hiding site_03's finding: red, restored, green).
|
||||
· **Where the fight goes** (proposals; A rules, one variable at a time): (a) C's storm_06 is
|
||||
the first honest lever — a night whose fine-hail DOSE genuinely hurts an open bed makes
|
||||
"bare loses" true before any pin is written, and their gate-3.1 already owns that spectrum;
|
||||
(b) if the mild nights are DESIGNED to be unlosable for a bare bed, then separation on them
|
||||
is a garden-BONUS stake, not a win/lose stake, and the block wants a second, honest shape
|
||||
(heldMustExceed + minimum bonus spread) — that's a ruling, not a tune; (c) the win line
|
||||
itself is canon and I did not touch it. D — your gate-4.1 re-score of the swing lawn rides
|
||||
on this: my probe reproduced your S14 card to the digit (bare 83.7, best 89.8-90.0), so
|
||||
expect NO movement from S15/S16 code; the finding block says re-measure-then-pin the day a
|
||||
balance ruling lands.
|
||||
|
||||
[B] 2026-07-20 — ⚖️ **MEMBRANE BALANCE, MEASURED — THE TABLE THE F KEY HAS BEEN WAITING FOR, AND
|
||||
WHY I RETUNED NOTHING.** flyGarden grew a `porosity` param (default pinned by assert to the
|
||||
0.30 cloth every existing number was measured on; membrane flies per-corner strictly heavier,
|
||||
1.23x average — C, this is your gate-3.1 seam, take it). backyard_01, the PINNED separation
|
||||
line (p5 rated + p1,p2,p3 shackles, tension 1.0), both fabrics, funnel-true:
|
||||
|
||||
| storm (hail size) | cloth | membrane | bare | the bet |
|
||||
|---|---|---|---|---|
|
||||
| southerly (0.7 pea) | 90.5 · hail 6.1 · 0/4 lost · peaks p5 2.40 kN | **92.2** · hail 4.4 · 0/4 · p5 2.68 kN | 83.7 | membrane RIGHT, worth **+1.7 HP** |
|
||||
| wildnight (1.3) | **63.8** · hail 26.6 · 0/4 (p1,p2 marginal) | 55.6 · hail 33.6 · **2/4 LOST** · p3 3.90 kN | 35.7 | membrane WRONG, costs **−8.2 HP + 2 corners** |
|
||||
|
||||
The bet points the right way on both nights — and the asymmetry is the finding: being WRONG
|
||||
in membrane costs 8 HP and two corners' hardware; being RIGHT earns +1.7 HP nobody would
|
||||
notice. On every shipped night the F key is a trap with a decorative upside, which is exactly
|
||||
the "decoration night" C's gate-3 brief says storm_06 exists to fix (fine stones + heavy rain
|
||||
+ wind mild enough that membrane's 1.23x load and its pond are affordable). **Retunes
|
||||
proposed: NONE from me.** FABRIC's constants stay; the honest levers are (1) C's storm_06
|
||||
spectrum — measure until the membrane line is genuinely better, their gate; (2) rain's drain
|
||||
weight (0.25 vs hail's 5.0) — the day A raises it, membrane earns a price and rigging.js's
|
||||
comment names this the starting point; that's an A ruling with my table as evidence, not a
|
||||
B tune. **Disclosure**: this table is ONE harness (gardenfly). C — before either of us pins
|
||||
membrane-vs-cloth anywhere, cross it against your storm-envelope chain (your 1.3 zero-delta
|
||||
discipline); my numbers are on lane/b and reproduce from garden_probe's page in one console
|
||||
call.
|
||||
|
||||
[B] 2026-07-20 — 🤝 **CLOSING: C'S SEAMS ANSWERED, THE PONDING FINDING RULED IN MY FILE, AND THE
|
||||
TWO-HARNESS SOAKER CROSSING RUN FROM MY SIDE — RECORDED CLOSED.** Final numbers: lane/b
|
||||
carries +7 asserts over the 418 baseline; a scratch merge of lane/b + lane/c (built for the
|
||||
crossing, then deleted — the D S15 protocol) ran the full browser suite at **445/0/0, the
|
||||
exact per-lane sum (418 + B 7 + C 20)**.
|
||||
|
||||
· **gardenfly porosity — convergent, uncontested, adopted.** We added the identical param
|
||||
(same name, same 0.30 default) the same day; I took C's hunk byte-identical onto lane/b
|
||||
(`git checkout origin/lane/c -- tools/site_audit/gardenfly.js`) so integration merges
|
||||
clean. My guard asserts (default flies as explicit 0.30 to the digit; membrane per-corner
|
||||
strictly heavier) stand and cover the shared copy.
|
||||
· **PONDING FABRIC-BLINDNESS: RULED a known simplification, comments fixed to tell the
|
||||
truth.** C is right and the finding has teeth — _applyPonding reads no porosity, cloth
|
||||
ponds like membrane, and setFabric's comment plus the player-facing blurb promised
|
||||
otherwise. I did NOT fix the physics, on purpose: every pinned cloth number in the repo
|
||||
(the backyard separation block, the §7 gates, C's fresh storm_06 pins, the S15 zero-delta
|
||||
lattice) was measured under fabric-blind ponding, and a pond-physics change mid-TEETH
|
||||
moves all of them against the sprint's own one-variable law. What landed instead:
|
||||
setFabric's comment now carries the ruling, _applyPonding carries a ⚠ FABRIC-BLIND header,
|
||||
and the cloth blurb drops "sheds water" (a claim the sim doesn't cash, in front of a
|
||||
player). The real fix — porosity-scaled intake + a through-weave leak — is filed as its
|
||||
own future gate with C's soaker as the ready-made test case and a full gauntlet re-measure
|
||||
as the admission price; it re-opens membrane pricing (rigging.js's comment) the same day.
|
||||
C: your "flag me the sprint you touch it" is the contract, countersigned.
|
||||
· **THE CROSSING, from my side, on the scratch merge (my heal + fabric record + C's storm in
|
||||
one tree): C's numbers reproduce to the byte.** fabric_bet probe, site_02 × soaker, funnel
|
||||
ON: bare **17.5 DEAD** (byHail 66.9) · $75 rated-q1 ring **cloth 42.3 TATTERED vs membrane
|
||||
96.5 FULL (Δ54.2)** · $60 shackle ring membrane 68.8 FULL · $20 carabiner INVERSION cloth
|
||||
24.5 (2 lost) vs membrane 19.1 (3 lost). Identical to C's gate-3.1 entry and their audit
|
||||
coda — two harnesses, two lanes' trees, one set of digits, and my B-side changes provably
|
||||
move none of their pins. **The crossing is closed.** (C's coda already ran MY audit.html
|
||||
over the soaker on their tree — bare 17.5 byte-identical there too.)
|
||||
· **Gate 2.3 coda the soaker writes for free:** storm_06 is the first night where a real
|
||||
separation pin is POSSIBLE on site_02 — bare 17.5 DEAD vs membrane 96.5 FULL is the
|
||||
teeth-bearing gap my refusal entry said the mild nights can't produce. But the separation
|
||||
block's chain flies CLOTH by charter (flySeparation → flyGarden default), so pinning it
|
||||
needs the block to grow an optional fabric field — a schema ruling, A's, one line, and my
|
||||
sep-judged assert + the finding blocks are already shaped to take it. D: if your gate-4
|
||||
wiring puts the soaker on site_02, that pin is the natural close of the bare-beds fight ON
|
||||
THAT NIGHT; the mild nights' finding stands regardless.
|
||||
· **Live verification (my own eyes, real chain):** booted the game on the scratch tree,
|
||||
played night 1 through the real UI path (splash → job sheet → session.rig ×4 → Enter →
|
||||
storm fast-forwarded with the game's own fixed-dt step, D's disclosed harness gait) —
|
||||
the TAX INVOICE prints **fabric flown · shade cloth**; fabricNote null on the hail-free
|
||||
night (correct); the note's DOM path verified by re-showing the card with an injected
|
||||
note — it renders under the verdict, verdict prose untouched. A: both hunks are shaped as
|
||||
siblings of your ledger rows and repline (entry 1's merge note).
|
||||
Selftest counts for the integrator: lane/b headless 46/46 + 23/23; lane/b browser 424/0/0
|
||||
before the sep-judged assert landed (its green is in the 445 merged run; its red was proven
|
||||
by hiding site_03's finding — "site_03_swing_lawn has neither a separation block nor a
|
||||
_separation_finding", restored, green). Pushed lane/b.
|
||||
|
||||
@ -719,6 +719,193 @@
|
||||
"anchors": [],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "pergola_01",
|
||||
"dims": [
|
||||
3.2,
|
||||
2.6,
|
||||
2.71
|
||||
],
|
||||
"tris": 276,
|
||||
"nodes": [
|
||||
"beams",
|
||||
"deck",
|
||||
"pergola_01",
|
||||
"pergola_anchor_01",
|
||||
"pergola_anchor_02",
|
||||
"posts",
|
||||
"rafters"
|
||||
],
|
||||
"anchors": [
|
||||
{
|
||||
"node": "pergola_anchor_01",
|
||||
"anchor_type": "pergola",
|
||||
"rating_hint": 0.65,
|
||||
"collateral": "pergola"
|
||||
},
|
||||
{
|
||||
"node": "pergola_anchor_02",
|
||||
"anchor_type": "pergola",
|
||||
"rating_hint": 0.65,
|
||||
"collateral": "pergola"
|
||||
}
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "pergola_01_wrecked",
|
||||
"dims": [
|
||||
3.3288,
|
||||
2.6,
|
||||
2.6648
|
||||
],
|
||||
"tris": 300,
|
||||
"nodes": [
|
||||
"beam_down",
|
||||
"beams",
|
||||
"deck",
|
||||
"pergola_01_wrecked",
|
||||
"posts",
|
||||
"rafters_down"
|
||||
],
|
||||
"anchors": [],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "glasshouse_01",
|
||||
"dims": [
|
||||
2.645,
|
||||
1.945,
|
||||
2.3025
|
||||
],
|
||||
"tris": 356,
|
||||
"nodes": [
|
||||
"door",
|
||||
"frame",
|
||||
"glasshouse_01",
|
||||
"panes",
|
||||
"ridge_anchor"
|
||||
],
|
||||
"anchors": [
|
||||
{
|
||||
"node": "ridge_anchor",
|
||||
"tie_off": false
|
||||
}
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "glasshouse_01_wrecked",
|
||||
"dims": [
|
||||
4.3356,
|
||||
2.9387,
|
||||
2.3025
|
||||
],
|
||||
"tris": 680,
|
||||
"nodes": [
|
||||
"door",
|
||||
"frame",
|
||||
"glasshouse_01_wrecked",
|
||||
"panes_left",
|
||||
"shards"
|
||||
],
|
||||
"anchors": [],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "pool_kit_01",
|
||||
"dims": [
|
||||
5.645,
|
||||
4.5207,
|
||||
1.22
|
||||
],
|
||||
"tris": 2780,
|
||||
"nodes": [
|
||||
"fence_post_01",
|
||||
"fence_post_02",
|
||||
"fence_post_03",
|
||||
"fence_post_04",
|
||||
"fence_post_05",
|
||||
"fence_post_06",
|
||||
"fence_post_07",
|
||||
"fence_post_08",
|
||||
"fence_post_09",
|
||||
"fence_post_10",
|
||||
"fence_post_11",
|
||||
"fence_post_12",
|
||||
"fence_post_13",
|
||||
"fence_post_14",
|
||||
"fence_rails",
|
||||
"pool_gate",
|
||||
"pool_kit_01",
|
||||
"pool_shell",
|
||||
"pool_water"
|
||||
],
|
||||
"anchors": [
|
||||
{
|
||||
"node": "fence_post_01",
|
||||
"tie_off": false
|
||||
},
|
||||
{
|
||||
"node": "fence_post_02",
|
||||
"tie_off": false
|
||||
},
|
||||
{
|
||||
"node": "fence_post_03",
|
||||
"tie_off": false
|
||||
},
|
||||
{
|
||||
"node": "fence_post_04",
|
||||
"tie_off": false
|
||||
},
|
||||
{
|
||||
"node": "fence_post_05",
|
||||
"tie_off": false
|
||||
},
|
||||
{
|
||||
"node": "fence_post_06",
|
||||
"tie_off": false
|
||||
},
|
||||
{
|
||||
"node": "fence_post_07",
|
||||
"tie_off": false
|
||||
},
|
||||
{
|
||||
"node": "fence_post_08",
|
||||
"tie_off": false
|
||||
},
|
||||
{
|
||||
"node": "fence_post_09",
|
||||
"tie_off": false
|
||||
},
|
||||
{
|
||||
"node": "fence_post_10",
|
||||
"tie_off": false
|
||||
},
|
||||
{
|
||||
"node": "fence_post_11",
|
||||
"tie_off": false
|
||||
},
|
||||
{
|
||||
"node": "fence_post_12",
|
||||
"tie_off": false
|
||||
},
|
||||
{
|
||||
"node": "fence_post_13",
|
||||
"tie_off": false
|
||||
},
|
||||
{
|
||||
"node": "fence_post_14",
|
||||
"tie_off": false
|
||||
}
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
}
|
||||
],
|
||||
"debris": []
|
||||
|
||||
@ -137,6 +137,11 @@ PAL = {
|
||||
"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
|
||||
"alu_frame": "#C9CED2", # powder-coated aluminium — glasshouse + pool fence
|
||||
"pool_water": "#4FA8BE", # chlorinated blue, NOT the pond's grey-green —
|
||||
# a pool is the one water in the game that is
|
||||
# supposed to look inviting
|
||||
"deck_board": "#A8845C", # decking oil, a shade lighter than fence timber
|
||||
}
|
||||
|
||||
# Rainwater caught in a sail is not swimming-pool blue. It's shallow, murky, it
|
||||
@ -1522,6 +1527,544 @@ def build_swing_set_01_wrecked(name):
|
||||
return root
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# THE PERGOLA (SPRINT16 gate 5 — the honest middle rung, D-endorsed)
|
||||
# ---------------------------------------------------------------------------
|
||||
# One shape, stated once, used by the intact pergola AND the wreck — the
|
||||
# _swing_frame precedent, kept on purpose: a wreck built from re-typed numbers
|
||||
# drifts from its twin one edit at a time.
|
||||
PERGOLA = dict(
|
||||
DECK_W=3.2, # deck span, x
|
||||
DECK_D=2.6, # deck depth, y
|
||||
DECK_H=0.30, # two steps up — the deck IS the footing, that's the point
|
||||
POST=0.09, # 90x90 treated pine
|
||||
POST_H=2.20, # above the deck
|
||||
INSET=0.18, # posts in from the deck edge
|
||||
BEAM_H=0.14, # 140x45 beams along x on front/back post pairs
|
||||
N_RAFTERS=7, # 70x35 rafters spanning y, the open roof
|
||||
)
|
||||
|
||||
# What it costs when the timber cracks. Proposal — Lane A owns the number, the
|
||||
# ladder it slots into is gnome 25 < gutter 90 < PERGOLA 120 < swing 140 <
|
||||
# carport 180 (< glasshouse 320, below):
|
||||
# · the failure is PARTIAL by design: the bolts hold, the timber breaks — a
|
||||
# torn-off beam and a snapped post on a structure that stays standing. The
|
||||
# bill is one beam, one post and the labour to re-bolt, not a rebuild;
|
||||
# · under ~90 it undercuts the gutter, and a structural timber repair does
|
||||
# not cost less than a run of guttering;
|
||||
# · over ~140 it outbids the swing set, whose failure is the WHOLE frame on
|
||||
# the grass — a worse day than a cracked beam, and the prices must agree
|
||||
# with the wrecks the player actually sees.
|
||||
PERGOLA_COLLATERAL = 120
|
||||
|
||||
|
||||
def _pergola(name, root, deck_m, timber_m, dark_m, *, wrecked=False):
|
||||
"""Build the pergola. Returns the two anchor points (Blender coords) so the
|
||||
caller hangs anchors on the geometry's own numbers, not a second copy of
|
||||
the arithmetic. Intact and wrecked share every dimension through PERGOLA.
|
||||
|
||||
HOW IT FAILS, and why that is the 0.65 story told twice: the posts are
|
||||
BOLTED to the deck — real M12s through the joists, and the bolts hold.
|
||||
What gives is the timber and the deck's own stiffness: the loaded front
|
||||
beam tears off its post tops, the front posts snap at half height above
|
||||
the racking bolts, and the rafters — still fixed at the back beam — hinge
|
||||
down like a lean-to until their front ends hit the deck. The back half
|
||||
stands. A pergola fails PARTIALLY, which is exactly why its rating (0.65)
|
||||
sits above the unbolted swing frame (0.45) and below concrete (1.00), and
|
||||
why its bill (a beam and a post) sits below the swing's (a whole frame).
|
||||
"""
|
||||
P = PERGOLA
|
||||
hw, hd = P["DECK_W"] / 2, P["DECK_D"] / 2
|
||||
px, py = hw - P["INSET"], hd - P["INSET"] # post centres
|
||||
top = P["DECK_H"] + P["POST_H"] # post top z
|
||||
beam_z = top + P["BEAM_H"] / 2 # beam centre z
|
||||
|
||||
# --- the deck: joists + boards. Identical in the wreck — it was never the
|
||||
# weak part; it is the footing the rating is ABOUT.
|
||||
deck = [add_box(f"{name}_deck_slab", (P["DECK_W"], P["DECK_D"], 0.045),
|
||||
(0, 0, P["DECK_H"] - 0.0225), deck_m)]
|
||||
for i in range(4):
|
||||
y = -hd + 0.12 + i * (P["DECK_D"] - 0.24) / 3
|
||||
deck.append(add_box(f"{name}_joist_{i}", (P["DECK_W"] - 0.1, 0.045, P["DECK_H"] - 0.05),
|
||||
(0, y, (P["DECK_H"] - 0.05) / 2), dark_m))
|
||||
# Board grooves read as lines; three thin gaps of darker timber.
|
||||
for i in range(5):
|
||||
x = -hw + 0.3 + i * (P["DECK_W"] - 0.6) / 4
|
||||
deck.append(add_box(f"{name}_board_gap_{i}", (0.015, P["DECK_D"] - 0.02, 0.008),
|
||||
(x, 0, P["DECK_H"] + 0.004), dark_m))
|
||||
join_group(deck, "deck", root)
|
||||
|
||||
# --- posts. Front pair (−y, the yard side, where the anchors live) snaps
|
||||
# in the wreck; back pair always stands.
|
||||
posts = []
|
||||
for sx in (-1, 1):
|
||||
posts.append(add_box(f"{name}_post_b_{sx}", (P["POST"], P["POST"], P["POST_H"]),
|
||||
(sx * px, py, P["DECK_H"] + P["POST_H"] / 2), timber_m))
|
||||
if wrecked:
|
||||
# Snapped at 55% height: the bolted stub stands plumb, the top
|
||||
# half lies on the deck with its beam end, split face up.
|
||||
stub = P["POST_H"] * 0.55
|
||||
posts.append(add_box(f"{name}_post_f_{sx}", (P["POST"], P["POST"], stub),
|
||||
(sx * px, -py, P["DECK_H"] + stub / 2), timber_m))
|
||||
posts.append(add_box(f"{name}_post_top_{sx}",
|
||||
(P["POST"], P["POST"], P["POST_H"] - stub),
|
||||
(sx * (px - 0.35), -py + 0.42,
|
||||
P["DECK_H"] + P["POST"] / 2), timber_m,
|
||||
rot=(math.radians(88), 0, math.radians(20 * sx))))
|
||||
else:
|
||||
posts.append(add_box(f"{name}_post_f_{sx}", (P["POST"], P["POST"], P["POST_H"]),
|
||||
(sx * px, -py, P["DECK_H"] + P["POST_H"] / 2), timber_m))
|
||||
join_group(posts, "posts", root)
|
||||
|
||||
# --- beams. Back beam always up; front beam is the one that tears off.
|
||||
beams = [add_box(f"{name}_beam_b", (P["DECK_W"] - 0.1, 0.045, P["BEAM_H"]),
|
||||
(0, py, beam_z), timber_m)]
|
||||
if not wrecked:
|
||||
beams.append(add_box(f"{name}_beam_f", (P["DECK_W"] - 0.1, 0.045, P["BEAM_H"]),
|
||||
(0, -py, beam_z), timber_m))
|
||||
join_group(beams, "beams", root)
|
||||
if wrecked:
|
||||
# The torn beam, flat on the deck along the front edge, slightly askew.
|
||||
# Rolled 90° about its own long axis so the 140 mm face lies on the
|
||||
# boards (a beam at rest lies on its side; on edge it would balance).
|
||||
join_group([add_box(f"{name}_beam_down", (P["DECK_W"] - 0.1, 0.045, P["BEAM_H"]),
|
||||
(0.18, -py + 0.15, P["DECK_H"] + 0.03),
|
||||
timber_m, rot=(math.radians(90), 0, math.radians(6)))],
|
||||
"beam_down", root)
|
||||
|
||||
# --- rafters: up in the intact roof; hinged down at the back beam in the
|
||||
# wreck, front ends resting on the deck — the lean-to collapse.
|
||||
rafters = []
|
||||
for i in range(P["N_RAFTERS"]):
|
||||
x = -hw + 0.35 + i * (P["DECK_W"] - 0.7) / (P["N_RAFTERS"] - 1)
|
||||
if wrecked:
|
||||
drop = math.atan2(top + P["BEAM_H"] - (P["DECK_H"] + 0.05), P["DECK_D"] - 0.3)
|
||||
L = math.hypot(top + P["BEAM_H"] - (P["DECK_H"] + 0.05), P["DECK_D"] - 0.3)
|
||||
rafters.append(add_box(f"{name}_rafter_{i}", (0.035, L, 0.07),
|
||||
(x, py - L / 2 * math.cos(drop),
|
||||
(top + P["BEAM_H"] + P["DECK_H"] + 0.05) / 2),
|
||||
timber_m, rot=(-drop, 0, 0)))
|
||||
else:
|
||||
rafters.append(add_box(f"{name}_rafter_{i}", (0.035, P["DECK_D"] - 0.15, 0.07),
|
||||
(x, 0, top + P["BEAM_H"] + 0.035), timber_m))
|
||||
join_group(rafters, "rafters_down" if wrecked else "rafters", root)
|
||||
|
||||
# Anchor points: where the front beam meets the post tops — the bolted
|
||||
# corner an author would genuinely strap to.
|
||||
return [(-px, -py, top + P["BEAM_H"] * 0.6), (px, -py, top + P["BEAM_H"] * 0.6)]
|
||||
|
||||
|
||||
def build_pergola_01(name):
|
||||
"""A timber pergola on a bolted deck — the ladder's missing middle rung.
|
||||
|
||||
SPRINT16 gate 5, proposed in S15 and carried on D's playtester endorsement:
|
||||
"the ladder jumps 0.45-on-grass straight to 1.00-in-concrete; a 0.65
|
||||
bolted-to-deck mid-rung turns fascia-or-nothing into a real decision."
|
||||
|
||||
WHY 0.65, and why one rating for the whole type (the manifest rule my own
|
||||
S15 entry proposed: new types ship ONE rating per type, so a node-less
|
||||
JSON anchor can resolve it unambiguously): the post-beam corner is real
|
||||
90x90 timber with real M12 bolts through a deck — genuinely better than a
|
||||
welded frame standing loose on grass (swing_frame 0.45), because the load
|
||||
path reaches something fixed. And genuinely worse than 300 mm of concrete
|
||||
(post 1.00), because what it reaches is a DECK: the deck flexes, the
|
||||
timber cracks before the bolts let go, and 0.65 is that sentence as a
|
||||
number. Typed `pergola` in its own enum word — calling it a post would
|
||||
promise concrete the deck cannot deliver (the swing_frame lesson, kept).
|
||||
"""
|
||||
root = add_empty(name)
|
||||
deck_m = get_material("Mat_Deck", PAL["deck_board"], 0.8)
|
||||
timber_m = get_material("Mat_Timber", PAL["timber"], 0.75)
|
||||
dark_m = get_material("Mat_TimberDark", PAL["timber_dark"], 0.8)
|
||||
|
||||
anchors = _pergola(name, root, deck_m, timber_m, dark_m, wrecked=False)
|
||||
for i, ap in enumerate(anchors):
|
||||
e = add_empty(f"pergola_anchor_{i + 1:02d}", ap, root, size=0.18)
|
||||
e["anchor_type"] = "pergola"
|
||||
e["rating_hint"] = 0.65
|
||||
e["collateral"] = "pergola"
|
||||
e["why"] = ("bolted post-beam corner: the bolts are real, the deck is "
|
||||
"the footing — better than loose-on-grass, worse than "
|
||||
"concrete, and the number argues exactly that")
|
||||
|
||||
stamp(root, name, "structure")
|
||||
root["collateral_key"] = "pergola"
|
||||
root["collateral_value"] = PERGOLA_COLLATERAL
|
||||
root["collateral_label"] = "the pergola"
|
||||
root["breakable"] = True
|
||||
return root
|
||||
|
||||
|
||||
def build_pergola_01_wrecked(name):
|
||||
"""The pergola after a loaded corner won: front beam torn off, front posts
|
||||
snapped above their bolts, rafters hinged down onto the deck like a
|
||||
lean-to. The back half — and the deck, and every bolt — still standing,
|
||||
because a bolted structure fails PARTIALLY, which is the whole 0.65 story.
|
||||
Same origin, same PERGOLA numbers, one flag different, so intact and
|
||||
wrecked cannot drift (the _swing_frame pattern)."""
|
||||
root = add_empty(name)
|
||||
deck_m = get_material("Mat_Deck", PAL["deck_board"], 0.8)
|
||||
timber_m = get_material("Mat_Timber", PAL["timber"], 0.75)
|
||||
dark_m = get_material("Mat_TimberDark", PAL["timber_dark"], 0.8)
|
||||
|
||||
_pergola(name, root, deck_m, timber_m, dark_m, wrecked=True)
|
||||
# No pergola_anchor_* survives — you cannot re-bolt to a snapped post from
|
||||
# inside a storm, and an anchor that outlives its structure is the
|
||||
# free-failure bug in a costume (fascia rule, third application).
|
||||
stamp(root, name, "structure")
|
||||
root["broken_variant_of"] = "pergola_01"
|
||||
root["collateral_key"] = "pergola"
|
||||
root["collateral_value"] = PERGOLA_COLLATERAL
|
||||
root["collateral_label"] = "the pergola"
|
||||
return root
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# THE GLASSHOUSE (SPRINT16 gate 5 — the collateral apex)
|
||||
# ---------------------------------------------------------------------------
|
||||
GLASSHOUSE = dict(
|
||||
W=2.6, # x — ridge runs along x
|
||||
D=1.9, # y
|
||||
WALL_H=1.70,
|
||||
RIDGE_H=2.28,
|
||||
BAR=0.045, # aluminium extrusion section
|
||||
)
|
||||
|
||||
# The apex, priced as one. Proposal — Lane A owns the number:
|
||||
# · it must top the carport (180) by a distance, or "apex" is a word: the
|
||||
# carport is one sheet of Colorbond; this is ~14 panes of horticultural
|
||||
# glass, and glass does not bend, it all goes at once. 320 is the panes,
|
||||
# the reglaze labour, and a racked door that never closes right again;
|
||||
# · it must stay under a six-night week's gross (~$540 in fees alone), so
|
||||
# one catastrophe is a week worked for nothing, not an automatic BROKE —
|
||||
# the carport ruling ("the worst thing on the bill and still a week you
|
||||
# can dig out of"), scaled to the thing that costs a week;
|
||||
# · unlike the bike (unpriced, nothing can break it) this ships priced ON
|
||||
# THE SPEC'S OWN ORDER: arc 2's glasshouse yard is the event's home, and
|
||||
# the price chain has to exist before that yard can be authored against
|
||||
# it. Until an event lands, nothing bills it — the value is inert data,
|
||||
# not a lie on an invoice. The tripwire is written in THREADS: the arc-2
|
||||
# yard must wire the wreck event before this number reaches a bill.
|
||||
GLASSHOUSE_COLLATERAL = 320
|
||||
|
||||
|
||||
def _glasshouse_frame(name, root, alu):
|
||||
"""The aluminium skeleton, shared by intact and wreck — the frame is the
|
||||
part that SURVIVES (aluminium racks, glass shatters), which is why the
|
||||
wreck stands at full height and reads as emptied, not felled."""
|
||||
G = GLASSHOUSE
|
||||
hw, hd, B = G["W"] / 2, G["D"] / 2, G["BAR"]
|
||||
parts = []
|
||||
# Corner + mid posts.
|
||||
for x in (-hw, 0, hw):
|
||||
for sy in (-1, 1):
|
||||
parts.append(add_box(f"{name}_post_{x:.1f}_{sy}", (B, B, G["WALL_H"]),
|
||||
(x, sy * hd, G["WALL_H"] / 2), alu))
|
||||
# Eave beams (long sides) + end sills.
|
||||
for sy in (-1, 1):
|
||||
parts.append(add_box(f"{name}_eave_{sy}", (G["W"], B, B),
|
||||
(0, sy * hd, G["WALL_H"]), alu))
|
||||
for sx in (-1, 1):
|
||||
parts.append(add_box(f"{name}_end_{sx}", (B, G["D"], B),
|
||||
(sx * hw, 0, G["WALL_H"]), alu))
|
||||
# Gable rafters up to the ridge.
|
||||
for sy in (-1, 1):
|
||||
p0 = (sx * hw, sy * hd, G["WALL_H"])
|
||||
p1 = (sx * hw, 0, G["RIDGE_H"])
|
||||
parts.append(add_tube_between(f"{name}_gable_{sx}_{sy}", p0, p1,
|
||||
B * 0.45, alu, verts=6))
|
||||
# The ridge — the straightest, most anchor-looking bar in any yard, which
|
||||
# is exactly why it gets a named node and a denial (below, on the empty).
|
||||
parts.append(add_box(f"{name}_ridge", (G["W"], B, B), (0, 0, G["RIDGE_H"]), alu))
|
||||
join_group(parts, "frame", root)
|
||||
|
||||
# Door frame on the -x gable end.
|
||||
join_group([
|
||||
add_box(f"{name}_door_l", (B * 0.7, B * 0.7, 1.55), (-hw - 0.001, -0.36, 0.775), alu),
|
||||
add_box(f"{name}_door_r", (B * 0.7, B * 0.7, 1.55), (-hw - 0.001, 0.36, 0.775), alu),
|
||||
add_box(f"{name}_door_t", (B * 0.7, 0.76, B * 0.7), (-hw - 0.001, 0, 1.55), alu),
|
||||
], "door", root)
|
||||
|
||||
|
||||
def build_glasshouse_01(name):
|
||||
"""A backyard glasshouse — the collateral apex, and NOT an anchor.
|
||||
|
||||
SPRINT16 gate 5. The palette's other structures are temptations because
|
||||
they offer bad steel; this one is a temptation because it offers NO steel
|
||||
while being ringed in members that look like it — an aluminium ridge bar
|
||||
at 2.28 m is the most tie-off-shaped object in the game. Every reading of
|
||||
it must come back "don't": the ridge carries an explicit tie_off denial,
|
||||
and NO node anywhere on either variant carries a rating_hint, which
|
||||
e.test pins — nothing about a glasshouse may adopt as an anchor, because
|
||||
30 kg of glass in a storm is not a thing you add load to, it is a thing
|
||||
you keep everything away from.
|
||||
|
||||
The panes read fragile at a glance because they are TRANSLUCENT — the only
|
||||
see-through walls in the palette — and that read survives export as a
|
||||
material fact (transparent + alpha), which e.test also pins: a repaint
|
||||
that turns the glass opaque has silently deleted the tell.
|
||||
"""
|
||||
root = add_empty(name)
|
||||
alu = get_material("Mat_AluFrame", PAL["alu_frame"], 0.35, metallic=0.7)
|
||||
glass = get_material("Mat_Glass", PAL["glass"], 0.05, metallic=0.0, opacity=0.30)
|
||||
G = GLASSHOUSE
|
||||
hw, hd = G["W"] / 2, G["D"] / 2
|
||||
|
||||
_glasshouse_frame(name, root, alu)
|
||||
|
||||
panes = []
|
||||
# Wall panes: long sides + gable ends (leaving the door opening).
|
||||
for sy in (-1, 1):
|
||||
panes.append(add_box(f"{name}_wall_{sy}", (G["W"] - 0.06, 0.012, G["WALL_H"] - 0.08),
|
||||
(0, sy * (hd - 0.012), G["WALL_H"] / 2), glass))
|
||||
panes.append(add_box(f"{name}_endwall_e", (0.012, G["D"] - 0.06, G["WALL_H"] - 0.08),
|
||||
(hw - 0.012, 0, G["WALL_H"] / 2), glass))
|
||||
for sy in (-1, 1): # west end: two strips beside the door
|
||||
panes.append(add_box(f"{name}_endwall_w_{sy}", (0.012, (G["D"] - 0.8) / 2 - 0.05, G["WALL_H"] - 0.08),
|
||||
(-hw + 0.012, sy * (0.4 + (G["D"] - 0.8) / 4), G["WALL_H"] / 2), glass))
|
||||
# Roof panes: two slopes from eave to ridge.
|
||||
slope = math.atan2(G["RIDGE_H"] - G["WALL_H"], hd)
|
||||
run = math.hypot(G["RIDGE_H"] - G["WALL_H"], hd)
|
||||
for sy in (-1, 1):
|
||||
panes.append(add_box(f"{name}_roof_{sy}", (G["W"] - 0.04, run - 0.03, 0.012),
|
||||
(0, sy * hd / 2, (G["WALL_H"] + G["RIDGE_H"]) / 2 + 0.01),
|
||||
glass, rot=(sy * slope, 0, 0)))
|
||||
# Gable triangles, approximated as narrow strips under the rafters.
|
||||
for sx in (-1, 1):
|
||||
panes.append(add_box(f"{name}_gable_pane_{sx}", (0.012, G["D"] * 0.5, (G["RIDGE_H"] - G["WALL_H"]) * 0.8),
|
||||
(sx * (hw - 0.012), 0, G["WALL_H"] + (G["RIDGE_H"] - G["WALL_H"]) * 0.42), glass))
|
||||
join_group(panes, "panes", root)
|
||||
|
||||
# The temptation, denied ON the data. Same mechanism as the swing set's
|
||||
# crossbar: the most anchor-looking member carries the explicit no.
|
||||
not_a_tie_off(
|
||||
add_empty("ridge_anchor", (0, 0, G["RIDGE_H"]), root, size=0.2),
|
||||
"the ridge bar — the straightest steel-looking line in the yard",
|
||||
"a 45 mm aluminium extrusion whose whole job is holding glass; load a "
|
||||
"sail corner on it and you are not testing the bar, you are testing "
|
||||
"fourteen panes at once")
|
||||
|
||||
stamp(root, name, "structure")
|
||||
root["collateral_key"] = "glasshouse"
|
||||
root["collateral_value"] = GLASSHOUSE_COLLATERAL
|
||||
root["collateral_label"] = "the glasshouse"
|
||||
root["fragile"] = True
|
||||
root["pane_count"] = 14
|
||||
return root
|
||||
|
||||
|
||||
def build_glasshouse_01_wrecked(name):
|
||||
"""The glasshouse after the storm found it. The frame is aluminium — it
|
||||
stays up, near enough at full height — but the glass is GONE: two cracked
|
||||
panes still hanging, the rest a glitter field of shards on the ground,
|
||||
thrown wider than the footprint ever was. A glasshouse doesn't fall, it
|
||||
empties; a wreck that read as a collapsed building would be lying about
|
||||
what glass does. Same frame helper as the intact one, so the two cannot
|
||||
drift."""
|
||||
rng = rng_for(name)
|
||||
root = add_empty(name)
|
||||
alu = get_material("Mat_AluFrame", PAL["alu_frame"], 0.35, metallic=0.7)
|
||||
glass = get_material("Mat_Glass", PAL["glass"], 0.05, metallic=0.0, opacity=0.30)
|
||||
G = GLASSHOUSE
|
||||
hw, hd = G["W"] / 2, G["D"] / 2
|
||||
|
||||
_glasshouse_frame(name, root, alu)
|
||||
|
||||
# Two survivors, one visibly askew in its frame.
|
||||
join_group([
|
||||
add_box(f"{name}_pane_left_a", (0.012, G["D"] * 0.4, G["WALL_H"] * 0.5),
|
||||
(hw - 0.012, -0.3, G["WALL_H"] * 0.35), glass),
|
||||
add_box(f"{name}_pane_left_b", (G["W"] * 0.3, 0.012, G["WALL_H"] * 0.45),
|
||||
(0.4, hd - 0.012, G["WALL_H"] * 0.3), glass,
|
||||
rot=(0, math.radians(8), 0)),
|
||||
], "panes_left", root)
|
||||
|
||||
# The shard field. Deterministic scatter, thrown mostly downwind (+x, the
|
||||
# storm convention every other wreck in the palette follows) and WIDER
|
||||
# than the standing footprint — the measured claim e.test pins.
|
||||
shards = []
|
||||
for i in range(26):
|
||||
a = rng.uniform(0, math.tau)
|
||||
r = rng.uniform(0.2, 1.0)
|
||||
x = math.cos(a) * (hw + r * 0.9) * (1.25 if math.cos(a) > 0 else 0.8)
|
||||
y = math.sin(a) * (hd + r * 0.6)
|
||||
s = rng.uniform(0.06, 0.22)
|
||||
shards.append(add_box(f"{name}_shard_{i}", (s, s * rng.uniform(0.4, 0.9), 0.008),
|
||||
(x, y, 0.006), glass,
|
||||
rot=(0, 0, rng.uniform(0, math.pi))))
|
||||
# A few inside on the beds' footprint too — the glass fell in as well as out.
|
||||
for i in range(8):
|
||||
x, y = rng.uniform(-hw * 0.8, hw * 0.8), rng.uniform(-hd * 0.7, hd * 0.7)
|
||||
s = rng.uniform(0.05, 0.16)
|
||||
shards.append(add_box(f"{name}_shard_in_{i}", (s, s * 0.7, 0.008),
|
||||
(x, y, 0.006), glass, rot=(0, 0, rng.uniform(0, math.pi))))
|
||||
join_group(shards, "shards", root)
|
||||
|
||||
stamp(root, name, "structure")
|
||||
root["broken_variant_of"] = "glasshouse_01"
|
||||
root["collateral_key"] = "glasshouse"
|
||||
root["collateral_value"] = GLASSHOUSE_COLLATERAL
|
||||
root["collateral_label"] = "the glasshouse"
|
||||
return root
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# THE POOL KIT (SPRINT16 gate 5 — the compliant ring whose steel all says no)
|
||||
# ---------------------------------------------------------------------------
|
||||
POOL_KIT = dict(
|
||||
RING_W=5.6, # fence ring, x
|
||||
RING_D=4.2, # fence ring, y
|
||||
FENCE_H=1.22, # AS 1926.1 minimum is 1.2 m — compliance is the flavour
|
||||
POOL_W=3.6,
|
||||
POOL_D=2.2,
|
||||
COPING_H=0.18, # raised fibreglass lip; keeps every vertex above grade
|
||||
POST=0.045, # 40 mm aluminium tube posts
|
||||
)
|
||||
|
||||
|
||||
def _pool_fence_posts():
|
||||
"""Post centres round the ring, deterministic order. Module-level so the
|
||||
builder and the ASSETS node list can never disagree about the count."""
|
||||
P = POOL_KIT
|
||||
hx, hy = P["RING_W"] / 2, P["RING_D"] / 2
|
||||
pts = []
|
||||
for x in (-hx, -hx / 2, 0, hx / 2, hx): # long sides, 5 each
|
||||
for sy in (-1, 1):
|
||||
pts.append((x, sy * hy))
|
||||
for y in (-hy / 2, hy / 2): # short sides, 2 each
|
||||
for sx in (-1, 1):
|
||||
pts.append((sx * hx, y))
|
||||
return pts # 14 posts
|
||||
|
||||
|
||||
def build_pool_kit_01(name):
|
||||
"""A backyard pool inside its compliant fence ring — the yard the palette
|
||||
could not dress, and the puzzle is the fence.
|
||||
|
||||
SPRINT16 gate 5. Fourteen 40 mm aluminium posts stand in a perfect ring
|
||||
at exactly the height a low tie line wants, core-mounted, evenly spaced —
|
||||
they LOOK like the most generous anchor field in the game. Every single
|
||||
one carries an explicit `tie_off: false`, because silence is not neutral:
|
||||
`adoptAnchor` reads `rating_hint ?? 1`, so an unrated node a site names
|
||||
becomes the best steel in the game by omission (the S14 audit lesson, and
|
||||
the reason the spec says "honest on every one"). A pool fence holds a
|
||||
child OUT; it does not hold a sail DOWN — it is 1.2 m of child-proofing
|
||||
on core-drilled tube, and a loaded corner would have the whole panel out
|
||||
of the ground before the shackle noticed.
|
||||
|
||||
That denial field is the level design: the pool yard's whole puzzle (arc
|
||||
2) is that the legal tie-offs are all in the wrong places, and the ring
|
||||
of honest nos in the middle of the yard is what makes it true.
|
||||
|
||||
UNPRICED, and that is a statement (the bike/jacaranda ruling, not the
|
||||
glasshouse's spec order): nothing in the sim can bend a pool fence or
|
||||
crack a coping yet, and billing an event the player cannot see is the lie
|
||||
the invoice exists to kill. `priced: False` + the reason ride in extras;
|
||||
price it the day debris can take a panel out.
|
||||
"""
|
||||
root = add_empty(name)
|
||||
alu = get_material("Mat_AluFrame", PAL["alu_frame"], 0.35, metallic=0.7)
|
||||
conc = get_material("Mat_Concrete", PAL["concrete"], 0.95)
|
||||
water = get_material("Mat_PoolWater", PAL["pool_water"], 0.08, opacity=0.88)
|
||||
P = POOL_KIT
|
||||
hx, hy = P["RING_W"] / 2, P["RING_D"] / 2
|
||||
pw, pd = P["POOL_W"] / 2, P["POOL_D"] / 2
|
||||
|
||||
# --- the pool: raised coping ring + water just below the lip -----------
|
||||
join_group([
|
||||
add_box(f"{name}_coping_n", (P["POOL_W"] + 0.3, 0.15, P["COPING_H"]),
|
||||
(0, pd + 0.075, P["COPING_H"] / 2), conc),
|
||||
add_box(f"{name}_coping_s", (P["POOL_W"] + 0.3, 0.15, P["COPING_H"]),
|
||||
(0, -pd - 0.075, P["COPING_H"] / 2), conc),
|
||||
add_box(f"{name}_coping_e", (0.15, P["POOL_D"], P["COPING_H"]),
|
||||
(pw + 0.075, 0, P["COPING_H"] / 2), conc),
|
||||
add_box(f"{name}_coping_w", (0.15, P["POOL_D"], P["COPING_H"]),
|
||||
(-pw - 0.075, 0, P["COPING_H"] / 2), conc),
|
||||
], "pool_shell", root)
|
||||
join_group([add_box(f"{name}_water", (P["POOL_W"], P["POOL_D"], 0.02),
|
||||
(0, 0, P["COPING_H"] - 0.045), water)], "pool_water", root)
|
||||
|
||||
# --- the fence: rails + pickets, joined; POSTS as individual named nodes
|
||||
# so a site (or the editor's node list, if it ever offers per-node
|
||||
# anchors) meets the denial ON the node it would name. RULE 1's regex
|
||||
# only guards *_anchor names; these are guarded by their own pin in
|
||||
# e.test — same rule, wider net.
|
||||
gate_span = (0.25, 1.15) # gate opening on the south run, x range
|
||||
rails = []
|
||||
for sy in (-1, 1):
|
||||
for z in (0.12, P["FENCE_H"] - 0.03):
|
||||
if sy < 0: # south run has the gate opening
|
||||
rails.append(add_box(f"{name}_rail_s{z:.2f}_a", (gate_span[0] + hx, 0.04, 0.04),
|
||||
((gate_span[0] - hx) / 2, -hy, z), alu))
|
||||
rails.append(add_box(f"{name}_rail_s{z:.2f}_b", (hx - gate_span[1], 0.04, 0.04),
|
||||
((gate_span[1] + hx) / 2, -hy, z), alu))
|
||||
else:
|
||||
rails.append(add_box(f"{name}_rail_n{z:.2f}", (P["RING_W"], 0.04, 0.04),
|
||||
(0, hy, z), alu))
|
||||
for sx in (-1, 1):
|
||||
for z in (0.12, P["FENCE_H"] - 0.03):
|
||||
rails.append(add_box(f"{name}_rail_{'e' if sx > 0 else 'w'}{z:.2f}",
|
||||
(0.04, P["RING_D"], 0.04), (sx * hx, 0, z), alu))
|
||||
# Pickets every ~0.1 m — the non-climbable read.
|
||||
for sy in (-1, 1):
|
||||
n = int(P["RING_W"] / 0.105)
|
||||
for i in range(1, n):
|
||||
x = -hx + i * P["RING_W"] / n
|
||||
if sy < 0 and gate_span[0] < x < gate_span[1]:
|
||||
continue
|
||||
rails.append(add_box(f"{name}_pk_y{sy}_{i}", (0.014, 0.014, P["FENCE_H"] - 0.14),
|
||||
(x, sy * hy, (P["FENCE_H"] - 0.14) / 2 + 0.1), alu))
|
||||
for sx in (-1, 1):
|
||||
n = int(P["RING_D"] / 0.105)
|
||||
for i in range(1, n):
|
||||
y = -hy + i * P["RING_D"] / n
|
||||
rails.append(add_box(f"{name}_pk_x{sx}_{i}", (0.014, 0.014, P["FENCE_H"] - 0.14),
|
||||
(sx * hx, y, (P["FENCE_H"] - 0.14) / 2 + 0.1), alu))
|
||||
join_group(rails, "fence_rails", root)
|
||||
|
||||
# The posts — fourteen honest nos, each its own node.
|
||||
for i, (x, y) in enumerate(_pool_fence_posts()):
|
||||
post = join_group([add_cyl(f"{name}_postmesh_{i}", P["POST"] / 2, P["FENCE_H"],
|
||||
(x, y, P["FENCE_H"] / 2), alu, verts=8)],
|
||||
f"fence_post_{i + 1:02d}", root)
|
||||
not_a_tie_off(
|
||||
post, "pool fence post — compliance steel, not rigging steel",
|
||||
"40 mm core-mounted aluminium tube holding a child-proof panel; "
|
||||
"it resists a climbing toddler, not a loaded sail corner — the "
|
||||
"ring LOOKS like fourteen perfect anchors and every one says no")
|
||||
|
||||
# The gate: self-closing, opens outward, latch high — the compliance
|
||||
# tells, in geometry and in data.
|
||||
gx0, gx1 = gate_span
|
||||
gate = join_group([
|
||||
add_box(f"{name}_gate_frame_l", (0.03, 0.03, P["FENCE_H"] - 0.1),
|
||||
(gx0 + 0.03, -hy - 0.06, (P["FENCE_H"] - 0.1) / 2 + 0.05), alu,
|
||||
rot=(0, 0, math.radians(-14))),
|
||||
add_box(f"{name}_gate_frame_r", (0.03, 0.03, P["FENCE_H"] - 0.1),
|
||||
(gx1 - 0.15, -hy - 0.28, (P["FENCE_H"] - 0.1) / 2 + 0.05), alu,
|
||||
rot=(0, 0, math.radians(-14))),
|
||||
add_box(f"{name}_gate_top", (gx1 - gx0 - 0.1, 0.03, 0.03),
|
||||
((gx0 + gx1) / 2 - 0.06, -hy - 0.17, P["FENCE_H"] - 0.08), alu,
|
||||
rot=(0, 0, math.radians(-14))),
|
||||
*[add_box(f"{name}_gate_pk_{i}", (0.014, 0.014, P["FENCE_H"] - 0.22),
|
||||
(gx0 + 0.1 + i * (gx1 - gx0 - 0.2) / 7,
|
||||
-hy - 0.08 - i * 0.028, (P["FENCE_H"] - 0.22) / 2 + 0.08), alu)
|
||||
for i in range(8)],
|
||||
], "pool_gate", root)
|
||||
gate["self_closing"] = True
|
||||
gate["opens"] = "outward, ajar — someone left it swinging, which is the tell"
|
||||
gate["latch_height_m"] = 1.5
|
||||
|
||||
stamp(root, name, "structure")
|
||||
root["priced"] = False
|
||||
root["unpriced_why"] = ("nothing in the sim can bend a pool fence or crack "
|
||||
"a coping yet — billing an unseen event is the lie "
|
||||
"the invoice exists to kill (the bike ruling); "
|
||||
"price it when debris can take a panel out")
|
||||
root["compliance"] = "AS 1926.1 flavour: 1.2 m, non-climbable, self-closing outward gate"
|
||||
return root
|
||||
|
||||
|
||||
def build_shed_01(name):
|
||||
"""Colorbond garden shed, skillion roof. Spare hardware lives in here."""
|
||||
root = add_empty(name)
|
||||
@ -2969,6 +3512,38 @@ ASSETS = [
|
||||
dict(name="fence_panel_snapped", fn=build_fence_panel_snapped,
|
||||
dims=((2.38, 2.60), (0.03, 1.05), (1.70, 1.90)),
|
||||
nodes=["palings", "rails", "debris_palings"]),
|
||||
# SPRINT16 gate 5 — the next temptations. The pergola is the ladder's
|
||||
# honest middle rung (D-endorsed, S15 THREADS): TALLER than the swing set
|
||||
# it out-rates, and its z floor is above the swing's 2.15 ceiling on
|
||||
# purpose — if it ever measures swing-height, someone shrank the rung.
|
||||
dict(name="pergola_01", fn=build_pergola_01,
|
||||
dims=((3.1, 3.4), (2.4, 2.8), (2.55, 2.85)),
|
||||
nodes=["deck", "posts", "beams", "rafters",
|
||||
"pergola_anchor_01", "pergola_anchor_02"]),
|
||||
# The wreck keeps the deck footprint but spills the torn beam past the
|
||||
# edge (x floor ABOVE the intact deck's 3.2) and its roof line is the
|
||||
# rafters hinged down — near intact height at the surviving back beam.
|
||||
dict(name="pergola_01_wrecked", fn=build_pergola_01_wrecked,
|
||||
dims=((3.2, 3.6), (2.4, 2.8), (2.55, 2.85)),
|
||||
nodes=["deck", "posts", "beams", "beam_down", "rafters_down"]),
|
||||
# The collateral apex. Narrow bands on z because the ridge IS the claim:
|
||||
# a glasshouse that grew would stop reading as the small fragile thing.
|
||||
dict(name="glasshouse_01", fn=build_glasshouse_01,
|
||||
dims=((2.5, 2.8), (1.8, 2.1), (2.15, 2.40)),
|
||||
nodes=["frame", "panes", "door", "ridge_anchor"]),
|
||||
# Emptied, not felled: SAME height band as the intact one (the frame is
|
||||
# aluminium and stands), but the shard field throws the plan dims WIDE —
|
||||
# the x/y floors sit above the intact ceilings, which is the "it emptied
|
||||
# outward" claim as a number.
|
||||
dict(name="glasshouse_01_wrecked", fn=build_glasshouse_01_wrecked,
|
||||
dims=((3.4, 5.4), (2.6, 3.8), (2.15, 2.40)),
|
||||
nodes=["frame", "door", "panes_left", "shards"]),
|
||||
# The compliance ring. z is pinned tight around 1.2 m because the height
|
||||
# IS the flavour (AS 1926.1); y reaches past the ring for the ajar gate.
|
||||
dict(name="pool_kit_01", fn=build_pool_kit_01,
|
||||
dims=((5.4, 5.9), (4.1, 4.8), (1.12, 1.32)),
|
||||
nodes=["pool_shell", "pool_water", "fence_rails", "pool_gate",
|
||||
"fence_post_01", "fence_post_14"]),
|
||||
]
|
||||
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 3.9 MiB After Width: | Height: | Size: 4.5 MiB |
172
tools/fabric_bet/probe.html
Normal file
172
tools/fabric_bet/probe.html
Normal file
@ -0,0 +1,172 @@
|
||||
<!doctype html>
|
||||
<!--
|
||||
fabric_bet/probe.html — does the F key matter TONIGHT? [Lane C, SPRINT16 gate 3.1]
|
||||
|
||||
The measurement harness behind the pea-hail soaker. Every hail night before
|
||||
storm_06 threw stones a 2 mm weave stops dead, so the fabric bet (SPRINT6)
|
||||
never had a night where membrane was the right answer. This page flies the
|
||||
SAME line on both fabrics through gardenfly.js — THE audit scoring chain
|
||||
(windForSite → attach → skyfx → garden.js), dressed anchors, live sway — so
|
||||
cloth-vs-membrane differ by exactly one variable: porosity.
|
||||
|
||||
Per line, per fabric: garden hp/state, byHail/byRain, corner peaks vs
|
||||
effective ratings, marginal flags, cost. Plus the bare bed (the control) and
|
||||
the storm's measured personality (stormStats — the numbers the forecast
|
||||
card will sell).
|
||||
|
||||
Funnel state is printed in the header, per the discipline (three harnesses
|
||||
in this repo's history flew site_02 funnel-off and believed it).
|
||||
|
||||
tools/fabric_bet/probe.html?site=backyard_01&storm=storm_06_soaker
|
||||
-->
|
||||
<meta charset="utf-8">
|
||||
<title>fabric_bet probe</title>
|
||||
<style>
|
||||
body { background:#111; color:#ddd; font:13px/1.5 ui-monospace,Menlo,monospace; margin:0; padding:20px; }
|
||||
h1 { font-size:15px; color:#fff; margin:0 0 2px; }
|
||||
.sub { color:#888; margin-bottom:14px; white-space:pre-wrap; }
|
||||
table { border-collapse:collapse; margin:8px 0; }
|
||||
td, th { padding:2px 12px 2px 0; white-space:nowrap; text-align:left; }
|
||||
th { color:#9ab; border-bottom:1px solid #333; }
|
||||
.ok { color:#6c6; } .bad { color:#e66; } .warn { color:#dc6; }
|
||||
.note { color:#888; margin-top:14px; white-space:pre-wrap; }
|
||||
</style>
|
||||
<h1>fabric_bet — the same line, both fabrics, the real chain</h1>
|
||||
<div class="sub" id="sub">loading…</div>
|
||||
<div id="out"></div>
|
||||
<div class="note" id="note"></div>
|
||||
|
||||
<script type="importmap">
|
||||
{ "imports": {
|
||||
"three": "../../web/world/vendor/three.module.js",
|
||||
"three/addons/": "../../web/world/vendor/addons/"
|
||||
} }
|
||||
</script>
|
||||
<script type="module">
|
||||
import * as THREE from '../../web/world/vendor/three.module.js';
|
||||
import { createWorld, loadSite } from '../../web/world/js/world.js';
|
||||
import { stormStats } from '../../web/world/js/weather.js';
|
||||
import { hailBlockFor } from '../../web/world/js/weather.core.js';
|
||||
import { flyGarden, hardwareByName } from '../site_audit/gardenfly.js';
|
||||
|
||||
const q = new URLSearchParams(location.search);
|
||||
const siteName = q.get('site') || 'backyard_01';
|
||||
const stormName = q.get('storm') || 'storm_06_soaker';
|
||||
const el = (id) => document.getElementById(id);
|
||||
const loadJSON = async (p) => (await fetch(p)).json();
|
||||
|
||||
// The candidate lines. COVER_QUAD is balance.test's canonical bed-covering
|
||||
// quad; the others are cheaper/worse-covering controls so the bet's shape is
|
||||
// visible across the price range, not just at one point.
|
||||
const LINES = {
|
||||
backyard_01: [
|
||||
{ label: 'cover quad, all carabiners ($20)', ids: ['p1', 'p2', 'p3', 'p4'], hw: ['carabiner', 'carabiner', 'carabiner', 'carabiner'] },
|
||||
{ label: 'cover quad, all shackles ($60)', ids: ['p1', 'p2', 'p3', 'p4'], hw: ['shackle', 'shackle', 'shackle', 'shackle'] },
|
||||
{ label: 'wildnight $80 line (cara+rated+shackle+rated)', ids: ['p1', 'p2', 'p3', 'p4'], hw: ['carabiner', 'rated shackle', 'shackle', 'rated shackle'] },
|
||||
{ label: 'separation-pin line p5+p1+p2+p3 ($75)', ids: ['p5', 'p1', 'p2', 'p3'], hw: ['rated shackle', 'shackle', 'shackle', 'shackle'] },
|
||||
// The full-cover quads — unbuyable on the wildnight (6.9 kN, tears), which
|
||||
// is exactly why the MILD night is where they come alive. p4+p5 span the
|
||||
// bed north, p1+p2 south; the hull holds the whole bed.
|
||||
{ label: 'FULL-COVER p4,p5,p1,p2 carabiners ($20)', ids: ['p4', 'p5', 'p1', 'p2'], hw: ['carabiner', 'carabiner', 'carabiner', 'carabiner'] },
|
||||
{ label: 'FULL-COVER p4,p5,p1,p2 shackles ($60)', ids: ['p4', 'p5', 'p1', 'p2'], hw: ['shackle', 'shackle', 'shackle', 'shackle'] },
|
||||
{ label: 'wide tree line t1,t2,p1,p2 shackles ($60)', ids: ['t1', 't2', 'p1', 'p2'], hw: ['shackle', 'shackle', 'shackle', 'shackle'] },
|
||||
{ label: 'house span h2,h3,p1,p2 shackles ($60)', ids: ['h2', 'h3', 'p1', 'p2'], hw: ['shackle', 'shackle', 'shackle', 'shackle'] },
|
||||
],
|
||||
site_02_corner_block: [
|
||||
{ label: 'S14 cheapest honest tr1,tr1b,q1,q3 ($40)', ids: ['tr1', 'tr1b', 'q1', 'q3'], hw: ['carabiner', 'carabiner', 'shackle', 'carabiner'] },
|
||||
{ label: 'best clean tr1,tr1b,q1,q4 (shackles $60)', ids: ['tr1', 'tr1b', 'q1', 'q4'], hw: ['shackle', 'shackle', 'shackle', 'shackle'] },
|
||||
// The bed ring — q1..q4 stand AROUND the bed, the yard's whole offer.
|
||||
{ label: 'bed ring q1,q2,q3,q4 carabiners ($20)', ids: ['q1', 'q2', 'q3', 'q4'], hw: ['carabiner', 'carabiner', 'carabiner', 'carabiner'] },
|
||||
{ label: 'bed ring q1,q2,q3,q4 shackles ($60)', ids: ['q1', 'q2', 'q3', 'q4'], hw: ['shackle', 'shackle', 'shackle', 'shackle'] },
|
||||
{ label: 'bed ring, rated on the funnel corner q1 ($75)', ids: ['q1', 'q2', 'q3', 'q4'], hw: ['rated shackle', 'shackle', 'shackle', 'shackle'] },
|
||||
{ label: 'bed ring $75, winched tight (tension 1.2)', ids: ['q1', 'q2', 'q3', 'q4'], hw: ['rated shackle', 'shackle', 'shackle', 'shackle'], tension: 1.2 },
|
||||
{ label: 'bed ring $60, winched tight (tension 1.2)', ids: ['q1', 'q2', 'q3', 'q4'], hw: ['shackle', 'shackle', 'shackle', 'shackle'], tension: 1.2 },
|
||||
],
|
||||
};
|
||||
|
||||
async function run() {
|
||||
const site = await loadSite(siteName);
|
||||
const scene = new THREE.Scene();
|
||||
let currentWind = {
|
||||
sample: (p, t, o) => (o || new THREE.Vector3()).set(0, 0, 4),
|
||||
speedAt: () => 4, rainAt: () => 0, rainMmPerHour: () => 0,
|
||||
gustTelegraph: () => null, eventsBetween: () => [],
|
||||
};
|
||||
const windProxy = {
|
||||
sample: (p, t, o) => currentWind.sample(p, t, o || new THREE.Vector3()),
|
||||
speedAt: (p, t) => currentWind.speedAt(p, t),
|
||||
rainAt: (t) => currentWind.rainAt(t),
|
||||
rainMmPerHour: (t) => (currentWind.rainMmPerHour ? currentWind.rainMmPerHour(t) : 0),
|
||||
gustTelegraph: (t) => currentWind.gustTelegraph(t),
|
||||
eventsBetween: (a, b) => currentWind.eventsBetween(a, b),
|
||||
setSheltersFromTrees() {},
|
||||
};
|
||||
const use = (w) => { currentWind = w; };
|
||||
const world = createWorld(scene, { wind: windProxy, site });
|
||||
await world.dress();
|
||||
const bed = world.gardenBed;
|
||||
const anchors = world.anchors;
|
||||
const venturi = site.wind?.venturi ?? [];
|
||||
|
||||
const stormDef = await loadJSON(`../../web/world/data/storms/${stormName}.json`);
|
||||
const s = stormStats(stormDef);
|
||||
const size = stormDef.hail?.size ?? 0;
|
||||
const clothBlock = size ? hailBlockFor(size, 0.30) : 1;
|
||||
|
||||
el('sub').textContent =
|
||||
`${site.name} · ${stormName} (${stormDef.duration}s) · bed ${bed.w}x${bed.d} @ (${bed.x},${bed.z})\n`
|
||||
+ `FUNNEL: ${venturi.length ? venturi.map((v) => `(${v.x},${v.z}) gain ${v.gain} — ON`).join(' · ') : 'none authored on this site'}\n`
|
||||
+ `storm truth (stormStats): sustained ${s.sustained.toFixed(1)} m/s · gust peak ${s.gustPeak.toFixed(1)} m/s `
|
||||
+ `(${(s.gustPeak * 3.6).toFixed(0)} km/h) · rain peak ${s.rainPeakMmPerHour.toFixed(0)} mm/hr · `
|
||||
+ `hail ${s.hailSeconds.toFixed(1)} s-equiv @ size ${size} · change at ${s.changeAt ?? '—'}s\n`
|
||||
+ `hailBlockFor(${size}, cloth 0.30) = ${(clothBlock * 100).toFixed(1)}% blocked · membrane = 100%`;
|
||||
|
||||
const bare = flyGarden({ anchors, bed, stormDef, siteDef: site, use });
|
||||
|
||||
const tbl = document.createElement('table');
|
||||
const head = tbl.insertRow();
|
||||
for (const h of ['line', 'fabric', '$', 'garden', 'byHail', 'byRain', 'lost', 'marginal', 'peaks (kN vs eff)'])
|
||||
{ const th = document.createElement('th'); th.textContent = h; head.appendChild(th); }
|
||||
|
||||
const rowFor = (label, g, fabric, cls) => {
|
||||
const tr = tbl.insertRow();
|
||||
const peaks = g.peaks.map((p) => `${p.id} ${(p.peakN / 1000).toFixed(2)}/${(p.effN / 1000).toFixed(2)}`).join(' ');
|
||||
[label, fabric, `$${g.cost}`, `${g.hp.toFixed(1)} ${g.state.toUpperCase()}`,
|
||||
g.byHail.toFixed(1), g.byRain.toFixed(1), String(g.cornersLost),
|
||||
g.marginal.join(',') || '—', peaks || '—']
|
||||
.forEach((c, i) => { const td = tr.insertCell(); td.textContent = c; if (i === 3) td.className = cls(g); });
|
||||
};
|
||||
const cls = (g) => (g.hp >= 66 ? 'ok' : g.hp >= 50 ? 'warn' : 'bad');
|
||||
|
||||
const results = { site: siteName, storm: stormName, bare, lines: [] };
|
||||
for (const L of LINES[siteName] ?? []) {
|
||||
const hw = L.hw.map(hardwareByName);
|
||||
const tension = L.tension ?? 1.0;
|
||||
const cloth = flyGarden({ anchors, bed, stormDef, siteDef: site, use, ids: L.ids, hw, tension, porosity: 0.30 });
|
||||
const mem = flyGarden({ anchors, bed, stormDef, siteDef: site, use, ids: L.ids, hw, tension, porosity: 0 });
|
||||
rowFor(L.label, cloth, 'cloth', cls);
|
||||
rowFor('', mem, 'MEMBRANE', cls);
|
||||
results.lines.push({ label: L.label, ids: L.ids, hw: L.hw, cloth, membrane: mem });
|
||||
}
|
||||
const tr = tbl.insertRow();
|
||||
['BARE BED', '—', '$0', `${bare.hp.toFixed(1)} ${bare.state.toUpperCase()}`,
|
||||
bare.byHail.toFixed(1), bare.byRain.toFixed(1), '4', '—', '—']
|
||||
.forEach((c) => { const td = tr.insertCell(); td.textContent = c; td.className = 'warn'; });
|
||||
el('out').appendChild(tbl);
|
||||
|
||||
const best = results.lines.reduce((b, r) => (r.membrane.hp > (b?.membrane.hp ?? -1) ? r : b), null);
|
||||
el('note').textContent = best
|
||||
? `best membrane line: "${best.label}" → ${best.membrane.hp.toFixed(1)} vs same line on cloth ${best.cloth.hp.toFixed(1)} `
|
||||
+ `(Δ ${(best.membrane.hp - best.cloth.hp).toFixed(1)} HP) · bare ${bare.hp.toFixed(1)}`
|
||||
: 'no lines authored for this site — add them to LINES';
|
||||
window.__fabricBet = results;
|
||||
document.title = `fabric_bet — ${stormName}`;
|
||||
}
|
||||
|
||||
run().catch((e) => {
|
||||
el('note').className = 'bad';
|
||||
el('note').textContent = `probe crashed: ${e.message}\n${e.stack || ''}`;
|
||||
window.__fabricBet = { error: e.message };
|
||||
document.title = 'fabric_bet — ERROR';
|
||||
});
|
||||
</script>
|
||||
@ -89,9 +89,17 @@ export function hardwareByName(name) {
|
||||
* @param {Array} [o.ids] 4 anchor ids; null/omitted = bare bed (the control)
|
||||
* @param {Array|object} [o.hw] hardware per pick (aligned with ids), or one tier for all 4
|
||||
* @param {number} [o.tension]
|
||||
* @param {number} [o.porosity] fabric: 0.30 = shade cloth (the default every
|
||||
* audit number to date was flown on — unchanged),
|
||||
* 0 = membrane. SPRINT16 gate 3.1 measures the
|
||||
* fabric bet through THIS chain, same driver as
|
||||
* every other garden number, so cloth-vs-membrane
|
||||
* differ by exactly one variable. [Lane C addition,
|
||||
* THREADS 2026-07-20 — B: contest at integration if
|
||||
* you want a different door.]
|
||||
* @returns {{ hp, state, byHail, byRain, cornersLost, peaks, marginal, cost }}
|
||||
*/
|
||||
export function flyGarden({ anchors, bed, stormDef, siteDef = null, use = null, ids = null, hw = null, tension = 1.0 }) {
|
||||
export function flyGarden({ anchors, bed, stormDef, siteDef = null, use = null, ids = null, hw = null, tension = 1.0, porosity = 0.30 }) {
|
||||
const wind = windForSite(stormDef, siteDef, anchors);
|
||||
use?.(wind);
|
||||
|
||||
@ -99,8 +107,9 @@ export function flyGarden({ anchors, bed, stormDef, siteDef = null, use = null,
|
||||
if (ids) {
|
||||
const hwArr = Array.isArray(hw) ? hw : Array(4).fill(hw ?? hardwareByName('rated shackle'));
|
||||
cost = hwArr.reduce((s, h) => s + (h.cost || 0), 0);
|
||||
// shade cloth (porosity 0.30): the fabric a competent player flies — same as sweep.js
|
||||
rig = new SailRig({ anchors, gridN: 10, porosity: 0.30 }).attach(ids, hwArr, tension);
|
||||
// shade cloth (porosity 0.30) unless the caller is measuring the fabric
|
||||
// bet — same default as sweep.js, so nothing pre-SPRINT16 moves.
|
||||
rig = new SailRig({ anchors, gridN: 10, porosity }).attach(ids, hwArr, tension);
|
||||
// D's skipped-attach trap: a rig without its shadow mesh scores as a bare
|
||||
// bed and the number LOOKS plausible. Refuse to fly it.
|
||||
if (!rig.rigged || !rig.pos || !rig.tris || !rig.tris.length) {
|
||||
|
||||
@ -112,6 +112,19 @@ export async function buildGardenflyTests() {
|
||||
} catch (err) { sepErr = String((err && err.stack) || err); }
|
||||
}
|
||||
|
||||
// SPRINT16 gate 2.3 [B]: every shipped yard has been JUDGED on separation —
|
||||
// either it pins a block (backyard_01) or it records a measured refusal
|
||||
// (_separation_finding). Read all three here so the assert below can hold
|
||||
// the invariant: neither is "unjudged", both at once is a stale finding.
|
||||
let sepStates = null, sepStatesErr = null;
|
||||
try {
|
||||
sepStates = [];
|
||||
for (const name of ['backyard_01', 'site_02_corner_block', 'site_03_swing_lawn']) {
|
||||
const sd = await loadSite(name);
|
||||
sepStates.push({ name, pinned: !!sd.separation, finding: !!sd._separation_finding });
|
||||
}
|
||||
} catch (err) { sepStatesErr = String((err && err.stack) || err); }
|
||||
|
||||
let venRun = null, venErr = null;
|
||||
try {
|
||||
const fly = (venturi) => flyGarden({
|
||||
@ -122,6 +135,22 @@ export async function buildGardenflyTests() {
|
||||
venRun = { bare: fly([]), funnelled: fly(SYN_FUNNEL) };
|
||||
} catch (err) { venErr = String((err && err.stack) || err); }
|
||||
|
||||
// SPRINT16 [B]: flyGarden grew a `porosity` param (storm_06's membrane bet
|
||||
// needs the audit engine to fly both fabrics). Two things must hold or every
|
||||
// pinned number in THREADS quietly changes meaning: the DEFAULT is still the
|
||||
// 0.30 shade cloth every pin was measured on, and membrane genuinely flies
|
||||
// as a different fabric (double the pressure term → heavier corners).
|
||||
let fabRun = null, fabErr = null;
|
||||
try {
|
||||
const flyFab = (porosity) => flyGarden({
|
||||
anchors: SYN_ANCHORS, bed: SYN_BED, stormDef: SYN_STORM,
|
||||
siteDef: { wind: { venturi: SYN_FUNNEL } },
|
||||
ids: ['a1', 'a2', 'a3', 'a4'], hw: hardwareByName('rated shackle'),
|
||||
...(porosity === undefined ? {} : { porosity }),
|
||||
});
|
||||
fabRun = { byDefault: flyFab(undefined), cloth: flyFab(0.30), membrane: flyFab(0) };
|
||||
} catch (err) { fabErr = String((err && err.stack) || err); }
|
||||
|
||||
return [
|
||||
['gardenfly: the pinned separation line, flown in BOTH chains — the 12s-skew disagreement, pinned', () => {
|
||||
// ⚠ THE STATE OF PLAY (2026-07-18, [B] THREADS, receipts in the entry):
|
||||
@ -168,6 +197,48 @@ export async function buildGardenflyTests() {
|
||||
`the siteDef's venturi is not reaching gardenfly's wind (windForSite must receive the SITE def)`);
|
||||
}
|
||||
}],
|
||||
['gate 2.3: every shipped yard is separation-JUDGED — pinned, or measured-and-refused', () => {
|
||||
// SPRINT16's rule made durable: a yard may pin a separation block or it
|
||||
// may carry a measured refusal (_separation_finding, the honest "this
|
||||
// yard cannot separate at $80 under its storm" verdict with receipts) —
|
||||
// but silence is not an option, and holding both means the finding went
|
||||
// stale the day the pin landed and must be deleted (its own text says so).
|
||||
if (sepStatesErr) throw new Error(`site read died: ${sepStatesErr}`);
|
||||
assert(sepStates.length === 3, `expected 3 shipped yards, read ${sepStates.length}`);
|
||||
for (const s of sepStates) {
|
||||
assert(s.pinned || s.finding,
|
||||
`${s.name} has neither a separation block nor a _separation_finding — the yard is UNJUDGED; ` +
|
||||
'run gardenfly, pin honestly or refuse honestly (SPRINT16 gate 2.3)');
|
||||
assert(!(s.pinned && s.finding),
|
||||
`${s.name} carries BOTH a pin and a refusal finding — the finding is stale, delete it in the pinning commit`);
|
||||
}
|
||||
return sepStates.map((s) => `${s.name}: ${s.pinned ? 'PINNED' : 'refused w/ finding'}`).join(' · ');
|
||||
}],
|
||||
['gardenfly: default fabric is still the pinned 0.30 cloth, and membrane flies heavier', () => {
|
||||
if (fabErr) throw new Error(`fabric flight died: ${fabErr}`);
|
||||
const { byDefault, cloth, membrane } = fabRun;
|
||||
// omitting porosity === saying 0.30, to the digit — every pinned number
|
||||
// in THREADS was measured on the default and must not move under it
|
||||
assert(byDefault.hp === cloth.hp, `default flew hp ${byDefault.hp} vs explicit cloth ${cloth.hp} — the default fabric drifted`);
|
||||
for (const d of byDefault.peaks) {
|
||||
const c = cloth.peaks.find((p) => p.id === d.id);
|
||||
assert(c && c.peakN === d.peakN, `corner ${d.id}: default peak ${d.peakN} != explicit-cloth ${c?.peakN} — the default fabric drifted`);
|
||||
}
|
||||
// porosity 0 must be a genuinely different fabric: the solid membrane
|
||||
// catches the full pressure term, so every corner flies heavier
|
||||
for (const m of membrane.peaks) {
|
||||
const c = cloth.peaks.find((p) => p.id === m.id);
|
||||
assert(m.peakN > c.peakN,
|
||||
`corner ${m.id}: membrane peak ${m.peakN} N not above cloth ${c.peakN} N — the porosity param is not reaching the rig`);
|
||||
}
|
||||
// Measured 1.23x on this synthetic yard (browser, 2026-07-20) — less
|
||||
// than the raw 1/0.7 pressure ratio because relative wind bleeds load as
|
||||
// the heavier-loaded membrane accelerates. Floor at 1.1: the per-corner
|
||||
// strictly-greater asserts above prove the wiring; this only guards
|
||||
// against the difference collapsing to epsilon.
|
||||
const up = membrane.peaks.reduce((s, m) => s + m.peakN / cloth.peaks.find((p) => p.id === m.id).peakN, 0) / 4;
|
||||
assert(up > 1.1, `membrane only ${up.toFixed(2)}x cloth load on average — porosity is wired but not biting`);
|
||||
}],
|
||||
['gardenfly: unknown hardware names throw — the shop has no mystery tier', () => {
|
||||
let threw = false;
|
||||
try { hardwareByName('titanium dream'); } catch { threw = true; }
|
||||
|
||||
@ -243,6 +243,10 @@ export async function scoreSite({ site, stormDef, stormName = null, sepStormDef
|
||||
bestGarden: bestGarden ? { ids: bestGarden.key, ...bestGarden.g } : null,
|
||||
bestMarginal: bestMarginal ? { ids: bestMarginal.key, ...bestMarginal.g } : null,
|
||||
separation, sepStormName, sepUnjudged,
|
||||
// SPRINT16 gate 2.3 [B]: a site that ATTEMPTED a pin and honestly refused
|
||||
// one records why in its JSON; the card prints it so NONE PINNED reads as
|
||||
// "measured and refused, here's the fight" rather than "nobody tried".
|
||||
separationFinding: site._separation_finding ?? null,
|
||||
collateral,
|
||||
MARGIN: AUDIT.MARGIN,
|
||||
};
|
||||
|
||||
@ -9,6 +9,21 @@
|
||||
|
||||
"gardenBed": { "x": 0, "z": 1, "w": 5, "d": 3.5 },
|
||||
|
||||
"_separation_finding": [
|
||||
"SPRINT16 gate 2.3 [B] — a separation pin was ATTEMPTED and honestly REFUSED. Measured",
|
||||
"post-ruling (garden_probe, funnel ON, storm_03b_earlybuster, 2026-07-20): bare bed 82.6",
|
||||
"FULL (hail 10.7 + rain 6.8 HP); best flown line 90.2; best gain +7.6 HP. The block's own",
|
||||
"semantics need 'bare LOSES the night' (bareMustLoseBelow, win line 50) and a bare bed",
|
||||
"WINS this night by 32 points — the early buster's pea hail simply cannot hurt an open bed",
|
||||
"enough. Any pin writable today would be a fudge: either a bareMustLoseBelow above the win",
|
||||
"line (the field name lying) or thresholds tuned until the slogan lands (the S13 fudge the",
|
||||
"gate exists to kill). THE FINDING: this yard cannot separate held from bare at ANY price",
|
||||
"under its shipped storm — that is a balance fact about the NIGHT's hail spectrum, not the",
|
||||
"rig market. The fight is in THREADS [B] 2026-07-20; candidate levers are C's hail",
|
||||
"spectrum, the win threshold, or per-night garden stakes — A rules. When a retune lands,",
|
||||
"re-measure, pin like backyard_01's, and DELETE this block in the same commit."
|
||||
],
|
||||
|
||||
"_design": [
|
||||
"DESIGN.md wants the corner block anchor-poor — 'nowhere to tie off'. An empty yard is just a",
|
||||
"smaller yard, so the trap is the interesting build: give it something that LOOKS like four free",
|
||||
|
||||
@ -48,6 +48,16 @@
|
||||
"The bare bed reads 83.7 FULL, which is the bare-beds-win pool item showing up on a",
|
||||
"third yard - gate-4 material, not this yard's doing and not tuned here."
|
||||
],
|
||||
"_separation_finding": [
|
||||
"SPRINT16 gate 2.3 [B] — a separation pin was ATTEMPTED and honestly REFUSED, same",
|
||||
"verdict as site_02's. Measured post-ruling (garden_probe, funnel ON,",
|
||||
"storm_03_southerly, 2026-07-20): bare bed 83.7 FULL (hail 10.7 + rain 5.6 HP); best",
|
||||
"flown 89.8; best gain +6.1 HP — D's S14 card numbers reproduced. Bare WINS the",
|
||||
"southerly by 34 points, so 'bareMustLoseBelow' cannot be written honestly at any",
|
||||
"threshold. The pea-hail nights cannot hurt an open bed enough for a sail to be the",
|
||||
"point of the night; that is the NIGHT's balance fact, filed in THREADS [B] 2026-07-20.",
|
||||
"When the balance ruling lands, re-measure, pin like backyard_01's, delete this block."
|
||||
],
|
||||
"yard": {
|
||||
"width": 24,
|
||||
"depth": 16
|
||||
|
||||
52
web/world/data/storms/storm_06_soaker.json
Normal file
52
web/world/data/storms/storm_06_soaker.json
Normal file
@ -0,0 +1,52 @@
|
||||
{
|
||||
"name": "Pea-Hail Soaker",
|
||||
"blurb": "Warm, wet, and rattling — fine hail on and off all night under steady soaking rain. The wind never gets serious. The stones are small enough to matter.",
|
||||
"rating": 3,
|
||||
"seed": 61207,
|
||||
"duration": 90,
|
||||
|
||||
"_why_comment": "SPRINT16 gate 3.1 — the first night the DEFAULT fabric is the wrong answer. Every prior hail night throws stones a 2 mm weave stops dead (storm_02 size 1.3, icenight 1.4 — cloth blocks 100%), so the F key never mattered. This storm's stones are size 0.45: UNDER the cloth aperture band (0.3..0.9 at porosity 0.30), so shade cloth blocks only 15.6% and leaks the rest onto the bed ALL NIGHT; membrane blocks everything. Wind is deliberately mild (sustained 9.2, gusts 15.2 m/s measured) so membrane's higher pressure load is affordable — the night's question is the fabric, per the one-variable rule. MEASURED on site_02 through gardenfly (fabric_bet probe, funnel ON, post-ruling tree, 2026-07-20): bare 17.5 DEAD · best cloth line at any price 42.3 TATTERED (a LOSS — 7.7 under the win line) · the SAME $75 ring on membrane 96.5 FULL · the $60 ring on membrane 68.8 FULL. Same line, one key, Δ54.2 HP. And membrane is NOT a free win: on $20 carabiners it reads 19.1 vs cloth's 24.5 — the doubled load tears cheap steel; bring shackles. This night was measured over BOTH shipped yards and it only works on the corner block: backyard_01's buyable geometry caps hail cover at ~31% (its full-cover quads pond-tear at 3.3+ kN even in this mild wind), so membrane cannot save what the sail cannot reach — the debut yard is site_02, and the pairing note is in THREADS for D.",
|
||||
|
||||
"baseCurve": [[0, 5.0], [15, 6.8], [30, 8.2], [50, 9.2], [70, 8.8], [90, 7.3]],
|
||||
|
||||
"gusts": {
|
||||
"firstAt": 4,
|
||||
"minGap": 6,
|
||||
"maxGap": 11,
|
||||
"powBase": 2.0,
|
||||
"powRand": 3.0,
|
||||
"powRamp": 2.0,
|
||||
"downdraftOfTotal": 0.3
|
||||
},
|
||||
|
||||
"_dir_comment": "A soft change at t=40 — later and gentler than the busters, swinging ~75 deg over 8 s. It keeps the series' one honest tell (the front wall rises in the quarter the new wind comes from) and it re-aims the yard: post-change the wind aligns with site_02's funnel axis, so q1 becomes the hot western corner (4.2 kN on cloth — the rated shackle's slot) while the pond load walks to the new downwind corner q2 (3.2-3.4 kN — the broom's slot). Two hot corners, one rated shackle in the budget: that is the night's rigging question once the F key is answered.",
|
||||
|
||||
"dirCurve": [[0, 0.6], [36, 0.7], [40, 0.4], [48, -0.6], [70, -0.7], [90, -0.65]],
|
||||
"dirWander": { "amp": 0.2, "rate": 0.1 },
|
||||
|
||||
"spatial": { "amp": 0.18, "scale": 11, "advect": 0.5 },
|
||||
|
||||
"events": [
|
||||
{ "t": 40, "type": "windchange", "telegraph": 6, "over": 8, "text": "the rain squares around" },
|
||||
{ "t": 56, "type": "debris", "model": "BlackTub_v2", "lateral": 2.0, "mass": 4, "text": "a tub floats off the path" },
|
||||
{ "t": 66, "type": "lightning", "power": 0.4 }
|
||||
],
|
||||
|
||||
"_rain_comment": "peakMmPerHour 44 and it rains from the first minute to the last — mean intensity ~0.75, so at decision 10's 40x the night delivers ~3 cm on a flat catch: enough that the bed ring (four 4.0 m posts, near-flat) ponds toward a real corner load on EITHER fabric (ponding reads geometry, not porosity — see THREADS), and the broom stays a tool. First drafts ran 55 then 48: at 55 the pond alone tore the $60 membrane ring's corners and the fabric lesson drowned in a rain lesson — 44 keeps exactly one pond-hot corner on the winning line.",
|
||||
|
||||
"rain": { "peakMmPerHour": 44, "curve": [[0, 0.15], [10, 0.45], [25, 0.75], [40, 0.9], [60, 1.0], [80, 0.85], [90, 0.7]] },
|
||||
|
||||
"_hail_comment": "size 0.45 = true pea hail, the storm's entire thesis (see _why_comment). Four authored bursts spread t=16..79 so the leak is 'all night', not a moment — and NO gust-synced hail on purpose: the stones arrive on the RAIN's schedule, not the wind's, which keeps the mild wind honest and the forecast legible. Weighted hail-seconds 14.9 measured — between the southerly's ~2 and the icenight's ~18 — and TUNED not maxed (the icenight's own lesson): first draft ran 17.2 and read bare 7.2 / best-cloth 33, a wall wearing a bet's clothes; at 14.9 the best cloth line loses by a clear 7.7 HP and the membrane ring wins FULL, which is the storm doing exactly one thing.",
|
||||
|
||||
"hail": {
|
||||
"size": 0.45,
|
||||
"bursts": [
|
||||
{ "t": 16, "ramp": 2, "hold": 5, "fade": 3, "intensity": 0.35 },
|
||||
{ "t": 33, "ramp": 2, "hold": 6, "fade": 3, "intensity": 0.43 },
|
||||
{ "t": 49, "ramp": 2, "hold": 8, "fade": 3, "intensity": 0.52 },
|
||||
{ "t": 70, "ramp": 2, "hold": 5, "fade": 4, "intensity": 0.39 }
|
||||
]
|
||||
},
|
||||
|
||||
"sky": { "darkness": 0.85, "cloudScroll": 0.07, "night": true }
|
||||
}
|
||||
@ -82,6 +82,14 @@ export const FACTORY_ANCHOR_RATINGS = Object.freeze({
|
||||
"house_yardside/fascia_anchor_03"
|
||||
]
|
||||
},
|
||||
"pergola": {
|
||||
"collateral": "pergola",
|
||||
"rating_hint": 0.65,
|
||||
"sources": [
|
||||
"pergola_01/pergola_anchor_01",
|
||||
"pergola_01/pergola_anchor_02"
|
||||
]
|
||||
},
|
||||
"post": {
|
||||
"rating_hint": 1.0,
|
||||
"sources": [
|
||||
|
||||
@ -81,9 +81,17 @@ export const PHASES = ['forecast', 'prep', 'storm', 'aftermath'];
|
||||
* concreted steel. This is a welded joint on a frame standing on grass: good
|
||||
* steel, no footing, rating_hint 0.45. Same lesson as the carport, which was
|
||||
* also nearly smuggled in as a post.
|
||||
*
|
||||
* `pergola` (SPRINT16 gate 5, E): a pergola's bolted post-beam corner — the
|
||||
* ladder's middle rung (0.65), D-endorsed in S15. Not a `post` for the same
|
||||
* reason the swing frame isn't: the word promises the footing, and this one's
|
||||
* footing is a DECK — real bolts into real joists, so better than
|
||||
* loose-on-grass (0.45), but the deck flexes and the timber cracks before
|
||||
* concrete would notice (1.00). One rating for the whole type, per the
|
||||
* manifest rule (THREADS [E] S15): unambiguous types stay node-less-safe.
|
||||
*/
|
||||
export const ANCHOR_TYPE = Object.freeze([
|
||||
'house', 'tree', 'post', 'carport', 'carport_post', 'swing_frame',
|
||||
'house', 'tree', 'post', 'carport', 'carport_post', 'swing_frame', 'pergola',
|
||||
]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@ -316,6 +316,50 @@ export const PALETTE = [
|
||||
})),
|
||||
}),
|
||||
},
|
||||
// SPRINT16 gate 5 — E's three palette entries, applied verbatim by the
|
||||
// integrator at merge (A delegated in THREADS; hunks filed by E with the
|
||||
// _v1 suffixes present and load-bearing, per D's S14 find).
|
||||
{
|
||||
// SPRINT16 gate 5, E. The ladder's middle rung, D-endorsed in S15: bolted
|
||||
// to a deck — better than loose-on-grass (0.45), worse than concrete
|
||||
// (1.00). One type, one rating, so the manifest resolves it node-less.
|
||||
kind: 'structure', label: 'Pergola (the middle rung)', model: 'pergola_01_v1',
|
||||
requires: ['pergola'],
|
||||
hint: 'two bolted corners at 0.65 — the honest step between swing frame and post',
|
||||
make: (id, x, z) => ({
|
||||
id, model: 'pergola_01_v1', wreckedModel: 'pergola_01_wrecked_v1',
|
||||
x, z, rotYDeg: 0, solid: true,
|
||||
collateralKey: 'pergola',
|
||||
collateralValue: 120, collateralLabel: 'the pergola',
|
||||
anchors: [1, 2].map((i) => ({
|
||||
id: `${id}_a${i}`, node: `pergola_anchor_0${i}`, type: 'pergola', work: 'cloth',
|
||||
})),
|
||||
}),
|
||||
},
|
||||
{
|
||||
// The collateral apex. NO anchors, on purpose — nothing about a glasshouse
|
||||
// may adopt (the ridge carries tie_off:false in the GLB). collateralKey is
|
||||
// EXPLICIT so the second one placed still bills (the carport lesson).
|
||||
kind: 'structure', label: 'Glasshouse (the apex, $320)', model: 'glasshouse_01_v1',
|
||||
hint: 'no anchors at all; $320 of glass — the thing you keep the whole rig away from',
|
||||
make: (id, x, z) => ({
|
||||
id, model: 'glasshouse_01_v1', wreckedModel: 'glasshouse_01_wrecked_v1',
|
||||
x, z, rotYDeg: 0, solid: true,
|
||||
collateralKey: 'glasshouse',
|
||||
collateralValue: 320, collateralLabel: 'the glasshouse',
|
||||
anchors: [],
|
||||
}),
|
||||
},
|
||||
{
|
||||
// The compliance ring. Fourteen posts, every one tie_off:false in the GLB;
|
||||
// unpriced BY RULING (the bike rule — price it when debris can bend it).
|
||||
kind: 'structure', label: 'Pool + fence ring', model: 'pool_kit_01_v1',
|
||||
hint: 'fourteen perfect-looking posts, every one an honest no — the legal tie-offs are elsewhere',
|
||||
make: (id, x, z) => ({
|
||||
id, model: 'pool_kit_01_v1', x, z, rotYDeg: 0, solid: true,
|
||||
anchors: [],
|
||||
}),
|
||||
},
|
||||
{
|
||||
kind: 'house', label: 'House (north edge)', model: 'house_yardside_v1', singleton: true,
|
||||
hint: 'three fascia anchors at rating_hint 0.35 — "the fascia board is a lie"',
|
||||
|
||||
@ -312,6 +312,12 @@ function render(out, s, ms) {
|
||||
} else if (s.sepUnjudged) {
|
||||
const cs = card(out, 'SEPARATION TARGET — NOT JUDGED', 'ed-warn');
|
||||
cs.append(el('div', 'ed-note', s.sepUnjudged));
|
||||
} else if (s.separationFinding) {
|
||||
// SPRINT16 gate 2.3 [B]: measured, and honestly REFUSED — different fact
|
||||
// from "nobody tried", and the card must not flatten the two together.
|
||||
const cs = card(out, 'SEPARATION TARGET — PIN REFUSED (measured)', 'ed-warn');
|
||||
cs.append(el('div', 'ed-note',
|
||||
Array.isArray(s.separationFinding) ? s.separationFinding.join(' ') : String(s.separationFinding)));
|
||||
} else {
|
||||
const cs = card(out, 'SEPARATION TARGET — NONE PINNED', 'ed-warn');
|
||||
cs.append(el('div', 'ed-note',
|
||||
|
||||
@ -59,6 +59,7 @@ export const STORM_KEYS = [
|
||||
'storm_02b_icenight',
|
||||
'storm_03_southerly',
|
||||
'storm_03b_earlybuster',
|
||||
'storm_06_soaker',
|
||||
];
|
||||
|
||||
export const DEFAULT_SEL = { storm: 'storm_03_southerly', time: 40 };
|
||||
|
||||
@ -16,6 +16,10 @@
|
||||
import * as THREE from '../vendor/three.module.js';
|
||||
import { STORM_LEN } from './contracts.js';
|
||||
import { forecastLines, leadFor } from './weather.js';
|
||||
// SPRINT16 — the end card compares the final rep against neutral. A constant,
|
||||
// not state: the HUD still only READS. week.js has no DOM and no THREE, so
|
||||
// this import costs nothing it didn't already cost main.js.
|
||||
import { REP } from './week.js';
|
||||
|
||||
const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v);
|
||||
const kmh = (ms) => ms * 3.6;
|
||||
@ -47,6 +51,14 @@ const ABN = 'ABN 12 345 678 901';
|
||||
*/
|
||||
const docketNo = (night) => 1040 + night;
|
||||
|
||||
/**
|
||||
* SPRINT16 gate 1 — the letterhead's number, formatted once. Stars, one
|
||||
* decimal (week.js REP moves in half-steps, so the decimal is always honest).
|
||||
* It prints under the ABN on job sheet AND invoice — E's letterhead contract:
|
||||
* the business is the constant, and this is the one number the business has.
|
||||
*/
|
||||
const repFmt = (r) => `★ ${Number(r).toFixed(1)}`;
|
||||
|
||||
// Load bar colours: green → amber → red. The amber band is deliberately wide;
|
||||
// a corner at 60% in a lull is one gust from 100% and the player should feel
|
||||
// that before the bar turns red on them.
|
||||
@ -123,6 +135,11 @@ const CSS = `
|
||||
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; }
|
||||
/* SPRINT16 gate 2.2 [B] — the fabric sentence under the verdict, quieter than
|
||||
the verdict itself: it is a footnote about the bet, not a second ruling.
|
||||
Sibling of .verdict (same pattern as the repline), so the verdict div and
|
||||
its a.test-pinned prose stay byte-untouched. */
|
||||
#hud-card .fabricnote { margin:8px 0 0; font-size:11px; color:#8ba0ad; letter-spacing:.04em; }
|
||||
|
||||
/* --- the week ---------------------------------------------------------- */
|
||||
#hud-card .pips { letter-spacing:.5em; font-size:15px; margin:-6px 0 10px; color:#3f5561; }
|
||||
@ -169,6 +186,15 @@ const CSS = `
|
||||
font-weight: 700; font-size: 11px; letter-spacing: .18em; color: #8ba0ad;
|
||||
}
|
||||
#hud-card .letterhead .docket .no { font-size: 10px; color: #55677a; letter-spacing: .06em; }
|
||||
/* SPRINT16 — the reputation line, under the ABN (the spec's words). Slightly
|
||||
brighter than the docket number because it is the one thing in this corner
|
||||
that MOVES, and a number that moves is a number the eye should find. */
|
||||
#hud-card .letterhead .docket .rep { font-size: 10px; color: #8ba0ad; letter-spacing: .08em; margin-top: 2px; }
|
||||
|
||||
/* SPRINT16 — the invoice speaks rep: one quiet line under the verdict, where
|
||||
the movement and its reason live. Not part of the ledger because it is not
|
||||
money — it is the thing that PRICES tomorrow's money. */
|
||||
#hud-card .repline { margin: 8px 0 0; font-size: 11px; color: #8ba0ad; letter-spacing: .04em; }
|
||||
|
||||
/* --- bill-to / job-for block -------------------------------------------
|
||||
Deliberately the same component on both documents. On the job sheet it's
|
||||
@ -753,6 +779,7 @@ showForecast({ key, def, site, tomorrowDef }, wk, onGo) {
|
||||
<div class="docket">
|
||||
<div class="kind">JOB SHEET</div>
|
||||
<div class="no">JOB ${docketNo(wk.night)} · ${ABN}</div>
|
||||
${wk.rep != null ? `<div class="rep">standing ${repFmt(wk.rep)}</div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="billto">
|
||||
@ -774,11 +801,23 @@ showForecast({ key, def, site, tomorrowDef }, wk, onGo) {
|
||||
// E's preview copy read "bed above 80% at dawn" and the mechanic pays
|
||||
// linearly, and a quote that misstates its own terms is a lie on paper.
|
||||
const q = wk.quote;
|
||||
// SPRINT16 — the multiplier is on the sheet the night it applies, and
|
||||
// only then: ×1.00 every night would teach that the number is decoration.
|
||||
// The warranty lines are the other half of gate 1's paper: booked by last
|
||||
// dawn's broken corners, worded as the spec words them ("no charge to the
|
||||
// client" IS the sentence — unpaid labour is the point), priced at the
|
||||
// parts' shop price, and already inside q.total so the sheet's conclusion
|
||||
// is the deducted number the invoice will pay. Quote==settle is week.js's
|
||||
// construction; this card just refuses to hide the deduction.
|
||||
const feeTag = q && q.feeMult !== 1 ? ` · standing ×${q.feeMult.toFixed(2)}` : '';
|
||||
const warrantyRows = (q?.warranty ?? []).map((it) => `
|
||||
<div class="row bad"><span>warranty — ${it.anchorId.toUpperCase()}, ${it.hw}, no charge to the client</span><b>−$${it.cost}</b></div>
|
||||
<div class="cond">your corner, down at dawn${it.addr ? ` — ${it.addr}` : ''} · parts at shop price, labour on you</div>`).join('');
|
||||
const schedule = q ? `
|
||||
<div class="sect">WHAT IT PAYS</div>
|
||||
<div class="sched">
|
||||
<div class="row headline"><span>callout fee</span><b>$${q.base}</b></div>
|
||||
<div class="cond">the night, rigged and stood up</div>
|
||||
<div class="row headline"><span>callout fee${feeTag}</span><b>$${q.base}</b></div>
|
||||
<div class="cond">the night, rigged and stood up${q.feeMult !== 1 ? ' — priced on the letterhead number' : ''}</div>
|
||||
<div class="row"><span>garden bonus</span><b>up to $${q.garden}</b></div>
|
||||
<div class="cond">${q.beyondSaving
|
||||
// SPRINT14 — B's pool item. On a gardenBeyondSaving night the bed
|
||||
@ -793,7 +832,7 @@ showForecast({ key, def, site, tomorrowDef }, wk, onGo) {
|
||||
? 'scales with the bed at dawn — and tonight the ice takes it whatever you rig'
|
||||
: "paid on what's left of the bed at dawn"}</div>
|
||||
<div class="row"><span>clean site</span><b>$${q.clean}</b></div>
|
||||
<div class="cond">nothing of theirs broken</div>
|
||||
<div class="cond">nothing of theirs broken</div>${warrantyRows}
|
||||
<div class="row max"><span>the night, done right</span><b>$${q.total}</b></div>
|
||||
</div>` : '';
|
||||
|
||||
@ -831,6 +870,13 @@ showForecast({ key, def, site, tomorrowDef }, wk, onGo) {
|
||||
${siteLine}
|
||||
<div class="stat">${f.wind}</div>
|
||||
<div class="stat">${f.rain}${hail ? ' · hail forecast' : ''}</div>
|
||||
${/* SPRINT16 — C's gate 3.3 seam (THREADS [A] seam contract): when
|
||||
forecastLines exposes ready-to-print stone-size and rain-rate
|
||||
lines, the sheet prints them verbatim; until then this renders
|
||||
nothing. The bet (cloth vs membrane) is argued HERE or nowhere —
|
||||
the forecast is the only place the player chooses from. */''}
|
||||
${f.stones ? `<div class="stat">${f.stones}</div>` : ''}
|
||||
${f.rainRate ? `<div class="stat">${f.rainRate}</div>` : ''}
|
||||
${f.confidence ? `<div class="stat" style="color:#8ba0ad">${f.confidence}</div>` : ''}
|
||||
${tomorrow}
|
||||
${schedule}
|
||||
@ -887,6 +933,21 @@ showForecast({ key, def, site, tomorrowDef }, wk, onGo) {
|
||||
const [head, sub, kick] = COPY[won ? (s.grade ?? 'clean') : 'gameover'];
|
||||
const nights = s.night;
|
||||
|
||||
// SPRINT16 — the end of the week speaks rep. A week that ends
|
||||
// broke-but-clean and a week that ends rich-with-warranties are
|
||||
// different endings (the gate's own words), and the difference is the
|
||||
// number the next client sees — so the last thing the game says carries
|
||||
// it. Keyed on where the letterhead number LANDED relative to neutral
|
||||
// (REP.START), crossed with whether you're still trading; the row above
|
||||
// it shows the warranty count so "rich" has its receipts.
|
||||
const repKick = s.rep == null ? '' : s.rep > REP.START
|
||||
? (won ? ' The name on the letterhead is worth more than it was on Monday.'
|
||||
: ' The book says broke. The number says the phone rings again.')
|
||||
: s.rep < REP.START
|
||||
? (won ? ' The money came in. The mornings after are what gets remembered.'
|
||||
: ' And word of the mornings after got there first.')
|
||||
: '';
|
||||
|
||||
card.innerHTML = `<div class="card endcard ${won ? 'win' : 'lose'}"
|
||||
style="background-image:url(./models/textures/${art})">
|
||||
<div class="endtext">
|
||||
@ -895,7 +956,9 @@ showForecast({ key, def, site, tomorrowDef }, wk, onGo) {
|
||||
<div class="row"><span>nights survived</span><b>${nights}/5</b></div>
|
||||
<div class="row"><span>gardens held</span><b>${s.held ?? 0}/5</b></div>
|
||||
<div class="row"><span>in the bank</span><b>$${s.bankAfter}</b></div>
|
||||
<div class="kicker">${kick}</div>
|
||||
${s.rep != null ? `<div class="row"><span>reputation</span><b>${repFmt(s.rep)}</b></div>` : ''}
|
||||
${s.warrantiesToDate ? `<div class="row"><span>warranty jobs booked</span><b>${s.warrantiesToDate}</b></div>` : ''}
|
||||
<div class="kicker">${kick}${repKick}</div>
|
||||
<button class="go">${won ? 'ANOTHER WEEK' : 'TRY AGAIN'}</button>
|
||||
</div>
|
||||
</div>`;
|
||||
@ -917,6 +980,10 @@ showForecast({ key, def, site, tomorrowDef }, wk, onGo) {
|
||||
['garden', `${r.hp.toFixed(0)}%`],
|
||||
['corners intact', `${r.cornersTotal - r.cornersLost}/${r.cornersTotal}`],
|
||||
['what got through', r.hailBlocked],
|
||||
// SPRINT16 gate 2.2 [Lane B] — the fabric bet is on the record: the
|
||||
// choice the player made at the F key, printed on the same paper as
|
||||
// what it did or didn't stop. Unconditional — the record is the point.
|
||||
...(r.fabric ? [['fabric flown', r.fabric.name]] : []),
|
||||
['hardware lost', r.bill ? `$${r.bill}` : 'none'],
|
||||
].map(([k, v]) => `<div class="row"><span>${k}</span><b>${v}</b></div>`).join('');
|
||||
|
||||
@ -948,9 +1015,16 @@ showForecast({ key, def, site, tomorrowDef }, wk, onGo) {
|
||||
// because "what this night was worth" and "where that leaves you" are two
|
||||
// different facts.
|
||||
const due = w ? w.pay - w.spent : 0;
|
||||
// SPRINT16 — the invoice answers the sheet's warranty lines word for
|
||||
// word (same items, same derivation in week.js), and the fee carries the
|
||||
// same standing multiplier the sheet quoted. The ledger must keep
|
||||
// summing exactly as shown — a.test pins pay to these rows.
|
||||
const invWarrantyRows = w ? (w.warranty ?? []).map((it) => `
|
||||
<div class="row bad"><span>warranty — ${it.anchorId.toUpperCase()}, ${it.hw}, no charge to the client</span><b>−$${it.cost}</b></div>`).join('') : '';
|
||||
const invFeeTag = w && w.feeMult && w.feeMult !== 1 ? ` · standing ×${w.feeMult.toFixed(2)}` : '';
|
||||
const money = w ? `
|
||||
<div class="ledger">
|
||||
<div class="row"><span>${w.won ? 'callout fee' : `callout fee — night lost (${Math.round(100 * w.fee / Math.max(1, w.fullFee))}%)`}</span><b>+$${w.fee}</b></div>
|
||||
<div class="row"><span>${w.won ? 'callout fee' : `callout fee — night lost (${Math.round(100 * w.fee / Math.max(1, w.fullFee))}%)`}${invFeeTag}</span><b>+$${w.fee}</b></div>
|
||||
<div class="row"><span>garden bonus — ${r.hp.toFixed(0)}% of the bed${
|
||||
// SPRINT14 — B's pool item, the invoice half. On a beyond-saving
|
||||
// night this row is always small, and next to "callout fee — night
|
||||
@ -963,7 +1037,7 @@ showForecast({ key, def, site, tomorrowDef }, wk, onGo) {
|
||||
}</span><b>+$${w.bonus}</b></div>
|
||||
${cleanRow}
|
||||
<div class="row"><span>gear recovered</span><b>+$${w.refund}</b></div>
|
||||
${collateralRows}
|
||||
${collateralRows}${invWarrantyRows}
|
||||
<div class="row bad"><span>spent on the rig</span><b>−$${w.spent}</b></div>
|
||||
<div class="row due ${due < 0 ? 'neg' : ''}"><span>AMOUNT DUE</span><b>${due < 0 ? '−' : '+'}$${Math.abs(due)}</b></div>
|
||||
<div class="row total"><span>in the bank</span><b>$${w.bankBefore} → $${w.bankAfter}</b></div>
|
||||
@ -987,6 +1061,7 @@ showForecast({ key, def, site, tomorrowDef }, wk, onGo) {
|
||||
<div class="docket">
|
||||
<div class="kind">TAX INVOICE</div>
|
||||
<div class="no">INV ${docketNo(w.night)} · ${ABN}</div>
|
||||
${w.rep != null ? `<div class="rep">standing ${repFmt(w.rep)}</div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="billto">
|
||||
@ -1001,6 +1076,19 @@ showForecast({ key, def, site, tomorrowDef }, wk, onGo) {
|
||||
${rows}
|
||||
${money}
|
||||
<div class="verdict ${r.win ? 'win' : 'lose'}">${r.verdict}</div>
|
||||
${/* SPRINT16 — the verdict speaks rep: the movement AND its reason, so
|
||||
the number on the letterhead is never a number that changed for no
|
||||
stated cause. week.js writes the reasons (including the flat
|
||||
"broke, but repaired by dawn" case) — this line just reads them. */''}
|
||||
${w && w.rep != null ? `<div class="repline">reputation ${repFmt(w.repBefore)} → ${repFmt(w.rep)}${
|
||||
w.repReasons?.length ? ` — ${w.repReasons.join(' · ')}` : ''}</div>` : ''}
|
||||
${/* SPRINT16 gate 2.2 [B] — when the fabric bet provably mattered the
|
||||
verdict page says so, one quiet line under the verdict (same
|
||||
pattern as A's repline: the verdict div itself stays byte-
|
||||
untouched). fabricNoteFor's thresholds decide; null = silent.
|
||||
(Keep-both at integration, per B's own merge notes — the two
|
||||
lines are siblings by design.) */''}
|
||||
${r.fabricNote ? `<div class="fabricnote">${r.fabricNote}</div>` : ''}
|
||||
<button class="go">${next}</button>
|
||||
${w?.client ? `<div class="terms">Payable 14 days. ${w.collateral ? 'They’ll want that made right by Friday.' : 'Thank you for your business.'}</div>` : ''}
|
||||
</div>`;
|
||||
|
||||
@ -23,7 +23,7 @@ import { createPlayer } from './player.js';
|
||||
import { Interact, wireYardActions } from './interact.js';
|
||||
import { createDebris } from './debris.js';
|
||||
import { createSkyFx } from './skyfx.js';
|
||||
import { createRiggingUI } from './rigging.js';
|
||||
import { createRiggingUI, fabricNoteFor } from './rigging.js';
|
||||
import { createHud } from './hud.js';
|
||||
import { createWeek, NIGHTS, nightAt } from './week.js';
|
||||
import { createGarden } from './garden.js';
|
||||
@ -272,6 +272,52 @@ export function verdictFor({ hp, lost, win, dmg, pondPeak, pondDumped, beyondSav
|
||||
return { mode: 'mostly', verdict: 'THE GARDEN MADE IT. Mostly.' };
|
||||
}
|
||||
|
||||
/**
|
||||
* SPRINT16 gate 1 — the work history, per corner at dawn: what the ledger
|
||||
* keeps. The night already computed every field; this is the one place they're
|
||||
* assembled, so week.js can store them (settlement.rig) and tomorrow's sheet
|
||||
* can book warranty off them. Shapes per the THREADS seam contract:
|
||||
*
|
||||
* broke it let go at least once tonight. Read off the EVENT TALLY,
|
||||
* not the corner flag — repairCorner() clears `broken`, so by
|
||||
* dawn the flag has forgotten every break that was fixed.
|
||||
* repaired broke, and was standing again at dawn (D's hold-E). NOT a
|
||||
* warranty item, by spec: fixing it mid-storm is the job.
|
||||
* brokenAtDawn still down when the storm ended — books tomorrow's warranty
|
||||
* line and takes tonight's rep hit. Riding it out broken is
|
||||
* the thing the ledger exists to price.
|
||||
*
|
||||
* `repaired` and `brokenAtDawn` partition `broke` — a corner that broke twice
|
||||
* and was fixed once is brokenAtDawn, not repaired. hw/hwCost are what was on
|
||||
* the corner AT DAWN (a spare-repaired corner reads the spare, honestly — that
|
||||
* is the steel the client's yard is wearing tomorrow). effRating is
|
||||
* rating × ratingHint, the number sail.js actually fails on — the same truth
|
||||
* the HUD bars and the verdict print, third surface, one formula.
|
||||
*
|
||||
* A value, exported, for the Enter-guard reason: scoreRun lives inside boot()
|
||||
* behind a WebGL context where no assert can reach it, and the ledger's
|
||||
* partition semantics are exactly the kind of rule that would otherwise only
|
||||
* ever be "tested" by a green suite that never ran it.
|
||||
*
|
||||
* @param {Array} corners rig.corners at dawn
|
||||
* @param {Map<string,{broke:number,repaired:number}>} [tally] the night's break/repair events per anchor
|
||||
*/
|
||||
export function rigRecordFor(corners, tally = new Map()) {
|
||||
return corners.map((c) => {
|
||||
const tal = tally.get(c.anchorId);
|
||||
const broke = (tal?.broke ?? 0) > 0 || !!c.broken;
|
||||
return {
|
||||
anchorId: c.anchorId,
|
||||
hw: c.hw.name,
|
||||
hwCost: c.hw.cost,
|
||||
effRating: Math.round(c.hw.rating * (c.anchor?.ratingHint ?? 1)),
|
||||
broke,
|
||||
repaired: broke && !c.broken,
|
||||
brokenAtDawn: !!c.broken,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase machine
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -839,7 +885,22 @@ export async function boot(opts = {}) {
|
||||
const { verdict, mode } = verdictFor({ hp, lost, win, dmg, pondPeak, pondDumped,
|
||||
beyondSaving: week.job.gardenBeyondSaving });
|
||||
|
||||
// SPRINT16 gate 2.2 [Lane B] — the fabric bet on the record. The F key
|
||||
// landed in the fresh-eyes review; this is the paperwork half: the invoice
|
||||
// carries the fabric every night (r.fabric → hud's rows), and the verdict
|
||||
// gets ONE extra sentence when the bet provably mattered (fabricNoteFor —
|
||||
// pure, thresholded, silent when a sentence would be a guess). The
|
||||
// FORECAST half — arguing the bet before you rig — is C's gate 3.3.
|
||||
const fabric = rigging.session.fabric;
|
||||
const fabricNote = fabricNoteFor({
|
||||
fabric,
|
||||
hailSize: defs[week.stormKey]?.hail?.size ?? 0,
|
||||
dmg, pondPeak,
|
||||
});
|
||||
|
||||
return {
|
||||
fabric: { id: fabric.id, name: fabric.name },
|
||||
fabricNote,
|
||||
hp,
|
||||
cornersLost: lost.length,
|
||||
cornersTotal: rig.corners.length || 4,
|
||||
@ -868,6 +929,8 @@ export async function boot(opts = {}) {
|
||||
verdict,
|
||||
/** Which failure mode the verdict is speaking from. Asserted in a.test.js. */
|
||||
verdictMode: mode,
|
||||
/** SPRINT16 gate 1 — the work history. See rigRecordFor above. */
|
||||
rigRecord: rigRecordFor(rig.corners, nightTally),
|
||||
};
|
||||
}
|
||||
|
||||
@ -940,6 +1003,10 @@ export async function boot(opts = {}) {
|
||||
{
|
||||
night: week.night, nights: week.nights, bank: week.bank, log: week.log,
|
||||
job: week.job, quote: week.quote(defs[week.stormKey]), isFinalNight: week.isFinalNight,
|
||||
// SPRINT16 — the letterhead's number. The quote carries it too
|
||||
// (quote.rep), but the sheet renders a letterhead even on nights whose
|
||||
// quote degrades away, so it rides the week block explicitly.
|
||||
rep: week.rep,
|
||||
},
|
||||
() => { stormKey = week.stormKey; game.setPhase('prep'); },
|
||||
);
|
||||
@ -960,6 +1027,22 @@ export async function boot(opts = {}) {
|
||||
if (!e.reason) pondDumped += e.kg;
|
||||
});
|
||||
|
||||
// SPRINT16 gate 1 — the night's break/repair tally, per anchor. The ledger's
|
||||
// `repaired` bit cannot be read off the corners alone: repairCorner() clears
|
||||
// `broken`, so at dawn a fixed corner looks identical to one that never went.
|
||||
// Same pattern as pondDumped above (subscribe once, reset at storm start) and
|
||||
// for the same reason: the Emitter survives re-rigs, and pre-storm events —
|
||||
// a divergence break on the calm day, say — are not the night's story.
|
||||
/** @type {Map<string, {broke:number, repaired:number}>} */
|
||||
const nightTally = new Map();
|
||||
const tallyFor = (id) => {
|
||||
let t = nightTally.get(id);
|
||||
if (!t) { t = { broke: 0, repaired: 0 }; nightTally.set(id, t); }
|
||||
return t;
|
||||
};
|
||||
rig.events.on('break', (e) => { tallyFor(e.anchorId).broke++; });
|
||||
rig.events.on('repair', (e) => { tallyFor(e.anchorId).repaired++; });
|
||||
|
||||
// --- phases -------------------------------------------------------------
|
||||
game.on('phaseChange', ({ to }) => {
|
||||
// Prep and forecast happen on the calm day; the storm you picked only
|
||||
@ -980,7 +1063,7 @@ export async function boot(opts = {}) {
|
||||
if (paused) { paused = false; hud.setPaused(false); }
|
||||
if (muted) setMuted(true);
|
||||
|
||||
if (to === 'storm') { pondPeak = 0; pondDumped = 0; }
|
||||
if (to === 'storm') { pondPeak = 0; pondDumped = 0; nightTally.clear(); }
|
||||
|
||||
if (to === 'forecast') {
|
||||
hud.setDawn(false);
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
|
||||
import { HARDWARE, START_BUDGET, SPARE_COST, FIXED_DT } from './contracts.js';
|
||||
import { SailRig, orderRing, TENSION_MIN, TENSION_MAX } from './sail.js';
|
||||
import { hailBlockFor } from './weather.core.js';
|
||||
|
||||
export { START_BUDGET, SPARE_COST };
|
||||
export const MAX_CORNERS = 4;
|
||||
@ -45,11 +46,68 @@ export const DEFAULT_TENSION = 1.0;
|
||||
* weight, membrane earns a price and this comment is the place to start.
|
||||
*/
|
||||
export const FABRIC = [
|
||||
{ id: 'cloth', name: 'shade cloth', porosity: 0.30, cost: 0, blurb: 'wind blows through — half the load, sheds water, leaks the finest hail' },
|
||||
// Blurb honesty (SPRINT16): "sheds water" deleted — C measured that ponding
|
||||
// is fabric-blind (_applyPonding never reads porosity; both cloths pond
|
||||
// identically), and a player-facing claim the sim doesn't cash is the worst
|
||||
// kind. Ruled a known simplification in sail.js setFabric's comment.
|
||||
{ id: 'cloth', name: 'shade cloth', porosity: 0.30, cost: 0, blurb: 'wind blows through — half the load, leaks the finest hail' },
|
||||
{ id: 'membrane', name: 'waterproof membrane', porosity: 0, cost: 0, blurb: 'stops rain and every stone — full wind load, and it ponds' },
|
||||
];
|
||||
export const DEFAULT_FABRIC = FABRIC[0];
|
||||
|
||||
/**
|
||||
* SPRINT16 gate 2.2 — the verdict's fabric sentence, ONLY when the bet
|
||||
* provably mattered. The invoice records the fabric every night (the record is
|
||||
* unconditional); this is the extra line the aftermath gets to say about it,
|
||||
* and it must never guess: dmg.hail can't be split into through-the-weave vs
|
||||
* fell-beside-the-sail after the fact, so the note speaks only when the
|
||||
* MODEL's own numbers make the claim safe.
|
||||
*
|
||||
* · Cloth on a night whose stones pass the weave (hailBlockFor < 1 — C's
|
||||
* 2 mm-weave ruling) with real hail damage: name the leak. That's the
|
||||
* spec's own case, "a membrane night survived in cloth should say what the
|
||||
* leak cost". The phrasing claims what is true — that much hail REACHED
|
||||
* the bed and the membrane stops those stones — not that membrane would
|
||||
* have saved exactly that many HP (some of it may have fallen past the
|
||||
* sail's edge, which no fabric fixes).
|
||||
* · Membrane that actually ponded (the HUD's own >80 kg 'ponded' bar): name
|
||||
* the trade — the pond IS the membrane's price, and the verdict prose for
|
||||
* ponded/broomed nights doesn't say which cloth bought it.
|
||||
* · Everything else: null. Big-hail nights (both fabrics block 100%), dry
|
||||
* nights, clean wins — the row on the invoice is the whole record.
|
||||
*
|
||||
* Pure, headless-testable; thresholds are data on the function so the asserts
|
||||
* and the wiring read the same numbers.
|
||||
*
|
||||
* @param {object} o
|
||||
* @param {object} o.fabric the FABRIC entry flown (session.fabric / summary.fabric)
|
||||
* @param {number} [o.hailSize] storm def's hail.size (1.0 ≈ 1.5 cm stone); 0/absent = no hail tonight
|
||||
* @param {object} [o.dmg] garden.damage {hail, rain}, HP
|
||||
* @param {number} [o.pondPeak] peak kg of water the sail held tonight
|
||||
* @returns {string|null} one sentence for the aftermath card, or null
|
||||
*/
|
||||
export function fabricNoteFor({ fabric, hailSize = 0, dmg = { hail: 0, rain: 0 }, pondPeak = 0 } = {}) {
|
||||
if (!fabric || !FABRIC.some((f) => f.id === fabric.id)) return null;
|
||||
if (fabric.porosity > 0) {
|
||||
const leaks = hailSize > 0 && hailBlockFor(hailSize, fabric.porosity) < 1;
|
||||
if (leaks && (dmg.hail ?? 0) >= fabricNoteFor.LEAK_MATTERS_HP) {
|
||||
return `Flown in ${fabric.name} — stones this fine rattle through the weave, and `
|
||||
+ `${Math.round(dmg.hail)} HP of hail reached the bed. The membrane stops every stone.`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if ((pondPeak ?? 0) > fabricNoteFor.POND_MATTERS_KG) {
|
||||
return `Flown in ${fabric.name} — it stopped the stones and the rain, and held `
|
||||
+ `${Math.round(pondPeak)} kg of what it stopped. That is the membrane's price.`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
/** The leak has to have cost real HP before the verdict brings it up. */
|
||||
fabricNoteFor.LEAK_MATTERS_HP = 5;
|
||||
/** verdictFor's own 'ponded' threshold (main.js pondPeak > 80), same number —
|
||||
* the note and the verdict must agree on when a pond is worth a sentence. */
|
||||
fabricNoteFor.POND_MATTERS_KG = 80;
|
||||
|
||||
const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
|
||||
|
||||
const OK = { ok: true };
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
* js/tests/b.test.js) and node.
|
||||
*/
|
||||
|
||||
import { RiggingSession, FABRIC } from './rigging.js';
|
||||
import { RiggingSession, FABRIC, fabricNoteFor } from './rigging.js';
|
||||
import { SailRig, TENSION_MIN, TENSION_MAX } from './sail.js';
|
||||
import { HARDWARE, START_BUDGET, SPARE_COST } from './contracts.js';
|
||||
|
||||
@ -359,6 +359,39 @@ test('fabric is a bet the F key can actually place (SPRINT15 — the dead mechan
|
||||
return 'cloth -> membrane -> cloth, $0 both ways, hessian refused';
|
||||
});
|
||||
|
||||
// --- SPRINT16 gate 2.2: the fabric on the record ---------------------------
|
||||
// The invoice row is unconditional wiring (main.js/hud.js, verified live);
|
||||
// what this pins is the VERDICT's fabric sentence — fabricNoteFor speaks only
|
||||
// when the bet provably mattered, and stays silent everywhere a sentence
|
||||
// would be a guess. Storm hail sizes are the shipped ones: southerly pea hail
|
||||
// 0.7 (cloth blocks 74% — hailBlockFor's measured number), wild night 1.3 and
|
||||
// ice night 1.4 (both fabrics block 100%).
|
||||
|
||||
test('fabricNoteFor: speaks only when the bet mattered, silent when a sentence would guess', () => {
|
||||
const [CLOTH, MEMBRANE] = FABRIC;
|
||||
// the spec's case: a pea-hail night flown in cloth, hail cost the bed real HP
|
||||
const leak = fabricNoteFor({ fabric: CLOTH, hailSize: 0.7, dmg: { hail: 22, rain: 3 } });
|
||||
assert(leak && leak.includes(CLOTH.name), `leak note must name the fabric flown, got: ${leak}`);
|
||||
assert(leak.includes('22'), `leak note must say what the leak cost, got: ${leak}`);
|
||||
// big stones cannot pass the weave — cloth blocked 100%, hail damage came
|
||||
// from where the sail wasn't, and blaming the weave for it would be a lie
|
||||
assert(fabricNoteFor({ fabric: CLOTH, hailSize: 1.3, dmg: { hail: 40, rain: 5 } }) === null,
|
||||
'cloth blamed for big-stone hail — hailBlockFor(1.3, 0.3) is 1.0, nothing leaked');
|
||||
// a leak that cost under the threshold is not worth a verdict sentence
|
||||
assert(fabricNoteFor({ fabric: CLOTH, hailSize: 0.7, dmg: { hail: fabricNoteFor.LEAK_MATTERS_HP - 1, rain: 0 } }) === null,
|
||||
'a trivial leak got a sentence — the threshold is not being read');
|
||||
// membrane's price is the pond, and only a pond the verdict itself would name
|
||||
const pond = fabricNoteFor({ fabric: MEMBRANE, hailSize: 0.7, dmg: { hail: 0, rain: 2 }, pondPeak: 240 });
|
||||
assert(pond && pond.includes(MEMBRANE.name) && pond.includes('240'), `pond note must name membrane and the kg held, got: ${pond}`);
|
||||
assert(fabricNoteFor({ fabric: MEMBRANE, hailSize: 0.7, dmg: { hail: 0, rain: 2 }, pondPeak: 40 }) === null,
|
||||
'membrane got a sentence over a pond the verdict would not even mention');
|
||||
// no hail data (a dry night), unknown fabric, missing fabric: silence, not a throw
|
||||
assert(fabricNoteFor({ fabric: CLOTH, dmg: { hail: 30, rain: 0 } }) === null, 'no hail tonight but the weave got blamed');
|
||||
assert(fabricNoteFor({ fabric: { id: 'hessian', name: 'hessian', porosity: 0.5 } }) === null, 'unknown fabric must be silent');
|
||||
assert(fabricNoteFor({}) === null && fabricNoteFor() === null, 'missing fabric must be silent, not a throw');
|
||||
return `leak note fires at 0.7-size stones + >=${fabricNoteFor.LEAK_MATTERS_HP} HP; pond note at >${fabricNoteFor.POND_MATTERS_KG} kg; all else silent`;
|
||||
});
|
||||
|
||||
export const RIGGING_TESTS = TESTS;
|
||||
|
||||
export function runRiggingSelftest() {
|
||||
|
||||
@ -529,6 +529,11 @@ export class SailRig {
|
||||
/**
|
||||
* Rain lands, runs downhill, and pools where it can't get out. Called after
|
||||
* the wind pass (which fills _nodeFlat / _nodeArea).
|
||||
*
|
||||
* ⚠ FABRIC-BLIND, on purpose and on the record (SPRINT16, C's finding):
|
||||
* nothing below reads `this.porosity`, so an open weave ponds exactly like a
|
||||
* membrane. Ruled a known simplification — see setFabric's comment for the
|
||||
* ruling and the re-measure bill a real through-weave leak would incur.
|
||||
* @param {number} mmPerHour wind.rainMmPerHour(t) — REAL-world rate, Lane C's data
|
||||
*/
|
||||
_applyPonding(mmPerHour, dt) {
|
||||
@ -921,6 +926,7 @@ export class SailRig {
|
||||
* undefined` is `load > NaN`: always false, i.e. an UNBREAKABLE anchor.
|
||||
*/
|
||||
_checkFailure(dt) {
|
||||
let diverged = false;
|
||||
for (let k = 0; k < 4; k++) {
|
||||
const c = this.corners[k];
|
||||
if (c.broken) continue;
|
||||
@ -938,6 +944,7 @@ export class SailRig {
|
||||
this.invMass[this.cornerIdx[k]] = 1 / this.nodeMass;
|
||||
this.dumpPond('corner blew');
|
||||
this.events.emit('break', { type: 'break', corner: c, anchorId: c.anchorId, hw: c.hw.name, t: this.t, reason: 'divergence' });
|
||||
diverged = true;
|
||||
continue;
|
||||
}
|
||||
if (c.load > c.hw.rating * (c.anchor.ratingHint ?? 1)) c.overload += dt;
|
||||
@ -957,9 +964,94 @@ export class SailRig {
|
||||
this.events.emit('break', { type: 'break', corner: c, anchorId: c.anchorId, hw: c.hw.name, t: this.t });
|
||||
}
|
||||
}
|
||||
// SPRINT16 gate 2.1 — THE HEAL. The break above made divergence detectable
|
||||
// (the fresh-eyes review's fix); this makes it survivable to LOOK at. The
|
||||
// freed corner otherwise keeps integrating from the NaN the solver left in
|
||||
// the spring network, and the cloth a player sees after the bang is a
|
||||
// shredded z-fighting mess, not a cloth. Heal ONLY on a divergence break —
|
||||
// an honest overload break has finite state and must not be touched.
|
||||
if (diverged) this._healNonFinite();
|
||||
if (this._dirtyRest) { this._applyRestLengths(); this._dirtyRest = false; }
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset every non-finite node from its nearest finite neighbour (grid rings,
|
||||
* straight neighbours before diagonals — _nbr's own order), or from the
|
||||
* corners' anchors when the whole cloth is poisoned. `prev` is matched to
|
||||
* `pos` on every healed node so verlet reads zero velocity out of the heal
|
||||
* instead of inventing a launch. Non-finite WATER is zeroed too: a NaN pond
|
||||
* re-poisons the healed positions on the very next substep through the
|
||||
* weight term (F += GRAVITY * w), so healing positions alone heals nothing.
|
||||
*
|
||||
* Deterministic (fixed iteration order, no randomness, no clock), and pure
|
||||
* repair: a rig with fully finite state is untouched byte-for-byte — the
|
||||
* negative control in sail.selftest.js asserts a clean storm never emits
|
||||
* 'heal'. Emits 'heal' {nodes, t} so a log can count what a bang cost.
|
||||
*
|
||||
* @returns {number} nodes healed
|
||||
*/
|
||||
_healNonFinite() {
|
||||
const pos = this.pos, prev = this.prev, w = this.water;
|
||||
const n = this.invMass.length;
|
||||
// A NaN pond is poison whether or not its node's position survived. On
|
||||
// the divergence path today this is a SECOND wire — the break's
|
||||
// dumpPond('corner blew') already fill(0)s the whole pond — but the heal's
|
||||
// own contract must not depend on the dump's side effect: if divergence
|
||||
// ever stops dumping (a "the cloth healed, keep the water" retune), the
|
||||
// NaN pond would corrupt pondMass/centroid/broom forever. Unit-asserted
|
||||
// directly in sail.selftest.js for exactly that reason.
|
||||
for (let i = 0; i < n; i++) if (!Number.isFinite(w[i])) w[i] = 0;
|
||||
|
||||
const sick = new Uint8Array(n); // 1 = position needs a donor
|
||||
let sickCount = 0, anyFinite = false, healed = 0;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const x = i * 3;
|
||||
if (Number.isFinite(pos[x]) && Number.isFinite(pos[x + 1]) && Number.isFinite(pos[x + 2])) {
|
||||
anyFinite = true;
|
||||
// finite position, poisoned velocity: match prev, keep the position
|
||||
if (!Number.isFinite(prev[x]) || !Number.isFinite(prev[x + 1]) || !Number.isFinite(prev[x + 2])) {
|
||||
prev[x] = pos[x]; prev[x + 1] = pos[x + 1]; prev[x + 2] = pos[x + 2];
|
||||
healed++;
|
||||
}
|
||||
} else { sick[i] = 1; sickCount++; }
|
||||
}
|
||||
if (sickCount) {
|
||||
// Whole cloth gone: re-seed the corners from their anchors — the one
|
||||
// world position a rig always knows — then flood from there.
|
||||
if (!anyFinite) {
|
||||
for (let k = 0; k < 4; k++) {
|
||||
const ci = this.cornerIdx[k], x = ci * 3;
|
||||
const p = this._anchorPos(this.corners[k].anchor, this.t);
|
||||
pos[x] = prev[x] = p.x; pos[x + 1] = prev[x + 1] = p.y; pos[x + 2] = prev[x + 2] = p.z;
|
||||
if (sick[ci]) { sick[ci] = 0; sickCount--; healed++; }
|
||||
}
|
||||
}
|
||||
// Ring-by-ring flood: each pass, a sick node bordering the finite region
|
||||
// copies that neighbour and joins it, so positions march inward from the
|
||||
// nearest healthy cloth. Terminates: the grid is connected, so every
|
||||
// pass with sick nodes left heals at least one.
|
||||
let changed = true;
|
||||
while (sickCount && changed) {
|
||||
changed = false;
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (!sick[i]) continue;
|
||||
for (let k = 0; k < 8; k++) {
|
||||
const m = this._nbr[i * 8 + k];
|
||||
if (m < 0 || sick[m]) continue;
|
||||
const x = i * 3, mx = m * 3;
|
||||
pos[x] = prev[x] = pos[mx];
|
||||
pos[x + 1] = prev[x + 1] = pos[mx + 1];
|
||||
pos[x + 2] = prev[x + 2] = pos[mx + 2];
|
||||
sick[i] = 0; sickCount--; healed++; changed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (healed) this.events.emit('heal', { type: 'heal', nodes: healed, t: this.t });
|
||||
return healed;
|
||||
}
|
||||
|
||||
// --- Lane D's seam (SPRINT2 decision 4) --------------------------------
|
||||
// D landed first and duck-typed these against the rig, so B conforms to D's
|
||||
// spelling rather than the other way round. Thin aliases on purpose: the
|
||||
@ -1018,10 +1110,22 @@ export class SailRig {
|
||||
}
|
||||
|
||||
/**
|
||||
* What the sail is made of. Porosity scales the wind pressure term and, since
|
||||
* rain lands on the horizontal projection of a face, an open weave sheds the
|
||||
* water it can't hold — so a porous cloth also ponds far less. Set BEFORE
|
||||
* attach(); RiggingSession.commit() does that.
|
||||
* What the sail is made of. Porosity scales the wind pressure term — and, as
|
||||
* of SPRINT16, that is ALL it scales. The previous version of this comment
|
||||
* claimed an open weave "sheds the water it can't hold — so a porous cloth
|
||||
* also ponds far less", and C's gate-3 measurement caught the cheque the sim
|
||||
* doesn't cash: _applyPonding never reads porosity, so cloth and membrane
|
||||
* pond IDENTICALLY (the $75 soaker ring's q2 gap, 3.24 vs 3.32 kN, is wind
|
||||
* term only). RULED a known simplification rather than silently fixed
|
||||
* ([B] THREADS 2026-07-20): every pinned cloth number in the repo — the
|
||||
* backyard separation block, the §7 gates, C's storm_06 pins, the S15
|
||||
* zero-delta lattice — was measured under fabric-blind ponding, and a pond-
|
||||
* physics change mid-TEETH would move all of them against the sprint's own
|
||||
* one-variable law. The fix (porosity-scaled intake + through-weave leak)
|
||||
* is real, wants its own gate with a full gauntlet re-measure, and C's
|
||||
* soaker is its ready-made test case; rigging.js's membrane-pricing comment
|
||||
* is the same conversation. Set BEFORE attach(); RiggingSession.commit()
|
||||
* does that.
|
||||
* @param {{porosity:number}|number} f a FABRIC entry or a raw porosity
|
||||
*/
|
||||
setFabric(f) {
|
||||
|
||||
@ -219,6 +219,114 @@ test('divergence BREAKS on the production path — a NaN corner cannot hold fore
|
||||
return `${broke.filter((e) => e.reason === 'divergence').length}/4 corners let go by divergence, HUD reads 0`;
|
||||
});
|
||||
|
||||
// --- SPRINT16 gate 2.1: THE HEAL -------------------------------------------
|
||||
// The break above (fresh-eyes review) made divergence detectable; these pin
|
||||
// that it is also SURVIVABLE TO LOOK AT. Before the heal, the freed corner
|
||||
// integrated from the NaN the solver left in the spring network and the cloth
|
||||
// after a bang was a shredded mess, not a cloth. The three tests are one
|
||||
// mechanism, three poison shapes: position NaN, velocity (prev) NaN via the
|
||||
// pond weight, and the negative control (honest breaks must never heal).
|
||||
|
||||
test('divergence heal: after the bang every node is finite within 1 s, prev matched', () => {
|
||||
// Shipped path: watchDivergence OFF, same rig shape as the break test above.
|
||||
const scenario = () => {
|
||||
const r = new SailRig({ anchors: makeAnchors(HEIGHTS_FLAT), gridN: 10 });
|
||||
r.attach(ALL_IDS, Array(4).fill(HARDWARE[2]), 1.0);
|
||||
const broke = [], heals = [];
|
||||
r.events.on('break', (e) => broke.push(e));
|
||||
r.events.on('heal', (e) => heals.push(e));
|
||||
const w = makeStubWind({ stormLen: 90 });
|
||||
runStorm(r, w, 2);
|
||||
assert(heals.length === 0, 'a healthy cloth healed something — the heal must be pure repair, never maintenance');
|
||||
const poisoned = r.N + 1; // interior node, review's shape
|
||||
r.pos[poisoned * 3 + 1] = NaN;
|
||||
// step one substep at a time so the break substep is observable exactly
|
||||
let steps = 0;
|
||||
while (!broke.some((e) => e.reason === 'divergence') && steps < Math.round(2 / SIM_DT)) {
|
||||
r.step(SIM_DT, w, (2 + steps * SIM_DT)); steps++;
|
||||
}
|
||||
assert(broke.some((e) => e.reason === 'divergence'), 'poison never broke a corner — the review\'s break regressed');
|
||||
assert(heals.length > 0 && heals[0].nodes > 0, 'the divergence break fired and the heal did not');
|
||||
// Immediately after the break substep: finite, and the healed node's prev
|
||||
// is MATCHED — verlet must read zero velocity out of the heal, not a launch.
|
||||
for (const v of r.pos) assert(Number.isFinite(v), 'node position still non-finite right after the heal substep');
|
||||
for (const v of r.prev) assert(Number.isFinite(v), 'node prev still non-finite right after the heal substep');
|
||||
for (let k = 0; k < 3; k++) {
|
||||
assert(r.pos[poisoned * 3 + k] === r.prev[poisoned * 3 + k],
|
||||
'healed node has pos != prev — the heal invented velocity for verlet to integrate');
|
||||
}
|
||||
// ...and the spec's bound: still finite a full second of sim later.
|
||||
runStorm(r, w, 1);
|
||||
for (const v of r.pos) assert(Number.isFinite(v), 'cloth went non-finite again inside 1 s — the heal did not hold');
|
||||
for (const v of r.water) assert(Number.isFinite(v), 'water went non-finite inside 1 s of the heal');
|
||||
return { pos: Float64Array.from(r.pos), heals: heals.length };
|
||||
};
|
||||
const a = scenario(), b2 = scenario();
|
||||
assert(a.pos.length === b2.pos.length, 'determinism runs differ in size');
|
||||
for (let i = 0; i < a.pos.length; i++) assert(a.pos[i] === b2.pos[i], `heal is nondeterministic: node component ${i} differs across identical runs`);
|
||||
return `poison → break → healed (${a.heals} heal event(s)), finite 1 s on, byte-equal across two runs`;
|
||||
});
|
||||
|
||||
test('divergence heal: the NaN pond is zeroed with the positions, and stays zeroed under live rain', () => {
|
||||
// Poison shape 2: divergence with the WATER poisoned too. This is what a real
|
||||
// blow-up leaves behind while it rains — NaN face normals feed NaN into
|
||||
// _nodeFlat and the rain-add writes it into the water field — so the test
|
||||
// injects both halves deterministically rather than racing the rain clock.
|
||||
// A heal that only fixed positions would leave pondMass() NaN forever: the
|
||||
// weight guard's `w[n] > 0` happens to filter NaN today, but the HUD's
|
||||
// ponding line, the broom's centroid and the conservation asserts all read
|
||||
// the water field directly, and one refactor of that guard makes a NaN pond
|
||||
// live poison. The heal zeroes it at the same moment it heals the cloth.
|
||||
const r = rig(HEIGHTS_FLAT, { hw: UNBREAKABLE });
|
||||
r.watchDivergence = false; // shipped path, as above
|
||||
const broke = [], heals = [];
|
||||
r.events.on('break', (e) => broke.push(e));
|
||||
r.events.on('heal', (e) => heals.push(e));
|
||||
const w = realWind(); // real storm: rain keeps landing after the heal
|
||||
runStorm(r, w, 40);
|
||||
assert(r.pondMass() > 50, `only ${r.pondMass().toFixed(0)} kg ponded before the poison — the water half of this test is vacuous`);
|
||||
const belly = Math.floor(r.N / 2) * r.N + Math.floor(r.N / 2);
|
||||
r.water[belly] = NaN;
|
||||
r.pos[belly * 3 + 1] = NaN;
|
||||
runStorm(r, w, 2);
|
||||
assert(broke.some((e) => e.reason === 'divergence'), 'the poisoned belly never broke a corner — the review\'s break regressed');
|
||||
assert(heals.length > 0, 'the poison broke a corner and nothing healed');
|
||||
runStorm(r, w, 1);
|
||||
for (const v of r.pos) assert(Number.isFinite(v), 'cloth non-finite 1 s after the heal — the pond re-poisoned it');
|
||||
for (const v of r.water) assert(Number.isFinite(v), 'water still non-finite after the heal — the heal is not zeroing the pond');
|
||||
assert(Number.isFinite(r.pondMass()), 'pondMass still NaN after the heal — the HUD broom line would read NaN');
|
||||
// Honest disclosure, measured: on the storm path above the water is ALREADY
|
||||
// clean by heal time, because the divergence break's dumpPond('corner blew')
|
||||
// fill(0)s the pond first — so the asserts above cannot isolate the heal's
|
||||
// own water pass. Pin that contract at the unit level instead: the heal must
|
||||
// zero non-finite water WITHOUT a dump in front of it, so the second wire is
|
||||
// live the day the dump's semantics change.
|
||||
r.water[belly] = NaN;
|
||||
const healedNodes = r._healNonFinite();
|
||||
assert(r.water[belly] === 0, 'heal left NaN water in place — it is leaning on dumpPond to clean the pond');
|
||||
assert(Number.isFinite(r.pondMass()), 'pondMass still NaN after a direct heal');
|
||||
return `poisoned belly + pond → divergence break at t=${broke.find((e) => e.reason === 'divergence').t.toFixed(1)}s → healed, ` +
|
||||
`pond finite under live rain; direct heal zeroes NaN water (${healedNodes} position(s) touched)`;
|
||||
});
|
||||
|
||||
test('divergence heal: negative control — honest overload breaks never trigger it', () => {
|
||||
// The heal must be IN ADDITION to the break, not a hand that touches every
|
||||
// failure: a cheap rig blowing corners honestly (finite loads, real weather)
|
||||
// must ride the whole storm without one heal event. If this goes red, the
|
||||
// heal has started editing healthy state, which is a sim change wearing a
|
||||
// repair's name.
|
||||
const w = makeStubWind({ seed: 11, stormLen: 90 });
|
||||
const r = rig(HEIGHTS_FLAT, { hw: HARDWARE[0], tension: 1.3 }); // the flogging test's rig: it blows corners
|
||||
const broke = [], heals = [];
|
||||
r.events.on('break', (e) => broke.push(e));
|
||||
r.events.on('heal', (e) => heals.push(e));
|
||||
runStorm(r, w, 90);
|
||||
assert(broke.length > 0, 'nothing blew — the control is vacuous, pick a cheaper rig');
|
||||
assert(broke.every((e) => e.reason !== 'divergence'), 'an honest overload break carried reason divergence');
|
||||
assert(heals.length === 0, `${heals.length} heal event(s) on honest breaks — the heal is touching finite state`);
|
||||
return `${broke.length} honest break(s), zero heals — the heal only answers divergence`;
|
||||
});
|
||||
|
||||
test('ponding: consolidation cannot stack one node past the cap (post-flow clamp)', () => {
|
||||
// The rain-add clamp only ran WHILE raining; once rain stopped, downhill
|
||||
// flow could pile a basin node arbitrarily high and nothing trimmed it.
|
||||
@ -359,6 +467,65 @@ test('determinism: variable frame dt matches fixed dt', () => {
|
||||
return 'ragged frame times converge on the fixed-dt trace';
|
||||
});
|
||||
|
||||
// SPRINT16 pool (B) — ROADMAP's "the >83 ms frame determinism bound
|
||||
// (documented, un-tested)", now tested — and MEASURED SHARPER than documented.
|
||||
// step() burns wall time in SIM_DT chunks up to MAX_SUBSTEPS(5) per call, and
|
||||
// `if (n === MAX_SUBSTEPS) this._acc = 0` fires whenever a call CONSUMES five
|
||||
// substeps — including when the leftover residual was a perfectly legal
|
||||
// sub-SIM_DT remainder. So the unconditionally-lossless frame bound is
|
||||
// (MAX_SUBSTEPS − 1) × SIM_DT ≈ 66.7 ms (a 67–83 ms frame may drop up to one
|
||||
// SIM_DT of residual depending on accumulator phase; ≥ 83 ms always drops).
|
||||
// Pinned AS MEASURED, not retuned: every trace ever recorded was made under
|
||||
// this guard, and no real frame lives in the 67–83 ms gap on purpose. If the
|
||||
// guard is ever refined to `&& this._acc >= SIM_DT` (making 83 ms the true
|
||||
// bound), move SAFE_MAX with it in the same commit — this header is the
|
||||
// documented waiver ROADMAP asked for, THREADS [B] SPRINT16 has the numbers.
|
||||
test('determinism bound: frames under 66.7 ms hold the trace; a hitch past 83 ms drops time by design', () => {
|
||||
const SAFE_MAX = 0.066; // just under (MAX_SUBSTEPS − 1) × SIM_DT — see header
|
||||
const w1 = makeStubWind({ seed: 5, stormLen: 20 });
|
||||
const fixed = rig(HEIGHTS_HYPAR);
|
||||
for (let i = 0; i < Math.round(20 / SIM_DT); i++) fixed.step(SIM_DT, w1, i * SIM_DT);
|
||||
|
||||
// Half 1: ragged frames spanning the WHOLE legal range converge on the
|
||||
// fixed trace (the existing 4–24 ms test's promise, extended to the bound).
|
||||
const w2 = makeStubWind({ seed: 5, stormLen: 20 });
|
||||
const ragged = rig(HEIGHTS_HYPAR);
|
||||
const rand = rng(31);
|
||||
let acc = 0;
|
||||
while (acc < 19.5) {
|
||||
const dt = 0.004 + rand() * (SAFE_MAX - 0.004);
|
||||
ragged.step(dt, w2, acc);
|
||||
acc += dt;
|
||||
}
|
||||
// Compare at equal SIM clocks, not equal wall clocks: a random frame sum
|
||||
// overshoots the target by up to SAFE_MAX — up to 3 extra substeps of honest
|
||||
// evolution, an off-by-N comparison rather than a determinism failure. Top
|
||||
// up with fixed steps until both rigs have burned identical sim time.
|
||||
while (ragged.t < fixed.t - 1e-9) { ragged.step(SIM_DT, w2, acc); acc += SIM_DT; }
|
||||
assert(ragged.t === fixed.t, `sim clocks differ after top-up: ${ragged.t} vs ${fixed.t}`);
|
||||
for (let k = 0; k < 4; k++) {
|
||||
const d = Math.abs(fixed.corners[k].load - ragged.corners[k].load);
|
||||
assert(d < 1e-6, `corner ${k} drifted ${d.toFixed(6)} N on frames inside the lossless bound — MAX_SUBSTEPS shrank?`);
|
||||
}
|
||||
assert(acc - ragged.t < SIM_DT + 1e-9,
|
||||
`${((acc - ragged.t) * 1000).toFixed(1)} ms of wall time went missing on legal frames — the guard is dropping time early`);
|
||||
|
||||
// Half 2: one 120 ms hitch loses exactly the time past 5 substeps — the sim
|
||||
// shrugs, it does not catch up, and the clock says so. This is the bound
|
||||
// BEING a bound; if this half goes red the spiral guard is gone.
|
||||
const w3 = makeStubWind({ seed: 5, stormLen: 20 });
|
||||
const hitched = rig(HEIGHTS_HYPAR);
|
||||
const HITCH = 0.120, dropped = HITCH - 5 * SIM_DT; // ≈ 36.7 ms lost
|
||||
let fed = 0;
|
||||
hitched.step(HITCH, w3, 0); fed += HITCH;
|
||||
while (fed < 20) { hitched.step(SIM_DT, w3, fed); fed += SIM_DT; }
|
||||
const deficit = fed - hitched.t;
|
||||
assert(deficit > dropped - SIM_DT - 1e-9 && deficit < dropped + SIM_DT + 1e-9,
|
||||
`fed ${fed.toFixed(3)} s of wall time, sim clock reads ${hitched.t.toFixed(3)} — deficit ${deficit.toFixed(4)} s, ` +
|
||||
`expected ≈${dropped.toFixed(4)} s (one hitch's overflow). The spiral guard moved`);
|
||||
return `frames ≤${(SAFE_MAX * 1000).toFixed(0)} ms: byte-faithful, no time lost; one 120 ms hitch drops ${(deficit * 1000).toFixed(1)} ms as designed`;
|
||||
});
|
||||
|
||||
test('re-attach resets the clock: night 2 flies the same storm as night 1', () => {
|
||||
// SPRINT13. main.js builds ONE SailRig at boot and re-attach()es it every
|
||||
// night, and step() runs in EVERY phase once rigged — so the clock ran on
|
||||
|
||||
291
web/world/js/stormgen.js
Normal file
291
web/world/js/stormgen.js
Normal file
@ -0,0 +1,291 @@
|
||||
'use strict';
|
||||
// SHADES — Lane C — makeStorm(seed): seeded storms inside authored envelopes.
|
||||
//
|
||||
// SPRINT16 gate 3.2 — GROUNDWORK, not a shipped night. ROADMAP arc 4's
|
||||
// roguelite ("procedurally generated storm sequences inside authored
|
||||
// envelopes") eats this; nothing in NIGHTS uses it yet, and nothing should
|
||||
// until that arc's own sprint prices it.
|
||||
//
|
||||
// The design rule, stated once: an ENVELOPE is authored (a designer says what
|
||||
// this storm family is allowed to be — every knob a [lo, hi] range, every
|
||||
// curve knot a fixed time with a ranged value), and a SEED picks one storm
|
||||
// from inside it. The generator draws every value in a FIXED order from one
|
||||
// mulberry32 stream, so:
|
||||
// · same seed + same envelope → the SAME def, byte-equal under
|
||||
// JSON.stringify (the determinism the whole sim is built on),
|
||||
// · a generated def carries its seed, so the gust timeline downstream
|
||||
// (buildGustTimeline reads def.seed) is seeded by the same number,
|
||||
// · every draw is range-clamped by construction, and checkEnvelope() can
|
||||
// verify conformance FROM THE DEF ALONE — it recomputes, it does not
|
||||
// trust the generator, so a hand-edited def fails honestly.
|
||||
//
|
||||
// Pure on purpose: imports weather.core only (no THREE, no DOM, no fetch), so
|
||||
// this runs under the node one-second loop exactly like the storm curves do.
|
||||
//
|
||||
// What an envelope deliberately does NOT range: knot TIMES (fixed per family —
|
||||
// a family whose acts move is two families), telegraph (the honesty rule is
|
||||
// not tunable), and GUST envelope shape (weather.core owns it). The change's
|
||||
// dirCurve is BUILT from the drawn change time so the windchange event and the
|
||||
// physics can never drift apart — the validator's own trap, made structural.
|
||||
|
||||
import { mulberry32, validateStorm, GUST } from './weather.core.js';
|
||||
|
||||
/** Round to 3 decimals — keeps generated defs readable and diffs stable.
|
||||
* Determinism does not depend on this (same ops → same doubles); byte-equal
|
||||
* JSON just reads better at 3 places than at 17. */
|
||||
const r3 = (v) => Math.round(v * 1000) / 1000;
|
||||
|
||||
/**
|
||||
* The authored envelopes, as data. One family ships as groundwork: the
|
||||
* soaker family, ranged around storm_06's MEASURED personality (fabric_bet
|
||||
* probe, THREADS 2026-07-20) so every member keeps the night's thesis —
|
||||
* pea stones under the cloth aperture, heavy rain, mild wind — while the
|
||||
* seed moves the acts around inside it.
|
||||
*
|
||||
* Envelope grammar (checkEnvelope enforces all of it):
|
||||
* duration fixed seconds
|
||||
* base { t: [knot times, first 0, last duration], v: [[lo,hi]] }
|
||||
* gusts every field [lo,hi]; minGap.lo must be ≥ GUST.TOTAL or
|
||||
* gusts could overlap (validator red)
|
||||
* change { at:[lo,hi], over:[lo,hi], telegraph: fixed,
|
||||
* startDir:[lo,hi], swing:[lo,hi] } — swing is ADDED to
|
||||
* the start direction; |swing| must be ≥ 0.6 everywhere
|
||||
* in range (validator demands ≥ 0.5 rad of real turn)
|
||||
* dirWander { amp:[lo,hi], rate:[lo,hi] }
|
||||
* spatial fixed (spatial texture is family identity, not weather)
|
||||
* rain { peakMmPerHour:[lo,hi], t: [knot times], v: [[lo,hi]] }
|
||||
* hail null, or { size:[lo,hi], count:[lo,hi] (integer),
|
||||
* window:[t0,t1], hold:[lo,hi], intensity:[lo,hi],
|
||||
* ramp: fixed, fade: fixed }
|
||||
* sky { darkness:[lo,hi], cloudScroll: fixed, night: fixed }
|
||||
*/
|
||||
export const ENVELOPES = {
|
||||
soaker_family: {
|
||||
name: 'soaker_family',
|
||||
duration: 90,
|
||||
base: {
|
||||
t: [0, 15, 30, 50, 70, 90],
|
||||
v: [[4, 6], [6, 7.5], [7.5, 9], [8.5, 10], [8, 9.5], [6.5, 8]],
|
||||
},
|
||||
gusts: {
|
||||
firstAt: [3, 6], minGap: [5.5, 7], maxGap: [9, 12],
|
||||
powBase: [1.5, 2.5], powRand: [2, 4], powRamp: [1.5, 2.5],
|
||||
downdraftOfTotal: [0.25, 0.35],
|
||||
},
|
||||
change: {
|
||||
at: [36, 52], over: [6, 10], telegraph: 6,
|
||||
startDir: [0.5, 0.8], swing: [-1.5, -1.1],
|
||||
},
|
||||
dirWander: { amp: [0.15, 0.25], rate: [0.08, 0.12] },
|
||||
spatial: { amp: 0.18, scale: 11, advect: 0.5 },
|
||||
rain: {
|
||||
peakMmPerHour: [38, 50],
|
||||
t: [0, 10, 25, 40, 60, 80, 90],
|
||||
v: [[0.1, 0.2], [0.35, 0.55], [0.65, 0.85], [0.8, 0.95], [0.9, 1.0], [0.75, 0.9], [0.6, 0.8]],
|
||||
},
|
||||
hail: {
|
||||
size: [0.4, 0.55], count: [3, 5], window: [14, 78],
|
||||
hold: [4, 9], intensity: [0.3, 0.55], ramp: 2, fade: 3,
|
||||
},
|
||||
sky: { darkness: [0.8, 0.9], cloudScroll: 0.07, night: true },
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* One storm from inside the envelope. Deterministic: same (seed, envelope) →
|
||||
* byte-equal def, proven by the selftest with JSON.stringify.
|
||||
*
|
||||
* @param {number} seed any uint32
|
||||
* @param {object} [env] an ENVELOPES entry (defaults to the soaker family)
|
||||
* @returns {object} a storm def that passes validateStorm and checkEnvelope
|
||||
*/
|
||||
export function makeStorm(seed, env = ENVELOPES.soaker_family) {
|
||||
const rng = mulberry32(seed >>> 0);
|
||||
const draw = ([lo, hi]) => lo + rng() * (hi - lo);
|
||||
const drawInt = ([lo, hi]) => lo + Math.floor(rng() * (hi - lo + 1e-9));
|
||||
|
||||
// Draw order is the contract: base knots, gusts, change, wander, rain
|
||||
// (peak then knots), hail (size, count, times, holds, intensities), sky.
|
||||
// Reordering these lines is a breaking change to every seed ever noted.
|
||||
const baseCurve = env.base.t.map((t, i) => [t, r3(draw(env.base.v[i]))]);
|
||||
|
||||
const g = env.gusts;
|
||||
const gusts = {
|
||||
firstAt: r3(draw(g.firstAt)),
|
||||
minGap: r3(draw(g.minGap)),
|
||||
maxGap: r3(draw(g.maxGap)),
|
||||
powBase: r3(draw(g.powBase)),
|
||||
powRand: r3(draw(g.powRand)),
|
||||
powRamp: r3(draw(g.powRamp)),
|
||||
downdraftOfTotal: r3(draw(g.downdraftOfTotal)),
|
||||
};
|
||||
|
||||
const at = r3(draw(env.change.at));
|
||||
const over = r3(draw(env.change.over));
|
||||
const startDir = r3(draw(env.change.startDir));
|
||||
const swing = r3(draw(env.change.swing));
|
||||
const endDir = r3(startDir + swing);
|
||||
// Built FROM the drawn change, so event and physics agree by construction:
|
||||
// hold until just before the change, swing across [at, at+over], settle a
|
||||
// touch past it, hold to the end. All knot times clamped inside duration.
|
||||
const settle = Math.min(env.duration, at + over + 14);
|
||||
const dirCurve = [
|
||||
[0, startDir],
|
||||
[r3(at - 4), startDir],
|
||||
[at, r3(startDir - swing * 0.15)],
|
||||
[r3(at + over), endDir],
|
||||
[r3(settle), r3(endDir - 0.05)],
|
||||
[env.duration, r3(endDir - 0.05)],
|
||||
];
|
||||
|
||||
const dirWander = { amp: r3(draw(env.dirWander.amp)), rate: r3(draw(env.dirWander.rate)) };
|
||||
|
||||
const rain = {
|
||||
peakMmPerHour: r3(draw(env.rain.peakMmPerHour)),
|
||||
curve: env.rain.t.map((t, i) => [t, r3(draw(env.rain.v[i]))]),
|
||||
};
|
||||
|
||||
let hail = null;
|
||||
if (env.hail) {
|
||||
const size = r3(draw(env.hail.size));
|
||||
const count = drawInt(env.hail.count);
|
||||
// Jittered slots: split the window into `count` equal slots and drop one
|
||||
// burst inside each — spread guaranteed, spacing seeded, no sort needed
|
||||
// (slot order IS time order, so draw order stays stable per burst).
|
||||
const [w0, w1] = env.hail.window;
|
||||
const slot = (w1 - w0) / count;
|
||||
const bursts = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const t = r3(w0 + slot * i + rng() * slot * 0.6);
|
||||
bursts.push({
|
||||
t,
|
||||
ramp: env.hail.ramp,
|
||||
hold: r3(draw(env.hail.hold)),
|
||||
fade: env.hail.fade,
|
||||
intensity: r3(draw(env.hail.intensity)),
|
||||
});
|
||||
}
|
||||
hail = { size, bursts };
|
||||
}
|
||||
|
||||
const sky = {
|
||||
darkness: r3(draw(env.sky.darkness)),
|
||||
cloudScroll: env.sky.cloudScroll,
|
||||
};
|
||||
if (env.sky.night) sky.night = true;
|
||||
|
||||
return {
|
||||
name: `${env.name} seed ${seed >>> 0}`,
|
||||
blurb: `Generated from the ${env.name} envelope — one of its storms, picked by seed ${seed >>> 0}.`,
|
||||
_generated: { envelope: env.name, seed: seed >>> 0 },
|
||||
seed: seed >>> 0,
|
||||
duration: env.duration,
|
||||
baseCurve,
|
||||
gusts,
|
||||
dirCurve,
|
||||
dirWander,
|
||||
spatial: { ...env.spatial },
|
||||
events: [
|
||||
{ t: at, type: 'windchange', telegraph: env.change.telegraph, over, text: 'the change comes through' },
|
||||
],
|
||||
rain,
|
||||
...(hail ? { hail } : {}),
|
||||
sky,
|
||||
};
|
||||
}
|
||||
|
||||
const inRange = (v, [lo, hi], eps = 1e-9) => Number.isFinite(v) && v >= lo - eps && v <= hi + eps;
|
||||
|
||||
/**
|
||||
* Does this def sit inside this envelope? Recomputed from the DEF, never from
|
||||
* remembering what the generator drew — a hand-edited def must fail here on
|
||||
* its own merits. Also runs validateStorm, so "inside the envelope" implies
|
||||
* "loadable storm". Returns { ok, errors } like the validator.
|
||||
*/
|
||||
export function checkEnvelope(def, env = ENVELOPES.soaker_family) {
|
||||
const errors = [];
|
||||
const bad = (m) => errors.push(`${env.name}: ${m}`);
|
||||
|
||||
const v = validateStorm(def, def?.name ?? 'generated');
|
||||
if (!v.ok) errors.push(...v.errors);
|
||||
if (!def || typeof def !== 'object') return { ok: false, errors };
|
||||
|
||||
if (def.duration !== env.duration) bad(`duration ${def.duration} ≠ family's ${env.duration}`);
|
||||
|
||||
// base curve: same knot times, every value in range
|
||||
const bc = def.baseCurve ?? [];
|
||||
if (bc.length !== env.base.t.length) bad(`baseCurve has ${bc.length} knots, family has ${env.base.t.length}`);
|
||||
else bc.forEach(([t, val], i) => {
|
||||
if (t !== env.base.t[i]) bad(`baseCurve knot ${i} at t=${t}, family fixes t=${env.base.t[i]}`);
|
||||
if (!inRange(val, env.base.v[i])) bad(`baseCurve[${i}] = ${val} outside [${env.base.v[i]}]`);
|
||||
});
|
||||
|
||||
const g = def.gusts ?? {};
|
||||
for (const k of ['firstAt', 'minGap', 'maxGap', 'powBase', 'powRand', 'powRamp', 'downdraftOfTotal']) {
|
||||
if (!inRange(g[k], env.gusts[k])) bad(`gusts.${k} = ${g[k]} outside [${env.gusts[k]}]`);
|
||||
}
|
||||
if ((g.minGap ?? 0) < GUST.TOTAL) bad(`gusts.minGap ${g.minGap} < ${GUST.TOTAL} — family authored an overlap`);
|
||||
|
||||
// the change: one windchange event, inside the window, physics agreeing
|
||||
const changes = (def.events ?? []).filter((e) => e.type === 'windchange');
|
||||
if (changes.length !== 1) bad(`family storms carry exactly one windchange, got ${changes.length}`);
|
||||
else {
|
||||
const ev = changes[0];
|
||||
if (!inRange(ev.t, env.change.at)) bad(`change at t=${ev.t} outside [${env.change.at}]`);
|
||||
if (!inRange(ev.over ?? 6, env.change.over)) bad(`change.over ${ev.over} outside [${env.change.over}]`);
|
||||
if ((ev.telegraph ?? 0) !== env.change.telegraph) bad(`change.telegraph ${ev.telegraph} ≠ family's ${env.change.telegraph}`);
|
||||
const dc = def.dirCurve ?? [];
|
||||
const start = dc[0]?.[1];
|
||||
const end = dc[dc.length - 1]?.[1];
|
||||
if (!inRange(start, env.change.startDir)) bad(`dirCurve start ${start} outside [${env.change.startDir}]`);
|
||||
// net swing (allowing the settle's small overshoot), against the family's
|
||||
const net = end - start;
|
||||
const [sLo, sHi] = env.change.swing;
|
||||
if (!inRange(net, [sLo - 0.1, sHi + 0.1])) bad(`net dir swing ${net?.toFixed?.(3)} outside [${sLo},${sHi}]±0.1`);
|
||||
}
|
||||
|
||||
const w = def.dirWander ?? {};
|
||||
if (!inRange(w.amp, env.dirWander.amp)) bad(`dirWander.amp ${w.amp} outside [${env.dirWander.amp}]`);
|
||||
if (!inRange(w.rate, env.dirWander.rate)) bad(`dirWander.rate ${w.rate} outside [${env.dirWander.rate}]`);
|
||||
|
||||
const sp = def.spatial ?? {};
|
||||
for (const k of ['amp', 'scale', 'advect']) {
|
||||
if (sp[k] !== env.spatial[k]) bad(`spatial.${k} ${sp[k]} ≠ family's ${env.spatial[k]}`);
|
||||
}
|
||||
|
||||
const rn = def.rain ?? {};
|
||||
if (!inRange(rn.peakMmPerHour, env.rain.peakMmPerHour)) {
|
||||
bad(`rain.peakMmPerHour ${rn.peakMmPerHour} outside [${env.rain.peakMmPerHour}]`);
|
||||
}
|
||||
const rc = rn.curve ?? [];
|
||||
if (rc.length !== env.rain.t.length) bad(`rain.curve has ${rc.length} knots, family has ${env.rain.t.length}`);
|
||||
else rc.forEach(([t, val], i) => {
|
||||
if (t !== env.rain.t[i]) bad(`rain knot ${i} at t=${t}, family fixes t=${env.rain.t[i]}`);
|
||||
if (!inRange(val, env.rain.v[i])) bad(`rain.curve[${i}] = ${val} outside [${env.rain.v[i]}]`);
|
||||
});
|
||||
|
||||
if (env.hail) {
|
||||
const h = def.hail;
|
||||
if (!h) bad('family hails; def has no hail block');
|
||||
else {
|
||||
if (!inRange(h.size, env.hail.size)) bad(`hail.size ${h.size} outside [${env.hail.size}]`);
|
||||
const bursts = h.bursts ?? [];
|
||||
if (!inRange(bursts.length, env.hail.count)) bad(`${bursts.length} bursts outside [${env.hail.count}]`);
|
||||
bursts.forEach((b, i) => {
|
||||
if (!inRange(b.t, env.hail.window)) bad(`burst[${i}] at t=${b.t} outside window [${env.hail.window}]`);
|
||||
if (!inRange(b.hold, env.hail.hold)) bad(`burst[${i}].hold ${b.hold} outside [${env.hail.hold}]`);
|
||||
if (!inRange(b.intensity, env.hail.intensity)) bad(`burst[${i}].intensity ${b.intensity} outside [${env.hail.intensity}]`);
|
||||
if (b.ramp !== env.hail.ramp) bad(`burst[${i}].ramp ${b.ramp} ≠ family's ${env.hail.ramp}`);
|
||||
if (b.fade !== env.hail.fade) bad(`burst[${i}].fade ${b.fade} ≠ family's ${env.hail.fade}`);
|
||||
});
|
||||
if (h.withGustsAbove != null) bad('family hail is authored-bursts only; withGustsAbove is not in the envelope');
|
||||
}
|
||||
} else if (def.hail) {
|
||||
bad('family does not hail; def carries a hail block');
|
||||
}
|
||||
|
||||
const sk = def.sky ?? {};
|
||||
if (!inRange(sk.darkness, env.sky.darkness)) bad(`sky.darkness ${sk.darkness} outside [${env.sky.darkness}]`);
|
||||
|
||||
return { ok: errors.length === 0, errors };
|
||||
}
|
||||
@ -13,14 +13,14 @@ import { createWorld, heightAt, loadSite, validateSite } from '../world.js';
|
||||
import { createCameraRig, spawnYawFor } from '../camera.js';
|
||||
import {
|
||||
CALM_STORM, accumulate, applyMute, canPlayHere, createGame, createWindRouter, enterCommits,
|
||||
stormsToPreload, verdictFor,
|
||||
rigRecordFor, stormsToPreload, verdictFor,
|
||||
} from '../main.js';
|
||||
import { createGarden } from '../garden.js';
|
||||
import { RiggingSession } from '../rigging.js';
|
||||
import { createSkyFx } from '../skyfx.js';
|
||||
import { SailRig, orderRing } from '../sail.js';
|
||||
import { loadStorm, createWind } from '../weather.js';
|
||||
import { createWeek, NIGHTS, nightAt, gradeFor, BROKE_BELOW, PAY } from '../week.js';
|
||||
import { createWeek, NIGHTS, nightAt, gradeFor, BROKE_BELOW, PAY, REP } from '../week.js';
|
||||
import { createHud } from '../hud.js';
|
||||
import { PALETTE, boundsFaults, emptyTemplate, exportSiteJSON, placeEntry } from '../editor.js';
|
||||
import { assert, assertClose, assertEq, assertLess, fixedLoop } from '../testkit.js';
|
||||
@ -253,7 +253,10 @@ export default async function run(t) {
|
||||
// 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');
|
||||
// SPRINT16: − warrantyTotal joins the sum (0 on a history-less week, so
|
||||
// this line is the formula's pin, not a behaviour change here).
|
||||
assertEq(s.pay, s.fee + s.bonus + s.refund + s.clean - s.collateral - s.warrantyTotal,
|
||||
'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');
|
||||
@ -534,6 +537,233 @@ export default async function run(t) {
|
||||
assert(b.collateralValue == null, 'the bike is unpriced BY RULING — build the fall first');
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// SPRINT16 gate 1 — THE LEDGER: work history, warranty, reputation
|
||||
// =========================================================================
|
||||
// The fixtures speak the seam contract's shapes (THREADS [A] 2026-07-20):
|
||||
// rc() is one corner of the rig record, cleanRun() a night nothing went
|
||||
// wrong on. s16def matches the def the older week tests use — feeFor(20).
|
||||
|
||||
const s16def = { baseCurve: [[0, 10]], gusts: { powBase: 5, powRamp: 5 } };
|
||||
const rc = (anchorId, over = {}) => ({
|
||||
anchorId, hw: 'shackle', hwCost: 15, effRating: 3200,
|
||||
broke: false, repaired: false, brokenAtDawn: false, ...over,
|
||||
});
|
||||
const cleanRun = (over = {}) => ({
|
||||
hp: 80, win: true, collateral: [], intactHardwareValue: 0,
|
||||
rigRecord: [rc('p1'), rc('p2'), rc('p3'), rc('p4')], ...over,
|
||||
});
|
||||
|
||||
t.test('SPRINT16: the work history — the tally remembers what the corner flag forgets', () => {
|
||||
// rigRecordFor is the value scoreRun calls (the Enter-guard lesson: the
|
||||
// closure inside boot() is unreachable, the rule is not). The trap it
|
||||
// exists to dodge: repairCorner() clears `broken`, so at dawn a corner
|
||||
// that broke and was fixed is indistinguishable from one that never went —
|
||||
// the EVENT tally is the only honest witness.
|
||||
const mk = (id, hw, hint, broken) => ({ anchorId: id, hw, broken, anchor: { ratingHint: hint } });
|
||||
const shackle = { name: 'shackle', cost: 15, rating: 3200 };
|
||||
const carab = { name: 'carabiner', cost: 5, rating: 1200 };
|
||||
const tally = new Map([
|
||||
['q2', { broke: 1, repaired: 1 }], // broke, fixed — flag forgot, tally didn't
|
||||
['q3', { broke: 2, repaired: 1 }], // broke twice, fixed once — still down
|
||||
]);
|
||||
const recs = rigRecordFor([
|
||||
mk('q1', carab, 0.22, false), // held all night on the lying beam
|
||||
mk('q2', shackle, 1, false), // hw at dawn = the spare, honestly
|
||||
mk('q3', shackle, 1, true),
|
||||
mk('q4', shackle, 1, true), // no tally entry at all (divergence-shaped) — still recorded
|
||||
], tally);
|
||||
const by = (id) => recs.find((r) => r.anchorId === id);
|
||||
|
||||
assertEq(by('q1').broke, false, 'a corner that held is not a break');
|
||||
assertEq(by('q1').effRating, Math.round(1200 * 0.22),
|
||||
'effRating is the number sail.js fails on — rating × hint, not the bare rating');
|
||||
assertEq(by('q2').broke, true, 'the tally remembers the break the cleared flag forgot');
|
||||
assertEq(by('q2').repaired, true, 'and standing at dawn = repaired');
|
||||
assertEq(by('q2').brokenAtDawn, false);
|
||||
assertEq(by('q3').repaired, false, 'broke twice, fixed once: brokenAtDawn, not repaired');
|
||||
assertEq(by('q3').brokenAtDawn, true);
|
||||
assertEq(by('q4').broke, true, 'a broken corner with no tally entry is still a break — belt AND braces');
|
||||
for (const r of recs) {
|
||||
assert(!(r.repaired && r.brokenAtDawn), `${r.anchorId}: repaired and brokenAtDawn must partition broke`);
|
||||
assertEq(r.repaired || r.brokenAtDawn, r.broke, `${r.anchorId}: every break ends exactly one way`);
|
||||
}
|
||||
// …and the week KEEPS it: the settlement carries the record verbatim.
|
||||
const w = createWeek();
|
||||
const s = w.settle(cleanRun({ rigRecord: recs }), s16def, 0);
|
||||
assertEq(s.rig, recs, 'settlement.rig IS the work history — the ledger keeps what the night computed');
|
||||
});
|
||||
|
||||
t.test('SPRINT16: warranty fires ONLY on corners still broken at dawn — a repair books nothing', () => {
|
||||
const w = createWeek();
|
||||
w.settle(cleanRun({ rigRecord: [
|
||||
rc('q2', { broke: true, repaired: true }), // mid-storm repair: NOT a warranty item (spec)
|
||||
rc('q3', { broke: true, brokenAtDawn: true }), // rode it out broken: THE item
|
||||
rc('p1'), rc('p2'),
|
||||
] }), s16def, 0);
|
||||
w.advance();
|
||||
const q = w.quote(s16def);
|
||||
assertEq(q.warranty.length, 1, 'one item: the repaired corner books nothing');
|
||||
assertEq(q.warranty[0].anchorId, 'q3');
|
||||
assertEq(q.warranty[0].hw, 'shackle');
|
||||
assertEq(q.warranty[0].cost, 15, "the broken hardware's shop price — parts; the labour is the point, unpaid");
|
||||
assertEq(q.warrantyTotal, 15);
|
||||
assert(q.warranty[0].addr, 'the item says whose yard it came from');
|
||||
// The item lives ONE morning: settle tonight clean, and tomorrow books none.
|
||||
w.settle(cleanRun(), s16def, 0);
|
||||
w.advance();
|
||||
assertEq(w.quote(s16def).warranty.length, 0, 'a warranty line does not haunt the week');
|
||||
});
|
||||
|
||||
t.test('SPRINT16: quote==settle holds WITH a warranty line — the deduction is at quote time', () => {
|
||||
const w = createWeek();
|
||||
w.settle(cleanRun({ rigRecord: [
|
||||
rc('q3', { broke: true, brokenAtDawn: true }), rc('p1'), rc('p2'), rc('p3'),
|
||||
] }), s16def, 0);
|
||||
w.advance();
|
||||
const q = w.quote(s16def);
|
||||
assert(q.warrantyTotal > 0, 'precondition: tonight carries the deduction');
|
||||
assertEq(q.total, q.base + q.garden + q.clean - q.warrantyTotal,
|
||||
'the quote CONCLUDES at the deducted number — the sheet does not hide it');
|
||||
const s = w.settle(cleanRun({ hp: 100 }), s16def, 0);
|
||||
assertEq(s.warrantyTotal, q.warrantyTotal, 'same items, same source — derived, never stored twice');
|
||||
assertEq(s.fee, q.base, 'the fee is the quoted fee');
|
||||
assertEq(s.fee + s.bonus + s.clean - s.warrantyTotal, q.total,
|
||||
'a perfect night pays exactly what the sheet quoted — the invariant, with teeth in it');
|
||||
assertEq(s.pay, s.fee + s.bonus + s.refund + s.clean - s.collateral - s.warrantyTotal,
|
||||
'and the invoice rows still sum to the total shown');
|
||||
});
|
||||
|
||||
t.test('SPRINT16: rep — clean up, dawn-broken down, collateral down harder; the garden is NOT an input', () => {
|
||||
const s1 = createWeek().settle(cleanRun(), s16def, 0);
|
||||
assertEq(s1.repBefore, REP.START, 'the outfit starts neutral');
|
||||
assertEq(s1.rep, REP.START + REP.CLEAN, 'a clean night moves the letterhead number up');
|
||||
assert(s1.repReasons.includes('clean night'), 'with its reason stated');
|
||||
|
||||
const s2 = createWeek().settle(cleanRun({ rigRecord: [
|
||||
rc('p1', { broke: true, repaired: true }), rc('p2'), rc('p3'), rc('p4'),
|
||||
] }), s16def, 0);
|
||||
assertEq(s2.repDelta, 0, 'broke-but-repaired is NEUTRAL: not clean, not a warranty — fixing it IS the job');
|
||||
assert(s2.repReasons.join(' ').includes('repaired by dawn'), 'and the flat line says why');
|
||||
|
||||
const s3 = createWeek().settle(cleanRun({ rigRecord: [
|
||||
rc('p1', { broke: true, brokenAtDawn: true }), rc('p2'), rc('p3'), rc('p4'),
|
||||
] }), s16def, 0);
|
||||
assertEq(s3.repDelta, REP.WARRANTY, 'riding it out broken costs the number');
|
||||
|
||||
const s4 = createWeek().settle(cleanRun({
|
||||
collateral: [{ what: 'the carport', cost: 180 }],
|
||||
}), s16def, 0);
|
||||
assertEq(s4.repDelta, REP.COLLATERAL, 'their property beats your hardware');
|
||||
assertLess(s4.repDelta, s3.repDelta, 'collateral hits HARDER than a blown corner — the spec\'s word');
|
||||
|
||||
// The pin that matters most: same rig, opposite garden — identical movement.
|
||||
// hp and win are absent from the movement BY CONSTRUCTION, which is what
|
||||
// makes night 5's designed loss free without an exemption to go stale.
|
||||
const a1 = createWeek().settle(cleanRun({ hp: 100, win: true }), s16def, 0);
|
||||
const b1 = createWeek().settle(cleanRun({ hp: 0, win: false }), s16def, 0);
|
||||
assertEq(a1.repDelta, b1.repDelta, 'rep never reads the garden');
|
||||
});
|
||||
|
||||
t.test("SPRINT16: night 5's designed loss costs NO rep — a clean rig still earns it", () => {
|
||||
const w = createWeek();
|
||||
for (let i = 0; i < 4; i++) w.advance();
|
||||
assertEq(nightAt(4).gardenBeyondSaving, true, 'precondition: the flagged night');
|
||||
// Garden lost by design, steel held, nothing of theirs broken: that was
|
||||
// the whole job tonight (the verdict's own words), and the number says so.
|
||||
const s = w.settle(cleanRun({ hp: 0, win: false }), s16def, 0);
|
||||
assertEq(s.repDelta, REP.CLEAN,
|
||||
'a designed loss the ledger punished would be the verdict lying again — it goes UP');
|
||||
});
|
||||
|
||||
t.test('SPRINT16: rep prices the fee, and the multiplier is on the sheet the night it applies', () => {
|
||||
const w = createWeek();
|
||||
w.settle(cleanRun(), s16def, 0); // → ★3.5
|
||||
w.advance();
|
||||
const q = w.quote(s16def);
|
||||
assertEq(q.rep, 3.5);
|
||||
assertEq(q.feeMult, REP.multiplier(3.5));
|
||||
assertEq(q.feeMult, 1.02, 'half-steps land EXACTLY on two decimals — the printed number is the number used');
|
||||
assertEq(q.base, Math.round(PAY.feeFor(20) * 1.02), 'the fee is priced on the standing');
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
const hud = createHud({ scene: new THREE.Scene() });
|
||||
try {
|
||||
hud.showForecast(
|
||||
{ key: 'storm_03b_earlybuster', def: earlyBusterDef, site: null, tomorrowDef: null },
|
||||
{ night: w.night, nights: w.nights, log: [], job: w.job,
|
||||
quote: q, bank: w.bank, rep: w.rep }, () => {});
|
||||
const doc = (sel) => document.querySelector(`#hud-card ${sel}`);
|
||||
assertEq(doc('.docket .rep')?.textContent, 'standing ★ 3.5',
|
||||
'the number is on the letterhead, under the ABN — E\'s contract');
|
||||
assert((doc('.sched .row.headline span')?.textContent ?? '').includes('×1.02'),
|
||||
'the multiplier prints on the fee row the night it applies');
|
||||
} finally { hud.dispose(); }
|
||||
}
|
||||
// …and settle pays the multiplied fee the sheet quoted.
|
||||
const s = w.settle(cleanRun({ hp: 100 }), s16def, 0);
|
||||
assertEq(s.fee, q.base, 'trust became visible money, and the invoice honoured it');
|
||||
assertEq(s.feeMult, q.feeMult);
|
||||
// Neutral control: a fresh week prints no multiplier tag at all.
|
||||
assertEq(createWeek().quote(s16def).feeMult, 1, 'neutral standing is ×1.00 — and the sheet stays quiet about it');
|
||||
});
|
||||
|
||||
t.test('SPRINT16 negative control: a clean week books ZERO warranty items and ends rep-positive', () => {
|
||||
const w = createWeek();
|
||||
let items = 0;
|
||||
for (let n = 0; n < 5; n++) {
|
||||
items += w.quote(s16def).warranty.length; // counted off the sheet…
|
||||
items += w.settle(cleanRun(), s16def, 0).warranty.length; // …and the invoice
|
||||
w.advance();
|
||||
}
|
||||
assertEq(items, 0, 'no sheet and no invoice ever carried a warranty line');
|
||||
assert(w.rep > REP.START, `the week ends rep-positive (★${w.rep})`);
|
||||
assertEq(w.rep, REP.MAX, 'five clean nights ride the clamp to the top of the scale');
|
||||
assertEq(w.log[4].warrantiesToDate, 0, 'and the end card has no receipts to show');
|
||||
w.reset();
|
||||
assertEq(w.rep, REP.START, 'reset() is a new outfit — rep goes back to neutral');
|
||||
});
|
||||
|
||||
t.test('SPRINT16: the invoice carries the warranty line and speaks rep; the endings differ', () => {
|
||||
if (typeof document === 'undefined') return 'SKIPPED — no DOM';
|
||||
const w = createWeek();
|
||||
w.settle(cleanRun({ rigRecord: [
|
||||
rc('q3', { broke: true, brokenAtDawn: true }), rc('p1'), rc('p2'), rc('p3'),
|
||||
] }), s16def, 0);
|
||||
w.advance();
|
||||
const s = w.settle(cleanRun({ hp: 90 }), s16def, 0);
|
||||
|
||||
const hud = createHud({ scene: new THREE.Scene() });
|
||||
try {
|
||||
hud.showAftermath({ hp: 90, cornersLost: 0, cornersTotal: 4, hailBlocked: 'x',
|
||||
bill: 0, collateral: [], subtitle: 'test', verdict: 'test', win: true, week: s }, () => {});
|
||||
const doc = (sel) => document.querySelector(`#hud-card ${sel}`);
|
||||
const ledger = doc('.ledger')?.textContent ?? '';
|
||||
assert(ledger.includes('warranty — Q3, shackle, no charge to the client'),
|
||||
`the warranty line, worded as the spec words it — got: "${ledger}"`);
|
||||
assert(ledger.includes('−$15'), 'and priced at the parts\' shop price');
|
||||
assert((doc('.docket .rep')?.textContent ?? '').includes('★'),
|
||||
'rep is under the ABN on the invoice too — both papers, one number');
|
||||
const repline = doc('.repline')?.textContent ?? '';
|
||||
assert(repline.includes('reputation'), 'the verdict speaks rep');
|
||||
assert(repline.includes('clean night'), `and states the reason: "${repline}"`);
|
||||
hud.hideCard();
|
||||
|
||||
// The two endings the gate names, rendered and compared: they must not
|
||||
// read the same, and rich-with-warranties must show its receipts.
|
||||
hud.showEndCard({ ...s, outcome: 'gameover', rep: 4.5, warrantiesToDate: 0, grade: 'solvent' }, () => {});
|
||||
const brokeClean = document.querySelector('#hud-card .kicker')?.textContent ?? '';
|
||||
hud.hideCard();
|
||||
hud.showEndCard({ ...s, outcome: 'win', rep: 1.5, warrantiesToDate: 4, grade: 'clean', held: 5 }, () => {});
|
||||
const richWarranties = document.querySelector('#hud-card .kicker')?.textContent ?? '';
|
||||
const endtext = document.querySelector('#hud-card .endtext')?.textContent ?? '';
|
||||
assert(endtext.includes('warranty jobs booked'), 'rich-with-warranties shows its receipts');
|
||||
assert(endtext.includes('reputation'), 'and the number is on the last card the game shows');
|
||||
assert(brokeClean.length > 0 && richWarranties.length > 0 && brokeClean !== richWarranties,
|
||||
'broke-but-clean and rich-with-warranties are DIFFERENT endings and must read as such');
|
||||
} finally { hud.dispose(); }
|
||||
});
|
||||
|
||||
// --- SPRINT11 gate 1: the three rulings, each pinned ----------------------
|
||||
|
||||
t.test('ruling: the carport is typed as a carport, not smuggled in as a post', () => {
|
||||
@ -557,15 +787,21 @@ export default async function run(t) {
|
||||
// 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.
|
||||
//
|
||||
// SPRINT16 (E's flag, defused): the bogus type was 'trampoline' — which is
|
||||
// literally gate 5's third asset, deferred to arc 2. The day it joined
|
||||
// ANCHOR_TYPE this fixture would have validated and the test gone red on a
|
||||
// correct change, at the worst possible moment. The bogus type must be a
|
||||
// thing the yard will NEVER offer.
|
||||
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' }],
|
||||
posts: [{ id: 'x1', x: 0, z: 0, h: 3, type: 'bouncy_castle', 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');
|
||||
assert(/bouncy_castle/.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' }] };
|
||||
|
||||
@ -16,7 +16,7 @@
|
||||
import * as THREE from '../../vendor/three.module.js';
|
||||
import { assert, assertClose, fixedLoop } from '../testkit.js';
|
||||
import { FIXED_DT, checkContract, DEBRIS_PIECE_FIELDS, createStubWind } from '../contracts.js';
|
||||
import { loadStorm, createWind, windForSite, forecastLines, forecastFor, leadFor } from '../weather.js';
|
||||
import { loadStorm, createWind, windForSite, forecastLines, forecastFor, leadFor, hailBlockFor } from '../weather.js';
|
||||
import { loadSite, createWorld } from '../world.js';
|
||||
// GATE 2.3 — the GAME's own wind wiring, IMPORTED rather than re-typed. A pin
|
||||
// that copied main.js's two lines would agree with a copy of the game forever,
|
||||
@ -30,6 +30,11 @@ import { createSkyFx, RainShadow } from '../skyfx.js';
|
||||
import { SailRig, HARDWARE } from '../sail.js';
|
||||
import { weatherCases } from './weather.selftest.js';
|
||||
import { ENVELOPE_TESTS } from '../../../../tools/storm_envelope/envelope.selftest.js';
|
||||
// SPRINT16 gate 3: the membrane night's pin flies THE audit chain (gardenfly),
|
||||
// and the seeded generator must satisfy the storm-side envelope harness.
|
||||
import { flyGarden, hardwareByName } from '../../../../tools/site_audit/gardenfly.js';
|
||||
import { stormEnvelope } from '../../../../tools/storm_envelope/envelope.js';
|
||||
import { makeStorm } from '../stormgen.js';
|
||||
|
||||
// Keep in step with data/storms/. The node runner globs the directory, so this
|
||||
// list going stale shows up here first — as it did when storm_03 landed and the
|
||||
@ -39,6 +44,9 @@ const STORMS = [
|
||||
// SPRINT6: the week's two variants — night 3 (same force, half the warning)
|
||||
// and night 5 (less wind, 2.8× the hail).
|
||||
'storm_03b_earlybuster', 'storm_02b_icenight',
|
||||
// SPRINT16 gate 3.1: the pea-hail soaker — the first night the default
|
||||
// fabric is the wrong answer. Its membrane-vs-cloth pin is below.
|
||||
'storm_06_soaker',
|
||||
];
|
||||
|
||||
/** @param {import('../testkit.js').Suite} t */
|
||||
@ -719,13 +727,17 @@ export default async function run(t) {
|
||||
['rain', (s) => s.rainPeak],
|
||||
['rainMmPerHour', (s) => s.rainPeakMmPerHour],
|
||||
];
|
||||
// gate 3.3: the stone-size band obeys the same law as every other number
|
||||
// on the card — nested, truth-holding, exact at lead 0. It lives at
|
||||
// f.hail.size, so the key row carries its own accessor.
|
||||
keys.push(['hail.size', (s) => s.hailSize, (f) => f.hail.size]);
|
||||
for (const [name, def] of Object.entries(storms)) {
|
||||
for (const [k, truthOf] of keys) {
|
||||
for (const [k, truthOf, getBand] 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 b = getBand ? getBand(f) : 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}`);
|
||||
@ -738,8 +750,9 @@ export default async function run(t) {
|
||||
}
|
||||
// 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}`);
|
||||
const eb = getBand ? getBand(exact) : exact[k];
|
||||
assert(eb.lo === eb.hi,
|
||||
`${name}.${k}: tonight should be exact, got ${eb.lo}–${eb.hi}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
@ -1093,4 +1106,182 @@ export default async function run(t) {
|
||||
+ 'different quantities that merely nearly coincide, and conflating them is the '
|
||||
+ 'mistake THREADS records A and C both nearly making');
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// SPRINT16 GATE 3.3 — the stone and the rain rate join the card
|
||||
t.test('gate 3.3: forecast stones and rain rate — worded, banded, exact tonight, never NaN', () => {
|
||||
const soaker = storms.storm_06_soaker;
|
||||
const tonight = forecastLines(soaker, 0);
|
||||
// tonight is exact: the soaker's truth, in the fabric bet's own words
|
||||
assert(tonight.stones === 'fine pea stones',
|
||||
`the soaker's stones read "${tonight.stones}" — size 0.45 is the fine pea the cloth leaks, `
|
||||
+ 'and the card is where the player learns that before spending a dollar');
|
||||
assert(tonight.rainRate === 'rain to 44 mm/hr',
|
||||
`the soaker's rain rate reads "${tonight.rainRate}" — want the exact 44 mm/hr at lead 0`);
|
||||
assert(/hail likely/.test(tonight.rain),
|
||||
`the soaker (14.9 hail-seconds) should call its hail likely: "${tonight.rain}"`);
|
||||
// the sizes cloth stops dead read as the big words
|
||||
assert(forecastLines(storms.storm_02_wildnight, 0).stones === 'golf ball stones',
|
||||
`wild night stones read "${forecastLines(storms.storm_02_wildnight, 0).stones}"`);
|
||||
assert(forecastLines(storms.storm_03_southerly, 0).stones === 'pea stones',
|
||||
`southerly stones read "${forecastLines(storms.storm_03_southerly, 0).stones}" — 0.7 is `
|
||||
+ "storm_03's own comment's word");
|
||||
// a hail-free storm advertises nothing
|
||||
assert(forecastLines(storms.storm_01_gentle, 0).stones === '',
|
||||
'the gentle storm advertised stones it does not have');
|
||||
// far out, the rate hedges as a band (the stone word may or may not span
|
||||
// two words — the BAND honesty is pinned in the resolving test above)
|
||||
const far = forecastLines(soaker, 1);
|
||||
assert(/–/.test(far.rainRate), `a week out the rain rate should hedge, got "${far.rainRate}"`);
|
||||
assert(/fine pea/.test(far.stones), `the soaker a week out still forecasts pea-family stones, got "${far.stones}"`);
|
||||
// no NaN/undefined reaches the card, any storm, any lead
|
||||
for (const [name, d] of Object.entries(storms)) {
|
||||
for (const lead of [0, 0.5, 1]) {
|
||||
const f = forecastLines(d, lead);
|
||||
for (const k of ['stones', 'rainRate']) {
|
||||
assert(typeof f[k] === 'string' && !/NaN|undefined/.test(f[k]),
|
||||
`${name} @lead ${lead}: ${k} reads "${f[k]}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// SPRINT16 GATE 3.2 — the generator satisfies the storm-side harness
|
||||
t.test('gate 3.2: a generated storm flies the storm_envelope harness, deterministically', () => {
|
||||
const anchors = [
|
||||
{ id: 'a', type: 'post', pos: { x: 0, y: 4, z: 0 } },
|
||||
{ id: 'b', type: 'post', pos: { x: 5, y: 4, z: 3 }, ratingHint: 0.45 },
|
||||
];
|
||||
const r1 = stormEnvelope({ anchors, stormDef: makeStorm(7) });
|
||||
const r2 = stormEnvelope({ anchors, stormDef: makeStorm(7) });
|
||||
for (const r of r1.rows) {
|
||||
assert(Number.isFinite(r.peak) && r.peak > 0, `vacuous envelope row for ${r.id}: peak ${r.peak}`);
|
||||
assert(Number.isFinite(r.dose) && r.dose > 0, `vacuous dose for ${r.id}`);
|
||||
assert(r.tPeak >= 0 && r.tPeak <= 90, `${r.id} peaked outside the storm at t=${r.tPeak}`);
|
||||
}
|
||||
r1.rows.forEach((r, i) => {
|
||||
const s = r2.rows[i];
|
||||
assert(r.peak === s.peak && r.tPeak === s.tPeak && r.dose === s.dose,
|
||||
`same seed, two envelope runs disagree at ${r.id}: ${r.peak} vs ${s.peak}`);
|
||||
});
|
||||
// hint reaches the ranking exactly as it does for authored storms
|
||||
assert(r1.rows[0].id === 'b',
|
||||
`equal-ish wind, hint 0.45 vs 1.0 — 'b' must rank first, got ${r1.rows.map((r) => r.id).join(',')}`);
|
||||
// and the seed is a real variable through the harness, not just the JSON
|
||||
const other = stormEnvelope({ anchors, stormDef: makeStorm(8) });
|
||||
assert(other.rows.some((r, i) => r.peak !== r1.rows[i].peak),
|
||||
'seeds 7 and 8 measured identically through the harness — the seed is not reaching the wind');
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// SPRINT16 GATE 3.1 — THE MEMBRANE NIGHT, PINNED THROUGH THE REAL CHAIN
|
||||
//
|
||||
// storm_06_soaker exists to make the F key matter: pea stones under the
|
||||
// cloth aperture, heavy rain, mild wind. The pin flies gardenfly — THE
|
||||
// audit scoring chain (windForSite → attach → skyfx → garden.js), dressed
|
||||
// site_02 anchors, live sway, funnel ON — with porosity the ONLY variable
|
||||
// between the cloth and membrane flights. Numbers measured 2026-07-20
|
||||
// (fabric_bet probe, post-ruling tree): bare 17.5 · cloth $75 ring 42.3 ·
|
||||
// membrane $75 ring 96.5 · membrane $60 ring 68.8. Thresholds pin the
|
||||
// BOUNDARIES the night stakes, with margin, not the digits.
|
||||
const soakerDef = storms.storm_06_soaker;
|
||||
const soakerSite = site02; // loaded above for gate 2.3
|
||||
let soakerFlights = null;
|
||||
{
|
||||
// garden_probe's proxy pattern: world.anchors fly THEMSELVES, and `use`
|
||||
// re-points the sway closures at the storm actually flying (the frozen-
|
||||
// tree landmine — tr1/tr1b are the pin's own corners, they must sway).
|
||||
let cur = createStubWind({ calm: true });
|
||||
const proxy = {
|
||||
sample: (p, t2, o) => cur.sample(p, t2, o || new THREE.Vector3()),
|
||||
speedAt: (p, t2) => cur.speedAt(p, t2),
|
||||
rainAt: (t2) => (cur.rainAt ? cur.rainAt(t2) : 0),
|
||||
rainMmPerHour: (t2) => (cur.rainMmPerHour ? cur.rainMmPerHour(t2) : 0),
|
||||
gustTelegraph: (t2) => (cur.gustTelegraph ? cur.gustTelegraph(t2) : null),
|
||||
eventsBetween: (a, b) => (cur.eventsBetween ? cur.eventsBetween(a, b) : []),
|
||||
setSheltersFromTrees() {},
|
||||
};
|
||||
const use = (w) => { cur = w; };
|
||||
try {
|
||||
const world = createWorld(new THREE.Scene(), { wind: proxy, site: structuredClone(soakerSite) });
|
||||
await world.dress(); // graybox would move the tree corners — fail loud instead
|
||||
const bed = world.gardenBed;
|
||||
const ring = ['q1', 'q2', 'q3', 'q4'];
|
||||
const hw75 = ['rated shackle', 'shackle', 'shackle', 'shackle'].map(hardwareByName);
|
||||
const hw60 = ['shackle', 'shackle', 'shackle', 'shackle'].map(hardwareByName);
|
||||
const hw20 = Array(4).fill(hardwareByName('carabiner'));
|
||||
const common = { anchors: world.anchors, bed, stormDef: soakerDef, siteDef: soakerSite, use };
|
||||
soakerFlights = {
|
||||
bare: flyGarden({ ...common }),
|
||||
cloth75: flyGarden({ ...common, ids: ring, hw: hw75, porosity: 0.30 }),
|
||||
mem75: flyGarden({ ...common, ids: ring, hw: hw75, porosity: 0 }),
|
||||
mem60: flyGarden({ ...common, ids: ring, hw: hw60, porosity: 0 }),
|
||||
cloth20: flyGarden({ ...common, ids: ring, hw: hw20, porosity: 0.30 }),
|
||||
mem20: flyGarden({ ...common, ids: ring, hw: hw20, porosity: 0 }),
|
||||
};
|
||||
} catch (err) {
|
||||
soakerFlights = { error: err };
|
||||
}
|
||||
}
|
||||
|
||||
t.test('GATE 3.1: the soaker flips the fabric bet — membrane wins the night cloth loses', () => {
|
||||
if (soakerFlights?.error) throw soakerFlights.error;
|
||||
const { bare, cloth75, mem75, mem60 } = soakerFlights;
|
||||
|
||||
// vacuity guards first: the night must actually threaten, through hail
|
||||
assert(bare.byHail > 40,
|
||||
`bare bed took only ${bare.byHail} HP of hail — the soaker has stopped soaking and this pin proves nothing`);
|
||||
assert(bare.hp < 30, `bare bed survived at ${bare.hp} — the night has no teeth`);
|
||||
|
||||
// the primitive the whole night stands on: these stones pass a 2 mm weave
|
||||
const size = soakerDef.hail.size;
|
||||
assert(hailBlockFor(size, 0.30) < 0.30,
|
||||
`cloth blocks ${(hailBlockFor(size, 0.30) * 100).toFixed(0)}% of size-${size} stones — the soaker's `
|
||||
+ 'hail has drifted above the weave aperture and the bet is dead');
|
||||
assert(hailBlockFor(size, 0) === 1, 'membrane stopped blocking — hailBlockFor broke');
|
||||
|
||||
// CLOTH LOSES: the best measured cloth line in the yard (this ring, $75)
|
||||
// ends under the win line — the default fabric is the wrong answer.
|
||||
assert(cloth75.hp < 50,
|
||||
`the $75 ring on CLOTH reads ${cloth75.hp} — over the win line, so the default fabric wins the `
|
||||
+ 'membrane night and storm_06 is a decoration (retune the hail spectrum, per the gate)');
|
||||
// ...but visibly beats bare: the cloth is leaking, not absent
|
||||
assert(cloth75.hp > bare.hp + 10,
|
||||
`cloth ${cloth75.hp} vs bare ${bare.hp} — cloth is doing nothing; the leak should be partial`);
|
||||
|
||||
// MEMBRANE WINS, same line, one variable: the F key is the night
|
||||
assert(mem75.hp > 90 && mem75.state === 'full',
|
||||
`the SAME $75 ring on MEMBRANE reads ${mem75.hp} ${mem75.state} — want >90 FULL (measured 96.5)`);
|
||||
assert(mem75.cornersLost < 2,
|
||||
`membrane $75 lost ${mem75.cornersLost} corners — main.js's win rule (lost<2) fails, the load is not affordable`);
|
||||
assert(mem75.cost <= 80, `the membrane line costs $${mem75.cost} — the $80 wallet cannot buy the night's answer`);
|
||||
|
||||
// and the bet is affordable a tier down: the $60 ring also wins on membrane
|
||||
// (measured 68.8 FULL, one pond corner late — the broom's job in play)
|
||||
assert(mem60.hp > 60 && mem60.cornersLost < 2,
|
||||
`the $60 ring on membrane reads ${mem60.hp} with ${mem60.cornersLost} lost — the bet should not `
|
||||
+ 'need the last dollar of the wallet');
|
||||
|
||||
// THE SEPARATION, pinned: same line, same steel, fabric alone is worth
|
||||
// most of a garden (measured Δ54.2 — threshold carries real margin)
|
||||
assert(mem75.hp - cloth75.hp > 40,
|
||||
`membrane-vs-cloth separation ${(mem75.hp - cloth75.hp).toFixed(1)} HP on the same $75 line — `
|
||||
+ 'under 40, the F key has stopped being the night');
|
||||
});
|
||||
|
||||
t.test('GATE 3.1 guard: membrane is not a free win — on cheap steel it tears', () => {
|
||||
// The inverse control, so the night cannot rot into "always press F":
|
||||
// the doubled load must make membrane WORSE than cloth on carabiners.
|
||||
// (Measured: cloth $20 ring 24.5 hp / 2 lost · membrane $20 ring 19.1 hp
|
||||
// / 3 lost.) Without this, fabric would be a one-way upgrade and the bet
|
||||
// a formality — the same decoration failure from the other side.
|
||||
if (soakerFlights?.error) throw soakerFlights.error;
|
||||
const { cloth20, mem20 } = soakerFlights;
|
||||
assert(cloth20.cornersLost > 0 || mem20.cornersLost > 0,
|
||||
'neither cheap ring lost a corner — carabiners hold the soaker and this control proves nothing');
|
||||
assert(mem20.cornersLost > cloth20.cornersLost || mem20.hp < cloth20.hp,
|
||||
`membrane on carabiners (${mem20.hp} hp, ${mem20.cornersLost} lost) should read WORSE than cloth `
|
||||
+ `(${cloth20.hp} hp, ${cloth20.cornersLost} lost) — the doubled load has stopped costing anything`);
|
||||
});
|
||||
}
|
||||
|
||||
@ -100,6 +100,22 @@ const ASSETS = [
|
||||
nodes: ['frame', 'crossbar', 'swings', 'frame_anchor_01', 'frame_anchor_02'] },
|
||||
{ name: 'swing_set_01_wrecked', h: [0.80, 1.05],
|
||||
nodes: ['frame', 'crossbar', 'swings'] },
|
||||
// SPRINT16 gate 5 — the next temptations. Height floors carry design claims:
|
||||
// the pergola's 2.55 floor sits ABOVE the swing set's 2.15 ceiling (the rung
|
||||
// it out-rates must stand taller than the rung below it), and the pool
|
||||
// fence's band brackets 1.2 m because the compliance height IS the flavour.
|
||||
{ name: 'pergola_01', h: [2.55, 2.85],
|
||||
nodes: ['deck', 'posts', 'beams', 'rafters',
|
||||
'pergola_anchor_01', 'pergola_anchor_02'] },
|
||||
{ name: 'pergola_01_wrecked', h: [2.55, 2.85],
|
||||
nodes: ['deck', 'posts', 'beams', 'beam_down', 'rafters_down'] },
|
||||
{ name: 'glasshouse_01', h: [2.15, 2.40],
|
||||
nodes: ['frame', 'panes', 'door', 'ridge_anchor'] },
|
||||
{ name: 'glasshouse_01_wrecked', h: [2.15, 2.40],
|
||||
nodes: ['frame', 'door', 'panes_left', 'shards'] },
|
||||
{ name: 'pool_kit_01', h: [1.12, 1.32],
|
||||
nodes: ['pool_shell', 'pool_water', 'fence_rails', 'pool_gate',
|
||||
'fence_post_01', 'fence_post_14'] },
|
||||
];
|
||||
|
||||
function sizeOf(gltf) {
|
||||
@ -502,7 +518,9 @@ export default async function run(t) {
|
||||
for (const [intact, broken] of [['garden_gnome_01', 'garden_gnome_01_broken'],
|
||||
['fence_panel', 'fence_panel_snapped'],
|
||||
['swing_set_01', 'swing_set_01_wrecked'],
|
||||
['house_yardside', 'house_yardside_wrecked']]) {
|
||||
['house_yardside', 'house_yardside_wrecked'],
|
||||
['pergola_01', 'pergola_01_wrecked'],
|
||||
['glasshouse_01', 'glasshouse_01_wrecked']]) {
|
||||
for (const n of [intact, broken]) {
|
||||
const box = new THREE.Box3().setFromObject(loaded.get(n).scene);
|
||||
assert(Math.abs(box.min.y) < 0.03,
|
||||
@ -792,6 +810,246 @@ export default async function run(t) {
|
||||
`gum is ${g.y.toFixed(2)} m tall and ${Math.max(g.x, g.z).toFixed(2)} m across — the control is broken`);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// SPRINT16 gate 5 — THE NEXT TEMPTATIONS. Three props, three different ways
|
||||
// of saying no: the pergola says "yes, at 0.65" (the ladder's middle rung),
|
||||
// the glasshouse says "no, and here is the bill if you make me" (the
|
||||
// collateral apex), the pool ring says "no" fourteen times in a perfect
|
||||
// circle. Every claim below is a number read off the shipped GLB.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
// The pergola's rating is pinned against the MANIFEST, not a literal — the
|
||||
// gate-1.2 lesson applied to the asset that gate's mechanism was built FOR:
|
||||
// a site declaring a node-less `pergola` anchor must resolve these exact
|
||||
// extras, so the manifest entry existing (and being unambiguous) IS the
|
||||
// feature. A pin that hard-coded 0.65 would stay green while the manifest
|
||||
// silently dropped the type.
|
||||
t.test('the pergola is the ladder\'s middle rung, and the manifest can resolve it', () => {
|
||||
const types = FACTORY_ANCHOR_RATINGS.types;
|
||||
const p = types.pergola;
|
||||
assert(p, 'the manifest does not rate "pergola" — either the bake is missing or the '
|
||||
+ 'type went AMBIGUOUS (its nodes disagree), and a node-less pergola anchor would '
|
||||
+ 'fall back to the default hint: the sail_post bug shape, on the asset built to bury it');
|
||||
assert(p.collateral === 'pergola', `manifest collateral is ${JSON.stringify(p.collateral)}, want "pergola"`);
|
||||
const swing = types.swing_frame.rating_hint, post = types.post.rating_hint;
|
||||
assert(p.rating_hint > swing && p.rating_hint < post,
|
||||
`pergola rates ${p.rating_hint} — the rung must sit strictly between the swing frame `
|
||||
+ `(${swing}, bolted to nothing) and the post (${post}, bolted to the planet); at either `
|
||||
+ 'end the ladder loses the rung D endorsed and fascia-or-nothing is back');
|
||||
|
||||
const g = loaded.get('pergola_01');
|
||||
assert(g, 'pergola_01 did not load');
|
||||
for (const n of ['pergola_anchor_01', 'pergola_anchor_02']) {
|
||||
const o = g.scene.getObjectByName(n);
|
||||
assert(o, `${n} missing`);
|
||||
assert(o.userData?.anchor_type === 'pergola',
|
||||
`${n} is typed ${JSON.stringify(o.userData?.anchor_type)} — a pergola is not a post; `
|
||||
+ 'the type string is the player\'s pre-rig read and "post" promises concrete');
|
||||
assert(o.userData?.rating_hint === p.rating_hint,
|
||||
`${n} bakes ${o.userData?.rating_hint} but the manifest says ${p.rating_hint}`);
|
||||
assert(o.userData?.collateral === 'pergola', `${n} must name what it takes with it`);
|
||||
}
|
||||
|
||||
// The price ladder, read off the GLBs so site JSON and factory can't tell
|
||||
// two stories: gutter < pergola < swing set < carport. The pergola bills
|
||||
// UNDER the swing set because its failure is partial (a beam and a post;
|
||||
// the bolts hold) where the swing's is total (the whole frame on the
|
||||
// grass) — prices must agree with the wrecks the player actually sees.
|
||||
const price = (a) => loaded.get(a)?.scene.getObjectByName(a)?.userData?.collateral_value;
|
||||
const cost = price('pergola_01');
|
||||
assert(typeof cost === 'number', 'pergola_01 carries no collateral_value');
|
||||
const root = g.scene.getObjectByName('pergola_01');
|
||||
assert(root.userData?.collateral_key === 'pergola',
|
||||
'collateral_key must name the string the anchors carry, or the price prices nothing');
|
||||
assert(cost > price('house_yardside') && cost < price('swing_set_01'),
|
||||
`the pergola (${cost}) must sit between the gutter (${price('house_yardside')}) and the `
|
||||
+ `swing set (${price('swing_set_01')}) — a partial timber repair beats a run of guttering `
|
||||
+ 'and loses to a whole frame replacement');
|
||||
|
||||
const w = loaded.get('pergola_01_wrecked')?.scene.getObjectByName('pergola_01_wrecked');
|
||||
assert(w?.userData?.collateral_value === cost,
|
||||
'the wrecked pergola must be priced the same as the one it used to be');
|
||||
assert(w?.userData?.broken_variant_of === 'pergola_01',
|
||||
'the wreck must name its intact twin so A can pair the swap');
|
||||
});
|
||||
|
||||
// The 0.65 story told by the wreck: a bolted structure fails PARTIALLY.
|
||||
// The front beam and the rafters come down; the deck, the bolts and the
|
||||
// back half stand. Measured, because "partial" is exactly the kind of word
|
||||
// that erodes into "rubble" one art pass at a time.
|
||||
t.test('the pergola fails partially: rafters hinge down, the deck stands, no anchor survives', () => {
|
||||
const w = loaded.get('pergola_01_wrecked');
|
||||
assert(w, 'pergola_01_wrecked did not load');
|
||||
for (const n of ['pergola_anchor_01', 'pergola_anchor_02']) {
|
||||
assert(!w.scene.getObjectByName(n),
|
||||
`${n} survives the wreck — you cannot re-bolt a snapped post mid-storm, and an anchor `
|
||||
+ 'that outlives its structure is the free-failure bug in a costume');
|
||||
}
|
||||
// The lean-to read: rafters still reach the surviving back beam AND rest
|
||||
// on the deck — high at one end, low at the other, in the same node.
|
||||
const down = new THREE.Box3().setFromObject(w.scene.getObjectByName('rafters_down'));
|
||||
assert(down.max.y > 2.3,
|
||||
`rafters_down tops out at y=${down.max.y.toFixed(2)} — they must still hang from the back beam`);
|
||||
assert(down.min.y < 0.5,
|
||||
`rafters_down bottoms out at y=${down.min.y.toFixed(2)} — the front ends should rest on the deck`);
|
||||
const beamDown = new THREE.Box3().setFromObject(w.scene.getObjectByName('beam_down'));
|
||||
assert(beamDown.max.y < 0.55,
|
||||
`beam_down tops out at y=${beamDown.max.y.toFixed(2)} — the torn beam lies on the deck`);
|
||||
// Intact control: the rafters were UP, or "down" above means nothing.
|
||||
const up = new THREE.Box3().setFromObject(loaded.get('pergola_01').scene.getObjectByName('rafters'));
|
||||
assert(up.min.y > 2.3,
|
||||
`intact rafters bottom out at y=${up.min.y.toFixed(2)} — the control is broken`);
|
||||
|
||||
// The wreck does not stand taller, and the DECK did not move: it is the
|
||||
// footing the rating is about, and the shared helper builds it once —
|
||||
// byte-drift between the twins' decks means someone forked the helper.
|
||||
const hi = new THREE.Box3().setFromObject(loaded.get('pergola_01').scene);
|
||||
const hw = new THREE.Box3().setFromObject(w.scene);
|
||||
assert(hw.max.y <= hi.max.y + 0.02,
|
||||
`the wreck stands ${hw.max.y.toFixed(2)} m vs the intact ${hi.max.y.toFixed(2)} — taller is a bug`);
|
||||
const deckA = new THREE.Box3().setFromObject(loaded.get('pergola_01').scene.getObjectByName('deck'));
|
||||
const deckB = new THREE.Box3().setFromObject(w.scene.getObjectByName('deck'));
|
||||
assert(deckA.min.distanceTo(deckB.min) < 0.01 && deckA.max.distanceTo(deckB.max) < 0.01,
|
||||
'the two decks differ — intact and wrecked no longer share the helper, and they will drift');
|
||||
});
|
||||
|
||||
// The glasshouse's one rule, pinned from every direction: NOTHING about it
|
||||
// may adopt as an anchor. Not the ridge (denied in words), not any node by
|
||||
// silence (a rating_hint anywhere on either variant is a red), not via the
|
||||
// enum (no node enrols an anchor_type). The ridge bar is the most
|
||||
// tie-off-shaped object in the game and that is exactly why the denial is
|
||||
// load-bearing rather than decorative.
|
||||
t.test('the glasshouse offers NOTHING: no rated node, no typed node, and the ridge denies', () => {
|
||||
for (const name of ['glasshouse_01', 'glasshouse_01_wrecked']) {
|
||||
const g = loaded.get(name);
|
||||
assert(g, `${name} did not load`);
|
||||
const rated = [], typed = [];
|
||||
g.scene.traverse((o) => {
|
||||
if (typeof o.userData?.rating_hint === 'number') rated.push(o.name);
|
||||
if (o.userData?.anchor_type !== undefined) typed.push(o.name);
|
||||
});
|
||||
assert(rated.length === 0,
|
||||
`${name} carries rating_hint on [${rated.join(', ')}] — a glasshouse node with a rating `
|
||||
+ 'is adoptable, and 30 kg of glass is not a thing you add load to');
|
||||
assert(typed.length === 0,
|
||||
`${name} enrols [${typed.join(', ')}] in the anchor enum — nothing here is an anchor of any type`);
|
||||
}
|
||||
const ridge = loaded.get('glasshouse_01').scene.getObjectByName('ridge_anchor');
|
||||
assert(ridge, 'ridge_anchor missing — the temptation must exist to be denied');
|
||||
assert(ridge.userData?.tie_off === false,
|
||||
'the ridge must carry tie_off:false — the most anchor-looking member says no ON the data');
|
||||
assert(typeof ridge.userData?.why === 'string' && ridge.userData.why.length > 0,
|
||||
'the denial must carry its reasoning — a bare flag is a rule, a why is a lesson');
|
||||
});
|
||||
|
||||
// The apex is a price claim, so it is pinned as one: strictly the worst
|
||||
// bill in the palette, keyed, and identical on the wreck. The billing
|
||||
// EVENT is arc 2's (nothing in the sim wrecks a glasshouse yet — the value
|
||||
// is inert data until then, which is why pricing it is not the bike bug);
|
||||
// this pin is the chain being ready the day that yard is authored.
|
||||
t.test('the glasshouse is the collateral apex: priced above everything, keyed, twin-priced', () => {
|
||||
const g = loaded.get('glasshouse_01')?.scene.getObjectByName('glasshouse_01');
|
||||
assert(g, 'glasshouse_01 root missing');
|
||||
const cost = g.userData?.collateral_value;
|
||||
assert(typeof cost === 'number', 'glasshouse carries no collateral_value');
|
||||
assert(g.userData?.collateral_key === 'glasshouse',
|
||||
'collateral_key must be explicit — the carport was one editor click from being FREE '
|
||||
+ 'because its price resolved off a coincidence of naming');
|
||||
const carport = loaded.get('carport_01')?.scene.getObjectByName('carport_01')?.userData?.collateral_value;
|
||||
assert(cost > carport,
|
||||
`the glasshouse (${cost}) must top the carport (${carport}) by construction — fourteen panes `
|
||||
+ 'of glass against one sheet of Colorbond, or "apex" is just a word');
|
||||
const w = loaded.get('glasshouse_01_wrecked')?.scene.getObjectByName('glasshouse_01_wrecked');
|
||||
assert(w?.userData?.collateral_value === cost,
|
||||
'the wrecked glasshouse must be priced the same as the one it used to be');
|
||||
assert(w?.userData?.broken_variant_of === 'glasshouse_01',
|
||||
'the wreck must name its intact twin so A can pair the swap');
|
||||
});
|
||||
|
||||
// What glass DOES, measured: the intact panes are translucent (the fragile
|
||||
// read is a material fact, not a vibe — turn the glass opaque and the tell
|
||||
// is deleted), and the wreck EMPTIES rather than falls (frame at full
|
||||
// height, shard field flat on the ground and thrown wider than the
|
||||
// building ever stood).
|
||||
t.test('the glasshouse reads fragile, and its wreck empties instead of falling', () => {
|
||||
const panes = loaded.get('glasshouse_01')?.scene.getObjectByName('panes');
|
||||
assert(panes, 'panes node missing');
|
||||
let translucent = false;
|
||||
panes.traverse((o) => {
|
||||
const m = o.material;
|
||||
if (m && m.transparent === true && m.opacity < 0.6) translucent = true;
|
||||
});
|
||||
assert(translucent,
|
||||
'no pane material is transparent with opacity < 0.6 — the glass has gone opaque, and '
|
||||
+ 'the only see-through walls in the palette no longer read as glass');
|
||||
|
||||
const hi = new THREE.Box3().setFromObject(loaded.get('glasshouse_01').scene);
|
||||
const wreck = loaded.get('glasshouse_01_wrecked');
|
||||
const hw = new THREE.Box3().setFromObject(wreck.scene);
|
||||
assert(Math.abs(hi.max.y - hw.max.y) < 0.05,
|
||||
`wreck ridge at y=${hw.max.y.toFixed(2)} vs intact ${hi.max.y.toFixed(2)} — the aluminium frame `
|
||||
+ 'survives; a felled glasshouse is lying about what glass does');
|
||||
const shards = new THREE.Box3().setFromObject(wreck.scene.getObjectByName('shards'));
|
||||
assert(shards.max.y < 0.05,
|
||||
`shards top out at y=${shards.max.y.toFixed(2)} — glass on the ground lies flat`);
|
||||
const spreadX = (hw.max.x - hw.min.x) - (hi.max.x - hi.min.x);
|
||||
const spreadZ = (hw.max.z - hw.min.z) - (hi.max.z - hi.min.z);
|
||||
assert(spreadX > 0.6 && spreadZ > 0.6,
|
||||
`the shard field adds only ${spreadX.toFixed(2)}/${spreadZ.toFixed(2)} m in plan — it must `
|
||||
+ 'throw glass clear of the footprint or the wreck reads as a dirty window, not a catastrophe');
|
||||
assert(wreck.scene.getObjectByName('panes_left'),
|
||||
'panes_left missing — a wreck with zero surviving panes reads as demolition, not storm');
|
||||
});
|
||||
|
||||
// The pool ring: fourteen posts, fourteen explicit nos. RULE 1's regex only
|
||||
// guards *_anchor names, and these posts don't match it — which is exactly
|
||||
// how they'd slip through as silent 1.0s the day a site names one (the
|
||||
// `?? 1` default is generous, and silence is not neutral). So the denial is
|
||||
// pinned per-post, by name, with the count exact: a fifteenth post added
|
||||
// without its denial goes red here, not in a shipped yard.
|
||||
t.test('the pool ring is fourteen honest nos — every fence post denies, nothing rates', () => {
|
||||
const g = loaded.get('pool_kit_01');
|
||||
assert(g, 'pool_kit_01 did not load');
|
||||
const posts = [];
|
||||
g.scene.traverse((o) => { if (/^fence_post_\d+$/.test(o.name)) posts.push(o); });
|
||||
assert(posts.length === 14,
|
||||
`found ${posts.length} fence posts, want exactly 14 — a post this count doesn't know `
|
||||
+ 'is a post whose denial nobody checked');
|
||||
for (const p of posts) {
|
||||
assert(p.userData?.tie_off === false,
|
||||
`${p.name} does not deny being a tie-off — silence here adopts at rating 1.0, the best `
|
||||
+ 'steel in the game conjured out of a missing field');
|
||||
assert(p.userData?.rating_hint === undefined,
|
||||
`${p.name} carries a rating_hint, which makes it adoptable — the ring's whole design is `
|
||||
+ 'that its steel is legally useless');
|
||||
assert(typeof p.userData?.why === 'string' && p.userData.why.length > 0,
|
||||
`${p.name} denies without reasoning`);
|
||||
}
|
||||
// Nothing anywhere on the kit rates or enrols — same sweep as the glasshouse.
|
||||
const rated = [], typed = [];
|
||||
g.scene.traverse((o) => {
|
||||
if (typeof o.userData?.rating_hint === 'number') rated.push(o.name);
|
||||
if (o.userData?.anchor_type !== undefined) typed.push(o.name);
|
||||
});
|
||||
assert(rated.length === 0 && typed.length === 0,
|
||||
`pool_kit_01 offers [${[...rated, ...typed].join(', ')}] — the kit must offer nothing`);
|
||||
|
||||
// Compliance is the flavour, so its numbers are pinned as facts: 1.2 m
|
||||
// posts, a self-closing gate, and no price until something can break it.
|
||||
const post1 = new THREE.Box3().setFromObject(g.scene.getObjectByName('fence_post_01'));
|
||||
assert(post1.max.y >= 1.15 && post1.max.y <= 1.32,
|
||||
`fence stands ${post1.max.y.toFixed(2)} m — AS 1926.1 says 1.2, and the height IS the flavour`);
|
||||
const gate = g.scene.getObjectByName('pool_gate');
|
||||
assert(gate?.userData?.self_closing === true, 'the gate must be self-closing — compliance is data');
|
||||
const root = g.scene.getObjectByName('pool_kit_01');
|
||||
assert(root?.userData?.priced === false && typeof root?.userData?.unpriced_why === 'string',
|
||||
'the kit must say OUT LOUD that it is unpriced and why (the bike ruling) — an unpriced '
|
||||
+ 'prop with no reason is indistinguishable from a forgotten one');
|
||||
assert(root?.userData?.collateral_value === undefined,
|
||||
'pool_kit_01 carries a collateral_value — pricing an event the sim cannot produce is the '
|
||||
+ 'lie the invoice exists to kill; when debris can take a panel out, price it THEN');
|
||||
});
|
||||
|
||||
// One GLB carries three wilt states as siblings; Lane A toggles .visible
|
||||
// rather than reloading, so all three have to be present at once.
|
||||
t.test('garden_bed carries all 3 damage states in one GLB', () => {
|
||||
|
||||
@ -14,6 +14,7 @@ import {
|
||||
createWindField, validateStorm, validateSiteWind, gustEnvelope, GUST, RAIN_TIME_COMPRESSION,
|
||||
stormStats, forecastFor, hailBlockFor,
|
||||
} from '../weather.core.js';
|
||||
import { makeStorm, checkEnvelope, ENVELOPES } from '../stormgen.js';
|
||||
|
||||
const DT = 1 / 60;
|
||||
|
||||
@ -44,7 +45,14 @@ export function weatherCases(storms) {
|
||||
const test = (name, fn) => cases.push({ name, fn });
|
||||
const assert = (cond, msg) => { if (!cond) throw new Error(msg); };
|
||||
|
||||
const defs = Object.entries(storms);
|
||||
// SPRINT16 gate 3.2: two generated storms ride the SAME per-storm honesty
|
||||
// gates as the authored ones below (telegraph lead, continuity, validator).
|
||||
// A family whose members can be dishonest is not an envelope, it's a slot
|
||||
// machine — fixed seeds so the suite stays deterministic run to run.
|
||||
const defs = [
|
||||
...Object.entries(storms),
|
||||
...[11, 47].map((s) => [`gen_soaker_${s}`, makeStorm(s)]),
|
||||
];
|
||||
|
||||
// ---- 1. gust telegraph lead (PLAN3D §5-C.5: always >= 1.2 s before ramp) ----
|
||||
// The whole gust read is "you SEE it coming, then it hits". If the lead ever
|
||||
@ -707,6 +715,7 @@ export function weatherCases(storms) {
|
||||
inside(f.gustPeak, s.gustPeak, 'gustPeak');
|
||||
inside(f.rain, s.rainPeak, 'rain');
|
||||
inside(f.hail.seconds, s.hailSeconds, 'hailSeconds');
|
||||
inside(f.hail.size, s.hailSize, 'hailSize'); // gate 3.3: the stone is a band too
|
||||
if (s.changeAt != null) inside(f.changeAt, s.changeAt, 'changeAt');
|
||||
}
|
||||
}
|
||||
@ -814,6 +823,84 @@ export function weatherCases(storms) {
|
||||
assert(peaPorous > peaMembrane + 0.3, `porous should leak real pea hail: ${peaPorous.toFixed(2)} vs ${peaMembrane.toFixed(2)}`);
|
||||
});
|
||||
|
||||
// ---- 14. makeStorm(seed) — SPRINT16 gate 3.2, arc 4's groundwork ----
|
||||
// A seeded generator inside authored envelopes. No shipped night uses it;
|
||||
// these cases are what "inside" and "deterministic" MEAN.
|
||||
|
||||
test('makeStorm: same seed → byte-equal def; different seed → different storm', () => {
|
||||
const a = JSON.stringify(makeStorm(1234));
|
||||
const b = JSON.stringify(makeStorm(1234));
|
||||
assert(a === b, 'two calls with the same seed produced different JSON — a hidden draw or a Date leaked in');
|
||||
const c = JSON.stringify(makeStorm(1235));
|
||||
assert(a !== c, 'seeds 1234 and 1235 produced the same storm — the seed is not reaching the draws');
|
||||
// the def carries its seed, so the gust timeline downstream is seeded by
|
||||
// the same number the caller noted down
|
||||
assert(makeStorm(1234).seed === 1234, 'generated def does not carry its seed');
|
||||
});
|
||||
|
||||
test('makeStorm: 100 seeds — every def validates AND sits inside its envelope', () => {
|
||||
let gustLo = Infinity, gustHi = 0;
|
||||
for (let s = 1; s <= 100; s++) {
|
||||
const d = makeStorm(s);
|
||||
const v = validateStorm(d, `gen_${s}`);
|
||||
assert(v.ok, `seed ${s} failed the storm validator:\n ${v.errors.join('\n ')}`);
|
||||
const e = checkEnvelope(d);
|
||||
assert(e.ok, `seed ${s} escaped its envelope:\n ${e.errors.join('\n ')}`);
|
||||
const st = stormStats(d);
|
||||
gustLo = Math.min(gustLo, st.gustPeak); gustHi = Math.max(gustHi, st.gustPeak);
|
||||
assert(st.hailSeconds > 2, `seed ${s} barely hails (${st.hailSeconds.toFixed(1)} s) — the family's thesis is pea hail all night`);
|
||||
assert(d.hail.size < 0.6, `seed ${s} threw size-${d.hail.size} stones — over the family's pea range, cloth would block them`);
|
||||
}
|
||||
metrics['stormgen.gustPeak.lo'] = +gustLo.toFixed(1);
|
||||
metrics['stormgen.gustPeak.hi'] = +gustHi.toFixed(1);
|
||||
// the family is genuinely a FAMILY: seeds move the storm, inside a band
|
||||
assert(gustHi - gustLo > 1, `100 seeds moved gustPeak only ${(gustHi - gustLo).toFixed(2)} m/s — the ranges are decoration`);
|
||||
assert(gustHi < 20, `a soaker gusted to ${gustHi.toFixed(1)} m/s — the family has stopped being mild`);
|
||||
});
|
||||
|
||||
test('checkEnvelope: a def outside the envelope goes red, not shrugged', () => {
|
||||
// vacuity guard first: the def we are about to mutate passes as generated
|
||||
const clean = makeStorm(7);
|
||||
assert(checkEnvelope(clean).ok, 'the unmutated control failed — every red below would be noise');
|
||||
|
||||
// each mutation is one escape route, and each must be NAMED in the errors
|
||||
const mutations = [
|
||||
['base speed over the family ceiling', (d) => { d.baseCurve[3][1] = 12; }, /baseCurve\[3\]/],
|
||||
['pea family throwing ice', (d) => { d.hail.size = 1.3; }, /hail\.size/],
|
||||
['gusts overlapping (minGap under the gust length)', (d) => { d.gusts.minGap = 4; }, /minGap/],
|
||||
['the change re-timed outside its window', (d) => { d.events[0].t = 20; d.dirCurve = [[0, 0.6], [16, 0.7], [28, -0.7], [90, -0.65]]; }, /change at t=20/],
|
||||
];
|
||||
for (const [label, mutate, want] of mutations) {
|
||||
const d = makeStorm(7);
|
||||
mutate(d);
|
||||
const e = checkEnvelope(d);
|
||||
assert(!e.ok, `checkEnvelope shrugged at: ${label}`);
|
||||
assert(e.errors.some((m) => want.test(m)),
|
||||
`${label}: red, but no error names the escape — got:\n ${e.errors.join('\n ')}`);
|
||||
}
|
||||
|
||||
// and the one that separates checkEnvelope from validateStorm: a def that
|
||||
// is a perfectly LOADABLE storm but is not a member of this family
|
||||
const stranger = makeStorm(7);
|
||||
stranger.rain.peakMmPerHour = 75; // a severe soaker — valid, off-family
|
||||
assert(validateStorm(stranger, 'stranger').ok, 'the stranger should still validate as a storm');
|
||||
assert(!checkEnvelope(stranger).ok, 'a valid-but-off-family storm passed the envelope — checkEnvelope is just the validator in a hat');
|
||||
});
|
||||
|
||||
test('makeStorm: the change event and the physics agree by construction', () => {
|
||||
// the validator's windchange/dirCurve drift trap, proven closed for every
|
||||
// seed we ship gates on — the event's promised swing is real in dirCurve
|
||||
for (const s of [3, 21, 88]) {
|
||||
const d = makeStorm(s);
|
||||
const ev = d.events.find((e) => e.type === 'windchange');
|
||||
const f = createWindField(d);
|
||||
const before = f.dirAt(ev.t - 6);
|
||||
const after = f.dirAt(ev.t + (ev.over ?? 6) + 6);
|
||||
const swing = Math.abs(after - before);
|
||||
assert(swing > 0.6, `seed ${s}: the change at t=${ev.t} only swung ${swing.toFixed(2)} rad`);
|
||||
}
|
||||
});
|
||||
|
||||
return { cases, metrics };
|
||||
}
|
||||
|
||||
|
||||
@ -638,7 +638,10 @@ export function stormStats(def) {
|
||||
const stats = {
|
||||
sustained, gustPeak, rainPeak,
|
||||
rainPeakMmPerHour: rainPeak * (def.rain?.peakMmPerHour ?? DEFAULT_PEAK_MM_PER_HOUR),
|
||||
hailPeak, hailSeconds,
|
||||
// Stone size (SPRINT16 gate 3.3): the fabric bet argues on the forecast,
|
||||
// and the stone is the argument — a pea leaks through cloth, ice doesn't.
|
||||
// 0 when the storm never hails, matching hailSeconds' convention.
|
||||
hailPeak, hailSeconds, hailSize: f.hailSize,
|
||||
changeAt: change ? change.t : null,
|
||||
};
|
||||
_statsCache.set(def, stats);
|
||||
@ -648,6 +651,9 @@ export function stormStats(def) {
|
||||
/** How wide each number's band runs at lead=1, as a fraction of the value. */
|
||||
const FORECAST_SPREAD = {
|
||||
sustained: 0.30, gustPeak: 0.35, rain: 0.45, changeAt: 0.22, hailSeconds: 0.7,
|
||||
// Stone size hedges less than duration: radar sees how big the cell is long
|
||||
// before it knows how long it will sit on you.
|
||||
hailSize: 0.30,
|
||||
};
|
||||
|
||||
/**
|
||||
@ -692,7 +698,14 @@ export function forecastFor(def, lead = 0) {
|
||||
rain: band(s.rainPeak, FORECAST_SPREAD.rain),
|
||||
rainMmPerHour: band(s.rainPeakMmPerHour, FORECAST_SPREAD.rain),
|
||||
changeAt: s.changeAt == null ? null : band(s.changeAt, FORECAST_SPREAD.changeAt),
|
||||
hail: { chance, seconds: band(s.hailSeconds, FORECAST_SPREAD.hailSeconds) },
|
||||
hail: {
|
||||
chance,
|
||||
seconds: band(s.hailSeconds, FORECAST_SPREAD.hailSeconds),
|
||||
// SPRINT16 gate 3.3 — the stone joins the card. Band, not a promise:
|
||||
// contains the truth at every lead, exact at lead 0 (band() guarantees
|
||||
// both). {lo:0, hi:0} for a storm that never hails.
|
||||
size: band(s.hailSize, FORECAST_SPREAD.hailSize),
|
||||
},
|
||||
truth: s,
|
||||
};
|
||||
}
|
||||
|
||||
@ -84,11 +84,32 @@ export function forecastLines(def, lead = 0) {
|
||||
: '· no change forecast';
|
||||
const hail = f.hail.chance === 'none' ? '' : `· hail ${f.hail.chance}`;
|
||||
|
||||
// SPRINT16 gate 3.3 — the stone and the rain RATE join the card (A prints
|
||||
// them; this owns the wording, same split as every other line here). The
|
||||
// stone is the fabric bet's whole argument: pea hail rattles through a
|
||||
// 2 mm weave, anything bigger cannot — so the size word is what tells a
|
||||
// player which fabric tonight wants, before a dollar is spent. Words map
|
||||
// hail.size units (1.0 ≈ a 1.5 cm stone): storm_03's 0.7 reads "pea" (its
|
||||
// own comment's word), the soaker's 0.45 "fine pea", the wild night's 1.3
|
||||
// and the ice night's 1.4 read "golf balls" — the sizes cloth stops dead.
|
||||
const stoneWord = (v) => (v < 0.6 ? 'fine pea' : v <= 0.9 ? 'pea' : v <= 1.2 ? 'marble' : 'golf ball');
|
||||
const sz = f.hail.size;
|
||||
const stones = f.hail.chance === 'none' ? '' : (
|
||||
stoneWord(sz.lo) === stoneWord(sz.hi)
|
||||
? `${stoneWord(sz.hi)} stones`
|
||||
: `${stoneWord(sz.lo)}–${stoneWord(sz.hi)} stones`
|
||||
);
|
||||
|
||||
return {
|
||||
name: (def.name ?? '').replace(/_/g, ' ').toUpperCase(),
|
||||
night: (def.sky?.night ?? (def.sky?.darkness ?? 0) > 0.6),
|
||||
wind: `sustained to ${sustained} m/s (${sustainedKmh} km/h) · gusts to ~${gusts} km/h`,
|
||||
rain: `rain ${rainWord} ${change} ${hail}`.trim(),
|
||||
// The gate-3.3 lines, separate fields so A's card can place them without
|
||||
// re-deriving anything. Empty string = nothing to print, same rule as
|
||||
// `confidence` below; both bands contain the truth at every lead.
|
||||
rainRate: `rain to ${band(f.rainMmPerHour, i)} mm/hr`,
|
||||
stones,
|
||||
// Only worth showing when it isn't tonight — "CONFIDENCE 100%" is noise.
|
||||
confidence: lead > 0 ? `forecast confidence ${Math.round(f.confidence * 100)}%` : '',
|
||||
hail: f.hail,
|
||||
|
||||
@ -171,6 +171,11 @@ export const PAY = {
|
||||
/**
|
||||
* Nothing of theirs broken (SPRINT11 gate 2 — the third line on the invoice).
|
||||
*
|
||||
* SPRINT16 note: `noCollateralBonus` is no longer the only unmeasured number
|
||||
* here — REP below joined it, with the same rule and the same cut-first
|
||||
* status. This one keeps seniority: it's pure new money every night, where
|
||||
* REP's swing is a percentage of a fee you had to earn.
|
||||
*
|
||||
* 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)
|
||||
@ -199,6 +204,54 @@ export const PAY = {
|
||||
noCollateralBonus: 20,
|
||||
};
|
||||
|
||||
/**
|
||||
* SPRINT16 gate 1 — REPUTATION. One number on the letterhead, and it moves pay.
|
||||
*
|
||||
* The unit is STARS, 0..5 in half-star steps, neutral start at 3.0 — ruled in
|
||||
* THREADS [A] 2026-07-20: stars because the spec asks for client-speak, and
|
||||
* half-steps because every number the letterhead prints stays exact (no float
|
||||
* drift on a business document).
|
||||
*
|
||||
* Movement is settled at DAWN OF THE NIGHT THAT EARNED IT, not the morning the
|
||||
* warranty line books — the rep and the warranty item come from the same dawn
|
||||
* inspection of the same rig record, they just surface one morning apart:
|
||||
* · clean night (zero breaks, zero collateral): +CLEAN
|
||||
* · each corner of yours still broken at dawn: −WARRANTY (it books tomorrow)
|
||||
* · each collateral event: −COLLATERAL (down harder)
|
||||
* A broke-but-repaired-by-dawn night is NEUTRAL by design: not clean (it
|
||||
* broke), not a warranty (you fixed it mid-storm on your own ladder — that IS
|
||||
* the job). And rep NEVER reads the garden — hp/win/gardenBeyondSaving are not
|
||||
* inputs, so night 5's designed loss cannot cost rep by construction rather
|
||||
* than by exemption. a.test pins that from both directions.
|
||||
*
|
||||
* Money: `multiplier(rep)` scales the callout fee at QUOTE TIME — quote() and
|
||||
* settle() both read rep-as-of-morning, and rep only updates after settle has
|
||||
* computed the fee, so the sheet and the invoice agree by ordering, not by
|
||||
* discipline. 0.04/star is chosen over the rounder-sounding 0.05 because every
|
||||
* half-step lands exactly on two decimals (×0.88 .. ×1.08) — the sheet prints
|
||||
* the multiplier, and a printed number that isn't the number used is the
|
||||
* quote-vs-settle lie wearing smaller type.
|
||||
*
|
||||
* ⚠️ UNMEASURED, same status as noCollateralBonus above: the swing is bounded
|
||||
* (±12/8% of fee ≈ $6..9 a night) so it cannot mint a runaway week by itself,
|
||||
* but nobody has played six nights under it yet. If gate 6's play says the
|
||||
* economy tilts, CLEAN and the multiplier slope are levers, cut second after
|
||||
* the clean bonus.
|
||||
*/
|
||||
export const REP = {
|
||||
START: 3.0,
|
||||
MIN: 0,
|
||||
MAX: 5,
|
||||
/** Clean night: no breaks (repaired or not), nothing of theirs broken. */
|
||||
CLEAN: 0.5,
|
||||
/** Per corner of yours still broken at dawn — the warranty ledger's rep half. */
|
||||
WARRANTY: -0.5,
|
||||
/** Per collateral event. Down harder: their property beats your hardware. */
|
||||
COLLATERAL: -1.0,
|
||||
/** Callout-fee multiplier at rep r. Exact at 2dp for every half-step. */
|
||||
multiplier: (r) => 1 + 0.04 * (r - 3),
|
||||
};
|
||||
|
||||
/**
|
||||
* Below this you cannot rig four corners at all — four carabiners is the floor.
|
||||
*
|
||||
@ -257,10 +310,19 @@ export function gustPeakOf(def) {
|
||||
* @property {number} bonus garden bonus
|
||||
* @property {number} refund intact hardware recovered, at half
|
||||
* @property {number} collateral what your failure broke of theirs
|
||||
* @property {number} pay fee + bonus + refund − collateral
|
||||
* @property {number} pay fee + bonus + refund − collateral − warrantyTotal
|
||||
* @property {number} bankBefore
|
||||
* @property {number} bankAfter
|
||||
* @property {'continue'|'win'|'gameover'} outcome
|
||||
* @property {object[]} rig SPRINT16 — the work history: one entry per corner
|
||||
* of the committed rig at dawn {anchorId, hw, hwCost, effRating, broke,
|
||||
* repaired, brokenAtDawn}. main.js's scoreRun assembles it; the week keeps it.
|
||||
* @property {object[]} warranty SPRINT16 — the items deducted TONIGHT (booked by
|
||||
* last dawn's brokenAtDawn corners) {anchorId, hw, cost, night, site, addr, client}
|
||||
* @property {number} warrantyTotal
|
||||
* @property {number} feeMult rep multiplier the fee was priced at
|
||||
* @property {number} rep after tonight's movement (repBefore/repDelta/repReasons too)
|
||||
* @property {number} warrantiesToDate cumulative items this week, for the end card
|
||||
*/
|
||||
|
||||
/**
|
||||
@ -272,9 +334,32 @@ export function createWeek(opts = {}) {
|
||||
let index = 0; // 0-based into NIGHTS
|
||||
let bank = startBank;
|
||||
let done = false;
|
||||
let rep = REP.START; // SPRINT16 — the number on the letterhead
|
||||
/** @type {Settlement[]} */
|
||||
const log = [];
|
||||
|
||||
/**
|
||||
* Tonight's warranty items — corners of YOURS the last dawn found still
|
||||
* broken. Derived from the previous settlement's rig record, never stored a
|
||||
* second time: quote() and settle() both call THIS, so the sheet and the
|
||||
* invoice read the same items by construction (the week's own invariant —
|
||||
* they never diverge — extended to the new line the same way the gardenMax
|
||||
* comment in settle() demands).
|
||||
*
|
||||
* `cost` is the broken hardware's shop price. The labour is free ON PURPOSE
|
||||
* — "no charge to the client" is the whole sentence, and unpaid labour is
|
||||
* what a warranty IS. A mid-storm repair books nothing (repaired corners
|
||||
* are not in the filter); riding it out broken is the item.
|
||||
*/
|
||||
function warrantyItems() {
|
||||
const prev = log[log.length - 1];
|
||||
if (!prev) return [];
|
||||
return (prev.rig ?? []).filter((c) => c.brokenAtDawn).map((c) => ({
|
||||
anchorId: c.anchorId, hw: c.hw, cost: c.hwCost,
|
||||
night: prev.night, site: prev.site, addr: prev.addr, client: prev.client,
|
||||
}));
|
||||
}
|
||||
|
||||
const week = {
|
||||
/** 1-based, for humans. "Night 3 of 5". */
|
||||
get night() { return index + 1; },
|
||||
@ -299,10 +384,22 @@ export function createWeek(opts = {}) {
|
||||
*/
|
||||
quote(def) {
|
||||
const j = nightAt(index);
|
||||
// SPRINT16: the fee is priced on your standing, and the deduction for
|
||||
// last dawn's broken corners is on the quote — AT QUOTE TIME, so settle
|
||||
// pays exactly what the sheet said. Both read the same sources settle
|
||||
// does (rep before tonight moves it; warrantyItems() off the log), which
|
||||
// is the invariant doing the work, not a matched pair of formulas.
|
||||
const feeMult = REP.multiplier(rep);
|
||||
const warranty = warrantyItems();
|
||||
const warrantyTotal = warranty.reduce((s, i) => s + i.cost, 0);
|
||||
return {
|
||||
base: PAY.feeFor(gustPeakOf(def)),
|
||||
base: Math.round(PAY.feeFor(gustPeakOf(def)) * feeMult),
|
||||
garden: j.pay.garden ?? PAY.gardenBonusMax,
|
||||
clean: j.pay.clean ?? PAY.noCollateralBonus,
|
||||
feeMult,
|
||||
rep,
|
||||
warranty,
|
||||
warrantyTotal,
|
||||
/**
|
||||
* SPRINT14 (B's pool item, offered S13 and accepted): the job sheet has
|
||||
* to say when the garden bonus is money the night cannot pay.
|
||||
@ -320,16 +417,22 @@ export function createWeek(opts = {}) {
|
||||
* still not telling it.
|
||||
*/
|
||||
beyondSaving: j.gardenBeyondSaving ?? false,
|
||||
get total() { return this.base + this.garden + this.clean; },
|
||||
// The quote's conclusion carries the warranty deduction — the sheet
|
||||
// promises the deducted number, and settle pays it. A quote that hid
|
||||
// the deduction until the invoice would be the divergence in reverse.
|
||||
get total() { return this.base + this.garden + this.clean - this.warrantyTotal; },
|
||||
};
|
||||
},
|
||||
get bank() { return bank; },
|
||||
/** SPRINT16 — the letterhead's number. Stars, 0..5, half-steps. */
|
||||
get rep() { return rep; },
|
||||
get isFinalNight() { return index === NIGHTS.length - 1; },
|
||||
get over() { return done; },
|
||||
get log() { return log; },
|
||||
|
||||
reset() {
|
||||
index = 0; bank = startBank; done = false; log.length = 0;
|
||||
rep = REP.START;
|
||||
return week;
|
||||
},
|
||||
|
||||
@ -343,7 +446,12 @@ export function createWeek(opts = {}) {
|
||||
*/
|
||||
settle(run, def, spent) {
|
||||
const bankBefore = bank;
|
||||
const fullFee = PAY.feeFor(gustPeakOf(def));
|
||||
// SPRINT16: rep prices the fee, and it MUST be tonight's morning rep —
|
||||
// the one the quote printed. rep is updated at the bottom of this
|
||||
// function, after the money is computed, so quote and settle agree by
|
||||
// ordering. Round after the multiplier, exactly as quote() does.
|
||||
const feeMult = REP.multiplier(rep);
|
||||
const fullFee = Math.round(PAY.feeFor(gustPeakOf(def)) * feeMult);
|
||||
const fee = run.win ? fullFee : Math.round(fullFee * PAY.lostNightShare);
|
||||
// Per-job override, PAY's default otherwise — and settle MUST read the same
|
||||
// source the quote does, or the job sheet promises a number the invoice
|
||||
@ -363,7 +471,13 @@ export function createWeek(opts = {}) {
|
||||
const cleanMax = job.pay.clean ?? PAY.noCollateralBonus;
|
||||
const clean = collateral === 0 ? cleanMax : 0;
|
||||
|
||||
const pay = fee + bonus + refund + clean - collateral;
|
||||
// SPRINT16 — warranty. The SAME derivation the quote used (same function,
|
||||
// same log state — the log only grows at the bottom of this call), so the
|
||||
// deduction the sheet promised is the deduction the invoice takes.
|
||||
const warranty = warrantyItems();
|
||||
const warrantyTotal = warranty.reduce((s, i) => s + i.cost, 0);
|
||||
|
||||
const pay = fee + bonus + refund + clean - collateral - warrantyTotal;
|
||||
|
||||
bank = Math.max(0, bankBefore - spent + pay);
|
||||
|
||||
@ -385,6 +499,41 @@ export function createWeek(opts = {}) {
|
||||
else outcome = 'continue';
|
||||
done = outcome !== 'continue';
|
||||
|
||||
// SPRINT16 — reputation moves at dawn of the night that earned it (the
|
||||
// THREADS ruling): the same dawn inspection that will book tomorrow's
|
||||
// warranty lines moves the number tonight. NOTE what is absent: hp, win,
|
||||
// gardenBeyondSaving. Rep never reads the garden, so a designed loss
|
||||
// cannot cost it BY CONSTRUCTION — the flag needs no exemption here, and
|
||||
// a.test pins that from both directions.
|
||||
const rigRec = run.rigRecord ?? [];
|
||||
const brokeCount = rigRec.filter((c) => c.broke).length;
|
||||
const dawnBroken = rigRec.filter((c) => c.brokenAtDawn).length;
|
||||
const collateralEvents = (run.collateral ?? []).length;
|
||||
const repBefore = rep;
|
||||
let repDelta = 0;
|
||||
/** @type {string[]} */
|
||||
const repReasons = [];
|
||||
if (brokeCount === 0 && collateralEvents === 0) {
|
||||
repDelta += REP.CLEAN;
|
||||
repReasons.push('clean night');
|
||||
} else if (dawnBroken === 0 && collateralEvents === 0) {
|
||||
// Broke, but every corner was standing again by dawn: NEUTRAL, and the
|
||||
// invoice's rep line says why rather than printing an unexplained flat
|
||||
// number. Fixing your own steel mid-storm is the job — it just isn't a
|
||||
// clean night, and it isn't a warranty either.
|
||||
repReasons.push('broke, but repaired by dawn');
|
||||
}
|
||||
if (dawnBroken > 0) {
|
||||
repDelta += REP.WARRANTY * dawnBroken;
|
||||
repReasons.push(`${dawnBroken} corner${dawnBroken > 1 ? 's' : ''} down at dawn`);
|
||||
}
|
||||
if (collateralEvents > 0) {
|
||||
repDelta += REP.COLLATERAL * collateralEvents;
|
||||
repReasons.push(`broke ${collateralEvents > 1 ? 'things' : 'something'} of theirs`);
|
||||
}
|
||||
rep = Math.min(REP.MAX, Math.max(REP.MIN, rep + repDelta));
|
||||
repDelta = rep - repBefore; // what actually moved, after the clamp
|
||||
|
||||
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,
|
||||
@ -393,7 +542,18 @@ export function createWeek(opts = {}) {
|
||||
// settlement so the HUD never has to reach back into week state.
|
||||
gardenBeyondSaving: job.gardenBeyondSaving ?? false,
|
||||
gardenMax,
|
||||
client: job.client, addr: job.addr, site: job.site };
|
||||
client: job.client, addr: job.addr, site: job.site,
|
||||
// SPRINT16 — the ledger. The rig record IS the work history (the night
|
||||
// computed all of it; the week just keeps it), the warranty items are
|
||||
// tonight's sheet answered on the invoice, and the rep block is the
|
||||
// letterhead's number with its receipts. warrantiesToDate feeds the
|
||||
// end card's rich-with-warranties reading without the HUD walking the
|
||||
// log itself.
|
||||
rig: rigRec,
|
||||
warranty, warrantyTotal, feeMult,
|
||||
rep, repBefore, repDelta, repReasons,
|
||||
warrantiesToDate: log.reduce((n, x) => n + (x.warranty?.length ?? 0), 0)
|
||||
+ warranty.length };
|
||||
log.push(s);
|
||||
return s;
|
||||
},
|
||||
|
||||
BIN
web/world/models/glasshouse_01_v1.glb
Normal file
BIN
web/world/models/glasshouse_01_v1.glb
Normal file
Binary file not shown.
BIN
web/world/models/glasshouse_01_wrecked_v1.glb
Normal file
BIN
web/world/models/glasshouse_01_wrecked_v1.glb
Normal file
Binary file not shown.
BIN
web/world/models/pergola_01_v1.glb
Normal file
BIN
web/world/models/pergola_01_v1.glb
Normal file
Binary file not shown.
BIN
web/world/models/pergola_01_wrecked_v1.glb
Normal file
BIN
web/world/models/pergola_01_wrecked_v1.glb
Normal file
Binary file not shown.
BIN
web/world/models/pool_kit_01_v1.glb
Normal file
BIN
web/world/models/pool_kit_01_v1.glb
Normal file
Binary file not shown.
Loading…
Reference in New Issue
Block a user