Compare commits
22 Commits
a7e71df125
...
f27539d716
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f27539d716 | ||
|
|
f53a0d5ee9 | ||
|
|
d8e7087c2c | ||
|
|
1073ac97e8 | ||
|
|
6508d53c89 | ||
|
|
a7cb661b62 | ||
|
|
186e75839d | ||
|
|
f84e00f46b | ||
|
|
f7a22eabb8 | ||
|
|
7a2024c2f9 | ||
|
|
458e9a5412 | ||
|
|
6cbae92028 | ||
|
|
e5df63128a | ||
|
|
edce5c2758 | ||
|
|
5bfd843b77 | ||
|
|
6461f2267a | ||
|
|
b74383722b | ||
|
|
453f6cb9d2 | ||
|
|
1571c2f1f9 | ||
|
|
71278da837 | ||
|
|
c6ded2db7d | ||
|
|
bfe87632e0 |
675
THREADS.md
675
THREADS.md
@ -7382,6 +7382,330 @@ anchors are your GLB), but the tooling is now waiting, not TODO.
|
||||
**SPRINT16 — TEETH — FIRES NOW: its own header's condition ("fires when 15 integrates") is met,
|
||||
its pins stand on post-ruling numbers as designed, and the swing lawn is waiting to become a
|
||||
real night.**
|
||||
|
||||
[A] 2026-07-20 — 🔌 **SEAM CONTRACT FOR D AND B (and C's forecast print), PUBLISHED EARLY AND ON
|
||||
PURPOSE — gate 1's data shapes, before a line of them is load-bearing.** Same pattern as the
|
||||
editor-page contract in S14 and B's storm-picker proposal in S15: the shapes below are what the
|
||||
ledger puts on the week, so D's gate-4 cold-judge and B's gate-2 paperwork can build against
|
||||
them today and veto them today. Nothing here is landed yet; the next [A] entry is the landing.
|
||||
|
||||
**1. The work history — `scoreRun()` grows `rigRecord`, the settlement carries it as `rig`.**
|
||||
Per corner of the committed rig, at dawn:
|
||||
`{ anchorId, hw, hwCost, effRating, broke, repaired, brokenAtDawn }`
|
||||
· `hw`/`hwCost` — name and SHOP price of what was on the corner at dawn (a corner repaired with
|
||||
the spare reads the spare, honestly). `effRating` = rating × ratingHint, rounded — the number
|
||||
sail.js actually fails on, same as the HUD bars and the verdict.
|
||||
· `broke` = it let go at least once tonight; `repaired` = broke and was standing again at dawn
|
||||
(D's hold-E, mid-storm); `brokenAtDawn` = still down when the storm ended. `repaired` and
|
||||
`brokenAtDawn` partition `broke` — a corner that broke twice and was fixed once is
|
||||
`brokenAtDawn`, not `repaired`.
|
||||
· main.js tallies rig 'break'/'repair' events per anchor, reset at storm start, exactly the
|
||||
pondDump pattern. week.js stores the record verbatim on the settlement (`s.rig`).
|
||||
|
||||
**2. Warranty — derived, never stored twice.** `week.quote(def)` and `week.settle(...)` BOTH
|
||||
compute tonight's warranty items from the SAME source — the previous settlement's `rig` record,
|
||||
`filter(brokenAtDawn)` — so quote==settle holds by construction, not by discipline. Shapes:
|
||||
· quote gains: `warranty: [{anchorId, hw, cost, night, site, addr, client}]`, `warrantyTotal`,
|
||||
and `total` now subtracts `warrantyTotal`. `cost` is the broken hardware's shop price —
|
||||
unpaid labour is the point, the parts bill is the number.
|
||||
· settlement gains: `warranty` + `warrantyTotal` (the same items, so the invoice answers the
|
||||
sheet line for line), and pay = fee + bonus + refund + clean − collateral − **warrantyTotal**.
|
||||
· A mid-storm repair is NOT a warranty item (spec): `repaired` corners book nothing. Riding it
|
||||
out broken is the item.
|
||||
|
||||
**3. Reputation — `week.rep`, stars, and it moves the fee.**
|
||||
· Unit RULED: **stars, 0..5, half-star steps, start 3.0 (neutral)**. Stars because the spec
|
||||
says client-speak and because half-steps keep every number exact — no float drift on a
|
||||
letterhead. Printed as `★ 3.5` under the ABN on job sheet AND invoice (E's letterhead kit —
|
||||
one number, one place, both papers).
|
||||
· Movement RULED — **rep moves at dawn of the night that earned it**, not the morning the
|
||||
warranty line books: clean night (zero breaks, zero collateral) +0.5 · each corner still
|
||||
broken at dawn −0.5 · each collateral event −1.0 (down harder, per spec). A broke-but-
|
||||
repaired-by-dawn night is NEUTRAL: not clean (it broke), not a warranty (you fixed it on
|
||||
your own ladder, mid-storm — that's the job). Clamped 0..5.
|
||||
· **Rep never reads the garden.** hp/win/beyondSaving are not inputs, so night 5's designed
|
||||
loss cannot cost rep BY CONSTRUCTION — the flag doesn't need an exemption, and the test pins
|
||||
a lost-garden clean-rig night 5 as rep-POSITIVE.
|
||||
· Money RULED: fee multiplier `1 + 0.04 × (rep − 3)`, so ★5 pays ×1.08 and ★0 pays ×0.88 —
|
||||
exact at two decimals for every half-step (0.05/star was tried and rounds dirty at 2dp).
|
||||
Applied to the callout fee at QUOTE TIME (quote and settle both read rep-as-of-morning; rep
|
||||
only updates after settle computes the fee, so the two agree by ordering). Printed on the fee
|
||||
row the night it isn't ×1.00. Bounded ±12/8% of fee ≈ ±$6..9/night — teeth, not a spiral;
|
||||
flagged UNMEASURED in week.js next to noCollateralBonus, same rule: if gate 6's play says it
|
||||
runs away, this is a lever, not a load-bearing measured constant.
|
||||
· Settlement gains: `rep` (after), `repBefore`, `repDelta`, `repReasons[]`, `feeMult`,
|
||||
`warrantiesToDate` (cumulative count, for the end card's rich-with-warranties reading).
|
||||
· No clamp of warranty against the fee, RULED: four rated shackles down at dawn can eat the
|
||||
whole morning's fee and then some. That is what riding it out broken costs; softening it
|
||||
re-installs the no-consequences week TEETH exists to kill.
|
||||
· Night-5 dawn breaks: the rep hit lands (dawn of the night that earned it) but the warranty
|
||||
item has no morning to book on — the week ends. Filed, not fudged: arc 2's warranty-callout
|
||||
job type is where that debt becomes a night of its own.
|
||||
|
||||
**4. C — the forecast print seam (your 3.3, my print).** Propose: `forecastLines(def, lead)`
|
||||
grows two READY-TO-PRINT strings, `stones` (hail stone size, banded per the lead rule) and
|
||||
`rainRate` (mm/h, banded). My card prints both verbatim as stat lines when truthy, and prints
|
||||
nothing when absent — so the seam is inert until you land it and needs no coordination window.
|
||||
Counter-propose freely; the card side is a two-line change whatever the names.
|
||||
|
||||
[A] 2026-07-20 — ⚖️ **GATE 1 LANDED: THE LEDGER — warranty on the sheet, rep on the letterhead,
|
||||
quote==settle by construction. Selftest 426/0/0 (418 + 8), five mutations run, all caught,
|
||||
every surface looked at on the rendered cards.** The seam contract two entries up is now
|
||||
true code; D and B, build against it as posted — nothing moved between contract and landing.
|
||||
|
||||
**What landed, by the spec's numbers:**
|
||||
· **1.1 the work history** — `rigRecordFor(corners, tally)` exported from main.js (the
|
||||
Enter-guard reason: scoreRun is unreachable behind boot()'s WebGL context). The night's
|
||||
break/repair EVENTS are tallied per anchor (reset at storm start, the pondDump pattern)
|
||||
because `repairCorner()` clears `broken` — by dawn the flag has forgotten every fixed
|
||||
break, and the tally is the only honest witness. Settlement carries the record as `s.rig`.
|
||||
· **1.2 warranty** — `warrantyItems()` in week.js derives tonight's items from the PREVIOUS
|
||||
settlement's rig record (`filter(brokenAtDawn)`); quote() and settle() both call it, and
|
||||
the log only grows at settle's end, so the sheet and the invoice read the same items by
|
||||
construction. The deduction is in `quote.total` (the sheet concludes at the deducted
|
||||
number) and in `pay`. Worded on both papers exactly as the spec words it: "warranty — P1,
|
||||
shackle, no charge to the client · −$15", parts at shop price, labour on you. Repaired
|
||||
corners book NOTHING (spec: a mid-storm repair is the job, not a warranty).
|
||||
· **1.3 reputation** — `week.rep`, stars 0..5 in half-steps, START 3.0, printed "standing
|
||||
★ 3.5" under the ABN on job sheet AND invoice. Fee multiplier `1 + 0.04×(rep−3)` at quote
|
||||
time, printed on the fee row the night it isn't ×1.00 ("callout fee · standing ×0.98"),
|
||||
settle pays the multiplied fee the sheet quoted (same read, same ordering — rep updates
|
||||
only after the money is computed). Movement: clean +0.5 · corner-down-at-dawn −0.5 each ·
|
||||
collateral −1.0 each; broke-but-repaired is NEUTRAL with its reason stated on the invoice
|
||||
("broke, but repaired by dawn"). **Rep never reads the garden** — hp/win/beyondSaving are
|
||||
not inputs, pinned from both directions (identical rigs, opposite gardens → identical
|
||||
movement; night 5 lost-garden clean-rig → rep UP).
|
||||
· **1.4 aftermath teaches it** — invoice gains "reputation ★ 2.5 → ★ 3.0 — clean night"
|
||||
under the verdict; end card gains reputation + warranty-jobs-booked rows and a rep-keyed
|
||||
closing sentence — broke-but-clean ("The book says broke. The number says the phone rings
|
||||
again.") and rich-with-warranties ("The money came in. The mornings after are what gets
|
||||
remembered.") are different endings, DOM-pinned different.
|
||||
· **1.5 tests** — 8 new in a.test, all five spec asserts plus the record partition and the
|
||||
DOM renders. **Mutations, each run in the browser and reverted:** warranty-on-any-break
|
||||
(2 red), settle-drops-multiplier (2 red), pay-forgets-warranty (1 red), record-trusts-
|
||||
the-cleared-flag (1 red), rep-reads-the-garden (2 red — both the construction pin AND the
|
||||
night-5 test). The existing ledger-sum assert now pins − warrantyTotal too.
|
||||
· **C's 3.3 print seam** — the sheet prints `f.stones` and `f.rainRate` verbatim when
|
||||
forecastLines exposes them, nothing when absent. Inert on this tree; land your half and
|
||||
the card is already wired.
|
||||
|
||||
**Rulings made this landing (beyond the seam-contract entry's):**
|
||||
· Warranty follows the OUTFIT, not the client — the spec's own words ("the NEXT morning's
|
||||
job sheet"), so a Hendersons corner can book on the Vasilaros sheet; the line carries the
|
||||
address it came from ("your corner, down at dawn — 14 Kurrajong St"), which reads as your
|
||||
van detouring on the way to the job. Arc 2's warranty-callout night is where this becomes
|
||||
its own job.
|
||||
· No clamp of warranty against the fee: four corners down at dawn can eat the morning. Teeth.
|
||||
· Corner ids print UPPERCASE in the warranty line (P1, Q3) — every other surface that names
|
||||
a corner (bars, verdicts, subtitles) speaks uppercase; the spec's lowercase "q3" was prose.
|
||||
· The multiplier prints ONLY when ≠ ×1.00 — a permanent ×1.00 tag would teach that the
|
||||
number is decoration.
|
||||
|
||||
**E — your three flags, answered:**
|
||||
1. `'pergola'` in ANCHOR_TYPE: **ADOPTED AS-IS, keep your edit.** One type, one rating,
|
||||
manifest-unambiguous, the swing_frame precedent applied correctly — no revert, no rename.
|
||||
2. a.test's 'trampoline' fixture: **DEFUSED this landing** — the bogus type is now
|
||||
'bouncy_castle' (a thing the yard will never offer), with your flag credited in the
|
||||
comment. The day the trampoline ships, the enum test welcomes it instead of reddening.
|
||||
3. Palette hunks: **leave for the integrator at merge, verbatim** — the world.js/leadFor
|
||||
precedent, two sprints proven. Flagged here so the [I] pass knows it's expected.
|
||||
|
||||
**PRICE RULINGS (routed to me, ruled):**
|
||||
· **Pergola $120 — ADOPTED.** Its anchors are real (0.65, cloth work), so the bill fires
|
||||
through the existing lost-corner path: real event, real money, and the ladder scans —
|
||||
25 gnome < 90 gutter < 120 pergola < 140 swing < 180 carport < 320 glasshouse, partial
|
||||
failure under total failure at every rung.
|
||||
· **Glasshouse $320 — ADOPTED, PRICED, no flip.** The bike ruling's teeth were "a price
|
||||
that can BILL for an event the player never sees"; this one cannot bill at all — main.js
|
||||
bills only through lost corners' collateral keys, and no anchor carries this key because
|
||||
the asset itself refuses adoption (E's tie_off:false ridge, mechanism-proven M2/M3). A
|
||||
priced key with no path to an invoice is a ladder rung waiting for its event, not a lie
|
||||
on paper. The number is right too: apex tops the carport by a distance, one catastrophe
|
||||
is a week worked for nothing, not auto-BROKE. **THE TRIPWIRE MOVES TO ARC 2:** the
|
||||
glasshouse yard may not ship until the wreck event (sail-collapse or debris shatter)
|
||||
exists to reach this key — same sentence structure as the bike assert, one arc later.
|
||||
· Pool kit unpriced by E's own bike-rule application: **endorsed** — the reason shipping in
|
||||
the GLB ("an unpriced prop with no reason is indistinguishable from a forgotten one") is
|
||||
the standard from now on.
|
||||
|
||||
**The card-eyeball receipts** (both card bugs in this repo's history were invisible to
|
||||
green suites): warranty morning job sheet — deduction inside "the night, done right",
|
||||
standing under the ABN, ×0.98 on the fee row with its condition line; the same morning's
|
||||
invoice — same items word for word, AMOUNT DUE +$71 summing every visible row, repline
|
||||
under the verdict; end card — ★ 2.0 + 3 warranty jobs over E's diptych, the
|
||||
mornings-after sentence closing the week. All three screenshotted at 800×450, nothing
|
||||
below the fold unreachable.
|
||||
|
||||
**For D's gate 4 cold-judge:** the fairness questions the sprint asks you to answer are
|
||||
all on surfaces now — the warranty line lands on the NEXT sheet with the address it came
|
||||
from; rep moves with stated reasons on every invoice; the multiplier is money on the fee
|
||||
row. If any of it reads arbitrary in play, the constants are REP in week.js (UNMEASURED,
|
||||
flagged, levers named) — file the feel, we retune the numbers, not the mechanism.
|
||||
|
||||
Pushed lane/a: bfe8763 (seam contract), 71278da (the ledger), this entry.
|
||||
[E] 2026-07-20 — 🏗 **GATE 5 LANDED: THE NEXT TEMPTATIONS — three props, three different ways of
|
||||
saying no. D, YOU ARE FLAGGED (palette hunks below); A, YOU ARE FLAGGED (one enum word, one
|
||||
fixture note, one pricing tension).** Selftest **434/0/0** (418 baseline + 16: ten auto
|
||||
scale/node checks on five new GLBs, six named pins), all verified in the browser on :8828.
|
||||
Everything on lane/e; nothing placeable until A applies the palette hunks — by design, the
|
||||
_v1 lesson: this time the palette strings ship IN THIS ENTRY, ready to paste.
|
||||
|
||||
**WHAT SHIPPED (gate 5 says two of three; here's the two, and the third is the rung):**
|
||||
· **glasshouse_01 (+ wrecked)** — the collateral apex. 14 translucent panes on an aluminium
|
||||
frame, ridge at 2.30 m. NOTHING about it may adopt: zero rated nodes, zero enum-typed nodes
|
||||
on either variant (pinned from all three directions), and the ridge — the most tie-off-
|
||||
shaped bar in the game — carries `tie_off:false` with its reasoning in words. The wreck
|
||||
EMPTIES instead of falling: frame at full height (aluminium racks, glass doesn't bend),
|
||||
two cracked survivors, and a shard field flat on the ground thrown ~1.7 m wider than the
|
||||
building ever stood. Both claims are measured pins, not adjectives.
|
||||
· **pool_kit_01** — pool + compliant fence ring (AS 1926.1 flavour: 1.22 m, non-climbable
|
||||
pickets, self-closing outward gate left ajar). The temptation is FOURTEEN 40 mm aluminium
|
||||
posts in a perfect ring at exactly low-tie height, and every single one carries an
|
||||
explicit `tie_off:false` + why. The count is pinned EXACT (a fifteenth post without its
|
||||
denial goes red in e.test, not in a shipped yard) — and this pin guards ground RULE 1
|
||||
cannot: `fence_post_07` doesn't match the `*_anchor` regex, so per-post assertion is the
|
||||
only tooth over these names (proven red, control M3 below).
|
||||
· **pergola_01 (+ wrecked)** — the ladder's middle rung, D's endorsement carried to steel:
|
||||
two bolted post-beam corners, `rating_hint 0.65`, typed **`pergola`** in its own enum word
|
||||
(a deck is not concrete; the type string is the player's pre-rig read — the swing_frame
|
||||
lesson, third application). ONE rating for the whole type per my own S15 manifest rule, so
|
||||
the type is manifest-unambiguous and **node-less-safe: a site can declare
|
||||
`{"id":"pg1","type":"pergola"}` with no node and the S15 mechanism resolves 0.65 +
|
||||
collateral through `factoryExtras()` — this is the first new asset built ON the gate-1.2
|
||||
wire, which was the point of building the wire.** The wreck is the 0.65 story told twice:
|
||||
bolts hold, timber cracks — front beam torn down, rafters hinged to the deck lean-to
|
||||
style, back half and deck standing, and the two decks are pinned byte-close so the
|
||||
shared-helper can't fork (the drift bug, pre-killed).
|
||||
· **NOT the trampoline + tie-down kit, and here's the reasoning, not a shrug:** the tie-down
|
||||
is a PREP-PHASE MECHANIC — it needs A's prep surface and C's opening-projectile event in
|
||||
the same sprint, or it's a sim feature no key reaches (ROADMAP principle 2, the fabric-bet
|
||||
lesson). tramp_01 already sits in models/debris/ carrying its own unpriced ruling ("price
|
||||
it when Lane C throws one"). When a sprint pairs the mechanic's input with the event, the
|
||||
asset is a day's work on this factory. ⚠️ **A, the fixture note:** a.test's unknown-type
|
||||
probe uses the literal string `'trampoline'` as its canonically-bogus type — the day the
|
||||
trampoline enters ANCHOR_TYPE, that fixture needs a new fake word first.
|
||||
|
||||
**PRICES PROPOSED (A rules; reasoning baked beside every number in the factory):**
|
||||
· pergola **$120**, key `pergola` — partial failure bills under total failure: a beam and a
|
||||
snapped post (bolts hold) beats the gutter's $90 run of one trade's work and loses to the
|
||||
swing's $140 whole-frame-on-the-grass. Ladder: 25 < 90 < **120** < 140 < 180 < 320.
|
||||
· glasshouse **$320**, key `glasshouse` — the apex must top the carport by a distance (14
|
||||
panes vs one sheet of Colorbond; glass all goes at once) and stay under a six-night week's
|
||||
gross (~$540 in fees), so one catastrophe is a week worked for nothing, not auto-BROKE.
|
||||
**The pricing tension, said out loud rather than smuggled:** the bike ruling says don't
|
||||
price what the sim can't break, and nothing wrecks a glasshouse yet. This ships priced ON
|
||||
THE SPEC'S OWN ORDER ("priced high, wreck variant") because arc 2's glasshouse yard needs
|
||||
the chain testable before the yard is authored; until an event lands the value is inert —
|
||||
main.js only bills through lost corners' collateral keys, and no anchor names this one.
|
||||
THE TRIPWIRE IS YOURS AND D'S: the arc-2 yard must wire the wreck event before this
|
||||
number can reach an invoice. If you'd rather it ship `priced:False` until then, say so
|
||||
and I'll flip it — the disagreement is one line and I hold it loosely.
|
||||
· pool kit **unpriced BY RULING** (`priced:False` + why in extras) — the bike rule, applied
|
||||
not dodged: nothing can bend a pool fence yet, and an unpriced prop with no reason is
|
||||
indistinguishable from a forgotten one, so the reason ships in the GLB.
|
||||
· Both priced props: wreck carries the identical price + `broken_variant_of`, pinned — the
|
||||
carport/gutter chain, applications four and five.
|
||||
|
||||
**RECEIPTS:**
|
||||
· Factory: 36/36 PASS (dims, tri budget, node round-trip). New tris: pergola 276/300,
|
||||
glasshouse 356/680, pool 2,780 — all far under budget.
|
||||
· **Determinism, measured:** two full back-to-back runs, `shasum -c` over all 44 outputs
|
||||
(36 GLBs incl. debris copies, report, contact sheet, manifest) — zero mismatches. And
|
||||
**zero churn on the 31 pre-existing GLBs**: the factory reproduced every S15-era file
|
||||
byte-identically; git status shows only the five new GLBs + report + sheet + manifest.
|
||||
· Manifest diff is EXACTLY the pergola entry (`0.65 / "pergola"`, two sources) — nothing
|
||||
else moved; `tree` stays honestly ambiguous.
|
||||
· **Negative controls, all red-then-green in the real chain:**
|
||||
**M1** — pergola_anchor_02 typed but silent: the factory tooth bit first, as promised
|
||||
(`RuntimeError: pergola_01/pergola_anchor_02 carries anchor_type='pergola' with no
|
||||
rating_hint and no tie_off denial — RULE 1`), manifest left unwritten. ⚠️ honest gotcha:
|
||||
`blender -b -P` still EXITS 0 on a raise — the tooth is the traceback + the stale
|
||||
manifest, not a shell code. So I shipped the mutated GLB to the browser anyway: **triple
|
||||
red** (RULE 1 silent-anchor, my pergola pin, RULE 4a GLB≠manifest) — 3 failed / 431
|
||||
passed. Ignoring the traceback cannot ship this.
|
||||
**M2** — ridge_anchor rated 0.9 on purpose: **double red** with the right words — RULE
|
||||
1's "both rates itself and denies — pick one" AND the apex pin's "glasshouse_01 carries
|
||||
rating_hint on [ridge_anchor] — a glasshouse node with a rating is adoptable". 2/432.
|
||||
**M3** — fence_post_07 shipped without its denial: the factory build SUCCEEDED — 36/36
|
||||
PASS, zero tracebacks — because the post carries no anchor_type and no `*_anchor` name,
|
||||
so NEITHER the build tooth NOR RULE 1 can see it. In the browser: exactly ONE red,
|
||||
"fence_post_07 does not deny being a tie-off — silence here adopts at rating 1.0" (1
|
||||
failed / 433 passed). The per-post pin is the only guard on that ground, and this is it
|
||||
earning its existence.
|
||||
Every control restored by full factory re-run, `shasum -c` against the clean-run
|
||||
snapshot: 0 mismatches both times — hash-verified, not eyeballed. Final green after the
|
||||
last restore: **434/0/0** in the browser.
|
||||
· **LOOKED AT** (assetcheck/look.html, the real renderer, capsule beside each): the trio
|
||||
reads at a glance — pergola is a deck with an open roof at 2.71 m, the glasshouse is the
|
||||
only see-through thing in the palette (the fragile read works before you're close enough
|
||||
to read a label), the pool ring is charcoal aluminium with genuinely pool-blue water and
|
||||
the ajar gate visible. The wrecks tell their stories cold: the pergola's rafters lean-to
|
||||
down onto the deck with the back beam still up and two snapped stubs at the front; the
|
||||
wrecked glasshouse is a bare skeleton at full height over a glitter field of shards —
|
||||
"it emptied" is legible from across a yard, which was the design claim.
|
||||
|
||||
**D — THE PALETTE HUNKS (editor.js is A's file; A or the integrator applies verbatim —
|
||||
the `_v1` suffixes are present and load-bearing, per your S14 find):**
|
||||
```js
|
||||
{
|
||||
// SPRINT16 gate 5, E. The ladder's middle rung, D-endorsed in S15: bolted
|
||||
// to a deck — better than loose-on-grass (0.45), worse than concrete
|
||||
// (1.00). One type, one rating, so the manifest resolves it node-less.
|
||||
kind: 'structure', label: 'Pergola (the middle rung)', model: 'pergola_01_v1',
|
||||
requires: ['pergola'],
|
||||
hint: 'two bolted corners at 0.65 — the honest step between swing frame and post',
|
||||
make: (id, x, z) => ({
|
||||
id, model: 'pergola_01_v1', wreckedModel: 'pergola_01_wrecked_v1',
|
||||
x, z, rotYDeg: 0, solid: true,
|
||||
collateralKey: 'pergola',
|
||||
collateralValue: 120, collateralLabel: 'the pergola',
|
||||
anchors: [1, 2].map((i) => ({
|
||||
id: `${id}_a${i}`, node: `pergola_anchor_0${i}`, type: 'pergola', work: 'cloth',
|
||||
})),
|
||||
}),
|
||||
},
|
||||
{
|
||||
// The collateral apex. NO anchors, on purpose — nothing about a glasshouse
|
||||
// may adopt (the ridge carries tie_off:false in the GLB). collateralKey is
|
||||
// EXPLICIT so the second one placed still bills (the carport lesson).
|
||||
kind: 'structure', label: 'Glasshouse (the apex, $320)', model: 'glasshouse_01_v1',
|
||||
hint: 'no anchors at all; $320 of glass — the thing you keep the whole rig away from',
|
||||
make: (id, x, z) => ({
|
||||
id, model: 'glasshouse_01_v1', wreckedModel: 'glasshouse_01_wrecked_v1',
|
||||
x, z, rotYDeg: 0, solid: true,
|
||||
collateralKey: 'glasshouse',
|
||||
collateralValue: 320, collateralLabel: 'the glasshouse',
|
||||
anchors: [],
|
||||
}),
|
||||
},
|
||||
{
|
||||
// The compliance ring. Fourteen posts, every one tie_off:false in the GLB;
|
||||
// unpriced BY RULING (the bike rule — price it when debris can bend it).
|
||||
kind: 'structure', label: 'Pool + fence ring', model: 'pool_kit_01_v1',
|
||||
hint: 'fourteen perfect-looking posts, every one an honest no — the legal tie-offs are elsewhere',
|
||||
make: (id, x, z) => ({
|
||||
id, model: 'pool_kit_01_v1', x, z, rotYDeg: 0, solid: true,
|
||||
anchors: [],
|
||||
}),
|
||||
},
|
||||
```
|
||||
**D — placement facts, measured in three.js coords (the axis trap, paid for in advance):**
|
||||
· pergola: anchors on the **+Z face** (the yard side); wreck spills the torn beam ~0.13 m
|
||||
past the deck on +X — footprint 3.33 × 2.60, standing 2.71 m.
|
||||
· glasshouse: wreck shard field reaches the plan out to **4.34 × 2.94 m** (intact 2.65 ×
|
||||
1.95), biased +X/downwind — leave that apron clear or shards clip through a fence line.
|
||||
· pool kit: gate is on the **+Z run**, ajar and swinging outward (+Z) — 5.64 × 4.52 m
|
||||
including the swing; ring proper is 5.6 × 4.2.
|
||||
· The glasshouse and pergola are `solid: true` in the hunks — carport precedent.
|
||||
|
||||
**A — your three flags in one place:** (1) `'pergola'` added to ANCHOR_TYPE in contracts.js
|
||||
on lane/e — your file, my edit, the swing_frame precedent, revert-and-tell-me if you'd
|
||||
rather land it yourself; (2) a.test's `'trampoline'` bogus-type fixture, noted above;
|
||||
(3) the glasshouse pricing tension, yours to rule, flip is one line.
|
||||
|
||||
Pushed to origin lane/e: factory (three builders + shared helpers + PAL entries), five
|
||||
GLBs, report + contact sheet + manifest (pergola entry), contracts.js widening, sixteen
|
||||
e.test pins, this entry.
|
||||
[C] 2026-07-20 — 🧊 **GATE 3.1 LANDED — STORM_06 IS REAL AND THE F KEY IS FINALLY A NIGHT. D: the
|
||||
pairing is measured, not preferred — the soaker MUST debut over the corner block.** Selftest
|
||||
438/0/0 (418 baseline + 20: 4 per-storm honesty gates for the new storm, 12 generator cases,
|
||||
@ -7523,3 +7847,354 @@ anchors are your GLB), but the tooling is now waiting, not TODO.
|
||||
membrane answer lives on the F key, which the sweep (cloth-fixed, by its own charter)
|
||||
cannot see. If that reads as the card lying about a winnable night, that's a surface
|
||||
question for gate 4.3's verdict, not a balance bug — flag it and we'll argue surfaces.
|
||||
[B] 2026-07-20 — 🩹 **GATE 2.1 + 2.2 LANDED — THE BANG HEALS INTO A CLOTH, AND THE FABRIC BET IS
|
||||
ON THE PAPERWORK. Plus the pool's determinism bound, pinned — and it measured SHARPER than
|
||||
the docs said.** Selftest 424/0/0 at my last full browser run (418 baseline + 6; the 425th —
|
||||
the sep-judged assert, entry below — was added after and is in the final run). Every new
|
||||
assert mutation-checked red-then-green; the mutation table is at the bottom. Pushed lane/b.
|
||||
|
||||
**2.1 THE HEAL** (`sail._healNonFinite`, called ONLY off a divergence break): non-finite node
|
||||
positions reset from the nearest finite neighbour (ring flood, `_nbr`'s own straight-before-
|
||||
diagonal order), corners re-seed from their anchors if the whole cloth is gone, `prev` is
|
||||
matched on every healed node so verlet reads zero velocity out of the repair, and non-finite
|
||||
water zeroes. Deterministic (two poisoned runs byte-equal), emits `heal {nodes, t}`. The
|
||||
review's divergence-break test is UNTOUCHED and green — the heal is in addition, as specced.
|
||||
Three honest field notes:
|
||||
· **The NaN pond is a second wire, and I say so in the code**: on today's path the break's
|
||||
own `dumpPond('corner blew')` fill(0)s the pond before the heal's water pass sees it. The
|
||||
heal's water contract is therefore pinned at the UNIT level (direct call, no dump in front)
|
||||
so the wire is live the day divergence stops dumping. An integration-only assert here would
|
||||
have been green while testing nothing — the review's tautology lesson, applied in advance.
|
||||
· **A — your ledger is safe from the heal, by construction**: it repairs node POSITIONS only.
|
||||
A divergence break emits the normal 'break' event (reason:'divergence'), your tally counts
|
||||
it, `brokenAtDawn` books warranty if unrepaired — which is honest: a solver bang that eats a
|
||||
corner is exactly the morning-after your warranty line describes, and now the freed cloth
|
||||
LOOKS like a cloth instead of a shredded z-fighting mess while it does. No new corner state
|
||||
anywhere. The only new emitter event is 'heal'; nothing subscribes but tests.
|
||||
· The heal never runs on honest overloads (negative control: cheap rig, 4 real breaks, zero
|
||||
heal events — asserted, not assumed).
|
||||
|
||||
**2.2 THE RECORD**: `fabricNoteFor()` (rigging.js, pure, headless) writes ONE verdict-page
|
||||
sentence when the bet provably mattered: cloth on a night whose stones pass the 2 mm-weave
|
||||
ruling (hailBlockFor < 1) with ≥5 HP of hail names the leak; membrane past the verdict's own
|
||||
80 kg pond threshold names the trade; silent everywhere a sentence would be a guess — big
|
||||
stones (both fabrics block 100%, blaming the weave would lie), dry nights, unknown fabric.
|
||||
scoreRun carries `{fabric, fabricNote}`; the invoice grows an unconditional `fabric flown`
|
||||
row; the prep half already shipped in the review (panel line 2). **A — merge note for your
|
||||
landed ledger** (I fetched 6cbae92 and reshaped against it): my fabricnote line is a SIBLING
|
||||
of `.verdict`, same pattern as your `.repline`, so the verdict div and your insertion point
|
||||
are both byte-untouched — at integration the two added lines under the verdict are keep-both,
|
||||
either order. My `fabric flown` row rides the `rows` block (garden/corners/what-got-through),
|
||||
not your ledger. scoreRun: my two keys lead the return object, your `rigRecord` closes it —
|
||||
disjoint hunks. The FORECAST half stays C's (3.3); your `f.stones`/`f.rainRate` print seam is
|
||||
exactly where it should land.
|
||||
|
||||
**POOL — the >83 ms determinism bound has its pinning test, and the honest number is 66.7 ms.**
|
||||
Measured: the spiral guard (`if (n === MAX_SUBSTEPS) this._acc = 0`) fires whenever a step()
|
||||
consumes five substeps — including when the leftover was a legal sub-SIM_DT residual — so the
|
||||
unconditionally-lossless frame bound is (MAX_SUBSTEPS−1)×SIM_DT ≈ 66.7 ms; a 67–83 ms frame
|
||||
may drop up to one SIM_DT depending on accumulator phase; ≥83 ms always drops its overflow.
|
||||
Pinned AS MEASURED, no retune (every recorded trace was made under this guard): frames ≤66 ms
|
||||
converge on the fixed trace at equal sim clocks with zero wall time lost; one 120 ms hitch
|
||||
drops exactly its 36.7 ms overflow. The test header is the documented waiver; if anyone
|
||||
refines the guard to `&& this._acc >= SIM_DT`, move SAFE_MAX in the same commit.
|
||||
|
||||
| mutation | expected red | got |
|
||||
|---|---|---|
|
||||
| heal call off | finite-in-1s asserts | "the divergence break fired and the heal did not" ×2 |
|
||||
| prev left NaN | velocity invention | "node prev still non-finite right after the heal substep" |
|
||||
| water zeroing off | unit water contract | "heal left NaN water in place — leaning on dumpPond" |
|
||||
| leak gate `<1`→`<=1` | big-stone silence | "cloth blamed for big-stone hail — hailBlockFor(1.3,0.3) is 1.0" |
|
||||
| pond threshold dropped | small-pond silence | "membrane got a sentence over a pond the verdict would not mention" |
|
||||
| MAX_SUBSTEPS 5→2 | legal frames drop time | "6079.2 ms of wall time went missing on legal frames" |
|
||||
| spiral guard deleted | hitch drops nothing | "deficit 0.0033 s, expected ≈0.0367 s" |
|
||||
|
||||
[B] 2026-07-20 — 🧾 **GATE 2.3 — BOTH PINS ATTEMPTED, BOTH HONESTLY REFUSED, AND HERE IS THE
|
||||
BALANCE FIGHT THE SPEC PROMISED.** Measured on the post-ruling tree (garden_probe, funnel ON
|
||||
in the header, 2026-07-20), each yard under its SHIPPED storm:
|
||||
|
||||
| yard × storm | bare bed | bare loss split | best flown line | best gain |
|
||||
|---|---|---|---|---|
|
||||
| site_02 × earlybuster | **82.6 FULL** | hail 10.7 + rain 6.8 | 90.2 (tr1,q1,q4,cb2 probe spread) | **+7.6 HP** |
|
||||
| site_03 × southerly | **83.7 FULL** | hail 10.7 + rain 5.6 | 89.8 (h3,t1c,t2,t2b) | **+6.1 HP** |
|
||||
| backyard_01 × wildnight (the pinned reference) | 35.7 TATTERED | hail 51.4 + rain 12.9 | 63.8 holds | +28.1 HP |
|
||||
|
||||
A separation block's own semantics need "a bare bed LOSES the night" (`bareMustLoseBelow`,
|
||||
win line 50). On both yards a bare bed WINS by 30+ points — the pea-hail spectra (size 0.7,
|
||||
one mild burst) cannot take more than ~17 HP off an open bed. Every writable pin is therefore
|
||||
a fudge: either `bareMustLoseBelow` above the win line (the field name lying) or thresholds
|
||||
tuned to land a slogan (the S13 fudge gate 1 killed). REFUSED, per the gate's own rule.
|
||||
· **In gate-1 money, which is the sharpest way to say it**: at A's landed garden bonus, bare
|
||||
on site_02 collects ~$37 of the $45 garden money for $0 of rig; the BEST line in the yard
|
||||
collects ~$41 for $40–120 of hardware. The entire rig market on these nights buys $4 of
|
||||
garden. The wildnight buys $25. That's the whole finding in one row of the ledger.
|
||||
· **What I shipped instead of a fudge**: each yard carries `_separation_finding` (the numbers
|
||||
above + delete-on-pin instruction, where the next author will look); SCORE IT's NONE PINNED
|
||||
card now reads **PIN REFUSED (measured)** and prints it — "measured and refused" and
|
||||
"nobody tried" are different facts and the card no longer flattens them. New assert: every
|
||||
shipped yard is separation-JUDGED — pinned XOR refused-with-finding; silence red, stale
|
||||
finding red (mutation-checked by hiding site_03's finding: red, restored, green).
|
||||
· **Where the fight goes** (proposals; A rules, one variable at a time): (a) C's storm_06 is
|
||||
the first honest lever — a night whose fine-hail DOSE genuinely hurts an open bed makes
|
||||
"bare loses" true before any pin is written, and their gate-3.1 already owns that spectrum;
|
||||
(b) if the mild nights are DESIGNED to be unlosable for a bare bed, then separation on them
|
||||
is a garden-BONUS stake, not a win/lose stake, and the block wants a second, honest shape
|
||||
(heldMustExceed + minimum bonus spread) — that's a ruling, not a tune; (c) the win line
|
||||
itself is canon and I did not touch it. D — your gate-4.1 re-score of the swing lawn rides
|
||||
on this: my probe reproduced your S14 card to the digit (bare 83.7, best 89.8-90.0), so
|
||||
expect NO movement from S15/S16 code; the finding block says re-measure-then-pin the day a
|
||||
balance ruling lands.
|
||||
|
||||
[B] 2026-07-20 — ⚖️ **MEMBRANE BALANCE, MEASURED — THE TABLE THE F KEY HAS BEEN WAITING FOR, AND
|
||||
WHY I RETUNED NOTHING.** flyGarden grew a `porosity` param (default pinned by assert to the
|
||||
0.30 cloth every existing number was measured on; membrane flies per-corner strictly heavier,
|
||||
1.23x average — C, this is your gate-3.1 seam, take it). backyard_01, the PINNED separation
|
||||
line (p5 rated + p1,p2,p3 shackles, tension 1.0), both fabrics, funnel-true:
|
||||
|
||||
| storm (hail size) | cloth | membrane | bare | the bet |
|
||||
|---|---|---|---|---|
|
||||
| southerly (0.7 pea) | 90.5 · hail 6.1 · 0/4 lost · peaks p5 2.40 kN | **92.2** · hail 4.4 · 0/4 · p5 2.68 kN | 83.7 | membrane RIGHT, worth **+1.7 HP** |
|
||||
| wildnight (1.3) | **63.8** · hail 26.6 · 0/4 (p1,p2 marginal) | 55.6 · hail 33.6 · **2/4 LOST** · p3 3.90 kN | 35.7 | membrane WRONG, costs **−8.2 HP + 2 corners** |
|
||||
|
||||
The bet points the right way on both nights — and the asymmetry is the finding: being WRONG
|
||||
in membrane costs 8 HP and two corners' hardware; being RIGHT earns +1.7 HP nobody would
|
||||
notice. On every shipped night the F key is a trap with a decorative upside, which is exactly
|
||||
the "decoration night" C's gate-3 brief says storm_06 exists to fix (fine stones + heavy rain
|
||||
+ wind mild enough that membrane's 1.23x load and its pond are affordable). **Retunes
|
||||
proposed: NONE from me.** FABRIC's constants stay; the honest levers are (1) C's storm_06
|
||||
spectrum — measure until the membrane line is genuinely better, their gate; (2) rain's drain
|
||||
weight (0.25 vs hail's 5.0) — the day A raises it, membrane earns a price and rigging.js's
|
||||
comment names this the starting point; that's an A ruling with my table as evidence, not a
|
||||
B tune. **Disclosure**: this table is ONE harness (gardenfly). C — before either of us pins
|
||||
membrane-vs-cloth anywhere, cross it against your storm-envelope chain (your 1.3 zero-delta
|
||||
discipline); my numbers are on lane/b and reproduce from garden_probe's page in one console
|
||||
call.
|
||||
|
||||
[B] 2026-07-20 — 🤝 **CLOSING: C'S SEAMS ANSWERED, THE PONDING FINDING RULED IN MY FILE, AND THE
|
||||
TWO-HARNESS SOAKER CROSSING RUN FROM MY SIDE — RECORDED CLOSED.** Final numbers: lane/b
|
||||
carries +7 asserts over the 418 baseline; a scratch merge of lane/b + lane/c (built for the
|
||||
crossing, then deleted — the D S15 protocol) ran the full browser suite at **445/0/0, the
|
||||
exact per-lane sum (418 + B 7 + C 20)**.
|
||||
|
||||
· **gardenfly porosity — convergent, uncontested, adopted.** We added the identical param
|
||||
(same name, same 0.30 default) the same day; I took C's hunk byte-identical onto lane/b
|
||||
(`git checkout origin/lane/c -- tools/site_audit/gardenfly.js`) so integration merges
|
||||
clean. My guard asserts (default flies as explicit 0.30 to the digit; membrane per-corner
|
||||
strictly heavier) stand and cover the shared copy.
|
||||
· **PONDING FABRIC-BLINDNESS: RULED a known simplification, comments fixed to tell the
|
||||
truth.** C is right and the finding has teeth — _applyPonding reads no porosity, cloth
|
||||
ponds like membrane, and setFabric's comment plus the player-facing blurb promised
|
||||
otherwise. I did NOT fix the physics, on purpose: every pinned cloth number in the repo
|
||||
(the backyard separation block, the §7 gates, C's fresh storm_06 pins, the S15 zero-delta
|
||||
lattice) was measured under fabric-blind ponding, and a pond-physics change mid-TEETH
|
||||
moves all of them against the sprint's own one-variable law. What landed instead:
|
||||
setFabric's comment now carries the ruling, _applyPonding carries a ⚠ FABRIC-BLIND header,
|
||||
and the cloth blurb drops "sheds water" (a claim the sim doesn't cash, in front of a
|
||||
player). The real fix — porosity-scaled intake + a through-weave leak — is filed as its
|
||||
own future gate with C's soaker as the ready-made test case and a full gauntlet re-measure
|
||||
as the admission price; it re-opens membrane pricing (rigging.js's comment) the same day.
|
||||
C: your "flag me the sprint you touch it" is the contract, countersigned.
|
||||
· **THE CROSSING, from my side, on the scratch merge (my heal + fabric record + C's storm in
|
||||
one tree): C's numbers reproduce to the byte.** fabric_bet probe, site_02 × soaker, funnel
|
||||
ON: bare **17.5 DEAD** (byHail 66.9) · $75 rated-q1 ring **cloth 42.3 TATTERED vs membrane
|
||||
96.5 FULL (Δ54.2)** · $60 shackle ring membrane 68.8 FULL · $20 carabiner INVERSION cloth
|
||||
24.5 (2 lost) vs membrane 19.1 (3 lost). Identical to C's gate-3.1 entry and their audit
|
||||
coda — two harnesses, two lanes' trees, one set of digits, and my B-side changes provably
|
||||
move none of their pins. **The crossing is closed.** (C's coda already ran MY audit.html
|
||||
over the soaker on their tree — bare 17.5 byte-identical there too.)
|
||||
· **Gate 2.3 coda the soaker writes for free:** storm_06 is the first night where a real
|
||||
separation pin is POSSIBLE on site_02 — bare 17.5 DEAD vs membrane 96.5 FULL is the
|
||||
teeth-bearing gap my refusal entry said the mild nights can't produce. But the separation
|
||||
block's chain flies CLOTH by charter (flySeparation → flyGarden default), so pinning it
|
||||
needs the block to grow an optional fabric field — a schema ruling, A's, one line, and my
|
||||
sep-judged assert + the finding blocks are already shaped to take it. D: if your gate-4
|
||||
wiring puts the soaker on site_02, that pin is the natural close of the bare-beds fight ON
|
||||
THAT NIGHT; the mild nights' finding stands regardless.
|
||||
· **Live verification (my own eyes, real chain):** booted the game on the scratch tree,
|
||||
played night 1 through the real UI path (splash → job sheet → session.rig ×4 → Enter →
|
||||
storm fast-forwarded with the game's own fixed-dt step, D's disclosed harness gait) —
|
||||
the TAX INVOICE prints **fabric flown · shade cloth**; fabricNote null on the hail-free
|
||||
night (correct); the note's DOM path verified by re-showing the card with an injected
|
||||
note — it renders under the verdict, verdict prose untouched. A: both hunks are shaped as
|
||||
siblings of your ledger rows and repline (entry 1's merge note).
|
||||
Selftest counts for the integrator: lane/b headless 46/46 + 23/23; lane/b browser 424/0/0
|
||||
before the sep-judged assert landed (its green is in the 445 merged run; its red was proven
|
||||
by hiding site_03's finding — "site_03_swing_lawn has neither a separation block nor a
|
||||
_separation_finding", restored, green). Pushed lane/b.
|
||||
[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 |
@ -112,6 +112,19 @@ export async function buildGardenflyTests() {
|
||||
} catch (err) { sepErr = String((err && err.stack) || err); }
|
||||
}
|
||||
|
||||
// SPRINT16 gate 2.3 [B]: every shipped yard has been JUDGED on separation —
|
||||
// either it pins a block (backyard_01) or it records a measured refusal
|
||||
// (_separation_finding). Read all three here so the assert below can hold
|
||||
// the invariant: neither is "unjudged", both at once is a stale finding.
|
||||
let sepStates = null, sepStatesErr = null;
|
||||
try {
|
||||
sepStates = [];
|
||||
for (const name of ['backyard_01', 'site_02_corner_block', 'site_03_swing_lawn']) {
|
||||
const sd = await loadSite(name);
|
||||
sepStates.push({ name, pinned: !!sd.separation, finding: !!sd._separation_finding });
|
||||
}
|
||||
} catch (err) { sepStatesErr = String((err && err.stack) || err); }
|
||||
|
||||
let venRun = null, venErr = null;
|
||||
try {
|
||||
const fly = (venturi) => flyGarden({
|
||||
@ -122,6 +135,22 @@ export async function buildGardenflyTests() {
|
||||
venRun = { bare: fly([]), funnelled: fly(SYN_FUNNEL) };
|
||||
} catch (err) { venErr = String((err && err.stack) || err); }
|
||||
|
||||
// SPRINT16 [B]: flyGarden grew a `porosity` param (storm_06's membrane bet
|
||||
// needs the audit engine to fly both fabrics). Two things must hold or every
|
||||
// pinned number in THREADS quietly changes meaning: the DEFAULT is still the
|
||||
// 0.30 shade cloth every pin was measured on, and membrane genuinely flies
|
||||
// as a different fabric (double the pressure term → heavier corners).
|
||||
let fabRun = null, fabErr = null;
|
||||
try {
|
||||
const flyFab = (porosity) => flyGarden({
|
||||
anchors: SYN_ANCHORS, bed: SYN_BED, stormDef: SYN_STORM,
|
||||
siteDef: { wind: { venturi: SYN_FUNNEL } },
|
||||
ids: ['a1', 'a2', 'a3', 'a4'], hw: hardwareByName('rated shackle'),
|
||||
...(porosity === undefined ? {} : { porosity }),
|
||||
});
|
||||
fabRun = { byDefault: flyFab(undefined), cloth: flyFab(0.30), membrane: flyFab(0) };
|
||||
} catch (err) { fabErr = String((err && err.stack) || err); }
|
||||
|
||||
return [
|
||||
['gardenfly: the pinned separation line, flown in BOTH chains — the 12s-skew disagreement, pinned', () => {
|
||||
// ⚠ THE STATE OF PLAY (2026-07-18, [B] THREADS, receipts in the entry):
|
||||
@ -168,6 +197,48 @@ export async function buildGardenflyTests() {
|
||||
`the siteDef's venturi is not reaching gardenfly's wind (windForSite must receive the SITE def)`);
|
||||
}
|
||||
}],
|
||||
['gate 2.3: every shipped yard is separation-JUDGED — pinned, or measured-and-refused', () => {
|
||||
// SPRINT16's rule made durable: a yard may pin a separation block or it
|
||||
// may carry a measured refusal (_separation_finding, the honest "this
|
||||
// yard cannot separate at $80 under its storm" verdict with receipts) —
|
||||
// but silence is not an option, and holding both means the finding went
|
||||
// stale the day the pin landed and must be deleted (its own text says so).
|
||||
if (sepStatesErr) throw new Error(`site read died: ${sepStatesErr}`);
|
||||
assert(sepStates.length === 3, `expected 3 shipped yards, read ${sepStates.length}`);
|
||||
for (const s of sepStates) {
|
||||
assert(s.pinned || s.finding,
|
||||
`${s.name} has neither a separation block nor a _separation_finding — the yard is UNJUDGED; ` +
|
||||
'run gardenfly, pin honestly or refuse honestly (SPRINT16 gate 2.3)');
|
||||
assert(!(s.pinned && s.finding),
|
||||
`${s.name} carries BOTH a pin and a refusal finding — the finding is stale, delete it in the pinning commit`);
|
||||
}
|
||||
return sepStates.map((s) => `${s.name}: ${s.pinned ? 'PINNED' : 'refused w/ finding'}`).join(' · ');
|
||||
}],
|
||||
['gardenfly: default fabric is still the pinned 0.30 cloth, and membrane flies heavier', () => {
|
||||
if (fabErr) throw new Error(`fabric flight died: ${fabErr}`);
|
||||
const { byDefault, cloth, membrane } = fabRun;
|
||||
// omitting porosity === saying 0.30, to the digit — every pinned number
|
||||
// in THREADS was measured on the default and must not move under it
|
||||
assert(byDefault.hp === cloth.hp, `default flew hp ${byDefault.hp} vs explicit cloth ${cloth.hp} — the default fabric drifted`);
|
||||
for (const d of byDefault.peaks) {
|
||||
const c = cloth.peaks.find((p) => p.id === d.id);
|
||||
assert(c && c.peakN === d.peakN, `corner ${d.id}: default peak ${d.peakN} != explicit-cloth ${c?.peakN} — the default fabric drifted`);
|
||||
}
|
||||
// porosity 0 must be a genuinely different fabric: the solid membrane
|
||||
// catches the full pressure term, so every corner flies heavier
|
||||
for (const m of membrane.peaks) {
|
||||
const c = cloth.peaks.find((p) => p.id === m.id);
|
||||
assert(m.peakN > c.peakN,
|
||||
`corner ${m.id}: membrane peak ${m.peakN} N not above cloth ${c.peakN} N — the porosity param is not reaching the rig`);
|
||||
}
|
||||
// Measured 1.23x on this synthetic yard (browser, 2026-07-20) — less
|
||||
// than the raw 1/0.7 pressure ratio because relative wind bleeds load as
|
||||
// the heavier-loaded membrane accelerates. Floor at 1.1: the per-corner
|
||||
// strictly-greater asserts above prove the wiring; this only guards
|
||||
// against the difference collapsing to epsilon.
|
||||
const up = membrane.peaks.reduce((s, m) => s + m.peakN / cloth.peaks.find((p) => p.id === m.id).peakN, 0) / 4;
|
||||
assert(up > 1.1, `membrane only ${up.toFixed(2)}x cloth load on average — porosity is wired but not biting`);
|
||||
}],
|
||||
['gardenfly: unknown hardware names throw — the shop has no mystery tier', () => {
|
||||
let threw = false;
|
||||
try { hardwareByName('titanium dream'); } catch { threw = true; }
|
||||
|
||||
@ -243,6 +243,10 @@ export async function scoreSite({ site, stormDef, stormName = null, sepStormDef
|
||||
bestGarden: bestGarden ? { ids: bestGarden.key, ...bestGarden.g } : null,
|
||||
bestMarginal: bestMarginal ? { ids: bestMarginal.key, ...bestMarginal.g } : null,
|
||||
separation, sepStormName, sepUnjudged,
|
||||
// SPRINT16 gate 2.3 [B]: a site that ATTEMPTED a pin and honestly refused
|
||||
// one records why in its JSON; the card prints it so NONE PINNED reads as
|
||||
// "measured and refused, here's the fight" rather than "nobody tried".
|
||||
separationFinding: site._separation_finding ?? null,
|
||||
collateral,
|
||||
MARGIN: AUDIT.MARGIN,
|
||||
};
|
||||
|
||||
@ -9,6 +9,21 @@
|
||||
|
||||
"gardenBed": { "x": 0, "z": 1, "w": 5, "d": 3.5 },
|
||||
|
||||
"_separation_finding": [
|
||||
"SPRINT16 gate 2.3 [B] — a separation pin was ATTEMPTED and honestly REFUSED. Measured",
|
||||
"post-ruling (garden_probe, funnel ON, storm_03b_earlybuster, 2026-07-20): bare bed 82.6",
|
||||
"FULL (hail 10.7 + rain 6.8 HP); best flown line 90.2; best gain +7.6 HP. The block's own",
|
||||
"semantics need 'bare LOSES the night' (bareMustLoseBelow, win line 50) and a bare bed",
|
||||
"WINS this night by 32 points — the early buster's pea hail simply cannot hurt an open bed",
|
||||
"enough. Any pin writable today would be a fudge: either a bareMustLoseBelow above the win",
|
||||
"line (the field name lying) or thresholds tuned until the slogan lands (the S13 fudge the",
|
||||
"gate exists to kill). THE FINDING: this yard cannot separate held from bare at ANY price",
|
||||
"under its shipped storm — that is a balance fact about the NIGHT's hail spectrum, not the",
|
||||
"rig market. The fight is in THREADS [B] 2026-07-20; candidate levers are C's hail",
|
||||
"spectrum, the win threshold, or per-night garden stakes — A rules. When a retune lands,",
|
||||
"re-measure, pin like backyard_01's, and DELETE this block in the same commit."
|
||||
],
|
||||
|
||||
"_design": [
|
||||
"DESIGN.md wants the corner block anchor-poor — 'nowhere to tie off'. An empty yard is just a",
|
||||
"smaller yard, so the trap is the interesting build: give it something that LOOKS like four free",
|
||||
|
||||
@ -48,6 +48,16 @@
|
||||
"The bare bed reads 83.7 FULL, which is the bare-beds-win pool item showing up on a",
|
||||
"third yard - gate-4 material, not this yard's doing and not tuned here."
|
||||
],
|
||||
"_separation_finding": [
|
||||
"SPRINT16 gate 2.3 [B] — a separation pin was ATTEMPTED and honestly REFUSED, same",
|
||||
"verdict as site_02's. Measured post-ruling (garden_probe, funnel ON,",
|
||||
"storm_03_southerly, 2026-07-20): bare bed 83.7 FULL (hail 10.7 + rain 5.6 HP); best",
|
||||
"flown 89.8; best gain +6.1 HP — D's S14 card numbers reproduced. Bare WINS the",
|
||||
"southerly by 34 points, so 'bareMustLoseBelow' cannot be written honestly at any",
|
||||
"threshold. The pea-hail nights cannot hurt an open bed enough for a sail to be the",
|
||||
"point of the night; that is the NIGHT's balance fact, filed in THREADS [B] 2026-07-20.",
|
||||
"When the balance ruling lands, re-measure, pin like backyard_01's, delete this block."
|
||||
],
|
||||
"yard": {
|
||||
"width": 24,
|
||||
"depth": 16
|
||||
|
||||
@ -82,6 +82,14 @@ export const FACTORY_ANCHOR_RATINGS = Object.freeze({
|
||||
"house_yardside/fascia_anchor_03"
|
||||
]
|
||||
},
|
||||
"pergola": {
|
||||
"collateral": "pergola",
|
||||
"rating_hint": 0.65,
|
||||
"sources": [
|
||||
"pergola_01/pergola_anchor_01",
|
||||
"pergola_01/pergola_anchor_02"
|
||||
]
|
||||
},
|
||||
"post": {
|
||||
"rating_hint": 1.0,
|
||||
"sources": [
|
||||
|
||||
@ -81,9 +81,17 @@ export const PHASES = ['forecast', 'prep', 'storm', 'aftermath'];
|
||||
* concreted steel. This is a welded joint on a frame standing on grass: good
|
||||
* steel, no footing, rating_hint 0.45. Same lesson as the carport, which was
|
||||
* also nearly smuggled in as a post.
|
||||
*
|
||||
* `pergola` (SPRINT16 gate 5, E): a pergola's bolted post-beam corner — the
|
||||
* ladder's middle rung (0.65), D-endorsed in S15. Not a `post` for the same
|
||||
* reason the swing frame isn't: the word promises the footing, and this one's
|
||||
* footing is a DECK — real bolts into real joists, so better than
|
||||
* loose-on-grass (0.45), but the deck flexes and the timber cracks before
|
||||
* concrete would notice (1.00). One rating for the whole type, per the
|
||||
* manifest rule (THREADS [E] S15): unambiguous types stay node-less-safe.
|
||||
*/
|
||||
export const ANCHOR_TYPE = Object.freeze([
|
||||
'house', 'tree', 'post', 'carport', 'carport_post', 'swing_frame',
|
||||
'house', 'tree', 'post', 'carport', 'carport_post', 'swing_frame', 'pergola',
|
||||
]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@ -316,6 +316,50 @@ export const PALETTE = [
|
||||
})),
|
||||
}),
|
||||
},
|
||||
// SPRINT16 gate 5 — E's three palette entries, applied verbatim by the
|
||||
// integrator at merge (A delegated in THREADS; hunks filed by E with the
|
||||
// _v1 suffixes present and load-bearing, per D's S14 find).
|
||||
{
|
||||
// SPRINT16 gate 5, E. The ladder's middle rung, D-endorsed in S15: bolted
|
||||
// to a deck — better than loose-on-grass (0.45), worse than concrete
|
||||
// (1.00). One type, one rating, so the manifest resolves it node-less.
|
||||
kind: 'structure', label: 'Pergola (the middle rung)', model: 'pergola_01_v1',
|
||||
requires: ['pergola'],
|
||||
hint: 'two bolted corners at 0.65 — the honest step between swing frame and post',
|
||||
make: (id, x, z) => ({
|
||||
id, model: 'pergola_01_v1', wreckedModel: 'pergola_01_wrecked_v1',
|
||||
x, z, rotYDeg: 0, solid: true,
|
||||
collateralKey: 'pergola',
|
||||
collateralValue: 120, collateralLabel: 'the pergola',
|
||||
anchors: [1, 2].map((i) => ({
|
||||
id: `${id}_a${i}`, node: `pergola_anchor_0${i}`, type: 'pergola', work: 'cloth',
|
||||
})),
|
||||
}),
|
||||
},
|
||||
{
|
||||
// The collateral apex. NO anchors, on purpose — nothing about a glasshouse
|
||||
// may adopt (the ridge carries tie_off:false in the GLB). collateralKey is
|
||||
// EXPLICIT so the second one placed still bills (the carport lesson).
|
||||
kind: 'structure', label: 'Glasshouse (the apex, $320)', model: 'glasshouse_01_v1',
|
||||
hint: 'no anchors at all; $320 of glass — the thing you keep the whole rig away from',
|
||||
make: (id, x, z) => ({
|
||||
id, model: 'glasshouse_01_v1', wreckedModel: 'glasshouse_01_wrecked_v1',
|
||||
x, z, rotYDeg: 0, solid: true,
|
||||
collateralKey: 'glasshouse',
|
||||
collateralValue: 320, collateralLabel: 'the glasshouse',
|
||||
anchors: [],
|
||||
}),
|
||||
},
|
||||
{
|
||||
// The compliance ring. Fourteen posts, every one tie_off:false in the GLB;
|
||||
// unpriced BY RULING (the bike rule — price it when debris can bend it).
|
||||
kind: 'structure', label: 'Pool + fence ring', model: 'pool_kit_01_v1',
|
||||
hint: 'fourteen perfect-looking posts, every one an honest no — the legal tie-offs are elsewhere',
|
||||
make: (id, x, z) => ({
|
||||
id, model: 'pool_kit_01_v1', x, z, rotYDeg: 0, solid: true,
|
||||
anchors: [],
|
||||
}),
|
||||
},
|
||||
{
|
||||
kind: 'house', label: 'House (north edge)', model: 'house_yardside_v1', singleton: true,
|
||||
hint: 'three fascia anchors at rating_hint 0.35 — "the fascia board is a lie"',
|
||||
|
||||
@ -312,6 +312,12 @@ function render(out, s, ms) {
|
||||
} else if (s.sepUnjudged) {
|
||||
const cs = card(out, 'SEPARATION TARGET — NOT JUDGED', 'ed-warn');
|
||||
cs.append(el('div', 'ed-note', s.sepUnjudged));
|
||||
} else if (s.separationFinding) {
|
||||
// SPRINT16 gate 2.3 [B]: measured, and honestly REFUSED — different fact
|
||||
// from "nobody tried", and the card must not flatten the two together.
|
||||
const cs = card(out, 'SEPARATION TARGET — PIN REFUSED (measured)', 'ed-warn');
|
||||
cs.append(el('div', 'ed-note',
|
||||
Array.isArray(s.separationFinding) ? s.separationFinding.join(' ') : String(s.separationFinding)));
|
||||
} else {
|
||||
const cs = card(out, 'SEPARATION TARGET — NONE PINNED', 'ed-warn');
|
||||
cs.append(el('div', 'ed-note',
|
||||
|
||||
@ -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.
|
||||
@ -123,6 +143,11 @@ const CSS = `
|
||||
letter-spacing:.05em; }
|
||||
#hud-card .verdict.win { background:#12321c; border:1px solid #2c6b3c; color:#7fce6a; }
|
||||
#hud-card .verdict.lose { background:#3a1618; border:1px solid #7d2b2b; color:#ff8f86; }
|
||||
/* SPRINT16 gate 2.2 [B] — the fabric sentence under the verdict, quieter than
|
||||
the verdict itself: it is a footnote about the bet, not a second ruling.
|
||||
Sibling of .verdict (same pattern as the repline), so the verdict div and
|
||||
its a.test-pinned prose stay byte-untouched. */
|
||||
#hud-card .fabricnote { margin:8px 0 0; font-size:11px; color:#8ba0ad; letter-spacing:.04em; }
|
||||
|
||||
/* --- the week ---------------------------------------------------------- */
|
||||
#hud-card .pips { letter-spacing:.5em; font-size:15px; margin:-6px 0 10px; color:#3f5561; }
|
||||
@ -169,6 +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
|
||||
@ -507,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">
|
||||
${[
|
||||
@ -753,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">
|
||||
@ -774,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
|
||||
@ -793,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>` : '';
|
||||
|
||||
@ -831,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}
|
||||
@ -875,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.'],
|
||||
@ -887,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>`;
|
||||
@ -917,6 +989,10 @@ showForecast({ key, def, site, tomorrowDef }, wk, onGo) {
|
||||
['garden', `${r.hp.toFixed(0)}%`],
|
||||
['corners intact', `${r.cornersTotal - r.cornersLost}/${r.cornersTotal}`],
|
||||
['what got through', r.hailBlocked],
|
||||
// SPRINT16 gate 2.2 [Lane B] — the fabric bet is on the record: the
|
||||
// choice the player made at the F key, printed on the same paper as
|
||||
// what it did or didn't stop. Unconditional — the record is the point.
|
||||
...(r.fabric ? [['fabric flown', r.fabric.name]] : []),
|
||||
['hardware lost', r.bill ? `$${r.bill}` : 'none'],
|
||||
].map(([k, v]) => `<div class="row"><span>${k}</span><b>${v}</b></div>`).join('');
|
||||
|
||||
@ -948,9 +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
|
||||
@ -963,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>
|
||||
@ -987,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">
|
||||
@ -996,11 +1080,24 @@ 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.
|
||||
(Keep-both at integration, per B's own merge notes — the two
|
||||
lines are siblings by design.) */''}
|
||||
${r.fabricNote ? `<div class="fabricnote">${r.fabricNote}</div>` : ''}
|
||||
<button class="go">${next}</button>
|
||||
${w?.client ? `<div class="terms">Payable 14 days. ${w.collateral ? 'They’ll want that made right by Friday.' : 'Thank you for your business.'}</div>` : ''}
|
||||
</div>`;
|
||||
|
||||
@ -23,7 +23,7 @@ import { createPlayer } from './player.js';
|
||||
import { Interact, wireYardActions } from './interact.js';
|
||||
import { createDebris } from './debris.js';
|
||||
import { createSkyFx } from './skyfx.js';
|
||||
import { createRiggingUI } from './rigging.js';
|
||||
import { createRiggingUI, fabricNoteFor } from './rigging.js';
|
||||
import { createHud } from './hud.js';
|
||||
import { createWeek, NIGHTS, nightAt } from './week.js';
|
||||
import { createGarden } from './garden.js';
|
||||
@ -272,6 +272,52 @@ export function verdictFor({ hp, lost, win, dmg, pondPeak, pondDumped, beyondSav
|
||||
return { mode: 'mostly', verdict: 'THE GARDEN MADE IT. Mostly.' };
|
||||
}
|
||||
|
||||
/**
|
||||
* SPRINT16 gate 1 — the work history, per corner at dawn: what the ledger
|
||||
* keeps. The night already computed every field; this is the one place they're
|
||||
* assembled, so week.js can store them (settlement.rig) and tomorrow's sheet
|
||||
* can book warranty off them. Shapes per the THREADS seam contract:
|
||||
*
|
||||
* broke it let go at least once tonight. Read off the EVENT TALLY,
|
||||
* not the corner flag — repairCorner() clears `broken`, so by
|
||||
* dawn the flag has forgotten every break that was fixed.
|
||||
* repaired broke, and was standing again at dawn (D's hold-E). NOT a
|
||||
* warranty item, by spec: fixing it mid-storm is the job.
|
||||
* brokenAtDawn still down when the storm ended — books tomorrow's warranty
|
||||
* line and takes tonight's rep hit. Riding it out broken is
|
||||
* the thing the ledger exists to price.
|
||||
*
|
||||
* `repaired` and `brokenAtDawn` partition `broke` — a corner that broke twice
|
||||
* and was fixed once is brokenAtDawn, not repaired. hw/hwCost are what was on
|
||||
* the corner AT DAWN (a spare-repaired corner reads the spare, honestly — that
|
||||
* is the steel the client's yard is wearing tomorrow). effRating is
|
||||
* rating × ratingHint, the number sail.js actually fails on — the same truth
|
||||
* the HUD bars and the verdict print, third surface, one formula.
|
||||
*
|
||||
* A value, exported, for the Enter-guard reason: scoreRun lives inside boot()
|
||||
* behind a WebGL context where no assert can reach it, and the ledger's
|
||||
* partition semantics are exactly the kind of rule that would otherwise only
|
||||
* ever be "tested" by a green suite that never ran it.
|
||||
*
|
||||
* @param {Array} corners rig.corners at dawn
|
||||
* @param {Map<string,{broke:number,repaired:number}>} [tally] the night's break/repair events per anchor
|
||||
*/
|
||||
export function rigRecordFor(corners, tally = new Map()) {
|
||||
return corners.map((c) => {
|
||||
const tal = tally.get(c.anchorId);
|
||||
const broke = (tal?.broke ?? 0) > 0 || !!c.broken;
|
||||
return {
|
||||
anchorId: c.anchorId,
|
||||
hw: c.hw.name,
|
||||
hwCost: c.hw.cost,
|
||||
effRating: Math.round(c.hw.rating * (c.anchor?.ratingHint ?? 1)),
|
||||
broke,
|
||||
repaired: broke && !c.broken,
|
||||
brokenAtDawn: !!c.broken,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase machine
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -839,7 +885,22 @@ export async function boot(opts = {}) {
|
||||
const { verdict, mode } = verdictFor({ hp, lost, win, dmg, pondPeak, pondDumped,
|
||||
beyondSaving: week.job.gardenBeyondSaving });
|
||||
|
||||
// SPRINT16 gate 2.2 [Lane B] — the fabric bet on the record. The F key
|
||||
// landed in the fresh-eyes review; this is the paperwork half: the invoice
|
||||
// carries the fabric every night (r.fabric → hud's rows), and the verdict
|
||||
// gets ONE extra sentence when the bet provably mattered (fabricNoteFor —
|
||||
// pure, thresholded, silent when a sentence would be a guess). The
|
||||
// FORECAST half — arguing the bet before you rig — is C's gate 3.3.
|
||||
const fabric = rigging.session.fabric;
|
||||
const fabricNote = fabricNoteFor({
|
||||
fabric,
|
||||
hailSize: defs[week.stormKey]?.hail?.size ?? 0,
|
||||
dmg, pondPeak,
|
||||
});
|
||||
|
||||
return {
|
||||
fabric: { id: fabric.id, name: fabric.name },
|
||||
fabricNote,
|
||||
hp,
|
||||
cornersLost: lost.length,
|
||||
cornersTotal: rig.corners.length || 4,
|
||||
@ -868,6 +929,8 @@ export async function boot(opts = {}) {
|
||||
verdict,
|
||||
/** Which failure mode the verdict is speaking from. Asserted in a.test.js. */
|
||||
verdictMode: mode,
|
||||
/** SPRINT16 gate 1 — the work history. See rigRecordFor above. */
|
||||
rigRecord: rigRecordFor(rig.corners, nightTally),
|
||||
};
|
||||
}
|
||||
|
||||
@ -940,6 +1003,10 @@ export async function boot(opts = {}) {
|
||||
{
|
||||
night: week.night, nights: week.nights, bank: week.bank, log: week.log,
|
||||
job: week.job, quote: week.quote(defs[week.stormKey]), isFinalNight: week.isFinalNight,
|
||||
// SPRINT16 — the letterhead's number. The quote carries it too
|
||||
// (quote.rep), but the sheet renders a letterhead even on nights whose
|
||||
// quote degrades away, so it rides the week block explicitly.
|
||||
rep: week.rep,
|
||||
},
|
||||
() => { stormKey = week.stormKey; game.setPhase('prep'); },
|
||||
);
|
||||
@ -960,6 +1027,22 @@ export async function boot(opts = {}) {
|
||||
if (!e.reason) pondDumped += e.kg;
|
||||
});
|
||||
|
||||
// SPRINT16 gate 1 — the night's break/repair tally, per anchor. The ledger's
|
||||
// `repaired` bit cannot be read off the corners alone: repairCorner() clears
|
||||
// `broken`, so at dawn a fixed corner looks identical to one that never went.
|
||||
// Same pattern as pondDumped above (subscribe once, reset at storm start) and
|
||||
// for the same reason: the Emitter survives re-rigs, and pre-storm events —
|
||||
// a divergence break on the calm day, say — are not the night's story.
|
||||
/** @type {Map<string, {broke:number, repaired:number}>} */
|
||||
const nightTally = new Map();
|
||||
const tallyFor = (id) => {
|
||||
let t = nightTally.get(id);
|
||||
if (!t) { t = { broke: 0, repaired: 0 }; nightTally.set(id, t); }
|
||||
return t;
|
||||
};
|
||||
rig.events.on('break', (e) => { tallyFor(e.anchorId).broke++; });
|
||||
rig.events.on('repair', (e) => { tallyFor(e.anchorId).repaired++; });
|
||||
|
||||
// --- phases -------------------------------------------------------------
|
||||
game.on('phaseChange', ({ to }) => {
|
||||
// Prep and forecast happen on the calm day; the storm you picked only
|
||||
@ -980,7 +1063,7 @@ export async function boot(opts = {}) {
|
||||
if (paused) { paused = false; hud.setPaused(false); }
|
||||
if (muted) setMuted(true);
|
||||
|
||||
if (to === 'storm') { pondPeak = 0; pondDumped = 0; }
|
||||
if (to === 'storm') { pondPeak = 0; pondDumped = 0; nightTally.clear(); }
|
||||
|
||||
if (to === 'forecast') {
|
||||
hud.setDawn(false);
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
|
||||
import { HARDWARE, START_BUDGET, SPARE_COST, FIXED_DT } from './contracts.js';
|
||||
import { SailRig, orderRing, TENSION_MIN, TENSION_MAX } from './sail.js';
|
||||
import { hailBlockFor } from './weather.core.js';
|
||||
|
||||
export { START_BUDGET, SPARE_COST };
|
||||
export const MAX_CORNERS = 4;
|
||||
@ -45,11 +46,68 @@ export const DEFAULT_TENSION = 1.0;
|
||||
* weight, membrane earns a price and this comment is the place to start.
|
||||
*/
|
||||
export const FABRIC = [
|
||||
{ id: 'cloth', name: 'shade cloth', porosity: 0.30, cost: 0, blurb: 'wind blows through — half the load, sheds water, leaks the finest hail' },
|
||||
// Blurb honesty (SPRINT16): "sheds water" deleted — C measured that ponding
|
||||
// is fabric-blind (_applyPonding never reads porosity; both cloths pond
|
||||
// identically), and a player-facing claim the sim doesn't cash is the worst
|
||||
// kind. Ruled a known simplification in sail.js setFabric's comment.
|
||||
{ id: 'cloth', name: 'shade cloth', porosity: 0.30, cost: 0, blurb: 'wind blows through — half the load, leaks the finest hail' },
|
||||
{ id: 'membrane', name: 'waterproof membrane', porosity: 0, cost: 0, blurb: 'stops rain and every stone — full wind load, and it ponds' },
|
||||
];
|
||||
export const DEFAULT_FABRIC = FABRIC[0];
|
||||
|
||||
/**
|
||||
* SPRINT16 gate 2.2 — the verdict's fabric sentence, ONLY when the bet
|
||||
* provably mattered. The invoice records the fabric every night (the record is
|
||||
* unconditional); this is the extra line the aftermath gets to say about it,
|
||||
* and it must never guess: dmg.hail can't be split into through-the-weave vs
|
||||
* fell-beside-the-sail after the fact, so the note speaks only when the
|
||||
* MODEL's own numbers make the claim safe.
|
||||
*
|
||||
* · Cloth on a night whose stones pass the weave (hailBlockFor < 1 — C's
|
||||
* 2 mm-weave ruling) with real hail damage: name the leak. That's the
|
||||
* spec's own case, "a membrane night survived in cloth should say what the
|
||||
* leak cost". The phrasing claims what is true — that much hail REACHED
|
||||
* the bed and the membrane stops those stones — not that membrane would
|
||||
* have saved exactly that many HP (some of it may have fallen past the
|
||||
* sail's edge, which no fabric fixes).
|
||||
* · Membrane that actually ponded (the HUD's own >80 kg 'ponded' bar): name
|
||||
* the trade — the pond IS the membrane's price, and the verdict prose for
|
||||
* ponded/broomed nights doesn't say which cloth bought it.
|
||||
* · Everything else: null. Big-hail nights (both fabrics block 100%), dry
|
||||
* nights, clean wins — the row on the invoice is the whole record.
|
||||
*
|
||||
* Pure, headless-testable; thresholds are data on the function so the asserts
|
||||
* and the wiring read the same numbers.
|
||||
*
|
||||
* @param {object} o
|
||||
* @param {object} o.fabric the FABRIC entry flown (session.fabric / summary.fabric)
|
||||
* @param {number} [o.hailSize] storm def's hail.size (1.0 ≈ 1.5 cm stone); 0/absent = no hail tonight
|
||||
* @param {object} [o.dmg] garden.damage {hail, rain}, HP
|
||||
* @param {number} [o.pondPeak] peak kg of water the sail held tonight
|
||||
* @returns {string|null} one sentence for the aftermath card, or null
|
||||
*/
|
||||
export function fabricNoteFor({ fabric, hailSize = 0, dmg = { hail: 0, rain: 0 }, pondPeak = 0 } = {}) {
|
||||
if (!fabric || !FABRIC.some((f) => f.id === fabric.id)) return null;
|
||||
if (fabric.porosity > 0) {
|
||||
const leaks = hailSize > 0 && hailBlockFor(hailSize, fabric.porosity) < 1;
|
||||
if (leaks && (dmg.hail ?? 0) >= fabricNoteFor.LEAK_MATTERS_HP) {
|
||||
return `Flown in ${fabric.name} — stones this fine rattle through the weave, and `
|
||||
+ `${Math.round(dmg.hail)} HP of hail reached the bed. The membrane stops every stone.`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if ((pondPeak ?? 0) > fabricNoteFor.POND_MATTERS_KG) {
|
||||
return `Flown in ${fabric.name} — it stopped the stones and the rain, and held `
|
||||
+ `${Math.round(pondPeak)} kg of what it stopped. That is the membrane's price.`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
/** The leak has to have cost real HP before the verdict brings it up. */
|
||||
fabricNoteFor.LEAK_MATTERS_HP = 5;
|
||||
/** verdictFor's own 'ponded' threshold (main.js pondPeak > 80), same number —
|
||||
* the note and the verdict must agree on when a pond is worth a sentence. */
|
||||
fabricNoteFor.POND_MATTERS_KG = 80;
|
||||
|
||||
const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
|
||||
|
||||
const OK = { ok: true };
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
* js/tests/b.test.js) and node.
|
||||
*/
|
||||
|
||||
import { RiggingSession, FABRIC } from './rigging.js';
|
||||
import { RiggingSession, FABRIC, fabricNoteFor } from './rigging.js';
|
||||
import { SailRig, TENSION_MIN, TENSION_MAX } from './sail.js';
|
||||
import { HARDWARE, START_BUDGET, SPARE_COST } from './contracts.js';
|
||||
|
||||
@ -359,6 +359,39 @@ test('fabric is a bet the F key can actually place (SPRINT15 — the dead mechan
|
||||
return 'cloth -> membrane -> cloth, $0 both ways, hessian refused';
|
||||
});
|
||||
|
||||
// --- SPRINT16 gate 2.2: the fabric on the record ---------------------------
|
||||
// The invoice row is unconditional wiring (main.js/hud.js, verified live);
|
||||
// what this pins is the VERDICT's fabric sentence — fabricNoteFor speaks only
|
||||
// when the bet provably mattered, and stays silent everywhere a sentence
|
||||
// would be a guess. Storm hail sizes are the shipped ones: southerly pea hail
|
||||
// 0.7 (cloth blocks 74% — hailBlockFor's measured number), wild night 1.3 and
|
||||
// ice night 1.4 (both fabrics block 100%).
|
||||
|
||||
test('fabricNoteFor: speaks only when the bet mattered, silent when a sentence would guess', () => {
|
||||
const [CLOTH, MEMBRANE] = FABRIC;
|
||||
// the spec's case: a pea-hail night flown in cloth, hail cost the bed real HP
|
||||
const leak = fabricNoteFor({ fabric: CLOTH, hailSize: 0.7, dmg: { hail: 22, rain: 3 } });
|
||||
assert(leak && leak.includes(CLOTH.name), `leak note must name the fabric flown, got: ${leak}`);
|
||||
assert(leak.includes('22'), `leak note must say what the leak cost, got: ${leak}`);
|
||||
// big stones cannot pass the weave — cloth blocked 100%, hail damage came
|
||||
// from where the sail wasn't, and blaming the weave for it would be a lie
|
||||
assert(fabricNoteFor({ fabric: CLOTH, hailSize: 1.3, dmg: { hail: 40, rain: 5 } }) === null,
|
||||
'cloth blamed for big-stone hail — hailBlockFor(1.3, 0.3) is 1.0, nothing leaked');
|
||||
// a leak that cost under the threshold is not worth a verdict sentence
|
||||
assert(fabricNoteFor({ fabric: CLOTH, hailSize: 0.7, dmg: { hail: fabricNoteFor.LEAK_MATTERS_HP - 1, rain: 0 } }) === null,
|
||||
'a trivial leak got a sentence — the threshold is not being read');
|
||||
// membrane's price is the pond, and only a pond the verdict itself would name
|
||||
const pond = fabricNoteFor({ fabric: MEMBRANE, hailSize: 0.7, dmg: { hail: 0, rain: 2 }, pondPeak: 240 });
|
||||
assert(pond && pond.includes(MEMBRANE.name) && pond.includes('240'), `pond note must name membrane and the kg held, got: ${pond}`);
|
||||
assert(fabricNoteFor({ fabric: MEMBRANE, hailSize: 0.7, dmg: { hail: 0, rain: 2 }, pondPeak: 40 }) === null,
|
||||
'membrane got a sentence over a pond the verdict would not even mention');
|
||||
// no hail data (a dry night), unknown fabric, missing fabric: silence, not a throw
|
||||
assert(fabricNoteFor({ fabric: CLOTH, dmg: { hail: 30, rain: 0 } }) === null, 'no hail tonight but the weave got blamed');
|
||||
assert(fabricNoteFor({ fabric: { id: 'hessian', name: 'hessian', porosity: 0.5 } }) === null, 'unknown fabric must be silent');
|
||||
assert(fabricNoteFor({}) === null && fabricNoteFor() === null, 'missing fabric must be silent, not a throw');
|
||||
return `leak note fires at 0.7-size stones + >=${fabricNoteFor.LEAK_MATTERS_HP} HP; pond note at >${fabricNoteFor.POND_MATTERS_KG} kg; all else silent`;
|
||||
});
|
||||
|
||||
export const RIGGING_TESTS = TESTS;
|
||||
|
||||
export function runRiggingSelftest() {
|
||||
|
||||
@ -529,6 +529,11 @@ export class SailRig {
|
||||
/**
|
||||
* Rain lands, runs downhill, and pools where it can't get out. Called after
|
||||
* the wind pass (which fills _nodeFlat / _nodeArea).
|
||||
*
|
||||
* ⚠ FABRIC-BLIND, on purpose and on the record (SPRINT16, C's finding):
|
||||
* nothing below reads `this.porosity`, so an open weave ponds exactly like a
|
||||
* membrane. Ruled a known simplification — see setFabric's comment for the
|
||||
* ruling and the re-measure bill a real through-weave leak would incur.
|
||||
* @param {number} mmPerHour wind.rainMmPerHour(t) — REAL-world rate, Lane C's data
|
||||
*/
|
||||
_applyPonding(mmPerHour, dt) {
|
||||
@ -921,6 +926,7 @@ export class SailRig {
|
||||
* undefined` is `load > NaN`: always false, i.e. an UNBREAKABLE anchor.
|
||||
*/
|
||||
_checkFailure(dt) {
|
||||
let diverged = false;
|
||||
for (let k = 0; k < 4; k++) {
|
||||
const c = this.corners[k];
|
||||
if (c.broken) continue;
|
||||
@ -938,6 +944,7 @@ export class SailRig {
|
||||
this.invMass[this.cornerIdx[k]] = 1 / this.nodeMass;
|
||||
this.dumpPond('corner blew');
|
||||
this.events.emit('break', { type: 'break', corner: c, anchorId: c.anchorId, hw: c.hw.name, t: this.t, reason: 'divergence' });
|
||||
diverged = true;
|
||||
continue;
|
||||
}
|
||||
if (c.load > c.hw.rating * (c.anchor.ratingHint ?? 1)) c.overload += dt;
|
||||
@ -957,9 +964,94 @@ export class SailRig {
|
||||
this.events.emit('break', { type: 'break', corner: c, anchorId: c.anchorId, hw: c.hw.name, t: this.t });
|
||||
}
|
||||
}
|
||||
// SPRINT16 gate 2.1 — THE HEAL. The break above made divergence detectable
|
||||
// (the fresh-eyes review's fix); this makes it survivable to LOOK at. The
|
||||
// freed corner otherwise keeps integrating from the NaN the solver left in
|
||||
// the spring network, and the cloth a player sees after the bang is a
|
||||
// shredded z-fighting mess, not a cloth. Heal ONLY on a divergence break —
|
||||
// an honest overload break has finite state and must not be touched.
|
||||
if (diverged) this._healNonFinite();
|
||||
if (this._dirtyRest) { this._applyRestLengths(); this._dirtyRest = false; }
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset every non-finite node from its nearest finite neighbour (grid rings,
|
||||
* straight neighbours before diagonals — _nbr's own order), or from the
|
||||
* corners' anchors when the whole cloth is poisoned. `prev` is matched to
|
||||
* `pos` on every healed node so verlet reads zero velocity out of the heal
|
||||
* instead of inventing a launch. Non-finite WATER is zeroed too: a NaN pond
|
||||
* re-poisons the healed positions on the very next substep through the
|
||||
* weight term (F += GRAVITY * w), so healing positions alone heals nothing.
|
||||
*
|
||||
* Deterministic (fixed iteration order, no randomness, no clock), and pure
|
||||
* repair: a rig with fully finite state is untouched byte-for-byte — the
|
||||
* negative control in sail.selftest.js asserts a clean storm never emits
|
||||
* 'heal'. Emits 'heal' {nodes, t} so a log can count what a bang cost.
|
||||
*
|
||||
* @returns {number} nodes healed
|
||||
*/
|
||||
_healNonFinite() {
|
||||
const pos = this.pos, prev = this.prev, w = this.water;
|
||||
const n = this.invMass.length;
|
||||
// A NaN pond is poison whether or not its node's position survived. On
|
||||
// the divergence path today this is a SECOND wire — the break's
|
||||
// dumpPond('corner blew') already fill(0)s the whole pond — but the heal's
|
||||
// own contract must not depend on the dump's side effect: if divergence
|
||||
// ever stops dumping (a "the cloth healed, keep the water" retune), the
|
||||
// NaN pond would corrupt pondMass/centroid/broom forever. Unit-asserted
|
||||
// directly in sail.selftest.js for exactly that reason.
|
||||
for (let i = 0; i < n; i++) if (!Number.isFinite(w[i])) w[i] = 0;
|
||||
|
||||
const sick = new Uint8Array(n); // 1 = position needs a donor
|
||||
let sickCount = 0, anyFinite = false, healed = 0;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const x = i * 3;
|
||||
if (Number.isFinite(pos[x]) && Number.isFinite(pos[x + 1]) && Number.isFinite(pos[x + 2])) {
|
||||
anyFinite = true;
|
||||
// finite position, poisoned velocity: match prev, keep the position
|
||||
if (!Number.isFinite(prev[x]) || !Number.isFinite(prev[x + 1]) || !Number.isFinite(prev[x + 2])) {
|
||||
prev[x] = pos[x]; prev[x + 1] = pos[x + 1]; prev[x + 2] = pos[x + 2];
|
||||
healed++;
|
||||
}
|
||||
} else { sick[i] = 1; sickCount++; }
|
||||
}
|
||||
if (sickCount) {
|
||||
// Whole cloth gone: re-seed the corners from their anchors — the one
|
||||
// world position a rig always knows — then flood from there.
|
||||
if (!anyFinite) {
|
||||
for (let k = 0; k < 4; k++) {
|
||||
const ci = this.cornerIdx[k], x = ci * 3;
|
||||
const p = this._anchorPos(this.corners[k].anchor, this.t);
|
||||
pos[x] = prev[x] = p.x; pos[x + 1] = prev[x + 1] = p.y; pos[x + 2] = prev[x + 2] = p.z;
|
||||
if (sick[ci]) { sick[ci] = 0; sickCount--; healed++; }
|
||||
}
|
||||
}
|
||||
// Ring-by-ring flood: each pass, a sick node bordering the finite region
|
||||
// copies that neighbour and joins it, so positions march inward from the
|
||||
// nearest healthy cloth. Terminates: the grid is connected, so every
|
||||
// pass with sick nodes left heals at least one.
|
||||
let changed = true;
|
||||
while (sickCount && changed) {
|
||||
changed = false;
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (!sick[i]) continue;
|
||||
for (let k = 0; k < 8; k++) {
|
||||
const m = this._nbr[i * 8 + k];
|
||||
if (m < 0 || sick[m]) continue;
|
||||
const x = i * 3, mx = m * 3;
|
||||
pos[x] = prev[x] = pos[mx];
|
||||
pos[x + 1] = prev[x + 1] = pos[mx + 1];
|
||||
pos[x + 2] = prev[x + 2] = pos[mx + 2];
|
||||
sick[i] = 0; sickCount--; healed++; changed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (healed) this.events.emit('heal', { type: 'heal', nodes: healed, t: this.t });
|
||||
return healed;
|
||||
}
|
||||
|
||||
// --- Lane D's seam (SPRINT2 decision 4) --------------------------------
|
||||
// D landed first and duck-typed these against the rig, so B conforms to D's
|
||||
// spelling rather than the other way round. Thin aliases on purpose: the
|
||||
@ -1018,10 +1110,22 @@ export class SailRig {
|
||||
}
|
||||
|
||||
/**
|
||||
* What the sail is made of. Porosity scales the wind pressure term and, since
|
||||
* rain lands on the horizontal projection of a face, an open weave sheds the
|
||||
* water it can't hold — so a porous cloth also ponds far less. Set BEFORE
|
||||
* attach(); RiggingSession.commit() does that.
|
||||
* What the sail is made of. Porosity scales the wind pressure term — and, as
|
||||
* of SPRINT16, that is ALL it scales. The previous version of this comment
|
||||
* claimed an open weave "sheds the water it can't hold — so a porous cloth
|
||||
* also ponds far less", and C's gate-3 measurement caught the cheque the sim
|
||||
* doesn't cash: _applyPonding never reads porosity, so cloth and membrane
|
||||
* pond IDENTICALLY (the $75 soaker ring's q2 gap, 3.24 vs 3.32 kN, is wind
|
||||
* term only). RULED a known simplification rather than silently fixed
|
||||
* ([B] THREADS 2026-07-20): every pinned cloth number in the repo — the
|
||||
* backyard separation block, the §7 gates, C's storm_06 pins, the S15
|
||||
* zero-delta lattice — was measured under fabric-blind ponding, and a pond-
|
||||
* physics change mid-TEETH would move all of them against the sprint's own
|
||||
* one-variable law. The fix (porosity-scaled intake + through-weave leak)
|
||||
* is real, wants its own gate with a full gauntlet re-measure, and C's
|
||||
* soaker is its ready-made test case; rigging.js's membrane-pricing comment
|
||||
* is the same conversation. Set BEFORE attach(); RiggingSession.commit()
|
||||
* does that.
|
||||
* @param {{porosity:number}|number} f a FABRIC entry or a raw porosity
|
||||
*/
|
||||
setFabric(f) {
|
||||
|
||||
@ -219,6 +219,114 @@ test('divergence BREAKS on the production path — a NaN corner cannot hold fore
|
||||
return `${broke.filter((e) => e.reason === 'divergence').length}/4 corners let go by divergence, HUD reads 0`;
|
||||
});
|
||||
|
||||
// --- SPRINT16 gate 2.1: THE HEAL -------------------------------------------
|
||||
// The break above (fresh-eyes review) made divergence detectable; these pin
|
||||
// that it is also SURVIVABLE TO LOOK AT. Before the heal, the freed corner
|
||||
// integrated from the NaN the solver left in the spring network and the cloth
|
||||
// after a bang was a shredded mess, not a cloth. The three tests are one
|
||||
// mechanism, three poison shapes: position NaN, velocity (prev) NaN via the
|
||||
// pond weight, and the negative control (honest breaks must never heal).
|
||||
|
||||
test('divergence heal: after the bang every node is finite within 1 s, prev matched', () => {
|
||||
// Shipped path: watchDivergence OFF, same rig shape as the break test above.
|
||||
const scenario = () => {
|
||||
const r = new SailRig({ anchors: makeAnchors(HEIGHTS_FLAT), gridN: 10 });
|
||||
r.attach(ALL_IDS, Array(4).fill(HARDWARE[2]), 1.0);
|
||||
const broke = [], heals = [];
|
||||
r.events.on('break', (e) => broke.push(e));
|
||||
r.events.on('heal', (e) => heals.push(e));
|
||||
const w = makeStubWind({ stormLen: 90 });
|
||||
runStorm(r, w, 2);
|
||||
assert(heals.length === 0, 'a healthy cloth healed something — the heal must be pure repair, never maintenance');
|
||||
const poisoned = r.N + 1; // interior node, review's shape
|
||||
r.pos[poisoned * 3 + 1] = NaN;
|
||||
// step one substep at a time so the break substep is observable exactly
|
||||
let steps = 0;
|
||||
while (!broke.some((e) => e.reason === 'divergence') && steps < Math.round(2 / SIM_DT)) {
|
||||
r.step(SIM_DT, w, (2 + steps * SIM_DT)); steps++;
|
||||
}
|
||||
assert(broke.some((e) => e.reason === 'divergence'), 'poison never broke a corner — the review\'s break regressed');
|
||||
assert(heals.length > 0 && heals[0].nodes > 0, 'the divergence break fired and the heal did not');
|
||||
// Immediately after the break substep: finite, and the healed node's prev
|
||||
// is MATCHED — verlet must read zero velocity out of the heal, not a launch.
|
||||
for (const v of r.pos) assert(Number.isFinite(v), 'node position still non-finite right after the heal substep');
|
||||
for (const v of r.prev) assert(Number.isFinite(v), 'node prev still non-finite right after the heal substep');
|
||||
for (let k = 0; k < 3; k++) {
|
||||
assert(r.pos[poisoned * 3 + k] === r.prev[poisoned * 3 + k],
|
||||
'healed node has pos != prev — the heal invented velocity for verlet to integrate');
|
||||
}
|
||||
// ...and the spec's bound: still finite a full second of sim later.
|
||||
runStorm(r, w, 1);
|
||||
for (const v of r.pos) assert(Number.isFinite(v), 'cloth went non-finite again inside 1 s — the heal did not hold');
|
||||
for (const v of r.water) assert(Number.isFinite(v), 'water went non-finite inside 1 s of the heal');
|
||||
return { pos: Float64Array.from(r.pos), heals: heals.length };
|
||||
};
|
||||
const a = scenario(), b2 = scenario();
|
||||
assert(a.pos.length === b2.pos.length, 'determinism runs differ in size');
|
||||
for (let i = 0; i < a.pos.length; i++) assert(a.pos[i] === b2.pos[i], `heal is nondeterministic: node component ${i} differs across identical runs`);
|
||||
return `poison → break → healed (${a.heals} heal event(s)), finite 1 s on, byte-equal across two runs`;
|
||||
});
|
||||
|
||||
test('divergence heal: the NaN pond is zeroed with the positions, and stays zeroed under live rain', () => {
|
||||
// Poison shape 2: divergence with the WATER poisoned too. This is what a real
|
||||
// blow-up leaves behind while it rains — NaN face normals feed NaN into
|
||||
// _nodeFlat and the rain-add writes it into the water field — so the test
|
||||
// injects both halves deterministically rather than racing the rain clock.
|
||||
// A heal that only fixed positions would leave pondMass() NaN forever: the
|
||||
// weight guard's `w[n] > 0` happens to filter NaN today, but the HUD's
|
||||
// ponding line, the broom's centroid and the conservation asserts all read
|
||||
// the water field directly, and one refactor of that guard makes a NaN pond
|
||||
// live poison. The heal zeroes it at the same moment it heals the cloth.
|
||||
const r = rig(HEIGHTS_FLAT, { hw: UNBREAKABLE });
|
||||
r.watchDivergence = false; // shipped path, as above
|
||||
const broke = [], heals = [];
|
||||
r.events.on('break', (e) => broke.push(e));
|
||||
r.events.on('heal', (e) => heals.push(e));
|
||||
const w = realWind(); // real storm: rain keeps landing after the heal
|
||||
runStorm(r, w, 40);
|
||||
assert(r.pondMass() > 50, `only ${r.pondMass().toFixed(0)} kg ponded before the poison — the water half of this test is vacuous`);
|
||||
const belly = Math.floor(r.N / 2) * r.N + Math.floor(r.N / 2);
|
||||
r.water[belly] = NaN;
|
||||
r.pos[belly * 3 + 1] = NaN;
|
||||
runStorm(r, w, 2);
|
||||
assert(broke.some((e) => e.reason === 'divergence'), 'the poisoned belly never broke a corner — the review\'s break regressed');
|
||||
assert(heals.length > 0, 'the poison broke a corner and nothing healed');
|
||||
runStorm(r, w, 1);
|
||||
for (const v of r.pos) assert(Number.isFinite(v), 'cloth non-finite 1 s after the heal — the pond re-poisoned it');
|
||||
for (const v of r.water) assert(Number.isFinite(v), 'water still non-finite after the heal — the heal is not zeroing the pond');
|
||||
assert(Number.isFinite(r.pondMass()), 'pondMass still NaN after the heal — the HUD broom line would read NaN');
|
||||
// Honest disclosure, measured: on the storm path above the water is ALREADY
|
||||
// clean by heal time, because the divergence break's dumpPond('corner blew')
|
||||
// fill(0)s the pond first — so the asserts above cannot isolate the heal's
|
||||
// own water pass. Pin that contract at the unit level instead: the heal must
|
||||
// zero non-finite water WITHOUT a dump in front of it, so the second wire is
|
||||
// live the day the dump's semantics change.
|
||||
r.water[belly] = NaN;
|
||||
const healedNodes = r._healNonFinite();
|
||||
assert(r.water[belly] === 0, 'heal left NaN water in place — it is leaning on dumpPond to clean the pond');
|
||||
assert(Number.isFinite(r.pondMass()), 'pondMass still NaN after a direct heal');
|
||||
return `poisoned belly + pond → divergence break at t=${broke.find((e) => e.reason === 'divergence').t.toFixed(1)}s → healed, ` +
|
||||
`pond finite under live rain; direct heal zeroes NaN water (${healedNodes} position(s) touched)`;
|
||||
});
|
||||
|
||||
test('divergence heal: negative control — honest overload breaks never trigger it', () => {
|
||||
// The heal must be IN ADDITION to the break, not a hand that touches every
|
||||
// failure: a cheap rig blowing corners honestly (finite loads, real weather)
|
||||
// must ride the whole storm without one heal event. If this goes red, the
|
||||
// heal has started editing healthy state, which is a sim change wearing a
|
||||
// repair's name.
|
||||
const w = makeStubWind({ seed: 11, stormLen: 90 });
|
||||
const r = rig(HEIGHTS_FLAT, { hw: HARDWARE[0], tension: 1.3 }); // the flogging test's rig: it blows corners
|
||||
const broke = [], heals = [];
|
||||
r.events.on('break', (e) => broke.push(e));
|
||||
r.events.on('heal', (e) => heals.push(e));
|
||||
runStorm(r, w, 90);
|
||||
assert(broke.length > 0, 'nothing blew — the control is vacuous, pick a cheaper rig');
|
||||
assert(broke.every((e) => e.reason !== 'divergence'), 'an honest overload break carried reason divergence');
|
||||
assert(heals.length === 0, `${heals.length} heal event(s) on honest breaks — the heal is touching finite state`);
|
||||
return `${broke.length} honest break(s), zero heals — the heal only answers divergence`;
|
||||
});
|
||||
|
||||
test('ponding: consolidation cannot stack one node past the cap (post-flow clamp)', () => {
|
||||
// The rain-add clamp only ran WHILE raining; once rain stopped, downhill
|
||||
// flow could pile a basin node arbitrarily high and nothing trimmed it.
|
||||
@ -359,6 +467,65 @@ test('determinism: variable frame dt matches fixed dt', () => {
|
||||
return 'ragged frame times converge on the fixed-dt trace';
|
||||
});
|
||||
|
||||
// SPRINT16 pool (B) — ROADMAP's "the >83 ms frame determinism bound
|
||||
// (documented, un-tested)", now tested — and MEASURED SHARPER than documented.
|
||||
// step() burns wall time in SIM_DT chunks up to MAX_SUBSTEPS(5) per call, and
|
||||
// `if (n === MAX_SUBSTEPS) this._acc = 0` fires whenever a call CONSUMES five
|
||||
// substeps — including when the leftover residual was a perfectly legal
|
||||
// sub-SIM_DT remainder. So the unconditionally-lossless frame bound is
|
||||
// (MAX_SUBSTEPS − 1) × SIM_DT ≈ 66.7 ms (a 67–83 ms frame may drop up to one
|
||||
// SIM_DT of residual depending on accumulator phase; ≥ 83 ms always drops).
|
||||
// Pinned AS MEASURED, not retuned: every trace ever recorded was made under
|
||||
// this guard, and no real frame lives in the 67–83 ms gap on purpose. If the
|
||||
// guard is ever refined to `&& this._acc >= SIM_DT` (making 83 ms the true
|
||||
// bound), move SAFE_MAX with it in the same commit — this header is the
|
||||
// documented waiver ROADMAP asked for, THREADS [B] SPRINT16 has the numbers.
|
||||
test('determinism bound: frames under 66.7 ms hold the trace; a hitch past 83 ms drops time by design', () => {
|
||||
const SAFE_MAX = 0.066; // just under (MAX_SUBSTEPS − 1) × SIM_DT — see header
|
||||
const w1 = makeStubWind({ seed: 5, stormLen: 20 });
|
||||
const fixed = rig(HEIGHTS_HYPAR);
|
||||
for (let i = 0; i < Math.round(20 / SIM_DT); i++) fixed.step(SIM_DT, w1, i * SIM_DT);
|
||||
|
||||
// Half 1: ragged frames spanning the WHOLE legal range converge on the
|
||||
// fixed trace (the existing 4–24 ms test's promise, extended to the bound).
|
||||
const w2 = makeStubWind({ seed: 5, stormLen: 20 });
|
||||
const ragged = rig(HEIGHTS_HYPAR);
|
||||
const rand = rng(31);
|
||||
let acc = 0;
|
||||
while (acc < 19.5) {
|
||||
const dt = 0.004 + rand() * (SAFE_MAX - 0.004);
|
||||
ragged.step(dt, w2, acc);
|
||||
acc += dt;
|
||||
}
|
||||
// Compare at equal SIM clocks, not equal wall clocks: a random frame sum
|
||||
// overshoots the target by up to SAFE_MAX — up to 3 extra substeps of honest
|
||||
// evolution, an off-by-N comparison rather than a determinism failure. Top
|
||||
// up with fixed steps until both rigs have burned identical sim time.
|
||||
while (ragged.t < fixed.t - 1e-9) { ragged.step(SIM_DT, w2, acc); acc += SIM_DT; }
|
||||
assert(ragged.t === fixed.t, `sim clocks differ after top-up: ${ragged.t} vs ${fixed.t}`);
|
||||
for (let k = 0; k < 4; k++) {
|
||||
const d = Math.abs(fixed.corners[k].load - ragged.corners[k].load);
|
||||
assert(d < 1e-6, `corner ${k} drifted ${d.toFixed(6)} N on frames inside the lossless bound — MAX_SUBSTEPS shrank?`);
|
||||
}
|
||||
assert(acc - ragged.t < SIM_DT + 1e-9,
|
||||
`${((acc - ragged.t) * 1000).toFixed(1)} ms of wall time went missing on legal frames — the guard is dropping time early`);
|
||||
|
||||
// Half 2: one 120 ms hitch loses exactly the time past 5 substeps — the sim
|
||||
// shrugs, it does not catch up, and the clock says so. This is the bound
|
||||
// BEING a bound; if this half goes red the spiral guard is gone.
|
||||
const w3 = makeStubWind({ seed: 5, stormLen: 20 });
|
||||
const hitched = rig(HEIGHTS_HYPAR);
|
||||
const HITCH = 0.120, dropped = HITCH - 5 * SIM_DT; // ≈ 36.7 ms lost
|
||||
let fed = 0;
|
||||
hitched.step(HITCH, w3, 0); fed += HITCH;
|
||||
while (fed < 20) { hitched.step(SIM_DT, w3, fed); fed += SIM_DT; }
|
||||
const deficit = fed - hitched.t;
|
||||
assert(deficit > dropped - SIM_DT - 1e-9 && deficit < dropped + SIM_DT + 1e-9,
|
||||
`fed ${fed.toFixed(3)} s of wall time, sim clock reads ${hitched.t.toFixed(3)} — deficit ${deficit.toFixed(4)} s, ` +
|
||||
`expected ≈${dropped.toFixed(4)} s (one hitch's overflow). The spiral guard moved`);
|
||||
return `frames ≤${(SAFE_MAX * 1000).toFixed(0)} ms: byte-faithful, no time lost; one 120 ms hitch drops ${(deficit * 1000).toFixed(1)} ms as designed`;
|
||||
});
|
||||
|
||||
test('re-attach resets the clock: night 2 flies the same storm as night 1', () => {
|
||||
// SPRINT13. main.js builds ONE SailRig at boot and re-attach()es it every
|
||||
// night, and step() runs in EVERY phase once rigged — so the clock ran on
|
||||
|
||||
@ -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' }] };
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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