Compare commits
3 Commits
a1b7d468a4
...
6e0608a6fb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e0608a6fb | ||
|
|
a180fce8c3 | ||
|
|
75d5f9a4bf |
186
THREADS.md
186
THREADS.md
@ -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 === iceIdx−1) 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 R−B 0
|
||||
corroded rgb(98,94,86) lum 94 sat 0.126 R−B +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.
|
||||
|
||||
@ -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": []
|
||||
|
||||
@ -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 |
@ -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,
|
||||
|
||||
@ -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',
|
||||
]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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");
|
||||
});
|
||||
}
|
||||
|
||||
@ -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 R−B 0
|
||||
// corroded rgb(98,94,86) lum 94 sat 0.126 R−B +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', () => {
|
||||
|
||||
@ -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';
|
||||
}
|
||||
|
||||
BIN
web/world/models/sail_post_corroded_v1.glb
Normal file
BIN
web/world/models/sail_post_corroded_v1.glb
Normal file
Binary file not shown.
BIN
web/world/models/sail_post_corroded_wrecked_v1.glb
Normal file
BIN
web/world/models/sail_post_corroded_wrecked_v1.glb
Normal file
Binary file not shown.
Loading…
Reference in New Issue
Block a user