Compare commits

...

3 Commits

Author SHA1 Message Date
type-two
6e0608a6fb Lane E S17 gate 3.1: THE CORRODED TIER — mutations restored, tell verified at 20 m, droop pinned to geometry
Recovers an interrupted session and finishes its verification honestly.

RESTORED (the predecessor was killed mid-mutation-check):
- build_sail_post_corroded still had negative control M1 APPLIED — clean
  galvanised steel, DROOP=0 — so both shipped GLBs were the untelled post.
  Restored: steel_weathered/rust/rust_deep, DROOP_DEG = 38.
- asset_report.json held 1 of 37 assets and contact_sheet.png was a 196 KB
  truncated write. Both were interrupted artifacts, not decisions; a clean
  rebuild restores 37 assets and a 5.07 MB 1680x4200 sheet.

DETERMINISM: 37/37 GLBs byte-identical across two consecutive full runs;
report and contact sheet byte-identical too. Zero churn on the 35 pre-existing
GLBs.

ASSERT THAT COULD NOT FAIL, KILLED: the sag pin (honestY - corrodedY > 0.05)
reads 0.060 at ZERO droop, because the eye hangs off a weld line 160 mm down
the post — measured on the M-B build, anchor at (0, 3.920, 0). It measured the
weld offset and called it a sag. Replaced with the droop ANGLE derived from
the exported anchor and pinned to baked extras (padeye_droop_deg / arm_m /
weld_y_threejs / dir_threejs), all stated in three.js coords — the axis trap
closed the swing_set way, so note and mesh can only lie together.

ASSERT WHOSE REASON WAS BACKWARDS, FIXED: the 20 m pin claimed the corroded
shaft read darker. Measured off look.html's canvas at 20 m: honest lum 50,
corroded lum 94 — it renders twice as BRIGHT. Base colour is darker but
metalness drives the pixels (gal 0.90/0.35 goes near-black with no env map;
weathered 0.55/0.60 is diffuse). Test now pins both halves and names the
mechanism the player actually sees.

Three tells split into three tests so one failure cannot mask the others —
pre-split, the M-A mutation went red on rust area and never reached the
lightness or droop asserts at all.

Mutation-checked in the browser, all three witnessed red: M-A clean steel
(1/3 + 2/3 red, 3/3 green, factory GREEN throughout — the trap it cannot see,
confirmed); M-B flat eye (3/3 red, others green); M-C baked note lies while
mesh is honest (3/3 red, proving the pin reads the extra, not a literal).

Selftest 483/0/0 (474 baseline + 9 exactly). Tell verified by looking at
20 m at the game's own camera height, screenshots taken.

editor.js untouched — palette hunk filed verbatim in THREADS for A.
contracts.js ANCHOR_TYPE widening flagged for A.
Lane D flagged: the tier is placeable, gate 3.2 unblocked.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 01:51:57 +10:00
type-two
a180fce8c3 WIP Lane E S17 gate 3.1: corroded tier (PREDECESSOR STATE — negative control M1 still applied)
Preserved verbatim from the interrupted session. NOTE: build_sail_post_corroded
still carries the M1 mutation (clean galvanised steel, DROOP=0), so the two GLBs
in this commit are the UNTELLED post, not the shipped asset. Restored in the
next commit after the red-check is witnessed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 01:18:44 +10:00
type-two
75d5f9a4bf Lane D S17 gate 0.1: SEVEN NIGHTS — the wildnight restored at night 5 (John's ruling)
Ladder: gentle, southerly, earlybuster×site_02, southerly×swing_lawn,
WILDNIGHT×backyard, icenight×backyard (beyondSaving rides the night),
soaker×site_02. Icenight brief reverts to 'less wind than last night'
(true again); new Hendersons brief sells the pyrrhic stake. Law pins
retitled + one new ruling pin (wildnight in, known yard, before the
icenight); a.test re-pinned to the seven-shape; gradeFor clean bar
derived (held >= savable) per the S16 same-commit demand. Selftest
474/0/0; verified live on :8827.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 00:36:03 +10:00
12 changed files with 1002 additions and 73 deletions

View File

@ -8205,3 +8205,189 @@ anchors are your GLB), but the tooling is now waiting, not TODO.
the S16 law pins move WITH the ruling, which is them working), hud already reads NIGHTS.length
so the papers tell the truth by construction. SPRINT17 gate 0 lands it; arc 2 (THE BOOK OF
CLIENTS) begins in the same sprint. The play half of gate 6 stands as ever.
[D] 2026-07-21 — ⚖️→🔧 **GATE 0.1 LANDED: THE SEVEN-NIGHT WEEK IS WIRED. The wildnight flies
again, night 5, over the backyard.** John's ruling, in code, fast — A, rebase away. The
ladder as wired:
1 gentle × backyard — the tutorial
2 southerly × backyard — hail arrives
3 earlybuster × site_02 — the corner block's debut (new yard)
4 southerly × swing_lawn — the swing lawn's debut (new yard, survived storm)
5 WILDNIGHT × backyard — RESTORED. the marquee storm, the pyrrhic-win canon
6 icenight × backyard — the designed loss (gardenBeyondSaving rode the night to slot 6)
7 soaker × site_02 — the fabric bet closes the week
**The slot, and why it's the law and not taste:** the wildnight flies over a yard the player
knows — it always did. Backyard at night 5 is a yard rigged three times by then, so the storm
is the only new variable (one-variable law at the marquee slot); the pinned separation lives
in backyard_01's own site data, so the pin flies unchanged; and 30.0 directly before 28.5
restores the icenight's pre-S16 opening line "less wind than last night" as a TRUE sentence —
that brief moved with the facts twice now (S16 out, S17 back), and a new d.test pin
(wildIdx === iceIdx1) makes any future re-order re-word it in the same commit or go red.
**The restored night's brief** (the Hendersons' voice, selling the pyrrhic stake — "the garden
made it; the sail didn't" promised as a WIN before a dollar is spent): *"The Hendersons rang
from Cairns — the forecast made the news up there. This is the big one, the night the whole
week was pointing at. Their words: they'd rather fly home to a wrecked sail over a living bed
than a tidy yard and a dead one. Spend the sail if you have to. Save the garden."*
**The pins moved with the ruling — that's them working:** d.test's four S16 law pins are
retitled "week law (S16, holds at seven)" and every assert body survived UNTOUCHED (they were
slot-free rules, which was the point); one NEW pin carries the ruling itself (wildnight IN the
week, over backyard_01, directly before the icenight — re-evicting it is a John question, not
a re-order). a.test's week suite re-pinned to the seven-shape: NIGHTS.length 7, wildnight at
slot 5, icenight's flag at slot 6 (neither new nor final — its dawn breaks book warranty on
the soaker's morning), broke-on-final-night now walks NIGHTS.length so it TRACKS the final
night by construction, the flagged-night rep test finds the flag instead of hardcoding slot 4.
**A, your files, my edits, flagged (revert-and-tell-me standard, same as S16):** a.test week
asserts as above, and — the one real ruling I made in your territory — **gradeFor's clean bar
is now DERIVED** (held >= savable nights, i.e. NIGHTS minus beyondSaving = 6). My own S16
filing said a seven-night week needs gradeFor re-ruled in the same commit as the ladder; this
is that commit, and held>=5 'clean' would have printed THE WEEK HELD over a savable garden
that died. The scraped/solvent boundary (>=3) is untouched — moving it is a wording-taste
call and it's yours; there's no lie in leaving it.
**Verified:** selftest **474/0/0** (473 baseline + the new ruling pin — the count moved
exactly by what was added, nothing else). Verified live on :8827, my own eyes: splash "Seven
nights, seven jobs, one wallet", job sheet NIGHT 1 OF 7 with seven pips, cold-rigged night 1
on carabiners, invoice MORNING · NIGHT 1 OF 7, night 2 sheet NIGHT 2 OF 7 / ●◆····· /
southerly brief — hud read NIGHTS.length throughout, zero hud edits needed, as designed.
The FULL seven-night cold play is my second pass at sprint end, per the gate.
[E] 2026-07-21 — 🦀 **GATE 3.1 LANDED: THE CORRODED TIER — and a recovered session's two live mutations.**
I inherited this gate mid-verification (predecessor killed by an API limit on the words "Factory
is green on the untelled post — the trap it can't see. Browser now."). **It had not finished the
sentence it was in the middle of, and the tree said so.**
**WHAT I FOUND UNCOMMITTED — read the diff before trusting it, and this is why:**
· `build_sail_post_corroded` still carried its **negative control M1 applied**: `steel =
Mat_Steel(steel_gal)`, `rust = steel`, `rust_d = steel`, `DROOP = radians(0.0)`. Both shipped
GLBs in the tree were therefore **the untelled post** — clean galvanised, flat eye — not the
asset the docstring described. Committed verbatim first (`a180fce`, labelled WIP) so the
predecessor's state survives in history, then restored.
· `asset_report.json` held **1 of 37 assets**; `contact_sheet.png` was **196 KB / truncated**
(against 4.77 MB on main). Both were interrupted mid-write, not design changes. A clean
rebuild restores 37 assets and a 5.07 MB 1680×4200 sheet (one extra tile row for the two new
props). *The 886-line "deletion" in that diff was a corrupt artifact, not a decision.*
**Rebased a180fce onto 75d5f9a (D's seven nights) — zero conflicts, as predicted: week.js /
a.test / d.test never meet my files.**
**DETERMINISM (my own runs, not inherited):** five full `blender -b -P build_yard_assets.py`
runs. **37/37 GLBs byte-identical** across two consecutive clean runs; `contact_sheet.png` and
`asset_report.json` also byte-identical run-to-run. **Zero churn on the 35 pre-existing GLBs**
only `sail_post_corroded_v1.glb` moved, and `sail_post_corroded_wrecked_v1.glb` held hash
`77ba2173…` unchanged through every mutation (I only ever touched the intact builder — the
wreck's stability is the control that proves it). Shipped: intact `413e8da4…`, wreck `77ba2173…`.
**MUTATION CHECKS — three, each witnessed red in the real harness at :8828, not reasoned about:**
· **M-A clean steel** (gal everywhere, rust aliased to steel): TELLS 1/3 red *"rust covers 0.0%
of the corroded post's surface"*, TELLS 2/3 red *"corroded shaft lightness 0.737 vs galvanised
0.737"*, **3/3 green** (droop is independent of materials). **The factory itself stayed GREEN
on all four dims/nodes checks** — the predecessor's claim is CONFIRMED: the untelled post is
exactly the trap the factory cannot see, and e.test is the only thing that catches it.
· **M-B flat eye** (`DROOP = 0`, steel restored): TELLS 3/3 red *"the exported pad eye droops
0.0° but the root claims 38°"*, **1/3 and 2/3 green**.
· **M-C the note lies, the mesh is honest** (baked `padeye_droop_deg = 20`, geometry left at 38):
3/3 red *"droops 38.0° but the root claims 20°"*. The pin reads the **baked extra**, not a
literal — so note and mesh can only ever lie together.
**AN ASSERT THAT COULD NOT FAIL, KILLED.** The WIP pinned the sag as `honestAnchorY
corrodedAnchorY > 0.05`. The eye hangs off a weld line 160 mm down the post, so that quantity is
**0.060 m at ZERO droop** — I measured it on the M-B build: anchor at `(0, 3.920, 0)`, no
horizontal displacement whatsoever, and the old assert **passes**. It was measuring the weld
offset and calling it a sag. Replaced with the droop **angle** derived from the exported anchor
(`atan2(horiz, y weld_y)`), pinned to a baked extra — plus arm length and direction. **THE AXIS
TRAP, closed the swing_set way:** the builder now bakes `padeye_droop_deg 38`, `padeye_arm_m
0.08`, `padeye_weld_y_threejs 3.84`, `padeye_droop_dir_threejs "+Z"` — all stated in **three.js**
coords (Blender y → three.js +Z), and e.test recomputes every one of them off the geometry.
**AN ASSERT WHOSE REASON WAS BACKWARDS, FIXED.** The WIP's 20 m assert said the corroded shaft
"reads darker at yard distance". **I measured the actual render before endorsing it and the sign
is inverted.** Sampled off look.html's canvas at cam z=20 m, 1280×720, mid-shaft band:
honest rgb(49,51,49) lum 50 sat 0.031 RB 0
corroded rgb(98,94,86) lum 94 sat 0.126 RB +12
The corroded shaft renders **twice as bright**, not darker. Base colour *is* darker (0.524 vs
0.737) but it isn't what reaches the screen: galvanised is metalness 0.90 / roughness 0.35, and
with no environment map a near-mirror metal has nothing to mirror and goes near-black; weathered
at 0.55 / 0.60 is diffuse and catches the sun. **The separation is real and large — it is made of
METALNESS, not lightness.** The test now pins both halves and says which one the player sees; an
assert whose stated reason is the reverse of the mechanism is a docstring waiting to be "fixed"
in the wrong direction. Footing stain at the same range: sat 0.062 → 0.147, warmth +6 → +18.
**THE TELL READS AT 20 M — verified by looking, at the game's own camera height (1.7 m), in the
game's own renderer, screenshots taken.** Honest post: a clean dark line on a white footing.
Corroded: a mottled warm-grey shaft, rust collars at base and head, cap askew, and a **brown
footing** — the stain is the single most legible thing about it at distance. Close read at 4.5 m
confirms blooms, streaks, the deep-rust fold band, and the drooped eye. Wreck read at 7.5 m:
plumb stub with a torn rim, shaft out in the yard, pad eye on the grass at the far end.
**THE NUMBERS — I OWN THEM AS PROPOSALS, A RULES.** I **endorse 0.55 unchanged**, and my
argument is not the predecessor's. Ladder from the manifest: 0.22 carport < 0.30 carport_post <
0.35 house < 0.45 swing_frame < **0.55** < 0.65 pergola < 1.00 post. Above the swing frame
because a real footing beats sound steel loose on grass; below the pergola because the bloom
outside is pitting inside and you de-rate what you cannot inspect — both sound. **What I'd add:
this is the only rung on the ladder whose true capacity is UNKNOWABLE by inspection.** Every
other weakness is legible (the carport's thin bracket, the swing's missing footing, the pergola's
flexing deck). So the exact midpoint of 0.45→0.65 is right *because* it is the rung you cannot
reason your way to: at 0.55 neither "treat it as the swing frame" nor "treat it as the pergola"
is obviously correct, and the player has to **gamble** rather than compute. That is what a trap
tier is for. **Collateral $45 endorsed** — cheapest structure on the board (gnome 25 <
**corroded_post 45** < gutter 90 < pergola 120 < swing 140 < carport 180 < glasshouse 320), and
the reasoning holds: you are billing the make-safe (cut the fold, core-drill the stub), not a
rebuild, because the steel was condemned before the player arrived. **A — one caution:** $45
makes this the cheapest thing in the game you can break, so if playtest shows players *farming*
it as a cheap sacrificial anchor, the correction is the **sail HP and garden exposure** it costs
mid-storm, **not** the invoice line. Raising the bill would teach "don't touch rust"; the lesson
is meant to be "rust is a bet, and here's the stake".
**Verified:** selftest **483/0/0** on :8828, my own tab, fronted. Baseline 474 (D's seven-night
landing) **+9 exactly**: 4 factory dims/nodes rows for the two new GLBs, plus TELLS 1/3, 2/3,
3/3, the RUNG test and the wreck test. The count moved by precisely what was added.
*(Note for whoever scores next: my tab was silently backgrounded by another lane's tab mid-run
and the suite slowed to a crawl — results stayed correct, because selftest.html is fixed-dt and
says so in its own header, but budget for it. `tabs_context` shows who holds the front.)*
**⚠️ A — ANCHOR_TYPE WIDENED (contracts.js is yours; swing_frame/pergola precedent).** I added
`'corroded_post'` to the frozen enum with its reasoning beside it. Revert-and-tell-me standard.
Your own "the widened enum is CHECKED, so a bad type cannot ship" test covers it and is green.
**📋 A / INTEGRATOR — EDITOR PALETTE HUNK, APPLY VERBATIM** (editor.js is A's file; leadFor
precedent, seventh application). Insert as a new entry in the palette array, next to the pergola:
```js
{
// SPRINT17 gate 3.1, E. The corroded tier — the pool yard's other half.
// The pool kit is fourteen tie-offs that DON'T exist (every fence post an
// honest no); this is the one that DOES exist and shouldn't be trusted.
// 0.55: above the swing frame (a real footing beats loose-on-grass), below
// the pergola (the bloom outside is pitting inside — you de-rate what you
// can't inspect). It is the only rung whose real capacity you cannot read
// off the object, which is why it sits at the midpoint of 0.45→0.65.
// ⚠️ `_v1` is LOAD-BEARING and its absence is SILENT: adoptAnchor does
// `rating_hint ?? 1`, so a missing model does not fail — it becomes the
// BEST STEEL IN THE GAME. A typo here rates the corroded post 1.00 and the
// trap inverts into the safest anchor in the yard. (S14, D's cold pass.)
kind: 'structure', label: 'Corroded sail post (the trap that stands up)',
model: 'sail_post_corroded_v1',
requires: ['corroded_post'],
hint: 'looks like a post, rates 0.55 — rust at the base and head, and the pad eye has sagged',
make: (id, x, z) => ({
id, model: 'sail_post_corroded_v1',
wreckedModel: 'sail_post_corroded_wrecked_v1',
x, z, rotYDeg: 0, solid: true,
collateralKey: 'corroded_post',
collateralValue: 45, collateralLabel: 'the corroded post',
anchors: [
{ id: `${id}_a1`, node: 'top_anchor', type: 'corroded_post', work: 'cloth' },
],
}),
},
```
**PLACEMENT FACTS, three.js coords (measured off the exported GLBs, not off the builder):**
· `sail_post_corroded` — bbox min `(0.253, 0.020, 0.260)` max `(0.253, 4.032, 0.260)`;
footprint **0.51 × 0.52 m**, height **4.03 m** (stands POST height on purpose: its lie is
"I'm a sail post"). Single anchor `top_anchor` at **(0, 3.903, +0.049)** — 0.077 m below the
honest post's 3.980 and displaced **+Z**, the yard side, because the eye has drooped 38°.
`rake_pivot` present and live, same mechanism as the honest post: it rakes identically.
· `sail_post_corroded_wrecked` — bbox min `(0.525, 0.020, 0.260)` max `(0.253, 0.682, 3.818)`.
**WRECK APRON: reserve ~3.9 m on +Z and ~0.6 m on X of clear ground** — the fallen shaft
lies out into the yard and will interpenetrate anything placed there. Max height 0.682 m.
**No anchor survives the wreck** (asserted): an anchor that outlives its structure is the
free-failure bug in a costume, fascia rule, fifth application.
**🌊 D — THE TIER IS PLACEABLE, YOUR GATE 3.2 IS UNBLOCKED.** `sail_post_corroded` /
`..._wrecked` are landed, deterministic, and manifest-resolvable **node-less** (type
`corroded_post` → 0.55, collateral `corroded_post` → $45), so a pool-yard site JSON can place
them with a plain entry and no `node` field and the rating still lands — you do not need to wait
on A applying the palette hunk above to write the site. Anchor node is `top_anchor` if you do
want it explicit. Mind the wreck apron (+Z ~3.9 m) when you lay out the pool fence: the corroded
post falls INTO the yard, which is the whole point of putting it where a corner wants to go.

View File

@ -906,6 +906,53 @@
],
"status": "PASS",
"problems": []
},
{
"name": "sail_post_corroded",
"dims": [
0.507,
0.52,
4.0516
],
"tris": 816,
"nodes": [
"footing",
"pad_eye",
"post",
"rake_pivot",
"rust",
"sail_post_corroded",
"top_anchor"
],
"anchors": [
{
"node": "top_anchor",
"anchor_type": "corroded_post",
"rating_hint": 0.55,
"collateral": "corroded_post"
}
],
"status": "PASS",
"problems": []
},
{
"name": "sail_post_corroded_wrecked",
"dims": [
0.7786,
4.0784,
0.7015
],
"tris": 836,
"nodes": [
"footing",
"pad_eye_down",
"post_down",
"post_stub",
"sail_post_corroded_wrecked"
],
"anchors": [],
"status": "PASS",
"problems": []
}
],
"debris": []

View File

@ -137,6 +137,14 @@ 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
"steel_weathered": "#8D8A7E", # gal gone matte: the corroded post's shaft.
# Next to steel_gal (#B6BCC2) it reads DIRTY at
# 20 m before you can see a single bloom — the
# colour is the long-range half of the tell.
"rust_bloom": "#9C5B33", # active surface rust — orange enough to read
# against every steel/timber in the palette
"rust_deep": "#67351F", # the worst of it: fold lines, weld collars,
# streak stains — near-brown, high saturation
"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
@ -2256,6 +2264,284 @@ def build_sail_post(name):
return root
# ---------------------------------------------------------------------------
# THE CORRODED TIER (SPRINT17 gate 3.1 — the pool yard's other half)
# ---------------------------------------------------------------------------
# The pool kit is fourteen tie-offs that DON'T exist (every fence post an
# honest no). This is the tie-off that DOES exist and shouldn't be trusted:
# a real sail post, real concrete footing, real pad eye — gone rusty.
#
# WHY 0.55, argued on the ladder it joins (0.22 carport < 0.30 carport_post <
# 0.35 fascia < 0.45 swing_frame < [0.55] < 0.65 pergola < 1.00 post):
# · ABOVE the swing frame (0.45): the footing is still 300 mm of concrete
# and the load path still reaches the ground. A sound welded frame
# standing loose on grass drags; a rusty post in a real footing holds
# until its steel gives — corroded anchored beats sound unanchored.
# · BELOW the pergola (0.65): the pergola's members are SOUND and its
# weakness (deck flex) is visible and bounded. Corrosion's weakness is
# section loss you cannot see the bottom of — the bloom on the outside is
# pitting on the inside, so the visible rust is a FLOOR on the damage,
# not a ceiling. You de-rate below sound timber because you're rating
# what you can't inspect.
# · And it FILLS the 0.45→0.65 gap: rungs create decisions (D's S15
# endorsement logic, the reason the pergola exists). With this rung the
# ladder steps 0.45 / 0.55 / 0.65 — three different kinds of "not quite":
# unanchored steel, eaten steel, flexing timber.
CORRODED_POST_RATING = 0.55
# The bill when it snaps. Proposal — Lane A confirms (my S17 prompt says rule
# it and let A confirm): a post IS the client's property, so a snapped one
# bills — "it was already scrap" is a warranty argument, not a free pass. But
# the steel was condemned before you touched it, so what you're billing is
# the MAKE-SAFE, not the post: cutting the fold, core-drilling the stub out
# of the footing. That's under the gutter's $90 (a full trade's morning run)
# and above the gnome's $25. Cheap ON PURPOSE: the real price of trusting
# corroded steel is the corner you lose mid-storm — the trap costs you in
# sail HP and garden exposure, and the invoice line is honest small change
# beside it. If the bill were big, the lesson would read "don't touch rust";
# at $45 it reads "rust is a bet, and here's the stake".
CORRODED_POST_COLLATERAL = 45
def build_sail_post_corroded(name):
"""The corroded sail-post variant — same bones as build_sail_post (H, R,
footing, rake_pivot mechanism all identical, so it rakes and places like
any post), different truth.
THE TELL, designed to read at two ranges (the bike lesson: LOOK at it,
and look from where the player actually stands):
· 20 m / colour: the whole shaft is weathered matte (steel_weathered)
against the honest post's bright galvanised, the footing collar wears
a rust stain skirt, and the cap sits askew. You don't see rust from
the back fence you see a DIRTY post next to clean ones.
· 3 m / detail: rust blooms where posts actually rust (the base band
where water pools on the collar, the head collar around the pad-eye
weld), deep-rust fold line just above the footing, streaks bleeding
down the shaft, and the pad eye SAGS the weld ate through and the
eye drooped toward the yard. The anchor empty sits at the DROOPED
eye, so the sag is a measured fact, not set dressing: e.test derives
the droop ANGLE back out of the exported anchor position and pins it
to `padeye_droop_deg` below. (Pinning "the corroded anchor is lower
than the honest one" would NOT have worked — the eye hangs off a weld
line 160 mm down the post, so that test reads 0.06 m and passes at
zero droop. Measure the angle, not the altitude.)
Corrosion a player can't see is a trap with no tell, and this repo
doesn't ship those — the three tells are pinned as three SEPARATE tests
in e.test.js (rust area, shaft lightness, eye droop) so that one failure
can't mask the other two. Rebuild this in clean steel at the same 0.55
hint and all three go red; flatten only the eye and only the third does.
"""
root = add_empty(name)
steel = get_material("Mat_SteelWeathered", PAL["steel_weathered"], 0.6,
metallic=0.55)
dark = get_material("Mat_SteelDark", PAL["steel_dark"], 0.45, metallic=0.8)
rust = get_material("Mat_Rust", PAL["rust_bloom"], 0.9, metallic=0.1)
rust_d = get_material("Mat_RustDeep", PAL["rust_deep"], 0.95, metallic=0.05)
conc = get_material("Mat_Concrete", PAL["concrete"], 0.95)
H, R = 4.0, 0.048 # same post the honest one is — that's the trap
DROOP_DEG = 38.0 # pad-eye sag off vertical. 38° is slumped
# past argument but still recognisably an eye
# you COULD clip to — which is the whole trap.
DROOP = math.radians(DROOP_DEG)
EYE_ARM = 0.08 # eye centre's distance from the weld line
WELD_Z = H - 0.16 # the slumped weld line the eye hinges at
# Footing identical to the honest post's, plus the stain skirt: rust
# bleeds onto the collar top, so even the GROUND LINE reads brown.
join_group([
add_cyl(f"{name}_collar", 0.26, 0.14, (0, 0, 0.05), conc, verts=14),
add_cyl(f"{name}_collar_top", 0.22, 0.04, (0, 0, 0.13), conc, verts=14),
add_cyl(f"{name}_stain", 0.23, 0.012, (0, 0, 0.148), rust_d, verts=14),
], "footing", root)
# Same rake mechanism as build_sail_post — rake is a runtime decision and
# a corroded post rakes like any other; the lean you might be tempted to
# bake here would double-apply the moment a player rakes it.
rake = add_empty("rake_pivot", (0, 0, 0.12), root, size=0.25)
rake["rake_axis"] = "x/z — rake AWAY from the load (DESIGN.md)"
rake["rake_default_deg"] = 8
above = []
above.append(join_group([
add_cyl(f"{name}_shaft", R, H, (0, 0, H / 2), steel, verts=12),
add_cyl(f"{name}_base_plate", 0.11, 0.02, (0, 0, 0.13), dark, verts=12),
# The cap sits askew — rust let the friction fit go. Silhouette tell.
add_cyl(f"{name}_cap", R * 1.2, 0.02, (0, 0, H + 0.008), dark, verts=12,
rot=(math.radians(14), 0, 0)),
], "post"))
# The corrosion itself, its own node so the mutation control can kill it
# cleanly: blooms slightly proud of the shaft (rust swells), streaks
# bleeding DOWN from each bloom. Positions are hand-placed, not rng — the
# blooms sit where posts actually rust, and determinism costs nothing.
corrosion = [
add_cyl(f"{name}_rust_base", R + 0.004, 0.34, (0, 0, 0.34), rust, verts=12),
add_cyl(f"{name}_rust_line", R + 0.007, 0.11, (0, 0, 0.185), rust_d, verts=12),
add_cyl(f"{name}_rust_head", R + 0.004, 0.26, (0, 0, H - 0.16), rust, verts=12),
add_cyl(f"{name}_rust_weld", R + 0.006, 0.06, (0, 0, WELD_Z), rust_d, verts=12),
]
for i, (ang_deg, length, z_top) in enumerate([
(200, 1.45, H - 0.24), # off the weld collar, yard side (-y)
(335, 0.95, H - 0.30),
(25, 0.70, H - 0.35),
(160, 0.80, 0.95), # wicking up off the base band
(300, 0.60, 0.85)]):
a = math.radians(ang_deg)
corrosion.append(add_box(
f"{name}_streak_{i}", (0.016, 0.016, length),
(math.cos(a) * (R + 0.003), math.sin(a) * (R + 0.003),
z_top - length / 2), rust_d, rot=(0, 0, a)))
above.append(join_group(corrosion, "rust"))
# The sagging pad eye: plate and ring both pitched DROOP about the weld
# line, hanging toward -y (three.js +Z, the yard side — same face the
# pergola presents). Built in the tilted plane directly, no post-rotation.
ring_c = (0, -math.sin(DROOP) * EYE_ARM,
WELD_Z + math.cos(DROOP) * EYE_ARM)
eye_parts = [add_box(f"{name}_padeye", (0.012, 0.07, 0.09),
(0, -math.sin(DROOP) * EYE_ARM / 2,
WELD_Z + math.cos(DROOP) * EYE_ARM / 2), rust_d,
rot=(-DROOP, 0, 0))]
r_eye, segs = 0.026, 10
pts = []
for i in range(segs + 1):
t = math.tau * i / segs
pts.append((math.cos(t) * r_eye,
ring_c[1] - math.sin(t) * r_eye * math.sin(DROOP),
ring_c[2] + math.sin(t) * r_eye * math.cos(DROOP)))
for i in range(segs):
eye_parts.append(add_tube_between(f"{name}_eye_s{i}", pts[i], pts[i + 1],
0.008, rust, verts=8))
above.append(join_group(eye_parts, "pad_eye"))
# The anchor sits AT the drooped eye — the sag is where you'd clip on,
# so the sag is data the sim sees, not paint.
e = add_empty("top_anchor", ring_c, size=0.2)
e["anchor_type"] = "corroded_post"
e["rating_hint"] = CORRODED_POST_RATING
e["collateral"] = "corroded_post"
e["why"] = ("a real post in a real footing, eaten: above the swing frame "
"(0.45) because concrete beats loose-on-grass, below the "
"pergola (0.65) because the bloom outside is pitting inside — "
"you de-rate what you can't inspect. The rung fills 0.45→0.65; "
"rungs create decisions")
above.append(e)
for o in above:
parent_keep_transform(o, rake)
stamp(root, name, "structure")
root["post_height"] = H
root["rake_note"] = "rotate about rake_pivot; rake away from the load"
root["collateral_key"] = "corroded_post"
root["collateral_value"] = CORRODED_POST_COLLATERAL
root["collateral_label"] = "the corroded post"
root["breakable"] = True
root["corrosion_note"] = ("the tell is load-bearing: e.test pins rust "
"area, shaft dullness and the eye droop — a "
"clean-steel rebuild at the same hint goes red")
# THE AXIS TRAP (E's swing_set precedent, fourth application). The droop
# is an ORIENTATION CLAIM, and orientation claims in this repo are the
# ones that rot: the exporter maps Blender (x,y,z) → three.js (x,z,y), so
# a docstring saying "-y" and a mesh leaning +Z agree only by luck. These
# extras are stated in THREE.JS coords and e.test recomputes them off the
# exported geometry — so note and mesh can only ever lie together.
# Blender ring_c (0, sinD·ARM, WELD_Z+cosD·ARM)
# → three.js (0, WELD_Z+cosD·ARM, +sinD·ARM)
# i.e. the eye hangs toward +Z, the yard side, the face the pergola
# presents — so the sag is pointed at the player who has to judge it.
root["padeye_droop_deg"] = DROOP_DEG
root["padeye_arm_m"] = EYE_ARM
root["padeye_droop_dir_threejs"] = "+Z"
root["padeye_weld_y_threejs"] = WELD_Z # blender z → three.js y
root["padeye_droop_note"] = (
"top_anchor sits EYE_ARM from the weld line, pitched DROOP_DEG off "
"vertical toward three.js +Z (the yard). e.test derives the angle "
"back out of the exported anchor position and pins it to this number: "
"flatten the eye and the note goes red with it")
return root
def build_sail_post_corroded_wrecked(name):
"""The corroded post after a loaded corner won: FOLDED at the deep-rust
line just above the footing (where the intact variant wears its worst
band the wreck fails exactly where the tell said it would), stub
standing plumb with a torn rim, the rest of the post flat on the grass
reaching ~3.8 m into the yard (-y here, three.js +Z), pad eye still
drooped at the far end with nothing left to hold. Same footing, same
numbers, same price as its twin (the carport/gutter chain, application
six). No anchor empty survives an anchor that outlives its structure
is the free-failure bug in a costume (fascia rule, fourth application)."""
root = add_empty(name)
steel = get_material("Mat_SteelWeathered", PAL["steel_weathered"], 0.6,
metallic=0.55)
dark = get_material("Mat_SteelDark", PAL["steel_dark"], 0.45, metallic=0.8)
rust = get_material("Mat_Rust", PAL["rust_bloom"], 0.9, metallic=0.1)
rust_d = get_material("Mat_RustDeep", PAL["rust_deep"], 0.95, metallic=0.05)
conc = get_material("Mat_Concrete", PAL["concrete"], 0.95)
H, R = 4.0, 0.048
FOLD = 0.46 # it let go at the rust line, not the weld:
# the fold is where the section was thinnest
join_group([
add_cyl(f"{name}_collar", 0.26, 0.14, (0, 0, 0.05), conc, verts=14),
add_cyl(f"{name}_collar_top", 0.22, 0.04, (0, 0, 0.13), conc, verts=14),
add_cyl(f"{name}_stain", 0.23, 0.012, (0, 0, 0.148), rust_d, verts=14),
], "footing", root)
# The stub: plumb (the footing held — that half of the 0.55 was honest),
# deep rust at the fold, torn rim leaning the way the post went.
join_group([
add_cyl(f"{name}_stub", R, FOLD, (0, 0, 0.14 + FOLD / 2), steel, verts=12),
add_cyl(f"{name}_base_plate", 0.11, 0.02, (0, 0, 0.13), dark, verts=12),
add_cyl(f"{name}_stub_rust", R + 0.007, 0.14, (0, 0, 0.14 + FOLD - 0.07),
rust_d, verts=12),
add_cone(f"{name}_tear", R + 0.004, 0.012, 0.09,
(0.005, -0.02, 0.14 + FOLD + 0.035), rust_d, verts=10,
rot=(math.radians(18), 0, 0)),
], "post_stub", root)
# The fallen length, resting on its radius, slightly yawed — nothing real
# falls on axis. Blooms ride along it where the intact bands were.
L = H - FOLD
yaw = math.radians(7)
rotv = (math.radians(90), 0, yaw)
def along(s): # a point s metres from the break end, down the fallen shaft
return (-math.sin(yaw) * (0.30 + s), -math.cos(yaw) * (0.30 + s), R)
down = [
add_cyl(f"{name}_shaft_down", R, L, along(L / 2), steel, verts=12, rot=rotv),
add_cyl(f"{name}_down_break", R + 0.007, 0.18, along(0.10), rust_d,
verts=12, rot=rotv),
add_cyl(f"{name}_down_mid", R + 0.004, 0.30, along(L * 0.55), rust,
verts=12, rot=rotv),
add_cyl(f"{name}_down_head", R + 0.004, 0.26, along(L - 0.35), rust,
verts=12, rot=rotv),
add_cyl(f"{name}_down_cap", R * 1.2, 0.02, along(L - 0.01), dark,
verts=12, rot=rotv),
]
join_group(down, "post_down", root)
# The pad eye at the far end, on the grass, still drooped, still rusty —
# the one part of this post that was never going to hold anything again.
ex, ey, _ = along(L - 0.10)
eye = [add_box(f"{name}_padeye_down", (0.012, 0.09, 0.07),
(ex + 0.09, ey, 0.035), rust_d,
rot=(0, math.radians(80), 0)),
add_arc_tube(f"{name}_eye_down", 0.026, 0.008, 0, math.tau, rust,
segs=10, center=(ex + 0.16, ey, 0.012), plane='XY')]
join_group(eye, "pad_eye_down", root)
stamp(root, name, "structure")
root["broken_variant_of"] = "sail_post_corroded"
root["collateral_key"] = "corroded_post"
root["collateral_value"] = CORRODED_POST_COLLATERAL
root["collateral_label"] = "the corroded post"
return root
def build_ladder_01(name):
root = add_empty(name)
alu = get_material("Mat_Steel", PAL["steel_gal"], 0.35, metallic=0.85)
@ -3544,6 +3830,19 @@ ASSETS = [
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"]),
# SPRINT17 gate 3.1 — the corroded tier. The intact variant must stand
# POST height (its lie is "I'm a sail post", so it stands like one; only
# the colour and the drooped eye say otherwise). The wreck's z ceiling is
# under a metre because the post FOLDED at the rust line — a corroded
# wreck still standing tall is a wreck that never told the truth — and
# its y floor is past 3.5 because the fallen shaft is out in the yard.
dict(name="sail_post_corroded", fn=build_sail_post_corroded,
dims=((0.40, 0.60), (0.40, 0.65), (3.90, 4.15)),
nodes=["footing", "post", "rust", "pad_eye", "top_anchor",
"rake_pivot"]),
dict(name="sail_post_corroded_wrecked", fn=build_sail_post_corroded_wrecked,
dims=((0.40, 0.80), (3.50, 4.40), (0.45, 0.95)),
nodes=["footing", "post_stub", "post_down", "pad_eye_down"]),
]

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 MiB

After

Width:  |  Height:  |  Size: 4.8 MiB

View File

@ -73,6 +73,13 @@ export const FACTORY_ANCHOR_RATINGS = Object.freeze({
"carport_01/post_anchor_02"
]
},
"corroded_post": {
"collateral": "corroded_post",
"rating_hint": 0.55,
"sources": [
"sail_post_corroded/top_anchor"
]
},
"house": {
"collateral": "gutter",
"rating_hint": 0.35,

View File

@ -89,9 +89,21 @@ export const PHASES = ['forecast', 'prep', 'storm', 'aftermath'];
* 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.
*
* `corroded_post` (SPRINT17 gate 3.1, E): a sail post gone rusty the pool
* yard's "corroded cheap hardware" thesis as an anchor word. NOT a `post`,
* and this time the word isn't about the footing (the footing is fine
* concrete, same as ever): it's about the STEEL. "post" promises sound
* galvanised tube; this is eaten tube whose visible bloom is a floor on the
* damage, not a ceiling. rating_hint 0.55 above the swing frame (footed
* beats loose), below the pergola (sound flexing timber beats steel you
* can't inspect the inside of). One rating for the whole type, per the
* manifest rule. The GLB wears the tell (rust, a sagging pad eye); the type
* string is the player's pre-rig read of the same fact.
*/
export const ANCHOR_TYPE = Object.freeze([
'house', 'tree', 'post', 'carport', 'carport_post', 'swing_frame', 'pergola',
'corroded_post',
]);
// ---------------------------------------------------------------------------

View File

@ -215,39 +215,45 @@ export default async function run(t) {
// --- the week (SPRINT8 gate 1) -------------------------------------------
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);
t.test('the week is seven escalating nights, each a storm and a site', () => {
// SPRINT16 gate 4 (D's wiring): five → six. SPRINT17 gate 0.1 (D again,
// wiring JOHN'S RULING 2026-07-21): six → SEVEN — the wildnight restored
// at night 5, between the swing lawn and the icenight. The swing lawn
// debuts night 4 under Tuesday's southerly; the soaker still closes the
// week over the corner block. The deeper pins — one-variable law,
// survived-storm debut, the measured soaker pairing, the wildnight's
// known-yard slot — live in d.test.js next to the wiring's owner.
assertEq(NIGHTS.length, 7);
// 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(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(4).storm, 'storm_02_wildnight', "night five is the wildnight — restored, John's seven-night ruling");
assertEq(nightAt(4).site, 'backyard_01', 'and it flies the backyard — the yard its separation is pinned to');
assertEq(nightAt(5).storm, 'storm_02b_icenight', 'night six is the ice night');
assertEq(nightAt(6).storm, 'storm_06_soaker', 'night seven is the fabric bet');
assertEq(nightAt(6).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 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 (const i of [0, 1, 2, 3, 5]) assertEq(nightAt(i).gardenBeyondSaving, false, `night ${i + 1} is savable`);
// SPRINT13 gate 1.4: the unsavable-garden flag is the icenight's alone, and
// a night without it is presumed savable — new nights can't inherit the
// excuse. The flag rides the NIGHT, not the slot: it moved 5 → 6 with the ladder.
assertEq(nightAt(5).gardenBeyondSaving, true, 'night 6 is beyond saving BY DESIGN — canon, ruled');
for (const i of [0, 1, 2, 3, 4, 6]) 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');
assert(!w.isFinalNight);
for (let i = 0; i < 2; i++) w.advance();
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, 'the ice night is no longer the last — its dawn breaks have a morning to book on now');
w.advance();
for (let i = 0; i < 3; i++) w.advance();
assertEq(w.night, 6);
assert(w.isFinalNight, 'night six is the last');
assert(!w.isFinalNight, 'the ice night is still not the last — its dawn breaks keep their morning to book on');
w.advance();
assertEq(w.night, 6, 'the ladder does not walk off its own end');
assertEq(w.night, 7);
assert(w.isFinalNight, 'night seven is the last');
w.advance();
assertEq(w.night, 7, 'the ladder does not walk off its own end');
});
t.test('money persists across nights, and the bank is next night\'s shop', () => {
@ -311,7 +317,7 @@ export default async function run(t) {
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');
assertEq(clients[NIGHTS.length - 1], clients[2], 'the closer 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', () => {
@ -381,10 +387,15 @@ export default async function run(t) {
// WEEK HELD — everything's still where you put it" over four dead gardens.
// Outlasting the week is not surviving it, and the end card is the last
// thing this game says to anyone.
assertEq(gradeFor(5), 'clean', 'five held is the only clean week');
// SPRINT17 gate 0.1 (D, the same-commit demand from the S16 filing): the
// clean bar is derived — every SAVABLE garden held. Seven nights minus the
// icenight's designed loss is 6; held>=5 'clean' would have printed THE
// WEEK HELD over a savable garden that died.
assertEq(gradeFor(6), 'clean', 'six held — every savable garden — is the only clean week');
assertEq(gradeFor(5), 'scraped', 'five of six savable is no longer clean — the bar moved with the ladder');
assertEq(gradeFor(4), 'scraped');
assertEq(gradeFor(3), 'scraped');
assertEq(gradeFor(1), 'solvent', 'one garden out of five is not a triumph');
assertEq(gradeFor(1), 'solvent', 'one garden out of six is not a triumph');
assertEq(gradeFor(0), 'solvent');
});
@ -398,17 +409,19 @@ 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. 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.
// broke is just being broke — the week still finished. SPRINT16 moved the
// last night to six; SPRINT17's seven-night ruling moves it again — the
// walk below reads NIGHTS.length, so this pin TRACKS the final night by
// construction, and the second-to-last-night control stays a real game
// over — both directions pinned so a re-ordering can't quietly move the rule.
const w2 = createWeek();
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();
for (let i = 0; i < NIGHTS.length - 2; 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');
'the second-to-last night is not the final night — going broke there ends the run');
});
// Gate 2 acceptance: both sites load from data, and the corner block is not
@ -678,16 +691,20 @@ export default async function run(t) {
// 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.
// makes the icenight'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", () => {
t.test("SPRINT16: the icenight's designed loss costs NO rep — a clean rig still earns it", () => {
// S17: the walk finds the flagged night instead of hardcoding slot 4 — the
// icenight moved to night 6 with the seven-night ruling and this pin is
// about the FLAG, not the slot.
const flaggedIdx = NIGHTS.findIndex((_, i) => nightAt(i).gardenBeyondSaving);
const w = createWeek();
for (let i = 0; i < 4; i++) w.advance();
assertEq(nightAt(4).gardenBeyondSaving, true, 'precondition: the flagged night');
for (let i = 0; i < flaggedIdx; i++) w.advance();
assertEq(nightAt(flaggedIdx).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);
@ -737,7 +754,7 @@ export default async function run(t) {
}
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.rep, REP.MAX, 'seven 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');
@ -1057,8 +1074,8 @@ export default async function run(t) {
assert(hailed.verdict !== rained.verdict, 'hail and rain deaths must not read identically');
});
t.test("night 5's loss is TAUGHT, not blamed: beyondSaving rewrites the garden verdicts only", () => {
// SPRINT13 gate 1.4, the night-5 ruling. Measured game-true (B's pay table,
t.test("the icenight's loss is TAUGHT, not blamed: beyondSaving rewrites the garden verdicts only", () => {
// SPRINT13 gate 1.4, the icenight ruling (night 5 then, night 6 since S17). Measured game-true (B's pay table,
// my probe agreeing): the icenight garden cannot reach the win line at any
// price — bare 0.0, best buyable 27.7. So week.js flags the night, and the
// verdict must stop implying a better rig existed. The worst liar was
@ -1310,7 +1327,7 @@ export default async function run(t) {
// --- SPRINT13 gate 1.4: THE PINNED SEPARATION TARGET ----------------------
//
// The ruling, RESTATED after B's skew finding (THREADS [A], twice — read the
// second entry): on the shipped week's wild night — night 4, THIS yard — the
// second entry): on the shipped week's wild night — night 5 since S17's ruling, THIS yard — the
// best line START_BUDGET can buy must HOLD and WIN comfortably (>60), a bare
// bed must LOSE (<50), and the spread must read as most of a plant state
// (≥25 HP), because the win line and the invoice are the game's real stakes.

View File

@ -20,8 +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.
// SPRINT16 gate 4 / SPRINT17 gate 0.1: the week-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;
@ -1139,14 +1140,19 @@ export default async function run(t) {
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
// ==================================== SPRINT16 gate 4 → SPRINT17 gate 0.1
// THE WEEK'S LAW, pinned next to its owner. Written as the SIX-night law in
// S16; John ruled SEVEN (2026-07-21 — the wildnight restored, my own gate-6
// filing's alternative), and the pins moved WITH the ruling, which is them
// working, not breaking: every rule below is slot-free, so restoring the
// wildnight at night 5 changed no assert body — only this prose, plus one
// NEW pin for the ruling itself (last in the block). 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 seven 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', () => {
t.test('week law (S16, holds at seven): 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',
@ -1161,7 +1167,7 @@ export default async function run(t) {
'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', () => {
t.test('week law (S16, holds at seven): 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);
@ -1171,7 +1177,7 @@ export default async function run(t) {
+ '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", () => {
t.test("week law (S16, holds at seven): 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
@ -1185,7 +1191,7 @@ export default async function run(t) {
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', () => {
t.test('week law (S16, holds at seven): 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');
@ -1193,6 +1199,34 @@ export default async function run(t) {
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)');
+ "(A filed the night-final gap in THREADS — the week's ordering closes it, at six AND at seven)");
});
t.test("SPRINT17 seven-night law: the wildnight is IN the week, over a yard the player knows", () => {
// JOHN'S RULING (2026-07-21), pinned so a future re-order can't quietly
// re-evict the marquee storm: gate 6 asked six-without-it or seven-with-it,
// and the answer was seven. Three facts carry the ruling:
const nights = NIGHTS.map((_, i) => nightAt(i));
const wildIdx = nights.findIndex((n) => n.storm === 'storm_02_wildnight');
assert(wildIdx >= 0,
"the wildnight flies — its S16 eviction was RULED wrong (THREADS [I] 2026-07-21); "
+ 'removing it again is a John question, not a re-order');
// The one-variable law at its slot: the wildnight flies over a yard the
// player has already rigged — it always did (the pinned separation lives
// in backyard_01's own site data, so the storm is the only thing new).
const wild = nights[wildIdx];
assertEq(wild.site, 'backyard_01',
"the wildnight flies the backyard — the pinned separation is THAT yard's site data; "
+ 'flying it elsewhere needs the separation re-measured FIRST, like the soaker pin above');
assert(nights.slice(0, wildIdx).some((n) => n.site === wild.site),
'and the yard was rigged on an earlier night — at the marquee storm the yard must not also be new');
// The icenight's brief opens "Less wind than last night" — TRUE only while
// the wildnight (peak 30.0) sits immediately before it (28.5). If this
// ordering changes, that brief's first sentence moves in the same commit
// (it already moved twice: S16 out, S17 back — the sentence follows the facts).
const iceIdx = nights.findIndex((n) => n.storm === 'storm_02b_icenight');
assertEq(wildIdx, iceIdx - 1,
"the wildnight sits directly before the icenight — the icenight's brief says 'less wind than "
+ "last night', and the paper never lies: re-order these and re-word that brief in the same commit");
});
}

View File

@ -116,6 +116,16 @@ const ASSETS = [
{ name: 'pool_kit_01', h: [1.12, 1.32],
nodes: ['pool_shell', 'pool_water', 'fence_rails', 'pool_gate',
'fence_post_01', 'fence_post_14'] },
// SPRINT17 gate 3.1 — the corroded tier. The intact variant stands POST
// height on purpose: its lie is "I'm a sail post", so it must stand like
// one, and only the colour and the drooped eye say otherwise. The wreck's
// ceiling is under a metre because the post FOLDED at the rust line — a
// corroded wreck still standing 4 m tall is a wreck that never told the
// truth about where it was weakest.
{ name: 'sail_post_corroded', h: [3.90, 4.15],
nodes: ['footing', 'post', 'rust', 'pad_eye', 'top_anchor', 'rake_pivot'] },
{ name: 'sail_post_corroded_wrecked', h: [0.45, 0.95],
nodes: ['footing', 'post_stub', 'post_down', 'pad_eye_down'] },
];
function sizeOf(gltf) {
@ -1050,6 +1060,273 @@ export default async function run(t) {
+ 'lie the invoice exists to kill; when debris can take a panel out, price it THEN');
});
// -------------------------------------------------------------------------
// SPRINT17 gate 3.1 — THE CORRODED TIER. The pool kit is fourteen tie-offs
// that don't exist; this is the one that exists and shouldn't be trusted.
// The design claim is "corrosion a player can't see is a trap with no tell,
// and this repo doesn't ship those" — so the TELL itself is pinned as
// numbers (area, lightness, sag), not adjectives. An untelled corroded post
// (same 0.55 hint, clean steel) passes the factory, passes RULE 4a/4b, and
// passes every dims/nodes check — this test is the ONLY thing that goes red
// on it, which is exactly why it exists. (Proven red in the S17 negative
// controls; receipts in THREADS.)
// -------------------------------------------------------------------------
/** Total triangle area of every mesh under a GLB scene, split by predicate
* on the mesh's material. World transforms applied; area is exact. */
const areaBy = (scene, pred) => {
scene.updateMatrixWorld(true);
let hit = 0, total = 0;
const a = new THREE.Vector3(), b = new THREE.Vector3(), c = new THREE.Vector3();
scene.traverse((o) => {
if (!o.isMesh) return;
const pos = o.geometry.attributes.position;
const idx = o.geometry.index;
const n = idx ? idx.count : pos.count;
let area = 0;
for (let i = 0; i < n; i += 3) {
const ia = idx ? idx.getX(i) : i, ib = idx ? idx.getX(i + 1) : i + 1,
ic = idx ? idx.getX(i + 2) : i + 2;
a.fromBufferAttribute(pos, ia).applyMatrix4(o.matrixWorld);
b.fromBufferAttribute(pos, ib).applyMatrix4(o.matrixWorld);
c.fromBufferAttribute(pos, ic).applyMatrix4(o.matrixWorld);
b.sub(a); c.sub(a);
area += b.cross(c).length() / 2;
}
total += area;
if (pred(o.material)) hit += area;
});
return { hit, total };
};
/** Rust, as a colour band: strongly saturated orange-brown, darker than
* mid. Nothing else in this palette is saturated in that hue (weathered
* steel and concrete are near-grey, s < 0.15; timber is lighter). Colours
* are read in the loader's working space thresholds chosen with margin
* on both sides (rust s 0.8, everything else 0.15). */
const isRust = (m) => {
const hsl = { h: 0, s: 0, l: 0 };
(m.color ?? new THREE.Color(0xffffff)).getHSL(hsl);
return hsl.h <= 0.12 && hsl.s >= 0.35 && hsl.l <= 0.45;
};
// The three tells are three SEPARATE tests on purpose. Shipped as one
// block, the first failing assert masked the other two — the S17 M1
// negative control (clean steel, flat eye) went red on rust area alone and
// never reached the lightness or droop checks, so their strength was
// unproven. Split, each tell is independently red-checkable, which is the
// only way "mutation-checked" means anything.
t.test('the corroded post TELLS (1/3): rust reads as AREA, and the honest post stays clean', () => {
const g = loaded.get('sail_post_corroded');
const honest = loaded.get('sail_post');
assert(g && honest, 'both post variants must load — the tell is a comparison');
// (a) The at-a-glance read, as a number: rust-coloured surface area. 5%
// of a 4 m post is roughly the base band + head bloom + streaks — under
// that and the corrosion is a caption, not a tell. (Shipped value ~19%.)
const { hit, total } = areaBy(g.scene, isRust);
assert(total > 0, 'no measurable surface at all');
const frac = hit / total;
assert(frac >= 0.05,
`rust covers ${(frac * 100).toFixed(1)}% of the corroded post's surface — under 5% ` +
'is corrosion nobody can read from the lawn, a trap with no tell');
// And the honest post must NOT read rusty — the tell only works if the
// clean one is clean. (Also guards the colour thresholds themselves.)
const h = areaBy(honest.scene, isRust);
assert(h.hit === 0,
`the HONEST sail post carries ${(h.hit / h.total * 100).toFixed(1)}% rust-coloured area — ` +
'either the post rusted or the rust predicate is reading galvanised steel as rust');
});
t.test('the corroded post TELLS (2/3): the shaft is NOT galvanised — base colour and PBR both moved', () => {
const g = loaded.get('sail_post_corroded');
const honest = loaded.get('sail_post');
assert(g && honest, 'both post variants must load — the tell is a comparison');
// (b) The 20 m read. This assert shipped in the S17 WIP saying the
// corroded shaft "reads darker at yard distance"; I measured the actual
// render before endorsing it and the SIGN IS BACKWARDS. Sampled off
// look.html's canvas at cam z=20 m, 1280×720, over the mid-shaft band:
//
// honest rgb(49,51,49) lum 50 sat 0.031 RB 0
// corroded rgb(98,94,86) lum 94 sat 0.126 RB +12
//
// The corroded shaft renders TWICE AS BRIGHT, not darker. Base colour is
// genuinely darker (0.524 vs 0.737) but it does not drive the pixels:
// galvanised is metalness 0.90 / roughness 0.35 and, with no environment
// map in this renderer, a near-mirror metal has almost nothing to mirror
// and goes near-black. The weathered shaft at 0.55 / 0.60 is far more
// diffuse, so it actually catches the sun. The separation is real and
// large — it is just made of METALNESS, not lightness.
//
// So the test now pins both halves, and the comment states which one the
// player is actually seeing. An assert whose stated reason is the reverse
// of the mechanism is a docstring waiting to be "fixed" in the wrong
// direction by whoever tunes this next.
const shaftOf = (gl) => {
let best = null;
gl.scene.getObjectByName('post').traverse((o) => {
if (!o.isMesh || isRust(o.material)) return;
const hsl = { h: 0, s: 0, l: 0 };
o.material.color.getHSL(hsl);
if (!best || hsl.l > best.l) best = { l: hsl.l, m: o.material };
});
return best;
};
const sHonest = shaftOf(honest), sCorroded = shaftOf(g);
assert(sHonest && sCorroded, 'could not find shaft materials on the post nodes');
assert(sCorroded.l < sHonest.l - 0.08,
`corroded shaft base lightness ${sCorroded.l.toFixed(3)} vs galvanised ${sHonest.l.toFixed(3)}` +
'the corroded shaft is wearing galvanised paint, so nothing distinguishes them but the rust');
// The half that actually reaches the screen. Weathered steel is rougher
// and less metallic than gal; if a future edit "tidies" these back to the
// galvanised values the post keeps its rust and loses its 20 m silhouette.
assert(sCorroded.m.roughness > sHonest.m.roughness + 0.15,
`corroded shaft roughness ${sCorroded.m.roughness} vs galvanised ${sHonest.m.roughness}` +
'weathered steel that is as polished as gal loses the long-range read (this is the ' +
'property that actually drove lum 94 vs 50 at 20 m, measured)');
assert(sCorroded.m.metalness < sHonest.m.metalness - 0.15,
`corroded shaft metalness ${sCorroded.m.metalness} vs galvanised ${sHonest.m.metalness}` +
'corroded steel must be the more diffuse of the two or the yard-distance contrast inverts');
});
t.test('the corroded post TELLS (3/3): the pad eye DROOPS — angle derived from geometry, pinned to the baked note', () => {
const g = loaded.get('sail_post_corroded');
const honest = loaded.get('sail_post');
assert(g && honest, 'both post variants must load — the tell is a comparison');
g.scene.updateMatrixWorld(true);
honest.scene.updateMatrixWorld(true);
const root = g.scene.getObjectByName('sail_post_corroded');
const anchor = g.scene.getObjectByName('top_anchor');
assert(root && anchor, 'root and top_anchor must both survive the export');
const u = root.userData;
// THE AXIS TRAP (E's swing_set precedent). The builder writes the droop
// as a claim in THREE.JS coords; this derives the same numbers back out
// of the exported anchor position. Note and mesh can only lie together.
//
// Why not "the corroded anchor sits lower than the honest one" — the
// assert this REPLACES? Because the eye hangs off a weld line 160 mm
// down the post, so that comparison reads 0.06 m at ZERO droop and sails
// past a `> 0.05` threshold. It measured the weld offset, not the sag:
// an assert that cannot fail on the thing it names is decoration. This
// one drives the derived angle to 0° when the eye is flat.
const baked = u.padeye_droop_deg;
const arm = u.padeye_arm_m;
const weldY = u.padeye_weld_y_threejs;
assert(typeof baked === 'number' && typeof arm === 'number' && typeof weldY === 'number',
'the droop must be BAKED as numbers on the root (deg, arm, weld y in three.js) — ' +
'an orientation claim that lives only in a docstring is the axis trap waiting to happen');
assert(baked > 0, `padeye_droop_deg is ${baked} — a corroded eye that does not droop has no tell`);
const p = anchor.getWorldPosition(new THREE.Vector3());
// The post axis is x=z=0; the eye hangs off the weld line at (0, weldY, 0).
const horiz = Math.hypot(p.x, p.z); // out from the axis
const vert = p.y - weldY; // up from the weld
const armMeasured = Math.hypot(horiz, vert);
assert(Math.abs(armMeasured - arm) < 0.004,
`the eye sits ${armMeasured.toFixed(4)} m off the weld line but the note says ${arm} m — ` +
'the baked arm and the exported geometry disagree');
const derivedDeg = Math.atan2(horiz, vert) * 180 / Math.PI;
assert(Math.abs(derivedDeg - baked) < 1.5,
`the exported pad eye droops ${derivedDeg.toFixed(1)}° but the root claims ${baked}° — ` +
'the mesh and its own note disagree about the sag (flatten the eye and this is what you see)');
// DIRECTION, measured — not asserted by reading the string. Blender y
// maps to three.js +Z, so the eye must hang toward the YARD, the face the
// player judges it from. A sag pointing into the fence is a tell nobody sees.
assert(u.padeye_droop_dir_threejs === '+Z',
`the baked droop direction is ${u.padeye_droop_dir_threejs} — this test only knows how to check +Z`);
assert(p.z > 0.5 * horiz,
`the eye hangs toward (x=${p.x.toFixed(3)}, z=${p.z.toFixed(3)}) — the note says +Z (the yard side), ` +
'so either the exporter axis mapping moved or the note is wrong');
assert(Math.abs(p.x) < 0.006,
`the eye is displaced ${p.x.toFixed(3)} m on X — the droop is meant to be purely +Z`);
// And the weaker claim, kept as a sanity check now that it is no longer
// carrying the argument: the drooped anchor does sit below the honest one.
const sag = honest.scene.getObjectByName('top_anchor')
.getWorldPosition(new THREE.Vector3()).y - p.y;
assert(sag > 0.05,
`the corroded top_anchor sits only ${sag.toFixed(3)} m below the honest one`);
});
t.test('corroded steel is a RUNG: above the swing frame, below sound timber, resolvable node-less', () => {
const T = FACTORY_ANCHOR_RATINGS.types;
const entry = T.corroded_post;
assert(entry, 'corroded_post is not in the ratings manifest — the type cannot resolve node-less');
assert(!FACTORY_ANCHOR_RATINGS.ambiguous.corroded_post,
'corroded_post is manifest-AMBIGUOUS — the one-rating-per-type rule (THREADS [E] S15) broke');
// The ladder claims, pinned against the MANIFEST, never against literals
// (a literal pin would stay green while the whole ladder reshuffled):
assert(T.swing_frame.rating_hint < entry.rating_hint,
`corroded post (${entry.rating_hint}) must out-rate the swing frame (${T.swing_frame.rating_hint}) — ` +
'a real concrete footing beats sound steel standing loose on grass');
assert(entry.rating_hint < T.pergola.rating_hint,
`corroded post (${entry.rating_hint}) must rate under the pergola (${T.pergola.rating_hint}) — ` +
'sound flexing timber beats steel whose bloom outside means pitting inside');
assert(entry.rating_hint < T.post.rating_hint,
'a corroded post rating at or above the honest post erases the tier entirely');
// It fails through the client's property: collateral named, priced, and
// priced UNDER the gutter — the bill is the make-safe, not the steel; the
// real price of trusting rust is the corner you lose mid-storm.
assert(entry.collateral === 'corroded_post',
'the manifest must carry the collateral so node-less anchors bill on failure');
const root = g('sail_post_corroded');
const gutter = g('house_yardside');
assert(typeof root.userData.collateral_value === 'number' && root.userData.collateral_value > 0,
'the corroded post names collateral nothing prices — free failure, the gutter bug');
assert(root.userData.collateral_value < gutter.userData.collateral_value,
`a snapped corroded post ($${root.userData.collateral_value}) must bill under the gutter ` +
`($${gutter.userData.collateral_value}) — condemned steel is a make-safe charge, not a rebuild`);
function g(name) {
const glb = loaded.get(name);
assert(glb, `${name} did not load`);
return glb.scene.getObjectByName(name);
}
});
t.test('the corroded wreck folded at the rust line: stub stands, shaft in the yard, nothing left to tie to', () => {
const w = loaded.get('sail_post_corroded_wrecked');
assert(w, 'sail_post_corroded_wrecked did not load');
const box = new THREE.Box3().setFromObject(w.scene);
// It FELL: nothing stands past the stub, and the fallen shaft reaches
// into the yard on +Z (the same face the pergola presents — placement
// facts in THREADS). Both directions measured, not asserted by name.
assert(box.max.y < 1.0,
`the wreck stands ${box.max.y.toFixed(2)} m — a corroded post that still stands never folded`);
assert(box.max.z > 3.2,
`the fallen shaft reaches z=${box.max.z.toFixed(2)} — it is meant to be ~3.5 m out in the yard (+Z)`);
// No anchor survives, nothing rates, nothing enrols — an anchor that
// outlives its structure is the free-failure bug in a costume (the
// fascia rule; fourth application).
assert(!w.scene.getObjectByName('top_anchor'),
'top_anchor survives the wreck — you cannot clip to a post that is lying on the grass');
const offers = [];
w.scene.traverse((o) => {
if (typeof o.userData?.rating_hint === 'number' || o.userData?.anchor_type !== undefined) {
offers.push(o.name);
}
});
assert(offers.length === 0,
`the wreck offers [${offers.join(', ')}] — a wreck must offer nothing`);
// Twin-priced, and it names its twin — the carport/gutter chain, sixth
// application, compared between the GLBs and never against a literal.
const wr = w.scene.getObjectByName('sail_post_corroded_wrecked');
const ir = loaded.get('sail_post_corroded')?.scene.getObjectByName('sail_post_corroded');
assert(wr?.userData?.broken_variant_of === 'sail_post_corroded',
'the wreck must name its intact twin so A can pair the swap');
assert(wr?.userData?.collateral_value === ir?.userData?.collateral_value,
'the wrecked post must be priced the same as the one it used to be');
assert(wr?.userData?.collateral_key === 'corroded_post',
'the wreck must keep the collateral key or the swap changes what the bill is about');
});
// 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', () => {

View File

@ -3,7 +3,7 @@
* (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.)
*
* Six nights, one wallet. This is deliberately a thin wrapper around
* Seven 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
@ -23,8 +23,9 @@ import { START_BUDGET, HARDWARE } from './contracts.js';
* 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 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
* 5 Wild Night gust 30.0, hail the marquee storm: the most wind of the week
* 6 Ice Night gust 28.5, hail less wind, worse ice, and the bed is already lost
* 7 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. Night 3 moves to the corner block
@ -48,11 +49,11 @@ import { START_BUDGET, HARDWARE } from './contracts.js';
* 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
* soaker closes the week 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.
* flies the new storm (the closer), 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
@ -61,13 +62,28 @@ import { START_BUDGET, HARDWARE } from './contracts.js';
* 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
* icenight = the beyond-saving flag, canon). THE WILD NIGHT (storm_02) was out
* of the rotation for exactly one integration. The honest fix named in that
* filing was a SEVEN-night week one entry re-added here, a ruling, not a
* refactor.
*
* SPRINT17 gate 0.1 (D, wiring JOHN'S RULING, 2026-07-21): SEVEN NIGHTS
* the wildnight comes back. The slot is between the swing lawn and the
* icenight, and the one-variable law places it, not taste: the wildnight has
* flown the backyard since Sprint 4 (the pinned separation lives in THAT
* yard's site data), so at night 5 the player is back on a yard they have
* rigged three times and the storm is the only new thing. The slot also keeps
* every S16 law intact by construction the swing lawn still debuts under a
* survived storm (night 4), the icenight is still neither new nor final
* (night 6, its dawn breaks book warranty on the soaker's morning), and the
* week's arc reads full / full / full / new-yard / won-but-tattered /
* the-designed-loss / the-fabric-bet: the pyrrhic win now sits directly
* before the night that cannot be won, which is the escalation the six-night
* week had to fake with wording. The icenight's brief reverts to its pre-S16
* sentence ("less wind than last night") because that fact is TRUE again
* wildnight peaks 30.0, icenight 28.5. gradeFor's clean bar moves with the
* ladder in this same commit (my own S16 filing's demand see gradeFor).
*
* Copy lives here as data, per SPRINT11's rule: client flavour is data, not code.
*/
export const NIGHTS = [
@ -106,31 +122,51 @@ export const NIGHTS = [
+ '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.',
},
{
// SPRINT17 gate 0.1 — THE WILDNIGHT, RESTORED (John's ruling: seven
// nights). Back at the Hendersons', the yard the player has rigged three
// times, so the storm is the only new thing — the one-variable law at the
// marquee slot. This is the pyrrhic-win canon (the pinned separation:
// best buyable line WINS at ~55-64 hp, tattered; bare bed loses), and the
// brief sells exactly that stake in the clients' own voice: they're
// ringing from Cairns about the BED, and they price the sail as the thing
// that spends itself protecting it. "The garden made it; the sail didn't"
// is the ending this night is FOR — the copy promises it as a win.
storm: 'storm_02_wildnight', site: 'backyard_01',
client: 'the Hendersons', addr: '14 Kurrajong St — the backyard',
brief: 'The Hendersons rang from Cairns — the forecast made the news up there. This is the big '
+ 'one, the night the whole week was pointing at. Their words: they\'d rather fly home to a '
+ 'wrecked sail over a living bed than a tidy yard and a dead one. Spend the sail if you '
+ 'have to. Save the garden.',
},
{
storm: 'storm_02b_icenight', site: 'backyard_01',
client: 'the Hendersons', addr: '14 Kurrajong St — the backyard',
// 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
// SPRINT17 gate 0.1 wording note (D): "Less wind than last night" is the
// pre-S16 sentence, back because the FACT is back — the wildnight (gust
// 30.0) sits at night 5 again, immediately ahead of this one (28.5). The
// S16 six-night week had no louder night before this, so the brief said
// "the most wind of the week" for one integration; the sentence moves
// with the facts, both directions. The teaching half — level with the
// player that the bed is already lost — is untouched, as ever.
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.',
// SPRINT13 gate 1.4 — A's ruling, DESIGN CANON: the icenight's garden is
// BEYOND SAVING, by design, at any price (ruled at night 5; the S17
// seven-night ladder flies it night 6 — the flag rides the NIGHT, not the
// slot). Measured game-true (B's pay table and
// A's probe, agreeing): bare bed 0.0 DEAD; best buyable garden 27.7; best
// clean 15.3 — every line in the game sits under WIN's hp >= 50, wallet or
// bank. The week's arc WANTS this: full / full / full, won-but-tattered
// (night 4, the pinned separation), then the night you cannot save — the
// (night 5, the pinned separation, restored S17), then the night you cannot save — the
// icenight is where the job stops being about the bed and becomes about
// your steel (the line that would hold it needs $105 the shop caps at
// 3.20 kN shackles against 3.22/3.25 peaks — C's few-hundredths whisker,
// kept on purpose). This flag exists so surfaces TEACH the loss instead of
// implying a better rig existed: verdictFor reads it (main.js) and stops
// blaming placement; the brief above levels with the player before they
// spend a dollar. If a retune ever makes night 5 winnable, delete this
// spend a dollar. If a retune ever makes this night winnable, delete this
// flag in the same commit or the verdict lies in the other direction.
gardenBeyondSaving: true,
},
@ -270,7 +306,7 @@ export const PAY = {
* 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
* inputs, so the icenight'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
@ -338,10 +374,24 @@ export const BROKE_BELOW = HARDWARE[0].cost * 4;
* client's garden died four times", and until this was measured nobody knew the
* game could even produce that ending.
*
* @param {number} held gardens saved, 0..5
* SPRINT17 gate 0.1 (D, in A's file, flagged revert-and-tell-me standard):
* the clean bar is now DERIVED, not the constant 5. My own S16 filing said a
* seven-night week needs gradeFor re-ruled in the same commit as the ladder,
* and this is that commit. The S16 coincidence gradeFor(5)='clean' landing
* exactly on the six-night week's best outcome (5 won + the designed loss)
* breaks at seven nights: held>=5 would print "THE WEEK HELD everything's
* still where you put it" over a savable garden that died, which is the end
* card lying, the one thing it exists not to do. So 'clean' is the RULE the
* constant was standing in for: every garden the week let you save. The
* scraped/solvent boundary (>=3) is untouched moving it is a taste call on
* A's wording and there's no lie in leaving it (3 of 6 savable is still "the
* least wrong call most nights"); re-rule it if you want, it's yours.
*
* @param {number} held gardens saved, 0..savable
*/
export function gradeFor(held) {
if (held >= 5) return 'clean';
const savable = NIGHTS.filter((_, i) => !nightAt(i).gardenBeyondSaving).length;
if (held >= savable) return 'clean';
if (held >= 3) return 'scraped';
return 'solvent';
}

Binary file not shown.

Binary file not shown.