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
|
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
|
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.
|
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",
|
"status": "PASS",
|
||||||
"problems": []
|
"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": []
|
"debris": []
|
||||||
|
|||||||
@ -137,6 +137,14 @@ PAL = {
|
|||||||
"bike_kid": "#D8483C", # the Henderson kid's bike — bought bright on purpose
|
"bike_kid": "#D8483C", # the Henderson kid's bike — bought bright on purpose
|
||||||
"bike_grip": "#3B4048", # grips and saddle
|
"bike_grip": "#3B4048", # grips and saddle
|
||||||
"ref_pink": "#E85C8A", # the reference capsule — deliberately loud
|
"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
|
"alu_frame": "#C9CED2", # powder-coated aluminium — glasshouse + pool fence
|
||||||
"pool_water": "#4FA8BE", # chlorinated blue, NOT the pond's grey-green —
|
"pool_water": "#4FA8BE", # chlorinated blue, NOT the pond's grey-green —
|
||||||
# a pool is the one water in the game that is
|
# a pool is the one water in the game that is
|
||||||
@ -2256,6 +2264,284 @@ def build_sail_post(name):
|
|||||||
return root
|
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):
|
def build_ladder_01(name):
|
||||||
root = add_empty(name)
|
root = add_empty(name)
|
||||||
alu = get_material("Mat_Steel", PAL["steel_gal"], 0.35, metallic=0.85)
|
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)),
|
dims=((5.4, 5.9), (4.1, 4.8), (1.12, 1.32)),
|
||||||
nodes=["pool_shell", "pool_water", "fence_rails", "pool_gate",
|
nodes=["pool_shell", "pool_water", "fence_rails", "pool_gate",
|
||||||
"fence_post_01", "fence_post_14"]),
|
"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"
|
"carport_01/post_anchor_02"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"corroded_post": {
|
||||||
|
"collateral": "corroded_post",
|
||||||
|
"rating_hint": 0.55,
|
||||||
|
"sources": [
|
||||||
|
"sail_post_corroded/top_anchor"
|
||||||
|
]
|
||||||
|
},
|
||||||
"house": {
|
"house": {
|
||||||
"collateral": "gutter",
|
"collateral": "gutter",
|
||||||
"rating_hint": 0.35,
|
"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
|
* 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
|
* concrete would notice (1.00). One rating for the whole type, per the
|
||||||
* manifest rule (THREADS [E] S15): unambiguous types stay node-less-safe.
|
* 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([
|
export const ANCHOR_TYPE = Object.freeze([
|
||||||
'house', 'tree', 'post', 'carport', 'carport_post', 'swing_frame', 'pergola',
|
'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) -------------------------------------------
|
// --- the week (SPRINT8 gate 1) -------------------------------------------
|
||||||
|
|
||||||
t.test('the week is six escalating nights, each a storm and a site', () => {
|
t.test('the week is seven escalating nights, each a storm and a site', () => {
|
||||||
// SPRINT16 gate 4 (D's wiring): five → six. The swing lawn debuts night 4
|
// SPRINT16 gate 4 (D's wiring): five → six. SPRINT17 gate 0.1 (D again,
|
||||||
// under Tuesday's southerly; the soaker closes the week over the corner
|
// wiring JOHN'S RULING 2026-07-21): six → SEVEN — the wildnight restored
|
||||||
// block. The deeper pins — one-variable law, survived-storm debut, the
|
// at night 5, between the swing lawn and the icenight. The swing lawn
|
||||||
// measured soaker pairing — live in d.test.js next to the wiring's owner.
|
// debuts night 4 under Tuesday's southerly; the soaker still closes the
|
||||||
assertEq(NIGHTS.length, 6);
|
// 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.
|
// 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(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).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(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(4).storm, 'storm_02_wildnight', "night five is the wildnight — restored, John's seven-night ruling");
|
||||||
assertEq(nightAt(5).storm, 'storm_06_soaker', 'night six is the fabric bet');
|
assertEq(nightAt(4).site, 'backyard_01', 'and it flies the backyard — the yard its separation is pinned to');
|
||||||
assertEq(nightAt(5).site, 'site_02_corner_block', "and it flies over the corner block — C's measured pairing");
|
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(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');
|
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
|
// SPRINT13 gate 1.4: the unsavable-garden flag is the icenight's alone, and
|
||||||
// night without it is presumed savable — new nights can't inherit the excuse.
|
// a night without it is presumed savable — new nights can't inherit the
|
||||||
assertEq(nightAt(4).gardenBeyondSaving, true, 'night 5 is beyond saving BY DESIGN — canon, ruled');
|
// excuse. The flag rides the NIGHT, not the slot: it moved 5 → 6 with the ladder.
|
||||||
for (const i of [0, 1, 2, 3, 5]) assertEq(nightAt(i).gardenBeyondSaving, false, `night ${i + 1} is savable`);
|
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();
|
const w = createWeek();
|
||||||
assertEq(w.night, 1); assertEq(w.bank, 80);
|
assertEq(w.night, 1); assertEq(w.bank, 80);
|
||||||
assertEq(w.site, 'backyard_01', 'week.site tracks the ladder');
|
assertEq(w.site, 'backyard_01', 'week.site tracks the ladder');
|
||||||
assert(!w.isFinalNight);
|
assert(!w.isFinalNight);
|
||||||
for (let i = 0; i < 2; i++) w.advance();
|
for (let i = 0; i < 2; i++) w.advance();
|
||||||
assertEq(w.site, 'site_02_corner_block', 'and reaches the corner block on night three');
|
assertEq(w.site, 'site_02_corner_block', 'and reaches the corner block on night three');
|
||||||
for (let i = 0; i < 2; i++) w.advance();
|
for (let i = 0; i < 3; 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();
|
|
||||||
assertEq(w.night, 6);
|
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();
|
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', () => {
|
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');
|
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');
|
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(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', () => {
|
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.
|
// 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
|
// Outlasting the week is not surviving it, and the end card is the last
|
||||||
// thing this game says to anyone.
|
// 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(4), 'scraped');
|
||||||
assertEq(gradeFor(3), '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');
|
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');
|
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
|
// 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
|
// broke is just being broke — the week still finished. SPRINT16 moved the
|
||||||
// night is SIX now, and night five going broke is a real game over again —
|
// last night to six; SPRINT17's seven-night ruling moves it again — the
|
||||||
// both directions pinned so the re-ordering can't quietly move the rule.
|
// 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();
|
const w2 = createWeek();
|
||||||
for (let i = 0; i < NIGHTS.length - 1; i++) w2.advance();
|
for (let i = 0; i < NIGHTS.length - 1; i++) w2.advance();
|
||||||
assert(w2.isFinalNight, 'precondition: the walk reached the final night');
|
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');
|
assertEq(w2.settle(ruin, def, 80).outcome, 'win', 'the final night always ends the week, not the run');
|
||||||
const w3 = createWeek();
|
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',
|
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
|
// 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.
|
// The pin that matters most: same rig, opposite garden — identical movement.
|
||||||
// hp and win are absent from the movement BY CONSTRUCTION, which is what
|
// 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 a1 = createWeek().settle(cleanRun({ hp: 100, win: true }), s16def, 0);
|
||||||
const b1 = createWeek().settle(cleanRun({ hp: 0, win: false }), s16def, 0);
|
const b1 = createWeek().settle(cleanRun({ hp: 0, win: false }), s16def, 0);
|
||||||
assertEq(a1.repDelta, b1.repDelta, 'rep never reads the garden');
|
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();
|
const w = createWeek();
|
||||||
for (let i = 0; i < 4; i++) w.advance();
|
for (let i = 0; i < flaggedIdx; i++) w.advance();
|
||||||
assertEq(nightAt(4).gardenBeyondSaving, true, 'precondition: the flagged night');
|
assertEq(nightAt(flaggedIdx).gardenBeyondSaving, true, 'precondition: the flagged night');
|
||||||
// Garden lost by design, steel held, nothing of theirs broken: that was
|
// 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.
|
// 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);
|
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');
|
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})`);
|
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');
|
assertEq(w.log[NIGHTS.length - 1].warrantiesToDate, 0, 'and the end card has no receipts to show');
|
||||||
w.reset();
|
w.reset();
|
||||||
assertEq(w.rep, REP.START, 'reset() is a new outfit — rep goes back to neutral');
|
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');
|
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", () => {
|
t.test("the icenight'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,
|
// 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
|
// 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
|
// 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
|
// 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 ----------------------
|
// --- SPRINT13 gate 1.4: THE PINNED SEPARATION TARGET ----------------------
|
||||||
//
|
//
|
||||||
// The ruling, RESTATED after B's skew finding (THREADS [A], twice — read the
|
// 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
|
// 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
|
// 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.
|
// (≥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 { assert, assertEq, assertClose, assertLess, fixedLoop } from '../testkit.js';
|
||||||
import { FIXED_DT } from '../contracts.js';
|
import { FIXED_DT } from '../contracts.js';
|
||||||
import { loadStorm, createWind } from '../weather.js';
|
import { loadStorm, createWind } from '../weather.js';
|
||||||
// SPRINT16 gate 4: the six-night law pins at the bottom of this file read the
|
// SPRINT16 gate 4 / SPRINT17 gate 0.1: the week-law pins at the bottom of this
|
||||||
// ladder. week.js is contracts-only underneath, so this stays headless-safe.
|
// file read the ladder. week.js is contracts-only underneath, so this stays
|
||||||
|
// headless-safe.
|
||||||
import { NIGHTS, nightAt } from '../week.js';
|
import { NIGHTS, nightAt } from '../week.js';
|
||||||
|
|
||||||
const DT = FIXED_DT;
|
const DT = FIXED_DT;
|
||||||
@ -1139,14 +1140,19 @@ export default async function run(t) {
|
|||||||
assertEq(p.carrying, null, 'and consumes it');
|
assertEq(p.carrying, null, 'and consumes it');
|
||||||
});
|
});
|
||||||
|
|
||||||
// ================================================================ SPRINT16 gate 4
|
// ==================================== SPRINT16 gate 4 → SPRINT17 gate 0.1
|
||||||
// THE SIX-NIGHT LAW, pinned next to its owner. a.test pins the ladder's
|
// THE WEEK'S LAW, pinned next to its owner. Written as the SIX-night law in
|
||||||
// shape (which storm, which site, which slot); these pin the RULES the
|
// S16; John ruled SEVEN (2026-07-21 — the wildnight restored, my own gate-6
|
||||||
// ordering must obey, so a future re-order that keeps six nights but breaks
|
// filing's alternative), and the pins moved WITH the ruling, which is them
|
||||||
// the law goes red with the law's own words. Every threshold here is
|
// 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.
|
// 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 nights = NIGHTS.map((_, i) => nightAt(i));
|
||||||
const PRE_S16_SITES = ['backyard_01', 'site_02_corner_block'];
|
const PRE_S16_SITES = ['backyard_01', 'site_02_corner_block'];
|
||||||
const PRE_S16_STORMS = ['storm_01_gentle', 'storm_02_wildnight', 'storm_02b_icenight',
|
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');
|
'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');
|
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');
|
assert(idx > 0, 'the swing lawn is in the rotation and is not night one');
|
||||||
const debut = nightAt(idx);
|
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');
|
+ '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
|
// 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
|
// 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
|
// 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');
|
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);
|
const flagged = NIGHTS.map((_, i) => nightAt(i)).filter((n) => n.gardenBeyondSaving);
|
||||||
assertEq(flagged.length, 1, 'exactly one designed loss in the week');
|
assertEq(flagged.length, 1, 'exactly one designed loss in the week');
|
||||||
assertEq(flagged[0].storm, 'storm_02b_icenight', 'and it is the ice night — the flag is a measured fact about');
|
assertEq(flagged[0].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);
|
const idx = NIGHTS.findIndex((_, i) => nightAt(i).gardenBeyondSaving);
|
||||||
assert(idx !== NIGHTS.length - 1,
|
assert(idx !== NIGHTS.length - 1,
|
||||||
'the designed loss must not be the final night: its dawn breaks need a morning to book warranty on '
|
'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],
|
{ name: 'pool_kit_01', h: [1.12, 1.32],
|
||||||
nodes: ['pool_shell', 'pool_water', 'fence_rails', 'pool_gate',
|
nodes: ['pool_shell', 'pool_water', 'fence_rails', 'pool_gate',
|
||||||
'fence_post_01', 'fence_post_14'] },
|
'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) {
|
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');
|
+ '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
|
// One GLB carries three wilt states as siblings; Lane A toggles .visible
|
||||||
// rather than reloading, so all three have to be present at once.
|
// rather than reloading, so all three have to be present at once.
|
||||||
t.test('garden_bed carries all 3 damage states in one GLB', () => {
|
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
|
* (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.)
|
* 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
|
* 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
|
* 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
|
* 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
|
* 2 Southerly Buster gust 20.0, hail — the change, and hail, arrive
|
||||||
* 3 Early Buster gust 20.0, hail — same teeth, change comes EARLY
|
* 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
|
* 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
|
* 5 Wild Night gust 30.0, hail — the marquee storm: the most wind of the week
|
||||||
* 6 Pea-Hail Soaker gust 15.2, fine hail — the night the default fabric is wrong
|
* 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.
|
* 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
|
* 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
|
* 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
|
* 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
|
* 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
|
* 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
|
* 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
|
* 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
|
* ⚠️ 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
|
* 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
|
* and every other candidate is load-bearing (gentle = the tutorial and the
|
||||||
* calm-day preload; southerly = the hail intro and the early buster's
|
* 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;
|
* 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
|
* icenight = the beyond-saving flag, canon). THE WILD NIGHT (storm_02) was out
|
||||||
* of the rotation. Its storm file, its tests, and the pinned backyard
|
* of the rotation for exactly one integration. The honest fix named in that
|
||||||
* separation all stand — it is un-flown by the week, not deleted. If that
|
* filing was a SEVEN-night week — one entry re-added here, a ruling, not a
|
||||||
* eviction is wrong, the honest fix is a SEVEN-night week (arc 2's own range
|
|
||||||
* is "five-to-seven"), which is one entry re-added here — a ruling, not a
|
|
||||||
* refactor.
|
* 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.
|
* Copy lives here as data, per SPRINT11's rule: client flavour is data, not code.
|
||||||
*/
|
*/
|
||||||
export const NIGHTS = [
|
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 '
|
+ '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.',
|
+ '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',
|
storm: 'storm_02b_icenight', site: 'backyard_01',
|
||||||
client: 'the Hendersons', addr: '14 Kurrajong St — the backyard',
|
client: 'the Hendersons', addr: '14 Kurrajong St — the backyard',
|
||||||
// SPRINT16 gate 4 wording note (D): this brief used to open "Less wind than
|
// SPRINT17 gate 0.1 wording note (D): "Less wind than last night" is the
|
||||||
// last night" — true when the wild night (gust 30) sat at night 4. The wild
|
// pre-S16 sentence, back because the FACT is back — the wildnight (gust
|
||||||
// night left the six-night rotation (see the ladder comment), so the ice
|
// 30.0) sits at night 5 again, immediately ahead of this one (28.5). The
|
||||||
// night is now the most wind the week throws; the sentence moved with the
|
// S16 six-night week had no louder night before this, so the brief said
|
||||||
// facts. The teaching half — level with the player that the bed is already
|
// "the most wind of the week" for one integration; the sentence moves
|
||||||
// lost — is untouched.
|
// with the facts, both directions. The teaching half — level with the
|
||||||
brief: 'The most wind of the week and golf-ball ice with it. Last night before they land. '
|
// player that the bed is already lost — is untouched, as ever.
|
||||||
+ 'Nobody\'s pretending the seedlings ride this one out — keep the sail on its feet and '
|
brief: 'Less wind than last night and more ice, which is worse for the bed and easier on the rig. '
|
||||||
+ 'the ice off the windows.',
|
+ 'Last night before they land. Nobody\'s pretending the seedlings ride this one out — keep '
|
||||||
// SPRINT13 gate 1.4 — A's ruling, DESIGN CANON: night 5's garden is BEYOND
|
+ 'the sail on its feet and the ice off the windows.',
|
||||||
// SAVING, by design, at any price. Measured game-true (B's pay table and
|
// 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
|
// 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
|
// 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
|
// 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
|
// 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
|
// 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,
|
// 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
|
// 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
|
// implying a better rig existed: verdictFor reads it (main.js) and stops
|
||||||
// blaming placement; the brief above levels with the player before they
|
// 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.
|
// flag in the same commit or the verdict lies in the other direction.
|
||||||
gardenBeyondSaving: true,
|
gardenBeyondSaving: true,
|
||||||
},
|
},
|
||||||
@ -270,7 +306,7 @@ export const PAY = {
|
|||||||
* A broke-but-repaired-by-dawn night is NEUTRAL by design: not clean (it
|
* 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
|
* 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
|
* 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.
|
* than by exemption. a.test pins that from both directions.
|
||||||
*
|
*
|
||||||
* Money: `multiplier(rep)` scales the callout fee at QUOTE TIME — quote() and
|
* 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
|
* client's garden died four times", and until this was measured nobody knew the
|
||||||
* game could even produce that ending.
|
* 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) {
|
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';
|
if (held >= 3) return 'scraped';
|
||||||
return 'solvent';
|
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