Compare commits
18 Commits
f84e00f46b
...
f27539d716
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f27539d716 | ||
|
|
f53a0d5ee9 | ||
|
|
d8e7087c2c | ||
|
|
1073ac97e8 | ||
|
|
6508d53c89 | ||
|
|
a7cb661b62 | ||
|
|
186e75839d | ||
|
|
a7e71df125 | ||
|
|
d03712ae47 | ||
|
|
6cbae92028 | ||
|
|
6461f2267a | ||
|
|
b74383722b | ||
|
|
453f6cb9d2 | ||
|
|
1571c2f1f9 | ||
|
|
d6af6ba5a2 | ||
|
|
71278da837 | ||
|
|
92a8e870f2 | ||
|
|
bfe87632e0 |
641
THREADS.md
641
THREADS.md
@ -7383,6 +7383,470 @@ anchors are your GLB), but the tooling is now waiting, not TODO.
|
||||
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 —
|
||||
@ -7557,3 +8021,180 @@ anchors are your GLB), but the tooling is now waiting, not TODO.
|
||||
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.
|
||||
[D] 2026-07-20 — 🗓 **GATE 4.1 + 4.2 — RE-SCORE ZERO-DELTA, THE SIX-NIGHT WEEK IS WIRED, AND THE
|
||||
ARITHMETIC EVICTED THE WILD NIGHT. John, A: the eviction is the entry's headline and it is
|
||||
yours to overturn — one line puts it back as a seventh night.** Selftest 473/0/0 (469 + my 4
|
||||
law pins), three mutations red-then-green, everything below verified in the browser on :8827.
|
||||
|
||||
**4.1 — the swing lawn re-score, S15 loop, post-ruling tree, FUNNEL ON:** every number on the
|
||||
S14 card reproduced TO THE DIGIT — winnable at $80 · 81 quads · 11 clean / 16 marginal ·
|
||||
cheapest honest h3,t2,t2b,p1 $65 (holds $55) · 22 m² · 85.3 FULL · bare 83.7 → best clean
|
||||
h3,t1c,t2,p1 90.0, sep +6.3 · best-reading t1c,t2,t2b,s1_f2 still MARGINAL at 91.8 (the trap
|
||||
line, still flagged as such). B predicted zero movement from their gate-2.3 probe and the
|
||||
prediction held — nothing to fix, nothing retuned. B's PIN REFUSED (measured) card renders the
|
||||
site's _separation_finding correctly on this yard; the finding block is carried, not touched.
|
||||
|
||||
**4.2 — the ladder as wired (week.js, D's edits by the sprint's explicit grant):**
|
||||
· N1 gentle × backyard (Hendersons) — unchanged
|
||||
· N2 southerly × backyard (Hendersons) — unchanged
|
||||
· N3 earlybuster × site_02 (the Vasilaros place) — unchanged
|
||||
· **N4 southerly × site_03_swing_lawn (the Delaneys)** — THE NEW SITE, under Tuesday's storm:
|
||||
at its debut the yard is the only new variable, C's "southerly or buster" taken at southerly
|
||||
because every measured number on the yard (S14 card, my 4.1 re-score, B's probe) is a
|
||||
southerly number.
|
||||
· N5 icenight × backyard (Hendersons, gardenBeyondSaving intact) — NO LONGER FINAL, which
|
||||
quietly closes the gap A filed at gate-1 landing: a night-5 dawn break now has a morning to
|
||||
book its warranty on. Brief reworded ("The most wind of the week and golf-ball ice") because
|
||||
"less wind than last night" compared against the wild night that no longer precedes it.
|
||||
· **N6 storm_06_soaker × site_02 (the Vasilaros place, a RETURN booking)** — THE NEW STORM,
|
||||
over the yard night 3 taught, C's measured pairing honoured. Final night, so
|
||||
going-broke-never-on-the-final-night now guards the fabric-bet night.
|
||||
**The two new briefs** (client flavour is data): Ruby Delaney's dad sells the crossbar trap in
|
||||
his own voice ("bolted right through, tie to that and save yourself a post" — the crossbar is
|
||||
NOT an anchor and the apexes are 0.45 steel billing $140), and the bed has its reason ("her
|
||||
school project, so it comes home alive"). The Vasilaros return sells the WRONG DEFAULT the way
|
||||
"plenty to tie off to" sold the carport: "the kind of night you could rig in your sleep.
|
||||
Whatever you flew last time will do, they said." — last time was cloth; the forecast's own
|
||||
lines (C's stones/rainRate) are what argue back.
|
||||
|
||||
**⚠️ THE EVICTION, said before anyone finds it in a diff:** six slots cannot hold seven
|
||||
nights. Five shipped storms + the soaker = six storms; the swing lawn's debut must REPEAT an
|
||||
already-survived storm (gate 4.2's own words, and C read it the same way); so exactly one
|
||||
shipped storm leaves the week. Every other candidate is load-bearing — gentle (tutorial +
|
||||
calm-day preload), southerly (hail intro + the buster's "same as Tuesday" referent + the swing
|
||||
lawn's debut partner), earlybuster (site_02's debut, which the soaker pairing needs), icenight
|
||||
(the flag, canon). **The WILD NIGHT is out of the rotation.** Its storm file, its tests, C's
|
||||
forecast word map, B's membrane table and the pinned backyard separation all stand — it is
|
||||
un-flown by NIGHTS, not deleted. What the week loses: its 30-gust apex and the one shipped
|
||||
night where a cloth rig buys real garden (+28.1 HP, B's table). What it gains: the soaker as
|
||||
the new teeth night (bare 17.5 DEAD → membrane 96.5 FULL — a Δ79 the wild night never
|
||||
offered). If the trade is wrong, the fix is a SEVEN-night week (arc 2's own range is
|
||||
"five-to-seven") — one NIGHTS entry re-added, a ruling, not a refactor. **Gate 6 material.**
|
||||
· The week asserts moved exactly as the sprint predicted (a.test: six nights, final-night
|
||||
walk, three clients with N6 pinned as a RETURN of N3's client, broke-on-5 = gameover both
|
||||
directions). Four new SIX-NIGHT LAW pins live in d.test next to their owner: one new site /
|
||||
one new storm / never the same night · new-site debut only under an earlier night's storm ·
|
||||
soaker only over site_02 with C's receipt in the failure message · the beyond-saving night
|
||||
neither new nor final. Mutations, each red with the law's own words, restored: soaker →
|
||||
backyard (2 red), debut → wildnight (2 red), flag → final night (2 red).
|
||||
· **A, your files, my edits, flagged:** hud.js now reads NIGHTS.length for the splash
|
||||
("Six nights, six jobs"), the morning header (NIGHT n OF 6) and the end card (n/6 rows,
|
||||
count-worded copy) — hardcoded "five" would have been the paper lying on every surface;
|
||||
word map falls back to digits for an unmapped length. a.test's week suite updated as above.
|
||||
Revert-and-tell-me standard applies.
|
||||
· **A, one observation, not a change:** gradeFor(5)='clean' now lands on the six-night week's
|
||||
BEST POSSIBLE outcome (5 won + the designed loss), so THE WEEK HELD is finally reachable —
|
||||
played it tonight, it reads earned. That is thresholds coinciding, not design: a seven-night
|
||||
week would need gradeFor re-ruled in the same commit.
|
||||
|
||||
[D] 2026-07-20 — 🎮 **GATE 4.3 — THE COLD-PLAY: SIX NIGHTS, ONE WALLET, REAL UI, AND A VERDICT
|
||||
FOR EVERY GATE. Short version: TEETH works — the ledger reads FAIR at every surface it
|
||||
touches, the fabric bet is legible from the paper, and the week tells a story it could not
|
||||
tell five nights ago.** Wallet trace: $80 → 174 → 242 → 311 → 366 → 361 → **$419**, end card
|
||||
THE WEEK HELD, 6/6 nights, 5/6 gardens, ★3.0, 2 warranty jobs booked. Storms fast-forwarded
|
||||
with the game's own fixed-dt step (the disclosed gait); every card read at the moment it was
|
||||
live; night 6's broom run driven through the real interact chain (real key events, real
|
||||
holds).
|
||||
|
||||
**GATE 1 (A) — the ledger: FAIR, and twice it was fair about ME.**
|
||||
· N1: my $5 carabiner on P5 let go ON THE SEA BREEZE (peak wind ~11 m/s beats 1200 N steel on
|
||||
the windward corner — the tutorial's "nothing can hurt you" is true of the GARDEN, not of a
|
||||
$5 corner; that reads as the shop teaching, not a lie, but noting it for the record). I rode
|
||||
it out broken: ★3.0 → ★2.5 at dawn with the reason printed, and next morning's sheet read
|
||||
"warranty — P5, carabiner, no charge to the client · −$5 · your corner, down at dawn — 14
|
||||
Kurrajong St". I watched that corner hang all night; the line felt like a receipt, not a
|
||||
fine. **Warranty callout on your sheet: FAIR.**
|
||||
· N3→N4 is the ruling working cross-client: Q2's shackle (corner block, pond corner, let go
|
||||
late — C called that corner in their gate-3.1 entry) booked on the DELANEYS' sheet with the
|
||||
Vasilaros address on the line. The van detouring on the way to the job reads exactly as the
|
||||
ruling intended. quote==settle held every morning it was tested, deduction on both papers.
|
||||
· **Rep moving pay: EARNED.** ×0.98 on the fee row the mornings after my two dawn breaks,
|
||||
×1.02 on night 6 after the icenight's clean +0.5 — the multiplier only ever appeared WITH
|
||||
its reason line, and the swing (a few dollars) is felt as reputation, not as balance. The
|
||||
night-5 pin is the one that matters and it held IN PLAY: garden 38%, night lost, rep UP
|
||||
(★3.0 → ★3.5, "clean night") with the verdict crediting the steel — "Yours held anyway.
|
||||
That was the whole job tonight, and you did it." A designed loss that pays respect instead
|
||||
of docking it. **Gate 1: PASS, no retune asked.**
|
||||
**GATE 2 (B) — the heal, the paper trail, the pin refusals.**
|
||||
· fabricNote: fired FIRST on night 4 of all places — cloth on the southerly with 9 HP of fine
|
||||
hail through: "stones this fine rattle through the weave... The membrane stops every stone."
|
||||
Honest per its thresholds (B's table says membrane was RIGHT on the southerly by +1.7), and
|
||||
pedagogically it is the whole reason night 6 was legible — see gate 3. N6's membrane note
|
||||
("held 391 kg of what it stopped. That is the membrane's price") lands the trade. Silent on
|
||||
the nights it would have been a guess. **The line fires when the bet mattered: PASS.**
|
||||
· The heal: six honest nights produced ZERO divergences — that datum first. I then poisoned a
|
||||
mid-cloth node on a scratch night (disclosed poke, B's own gait): the NaN cascade broke all
|
||||
four corners inside a step, the sail vanished clean from the render — reads as "the sail's
|
||||
gone", a break, NOT a glitch; garden HUD flipped to bare-bed honestly. **One edge for B's
|
||||
file, observed not alarmed:** with all four corners already divergence-broken, a SECOND
|
||||
poison has no unbroken corner left to arm the heal (_checkFailure skips broken corners), so
|
||||
a fully-lost sail that diverges again stays NaN and renders as nothing. Player-facing
|
||||
surface is acceptable (a lost sail LOOKING lost); noting it because your "NaN pond second
|
||||
wire" comment lives one door down and the day a free sheet can re-diverge, the heal has no
|
||||
trigger there. Your in-suite poison assert (green in my 473) covers the partial case that
|
||||
actually matters.
|
||||
· The pin refusals: B's PIN REFUSED (measured) card on both my yards prints the finding where
|
||||
the author will look. Correct surface, nothing to add.
|
||||
**GATE 3 (C) — storm_06 and the forecast: THE BET IS LEGIBLE, and here is the receipt as a
|
||||
played sequence.** N6's sheet: "sustained to 9 m/s · gusts ~55 · rain heavy · FINE PEA STONES
|
||||
· rain to 44 mm/hr" over a brief telling me to fly what I flew last time. Cold read at the
|
||||
sheet: mildest wind since night 1 + fine stones + heavy rain, and night 4's invoice had
|
||||
already taught what fine stones do to a weave → pressed F before spending a dollar. Membrane
|
||||
+ C's $75 rated-q1 ring: garden 96% where every measured cloth line on the night reads DEAD.
|
||||
**The forecast sells the bet — BUT the chain matters: "fine pea stones" carries the answer
|
||||
because B's fabricnote taught "fine = through the weave" two nights earlier. Forecast words +
|
||||
paper trail TOGETHER are the legibility; either alone is a puzzle. In the shipped order the
|
||||
chain works. PASS.**
|
||||
· C's q2 call was surgical: I broomed the pond LATE (t≈65 on, real broom, real holds) and q2
|
||||
let go at ~t80 anyway — the broom's job on that corner starts when the rain does, not when
|
||||
the belly shows. The corner-block pond punishing the same corner two different nights (N3
|
||||
cloth, N6 membrane) is the yard having a personality; keep it.
|
||||
· Final-night dawn breaks: q2 down at dawn on N6 cost the rep (★3.5 → ★3.0, printed) and
|
||||
booked NOTHING — no morning left. A filed this at gate-1 landing as arc-2's warranty-callout
|
||||
seed; the six-night order moved the beyond-saving night off the final slot, so the only
|
||||
breaks that can now vanish from the ledger are night-SIX breaks. Same filing, smaller hole,
|
||||
arc 2 closes it.
|
||||
**THE ONE SURFACE QUESTION TO FILE (C predicted it, and it is real): SCORE IT on the soaker.**
|
||||
Flew it over site_02 post-play: the card says **"✓ WINNABLE — at $80"** and then shows a
|
||||
garden table where EVERY line is DEAD (cheapest honest 19.1, best clean 32, bare 17.5) — and
|
||||
NOTHING on the card says the sweep is cloth-fixed. The numbers are honest and C's charter is
|
||||
sound; the SILENCE is the problem: on the one night where fabric is the whole answer, a
|
||||
reader without the charter in their head concludes "winnable rig, unwinnable garden — the
|
||||
night is broken." **Ask for A/B (one line, not a re-architecture): the score header should
|
||||
name its fabric the way it names its funnel state — "fabric · shade cloth (sweep charter)" —
|
||||
and on a storm whose stones pass the weave ruling, one footnote line: "membrane not swept —
|
||||
the F key is not in this card." Who owns the line is yours to settle (B's card, A's page).**
|
||||
Filed as a surface question, not a bug, exactly per C's coda.
|
||||
|
||||
Pushed lane/d: d8e7087 (the wiring), this entry and its sibling above. Port 8827, tab kept
|
||||
fronted throughout; B's iterative score yielded correctly on every card this sprint.
|
||||
|
||||
[I] 2026-07-20 — **SPRINT 16 (TEETH) INTEGRATION (main).** All five lanes merged; selftest **473/0/0**
|
||||
on the final tree, the exact per-lane sum at every stage (469 builders + D's 4 law pins),
|
||||
verified in the browser. Deployed to partly.party/hardyards. The week is SIX nights, the
|
||||
letterhead reads "standing ★ 3.0", the splash says "Six nights, six jobs, one wallet", and
|
||||
your work follows you: warranty items book on the next morning's sheet (cross-client, with the
|
||||
source address), reputation moves pay with its reasons printed, and the F key is finally a
|
||||
night — the soaker, where every cloth line loses and the same $75 ring on membrane reads FULL.
|
||||
|
||||
**Integrator applications this round, all pre-authorized:** hud.js keep-both (A's repline + B's
|
||||
fabricnote, siblings by B's own design); E's three palette hunks verbatim (pergola, glasshouse,
|
||||
pool ring — _v1 suffixes present); no other cross-lane edits needed — the seam discipline held
|
||||
so well that five lanes touching week/hud/sail/rigging/editor in one sprint produced exactly
|
||||
ONE code conflict, and it was the keep-both its author predicted.
|
||||
|
||||
**The sprint's honest moments, for the record:** B REFUSED gate 2.3's pin with receipts (bare
|
||||
beds win the earlybuster and southerly nights by 30+; a writable pin would be a fudge — shipped
|
||||
`_separation_finding` blocks and a pinned-XOR-refused assert instead, balance fight filed for
|
||||
A). C's ponding-is-fabric-blind finding was ruled a known simplification with the comments
|
||||
corrected rather than silently fixed mid-sprint. D's poison-poke heal edge (a fully-broken sail
|
||||
can't re-arm the heal) and the soaker score-header disclosure (the card says WINNABLE over an
|
||||
all-DEAD cloth garden without naming its cloth charter) are filed for B and A/B respectively.
|
||||
|
||||
⚖️ **GATE 6 CARRIES A RULING ONLY JOHN CAN MAKE: the arithmetic evicted the WILD NIGHT.** Six
|
||||
slots cannot hold five old storms + the swing lawn's survived-storm debut + the soaker under
|
||||
the one-variable law, and every other candidate night is load-bearing (D's filing has the full
|
||||
accounting). The wildnight — the marquee storm, the pyrrhic-win canon, A's separation pin's
|
||||
home — is currently NOT IN THE WEEK. D's one-line alternative: a seven-night week. John: play
|
||||
the six at partly.party/hardyards, then rule — six without the wildnight, or seven with it.
|
||||
Everything else about arc 1 (teeth) is DONE.
|
||||
|
||||
@ -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>
|
||||
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"',
|
||||
|
||||
@ -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,18 @@
|
||||
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 { NIGHTS, REP } from './week.js';
|
||||
|
||||
// SPRINT16 gate 4 (Lane D, flagged for A): the week is data — NIGHTS.length —
|
||||
// and these surfaces used to hardcode "five". A six-night week whose splash,
|
||||
// morning header and end card still said 5 would be the paper lying, so every
|
||||
// count below reads the ladder. The word map keeps the splash's prose voice;
|
||||
// an unmapped length falls back to the digit, which is honest if inelegant.
|
||||
const NIGHT_COUNT = NIGHTS.length;
|
||||
const NIGHT_WORD = { 5: 'Five', 6: 'Six', 7: 'Seven' }[NIGHT_COUNT] ?? String(NIGHT_COUNT);
|
||||
|
||||
const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v);
|
||||
const kmh = (ms) => ms * 3.6;
|
||||
@ -47,6 +59,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.
|
||||
@ -174,6 +194,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
|
||||
@ -512,7 +541,7 @@ export function createHud(d) {
|
||||
</div>
|
||||
</div>
|
||||
<p class="premise">Rig shade sails, then keep the client's garden alive
|
||||
through the storm. Five nights, five jobs, one wallet.</p>
|
||||
through the storm. ${NIGHT_WORD} nights, ${NIGHT_WORD.toLowerCase()} jobs, one wallet.</p>
|
||||
<div class="sect">THE CONTROLS</div>
|
||||
<div class="keys">
|
||||
${[
|
||||
@ -758,6 +787,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">
|
||||
@ -779,11 +809,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
|
||||
@ -798,7 +840,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>` : '';
|
||||
|
||||
@ -836,6 +878,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}
|
||||
@ -880,9 +929,10 @@ showForecast({ key, def, site, tomorrowDef }, wk, onGo) {
|
||||
// scraped one, and a third for the ending nobody knew existed until the
|
||||
// economy was measured: solvent, with the gardens dead behind you.
|
||||
const COPY = {
|
||||
clean: ['THE WEEK HELD', "Five nights. Everything's still where you put it.",
|
||||
clean: ['THE WEEK HELD', `${NIGHT_WORD} nights. Everything's still where you put it.`,
|
||||
"Nobody thanks you for the storm that did nothing. That's the job."],
|
||||
scraped: ['FIVE NIGHTS, FIVE MORNINGS', 'You made the least wrong call five times running.',
|
||||
scraped: [`${NIGHT_WORD.toUpperCase()} NIGHTS, ${NIGHT_WORD.toUpperCase()} MORNINGS`,
|
||||
`You made the least wrong call ${NIGHT_WORD.toLowerCase()} times running.`,
|
||||
'Some of it held. You know which bits didn\'t.'],
|
||||
solvent: ['STILL TRADING', 'The books balanced. The garden didn\'t.',
|
||||
'You can be paid all week and still have nothing to point at.'],
|
||||
@ -892,15 +942,32 @@ 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">
|
||||
<h1>${head}</h1>
|
||||
<h2>${sub}</h2>
|
||||
<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>nights survived</span><b>${nights}/${NIGHT_COUNT}</b></div>
|
||||
<div class="row"><span>gardens held</span><b>${s.held ?? 0}/${NIGHT_COUNT}</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>`;
|
||||
@ -957,9 +1024,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
|
||||
@ -972,7 +1046,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>
|
||||
@ -996,6 +1070,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">
|
||||
@ -1005,15 +1080,23 @@ showForecast({ key, def, site, tomorrowDef }, wk, onGo) {
|
||||
|
||||
card.innerHTML = `<div class="card jobcard">
|
||||
${letterhead}
|
||||
<h1>MORNING${w ? ` · NIGHT ${w.night} OF 5` : ''}</h1>
|
||||
<h1>MORNING${w ? ` · NIGHT ${w.night} OF ${w.nights ?? NIGHT_COUNT}` : ''}</h1>
|
||||
<h2>${r.subtitle}</h2>
|
||||
${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. */''}
|
||||
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>` : ''}
|
||||
|
||||
@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -883,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),
|
||||
};
|
||||
}
|
||||
|
||||
@ -955,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'); },
|
||||
);
|
||||
@ -975,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
|
||||
@ -995,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);
|
||||
|
||||
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';
|
||||
@ -215,17 +215,25 @@ export default async function run(t) {
|
||||
|
||||
// --- the week (SPRINT8 gate 1) -------------------------------------------
|
||||
|
||||
t.test('the week is five escalating nights, each a storm and a site', () => {
|
||||
assertEq(NIGHTS.length, 5);
|
||||
t.test('the week is six escalating nights, each a storm and a site', () => {
|
||||
// SPRINT16 gate 4 (D's wiring): five → six. The swing lawn debuts night 4
|
||||
// under Tuesday's southerly; the soaker closes the week over the corner
|
||||
// block. The deeper pins — one-variable law, survived-storm debut, the
|
||||
// measured soaker pairing — live in d.test.js next to the wiring's owner.
|
||||
assertEq(NIGHTS.length, 6);
|
||||
// SPRINT10: a night is {storm, site} now. nightAt() normalises either shape.
|
||||
assertEq(nightAt(0).storm, 'storm_01_gentle', 'night one must be the one that cannot hurt you');
|
||||
assertEq(nightAt(4).storm, 'storm_02b_icenight', 'night five is the ice night');
|
||||
assertEq(nightAt(3).storm, 'storm_03_southerly', 'night four repeats the southerly — the yard is the only new thing');
|
||||
assertEq(nightAt(3).site, 'site_03_swing_lawn', 'night four is the swing lawn');
|
||||
assertEq(nightAt(4).storm, 'storm_02b_icenight', 'night five is still the ice night');
|
||||
assertEq(nightAt(5).storm, 'storm_06_soaker', 'night six is the fabric bet');
|
||||
assertEq(nightAt(5).site, 'site_02_corner_block', "and it flies over the corner block — C's measured pairing");
|
||||
assertEq(nightAt(2).site, 'site_02_corner_block', 'night three moves to the corner block');
|
||||
assertEq(nightAt(0).site, 'backyard_01', 'the rest are the backyard');
|
||||
assertEq(nightAt(0).site, 'backyard_01', 'the retainer nights are the backyard');
|
||||
// SPRINT13 gate 1.4: the unsavable-garden flag is night 5's alone, and a
|
||||
// night without it is presumed savable — new nights can't inherit the excuse.
|
||||
assertEq(nightAt(4).gardenBeyondSaving, true, 'night 5 is beyond saving BY DESIGN — canon, ruled');
|
||||
for (let i = 0; i < 4; i++) assertEq(nightAt(i).gardenBeyondSaving, false, `night ${i + 1} is savable`);
|
||||
for (const i of [0, 1, 2, 3, 5]) assertEq(nightAt(i).gardenBeyondSaving, false, `night ${i + 1} is savable`);
|
||||
const w = createWeek();
|
||||
assertEq(w.night, 1); assertEq(w.bank, 80);
|
||||
assertEq(w.site, 'backyard_01', 'week.site tracks the ladder');
|
||||
@ -234,9 +242,12 @@ export default async function run(t) {
|
||||
assertEq(w.site, 'site_02_corner_block', 'and reaches the corner block on night three');
|
||||
for (let i = 0; i < 2; i++) w.advance();
|
||||
assertEq(w.night, 5);
|
||||
assert(w.isFinalNight, 'night five is the last');
|
||||
assert(!w.isFinalNight, 'the ice night is no longer the last — its dawn breaks have a morning to book on now');
|
||||
w.advance();
|
||||
assertEq(w.night, 5, 'the ladder does not walk off its own end');
|
||||
assertEq(w.night, 6);
|
||||
assert(w.isFinalNight, 'night six is the last');
|
||||
w.advance();
|
||||
assertEq(w.night, 6, 'the ladder does not walk off its own end');
|
||||
});
|
||||
|
||||
t.test('money persists across nights, and the bank is next night\'s shop', () => {
|
||||
@ -253,7 +264,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');
|
||||
@ -289,14 +303,15 @@ export default async function run(t) {
|
||||
assert(j.brief && j.brief.length > 20, `night ${i + 1} has a brief worth reading`);
|
||||
assert(j.site, `night ${i + 1} has a yard`);
|
||||
}
|
||||
// The week's shape, which is DATA and not decoration: four nights on
|
||||
// retainer at one yard while they're away, and ONE short-notice callout to
|
||||
// a stranger's corner block. Night 3 landing differently is the whole point
|
||||
// of the ladder — a different site MEANS a different client.
|
||||
// The week's shape, which is DATA and not decoration: the Hendersons'
|
||||
// retainer, one short-notice callout to a stranger's corner block, the
|
||||
// Delaneys' swing lawn — and the corner block calling BACK, which is
|
||||
// SPRINT16's whole thesis (your work follows you) written into the ladder.
|
||||
const clients = NIGHTS.map((_, i) => nightAt(i).client);
|
||||
assertEq(new Set(clients).size, 2, 'two clients: the retainer and the one-off');
|
||||
assertEq(new Set(clients).size, 3, 'three clients: the retainer, the one-off, and the swing lawn');
|
||||
assert(clients[2] !== clients[0], 'night 3 is somebody else');
|
||||
assertEq(nightAt(2).site, 'site_02_corner_block', "and it's their corner block");
|
||||
assertEq(clients[5], clients[2], 'night six is the corner block client again — a return booking, not a stranger');
|
||||
});
|
||||
|
||||
t.test('an unbriefed night is a job with no letterhead, not a crash', () => {
|
||||
@ -383,10 +398,17 @@ export default async function run(t) {
|
||||
assertEq(s.outcome, 'gameover', 'you cannot rig four corners, so the week is over');
|
||||
|
||||
// On the last night there is no next shop to be unable to afford, so being
|
||||
// broke is just being broke — the week still finished.
|
||||
// broke is just being broke — the week still finished. SPRINT16: the last
|
||||
// night is SIX now, and night five going broke is a real game over again —
|
||||
// both directions pinned so the re-ordering can't quietly move the rule.
|
||||
const w2 = createWeek();
|
||||
for (let i = 0; i < 4; i++) w2.advance();
|
||||
assertEq(w2.settle(ruin, def, 80).outcome, 'win', 'night five always ends the week, not the run');
|
||||
for (let i = 0; i < NIGHTS.length - 1; i++) w2.advance();
|
||||
assert(w2.isFinalNight, 'precondition: the walk reached the final night');
|
||||
assertEq(w2.settle(ruin, def, 80).outcome, 'win', 'the final night always ends the week, not the run');
|
||||
const w3 = createWeek();
|
||||
for (let i = 0; i < 4; i++) w3.advance();
|
||||
assertEq(w3.settle(ruin, def, 80).outcome, 'gameover',
|
||||
'night five is not the final night any more — going broke there ends the run');
|
||||
});
|
||||
|
||||
// Gate 2 acceptance: both sites load from data, and the corner block is not
|
||||
@ -534,6 +556,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 < NIGHTS.length; 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, 'six clean nights ride the clamp to the top of the scale');
|
||||
assertEq(w.log[NIGHTS.length - 1].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 +806,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`);
|
||||
});
|
||||
}
|
||||
|
||||
@ -20,6 +20,9 @@ import { needsLadder as realNeedsLadder, createLadder } from '../ladder.js';
|
||||
import { assert, assertEq, assertClose, assertLess, fixedLoop } from '../testkit.js';
|
||||
import { FIXED_DT } from '../contracts.js';
|
||||
import { loadStorm, createWind } from '../weather.js';
|
||||
// SPRINT16 gate 4: the six-night law pins at the bottom of this file read the
|
||||
// ladder. week.js is contracts-only underneath, so this stays headless-safe.
|
||||
import { NIGHTS, nightAt } from '../week.js';
|
||||
|
||||
const DT = FIXED_DT;
|
||||
|
||||
@ -1135,4 +1138,61 @@ export default async function run(t) {
|
||||
assertEq(repaired, 0, 're-rig fires with a spare');
|
||||
assertEq(p.carrying, null, 'and consumes it');
|
||||
});
|
||||
|
||||
// ================================================================ SPRINT16 gate 4
|
||||
// THE SIX-NIGHT LAW, pinned next to its owner. a.test pins the ladder's
|
||||
// shape (which storm, which site, which slot); these pin the RULES the
|
||||
// ordering must obey, so a future re-order that keeps six nights but breaks
|
||||
// the law goes red with the law's own words. Every threshold here is
|
||||
// ROADMAP principle 5 or a measured THREADS receipt, not taste.
|
||||
|
||||
t.test('SPRINT16 six-night law: exactly one new site, exactly one new storm, never the same night', () => {
|
||||
const nights = NIGHTS.map((_, i) => nightAt(i));
|
||||
const PRE_S16_SITES = ['backyard_01', 'site_02_corner_block'];
|
||||
const PRE_S16_STORMS = ['storm_01_gentle', 'storm_02_wildnight', 'storm_02b_icenight',
|
||||
'storm_03_southerly', 'storm_03b_earlybuster'];
|
||||
const newSiteNights = nights.filter((n) => !PRE_S16_SITES.includes(n.site));
|
||||
const newStormNights = nights.filter((n) => !PRE_S16_STORMS.includes(n.storm));
|
||||
assertEq(newSiteNights.length, 1, 'exactly ONE night is the new site');
|
||||
assertEq(newSiteNights[0].site, 'site_03_swing_lawn');
|
||||
assertEq(newStormNights.length, 1, 'exactly ONE night flies the new storm');
|
||||
assertEq(newStormNights[0].storm, 'storm_06_soaker');
|
||||
assert(newSiteNights[0] !== newStormNights[0],
|
||||
'and they are NOT the same night — a player who loses must be able to name what beat them');
|
||||
});
|
||||
|
||||
t.test('SPRINT16 six-night law: the new site debuts under a storm already survived', () => {
|
||||
const idx = NIGHTS.findIndex((_, i) => nightAt(i).site === 'site_03_swing_lawn');
|
||||
assert(idx > 0, 'the swing lawn is in the rotation and is not night one');
|
||||
const debut = nightAt(idx);
|
||||
const earlier = NIGHTS.slice(0, idx).map((_, i) => nightAt(i).storm);
|
||||
assert(earlier.includes(debut.storm),
|
||||
`night ${idx + 1} flies ${debut.storm}, which must have flown on an earlier night — `
|
||||
+ 'at the swing lawn\'s debut the yard is the ONLY new variable');
|
||||
});
|
||||
|
||||
t.test("SPRINT16 six-night law: the soaker flies the corner block — C's measured pairing", () => {
|
||||
// THREADS [C] 2026-07-20: the backyard's buyable geometry caps hail cover
|
||||
// over the bed at ~31% — best membrane line there reads 37.5, a bet with
|
||||
// no win in it. The soaker over anything but site_02 needs a re-measure
|
||||
// FIRST; this pin is where a re-pairing finds that out.
|
||||
const soaker = NIGHTS.map((_, i) => nightAt(i)).find((n) => n.storm === 'storm_06_soaker');
|
||||
assert(soaker, 'the soaker is in the week');
|
||||
assertEq(soaker.site, 'site_02_corner_block',
|
||||
'storm_06 over any other yard is unmeasured — fly gardenfly over the new pairing before moving this');
|
||||
const debutIdx = NIGHTS.findIndex((_, i) => nightAt(i).site === soaker.site);
|
||||
const soakerIdx = NIGHTS.findIndex((_, i) => nightAt(i).storm === 'storm_06_soaker');
|
||||
assert(debutIdx < soakerIdx, 'and the yard was taught on an earlier night — the storm is the only new variable');
|
||||
});
|
||||
|
||||
t.test('SPRINT16 six-night law: the beyond-saving night is neither new nor final', () => {
|
||||
const flagged = NIGHTS.map((_, i) => nightAt(i)).filter((n) => n.gardenBeyondSaving);
|
||||
assertEq(flagged.length, 1, 'exactly one designed loss in the week');
|
||||
assertEq(flagged[0].storm, 'storm_02b_icenight', 'and it is the ice night — the flag is a measured fact about');
|
||||
assertEq(flagged[0].site, 'backyard_01', 'THIS yard under THIS storm (bare 0.0, best buyable 27.7, win at 50)');
|
||||
const idx = NIGHTS.findIndex((_, i) => nightAt(i).gardenBeyondSaving);
|
||||
assert(idx !== NIGHTS.length - 1,
|
||||
'the designed loss must not be the final night: its dawn breaks need a morning to book warranty on '
|
||||
+ '(A filed the night-5-final gap in THREADS — the six-night order is what closes it)');
|
||||
});
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
/**
|
||||
* SHADES — the week. Lane A owns this file.
|
||||
* (SPRINT16 gate 4: the NIGHTS ladder and the two new briefs are Lane D's
|
||||
* edits, by the sprint's explicit grant — A owns everything else here.)
|
||||
*
|
||||
* Five nights, one yard, one wallet. This is deliberately a thin wrapper around
|
||||
* Six nights, one wallet. This is deliberately a thin wrapper around
|
||||
* the phase machine rather than a new system: main.js already knows how to run
|
||||
* forecast → prep → storm → aftermath, and the week's whole job is to answer
|
||||
* three questions between rounds — which storm is tonight, how much money do you
|
||||
@ -20,30 +22,51 @@ import { START_BUDGET, HARDWARE } from './contracts.js';
|
||||
* 1 Sea Breeze gust 10.5, no hail — the tutorial: nothing can hurt you
|
||||
* 2 Southerly Buster gust 20.0, hail — the change, and hail, arrive
|
||||
* 3 Early Buster gust 20.0, hail — same teeth, change comes EARLY
|
||||
* 4 Wild Night gust 30.0, hail — the one the whole repo tuned for
|
||||
* 5 Ice Night gust 28.5, hail — less wind, more ice
|
||||
* 4 Southerly again gust 20.0, hail — the storm you know, the yard you don't
|
||||
* 5 Ice Night gust 28.5, hail — the most wind of the week, and worse ice
|
||||
* 6 Pea-Hail Soaker gust 15.2, fine hail — the night the default fabric is wrong
|
||||
* Night 3 is the trap: it looks like night 2 on the forecast card and isn't.
|
||||
*
|
||||
* SPRINT10: a night is now a STORM and a SITE. Nights 1/2/4/5 are the backyard;
|
||||
* night 3 moves to the corner block — the same "looks like night 2 and isn't"
|
||||
* beat, sharpened: it's a new yard you've never rigged, its carport is a trap,
|
||||
* and its storm carries the southerly change that makes the venturi scream. The
|
||||
* plain-string form is still accepted (defaults to backyard_01) so nothing that
|
||||
* reads NIGHTS[i] as a storm key breaks.
|
||||
* SPRINT10: a night is now a STORM and a SITE. Night 3 moves to the corner block
|
||||
* — the same "looks like night 2 and isn't" beat, sharpened: it's a new yard
|
||||
* you've never rigged, its carport is a trap, and its storm carries the
|
||||
* southerly change that makes the venturi scream. The plain-string form is
|
||||
* still accepted (defaults to backyard_01) so nothing that reads NIGHTS[i] as
|
||||
* a storm key breaks.
|
||||
*
|
||||
* SPRINT11: a night is now a JOB. Same ladder, same yards — what's new is the
|
||||
* frame DESIGN.md has had from line one: *"work arrives as callouts — each one a
|
||||
* site with existing conditions, a client brief, a budget, and a forecast
|
||||
* window."* You are not surviving five nights; you are working five jobs.
|
||||
* SPRINT11: a night is now a JOB. What's new is the frame DESIGN.md has had
|
||||
* from line one: *"work arrives as callouts — each one a site with existing
|
||||
* conditions, a client brief, a budget, and a forecast window."* You are not
|
||||
* surviving the week; you are working its jobs.
|
||||
*
|
||||
* The week that fell out of the data, once it was asked to name a client: the
|
||||
* Hendersons are away (SPRINT11's own line — they leave for Cairns), so four of
|
||||
* the five nights are the same yard, on retainer, through a bad week. Night 3 is
|
||||
* a different site, so it is necessarily a different client — a short-notice
|
||||
* callout to a yard you have never rigged, on the night the change comes early.
|
||||
* That is not flavour bolted on: it is what `site: site_02_corner_block` already
|
||||
* MEANT, finally said out loud. The corner block lands harder because it's a
|
||||
* stranger's place and you're there at short notice.
|
||||
* Hendersons are away (SPRINT11's own line — they leave for Cairns), so the
|
||||
* backyard nights are the same yard, on retainer, through a bad week. A
|
||||
* different site is necessarily a different client.
|
||||
*
|
||||
* SPRINT16 gate 4 (Lane D, by the sprint's explicit grant): SIX nights. The
|
||||
* swing lawn enters the rotation at night 4 under the southerly — the ONE
|
||||
* variable rule (ROADMAP principle 5), applied literally: at its debut the only
|
||||
* new thing is the yard, because the storm is Tuesday's, already survived. The
|
||||
* soaker flies night 6 over the corner block — a yard night 3 taught — because
|
||||
* C MEASURED the pairing (THREADS [C] 2026-07-20): the backyard's buyable
|
||||
* geometry caps hail cover over the bed at ~31%, so over there the membrane
|
||||
* bet has no win in it. Exactly one night is the new site (4), exactly one
|
||||
* flies the new storm (6), and they are not the same night.
|
||||
*
|
||||
* ⚠️ THE STRUCTURAL CONSEQUENCE, SAID OUT LOUD (D, filed in THREADS for John's
|
||||
* gate 6): six slots cannot hold seven nights. Five shipped storms + the
|
||||
* soaker is six distinct storms; the swing lawn's debut must REPEAT a storm
|
||||
* the player already survived; so exactly one shipped storm leaves the week —
|
||||
* and every other candidate is load-bearing (gentle = the tutorial and the
|
||||
* calm-day preload; southerly = the hail intro and the early buster's
|
||||
* referent; early buster = site_02's debut, which the soaker's pairing needs;
|
||||
* icenight = the beyond-saving flag, canon). THE WILD NIGHT (storm_02) is out
|
||||
* of the rotation. Its storm file, its tests, and the pinned backyard
|
||||
* separation all stand — it is un-flown by the week, not deleted. If that
|
||||
* eviction is wrong, the honest fix is a SEVEN-night week (arc 2's own range
|
||||
* is "five-to-seven"), which is one entry re-added here — a ruling, not a
|
||||
* refactor.
|
||||
*
|
||||
* Copy lives here as data, per SPRINT11's rule: client flavour is data, not code.
|
||||
*/
|
||||
@ -72,17 +95,29 @@ export const NIGHTS = [
|
||||
+ 'to tie off to.',
|
||||
},
|
||||
{
|
||||
storm: 'storm_02_wildnight', site: 'backyard_01',
|
||||
client: 'the Hendersons', addr: '14 Kurrajong St — the backyard',
|
||||
brief: 'Back at the Hendersons\', and this is the night the whole week was pointing at. Rig it like '
|
||||
+ 'they\'re watching, because Friday they will be.',
|
||||
// SPRINT16 gate 4 — THE NEW SITE, and nothing else new: Tuesday's southerly
|
||||
// over D's swing lawn. The brief does what "plenty to tie off to" did for
|
||||
// the carport — the client's own voice sells the trap (the crossbar between
|
||||
// the swing apexes is a dead-level rail and NOT an anchor; the apexes are
|
||||
// 0.45 steel that bills $140) — and the kid gives the bed its reason.
|
||||
storm: 'storm_03_southerly', site: 'site_03_swing_lawn',
|
||||
client: 'the Delaneys', addr: '31 Ferndale Ave — the swing lawn',
|
||||
brief: 'Same southerly as Tuesday, over a lawn you\'ve never rigged. The swing set is Ruby\'s — '
|
||||
+ 'her dad reckons the crossbar\'s bolted right through, tie to that and save yourself a '
|
||||
+ 'post. The veggie bed is her school project, so it comes home alive.',
|
||||
},
|
||||
{
|
||||
storm: 'storm_02b_icenight', site: 'backyard_01',
|
||||
client: 'the Hendersons', addr: '14 Kurrajong St — the backyard',
|
||||
brief: 'Less wind than last night and more ice, which is worse for the bed and easier on the rig. '
|
||||
+ 'Last night before they land. Nobody\'s pretending the seedlings ride this one out — keep '
|
||||
+ 'the sail on its feet and the ice off the windows.',
|
||||
// SPRINT16 gate 4 wording note (D): this brief used to open "Less wind than
|
||||
// last night" — true when the wild night (gust 30) sat at night 4. The wild
|
||||
// night left the six-night rotation (see the ladder comment), so the ice
|
||||
// night is now the most wind the week throws; the sentence moved with the
|
||||
// facts. The teaching half — level with the player that the bed is already
|
||||
// lost — is untouched.
|
||||
brief: 'The most wind of the week and golf-ball ice with it. Last night before they land. '
|
||||
+ 'Nobody\'s pretending the seedlings ride this one out — keep the sail on its feet and '
|
||||
+ 'the ice off the windows.',
|
||||
// SPRINT13 gate 1.4 — A's ruling, DESIGN CANON: night 5's garden is BEYOND
|
||||
// SAVING, by design, at any price. Measured game-true (B's pay table and
|
||||
// A's probe, agreeing): bare bed 0.0 DEAD; best buyable garden 27.7; best
|
||||
@ -99,6 +134,20 @@ export const NIGHTS = [
|
||||
// flag in the same commit or the verdict lies in the other direction.
|
||||
gardenBeyondSaving: true,
|
||||
},
|
||||
{
|
||||
// SPRINT16 gate 4 — THE NEW STORM, over a yard night 3 taught. C's pairing
|
||||
// receipt (THREADS [C] 2026-07-20): the soaker MUST fly the corner block —
|
||||
// the backyard caps hail cover at ~31% and the bet has no win there. The
|
||||
// brief sells the WRONG default the way the carport got sold: fine hail
|
||||
// through a 2 mm weave is exactly the night cloth loses, and the client is
|
||||
// the one telling you it's easy. The forecast (C's stones/rain lines) is
|
||||
// where the truth argues back — the brief stays in character.
|
||||
storm: 'storm_06_soaker', site: 'site_02_corner_block',
|
||||
client: 'the Vasilaros place', addr: '2 Bight Rd — the corner block',
|
||||
brief: 'The Vasilaros place again — they asked for you by name this time. Hardly any wind in it, '
|
||||
+ 'just rain and a fine rattle of hail; the kind of night you could rig in your sleep. '
|
||||
+ 'Whatever you flew last time will do, they said.',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
@ -171,6 +220,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 +253,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 +359,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 +383,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 +433,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 +466,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 +495,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 +520,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 +548,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 +591,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