diff --git a/THREADS.md b/THREADS.md index 64d56ec..5d27b72 100644 --- a/THREADS.md +++ b/THREADS.md @@ -8816,3 +8816,144 @@ anchors are your GLB), but the tooling is now waiting, not TODO. Pushed lane/b: aa0b36a (gate 0.2), 1bcfcda (gates 0.3+2), this entry. Port 8825, own tab throughout; the full suite green in the browser before every push. +[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. diff --git a/tools/blender/asset_report.json b/tools/blender/asset_report.json index 87ceeac..900cc5e 100644 --- a/tools/blender/asset_report.json +++ b/tools/blender/asset_report.json @@ -906,6 +906,53 @@ ], "status": "PASS", "problems": [] + }, + { + "name": "sail_post_corroded", + "dims": [ + 0.507, + 0.52, + 4.0516 + ], + "tris": 816, + "nodes": [ + "footing", + "pad_eye", + "post", + "rake_pivot", + "rust", + "sail_post_corroded", + "top_anchor" + ], + "anchors": [ + { + "node": "top_anchor", + "anchor_type": "corroded_post", + "rating_hint": 0.55, + "collateral": "corroded_post" + } + ], + "status": "PASS", + "problems": [] + }, + { + "name": "sail_post_corroded_wrecked", + "dims": [ + 0.7786, + 4.0784, + 0.7015 + ], + "tris": 836, + "nodes": [ + "footing", + "pad_eye_down", + "post_down", + "post_stub", + "sail_post_corroded_wrecked" + ], + "anchors": [], + "status": "PASS", + "problems": [] } ], "debris": [] diff --git a/tools/blender/build_yard_assets.py b/tools/blender/build_yard_assets.py index 32b673c..394c399 100644 --- a/tools/blender/build_yard_assets.py +++ b/tools/blender/build_yard_assets.py @@ -137,6 +137,14 @@ PAL = { "bike_kid": "#D8483C", # the Henderson kid's bike β€” bought bright on purpose "bike_grip": "#3B4048", # grips and saddle "ref_pink": "#E85C8A", # the reference capsule β€” deliberately loud + "steel_weathered": "#8D8A7E", # gal gone matte: the corroded post's shaft. + # Next to steel_gal (#B6BCC2) it reads DIRTY at + # 20 m before you can see a single bloom β€” the + # colour is the long-range half of the tell. + "rust_bloom": "#9C5B33", # active surface rust β€” orange enough to read + # against every steel/timber in the palette + "rust_deep": "#67351F", # the worst of it: fold lines, weld collars, + # streak stains β€” near-brown, high saturation "alu_frame": "#C9CED2", # powder-coated aluminium β€” glasshouse + pool fence "pool_water": "#4FA8BE", # chlorinated blue, NOT the pond's grey-green β€” # a pool is the one water in the game that is @@ -2256,6 +2264,284 @@ def build_sail_post(name): return root +# --------------------------------------------------------------------------- +# THE CORRODED TIER (SPRINT17 gate 3.1 β€” the pool yard's other half) +# --------------------------------------------------------------------------- +# The pool kit is fourteen tie-offs that DON'T exist (every fence post an +# honest no). This is the tie-off that DOES exist and shouldn't be trusted: +# a real sail post, real concrete footing, real pad eye β€” gone rusty. +# +# WHY 0.55, argued on the ladder it joins (0.22 carport < 0.30 carport_post < +# 0.35 fascia < 0.45 swing_frame < [0.55] < 0.65 pergola < 1.00 post): +# Β· ABOVE the swing frame (0.45): the footing is still 300 mm of concrete +# and the load path still reaches the ground. A sound welded frame +# standing loose on grass drags; a rusty post in a real footing holds +# until its steel gives β€” corroded anchored beats sound unanchored. +# Β· BELOW the pergola (0.65): the pergola's members are SOUND and its +# weakness (deck flex) is visible and bounded. Corrosion's weakness is +# section loss you cannot see the bottom of β€” the bloom on the outside is +# pitting on the inside, so the visible rust is a FLOOR on the damage, +# not a ceiling. You de-rate below sound timber because you're rating +# what you can't inspect. +# Β· And it FILLS the 0.45β†’0.65 gap: rungs create decisions (D's S15 +# endorsement logic, the reason the pergola exists). With this rung the +# ladder steps 0.45 / 0.55 / 0.65 β€” three different kinds of "not quite": +# unanchored steel, eaten steel, flexing timber. +CORRODED_POST_RATING = 0.55 + +# The bill when it snaps. Proposal β€” Lane A confirms (my S17 prompt says rule +# it and let A confirm): a post IS the client's property, so a snapped one +# bills β€” "it was already scrap" is a warranty argument, not a free pass. But +# the steel was condemned before you touched it, so what you're billing is +# the MAKE-SAFE, not the post: cutting the fold, core-drilling the stub out +# of the footing. That's under the gutter's $90 (a full trade's morning run) +# and above the gnome's $25. Cheap ON PURPOSE: the real price of trusting +# corroded steel is the corner you lose mid-storm β€” the trap costs you in +# sail HP and garden exposure, and the invoice line is honest small change +# beside it. If the bill were big, the lesson would read "don't touch rust"; +# at $45 it reads "rust is a bet, and here's the stake". +CORRODED_POST_COLLATERAL = 45 + + +def build_sail_post_corroded(name): + """The corroded sail-post variant β€” same bones as build_sail_post (H, R, + footing, rake_pivot mechanism all identical, so it rakes and places like + any post), different truth. + + THE TELL, designed to read at two ranges (the bike lesson: LOOK at it, + and look from where the player actually stands): + Β· 20 m / colour: the whole shaft is weathered matte (steel_weathered) + against the honest post's bright galvanised, the footing collar wears + a rust stain skirt, and the cap sits askew. You don't see rust from + the back fence β€” you see a DIRTY post next to clean ones. + Β· 3 m / detail: rust blooms where posts actually rust (the base band + where water pools on the collar, the head collar around the pad-eye + weld), deep-rust fold line just above the footing, streaks bleeding + down the shaft, and the pad eye SAGS β€” the weld ate through and the + eye drooped toward the yard. The anchor empty sits at the DROOPED + eye, so the sag is a measured fact, not set dressing: e.test derives + the droop ANGLE back out of the exported anchor position and pins it + to `padeye_droop_deg` below. (Pinning "the corroded anchor is lower + than the honest one" would NOT have worked β€” the eye hangs off a weld + line 160 mm down the post, so that test reads 0.06 m and passes at + zero droop. Measure the angle, not the altitude.) + + Corrosion a player can't see is a trap with no tell, and this repo + doesn't ship those β€” the three tells are pinned as three SEPARATE tests + in e.test.js (rust area, shaft lightness, eye droop) so that one failure + can't mask the other two. Rebuild this in clean steel at the same 0.55 + hint and all three go red; flatten only the eye and only the third does. + """ + root = add_empty(name) + steel = get_material("Mat_SteelWeathered", PAL["steel_weathered"], 0.6, + metallic=0.55) + dark = get_material("Mat_SteelDark", PAL["steel_dark"], 0.45, metallic=0.8) + rust = get_material("Mat_Rust", PAL["rust_bloom"], 0.9, metallic=0.1) + rust_d = get_material("Mat_RustDeep", PAL["rust_deep"], 0.95, metallic=0.05) + conc = get_material("Mat_Concrete", PAL["concrete"], 0.95) + H, R = 4.0, 0.048 # same post the honest one is β€” that's the trap + DROOP_DEG = 38.0 # pad-eye sag off vertical. 38Β° is slumped + # past argument but still recognisably an eye + # you COULD clip to β€” which is the whole trap. + DROOP = math.radians(DROOP_DEG) + EYE_ARM = 0.08 # eye centre's distance from the weld line + WELD_Z = H - 0.16 # the slumped weld line the eye hinges at + + # Footing identical to the honest post's, plus the stain skirt: rust + # bleeds onto the collar top, so even the GROUND LINE reads brown. + join_group([ + add_cyl(f"{name}_collar", 0.26, 0.14, (0, 0, 0.05), conc, verts=14), + add_cyl(f"{name}_collar_top", 0.22, 0.04, (0, 0, 0.13), conc, verts=14), + add_cyl(f"{name}_stain", 0.23, 0.012, (0, 0, 0.148), rust_d, verts=14), + ], "footing", root) + + # Same rake mechanism as build_sail_post β€” rake is a runtime decision and + # a corroded post rakes like any other; the lean you might be tempted to + # bake here would double-apply the moment a player rakes it. + rake = add_empty("rake_pivot", (0, 0, 0.12), root, size=0.25) + rake["rake_axis"] = "x/z β€” rake AWAY from the load (DESIGN.md)" + rake["rake_default_deg"] = 8 + + above = [] + above.append(join_group([ + add_cyl(f"{name}_shaft", R, H, (0, 0, H / 2), steel, verts=12), + add_cyl(f"{name}_base_plate", 0.11, 0.02, (0, 0, 0.13), dark, verts=12), + # The cap sits askew β€” rust let the friction fit go. Silhouette tell. + add_cyl(f"{name}_cap", R * 1.2, 0.02, (0, 0, H + 0.008), dark, verts=12, + rot=(math.radians(14), 0, 0)), + ], "post")) + + # The corrosion itself, its own node so the mutation control can kill it + # cleanly: blooms slightly proud of the shaft (rust swells), streaks + # bleeding DOWN from each bloom. Positions are hand-placed, not rng β€” the + # blooms sit where posts actually rust, and determinism costs nothing. + corrosion = [ + add_cyl(f"{name}_rust_base", R + 0.004, 0.34, (0, 0, 0.34), rust, verts=12), + add_cyl(f"{name}_rust_line", R + 0.007, 0.11, (0, 0, 0.185), rust_d, verts=12), + add_cyl(f"{name}_rust_head", R + 0.004, 0.26, (0, 0, H - 0.16), rust, verts=12), + add_cyl(f"{name}_rust_weld", R + 0.006, 0.06, (0, 0, WELD_Z), rust_d, verts=12), + ] + for i, (ang_deg, length, z_top) in enumerate([ + (200, 1.45, H - 0.24), # off the weld collar, yard side (-y) + (335, 0.95, H - 0.30), + (25, 0.70, H - 0.35), + (160, 0.80, 0.95), # wicking up off the base band + (300, 0.60, 0.85)]): + a = math.radians(ang_deg) + corrosion.append(add_box( + f"{name}_streak_{i}", (0.016, 0.016, length), + (math.cos(a) * (R + 0.003), math.sin(a) * (R + 0.003), + z_top - length / 2), rust_d, rot=(0, 0, a))) + above.append(join_group(corrosion, "rust")) + + # The sagging pad eye: plate and ring both pitched DROOP about the weld + # line, hanging toward -y (three.js +Z, the yard side β€” same face the + # pergola presents). Built in the tilted plane directly, no post-rotation. + ring_c = (0, -math.sin(DROOP) * EYE_ARM, + WELD_Z + math.cos(DROOP) * EYE_ARM) + eye_parts = [add_box(f"{name}_padeye", (0.012, 0.07, 0.09), + (0, -math.sin(DROOP) * EYE_ARM / 2, + WELD_Z + math.cos(DROOP) * EYE_ARM / 2), rust_d, + rot=(-DROOP, 0, 0))] + r_eye, segs = 0.026, 10 + pts = [] + for i in range(segs + 1): + t = math.tau * i / segs + pts.append((math.cos(t) * r_eye, + ring_c[1] - math.sin(t) * r_eye * math.sin(DROOP), + ring_c[2] + math.sin(t) * r_eye * math.cos(DROOP))) + for i in range(segs): + eye_parts.append(add_tube_between(f"{name}_eye_s{i}", pts[i], pts[i + 1], + 0.008, rust, verts=8)) + above.append(join_group(eye_parts, "pad_eye")) + + # The anchor sits AT the drooped eye β€” the sag is where you'd clip on, + # so the sag is data the sim sees, not paint. + e = add_empty("top_anchor", ring_c, size=0.2) + e["anchor_type"] = "corroded_post" + e["rating_hint"] = CORRODED_POST_RATING + e["collateral"] = "corroded_post" + e["why"] = ("a real post in a real footing, eaten: above the swing frame " + "(0.45) because concrete beats loose-on-grass, below the " + "pergola (0.65) because the bloom outside is pitting inside β€” " + "you de-rate what you can't inspect. The rung fills 0.45β†’0.65; " + "rungs create decisions") + above.append(e) + for o in above: + parent_keep_transform(o, rake) + + stamp(root, name, "structure") + root["post_height"] = H + root["rake_note"] = "rotate about rake_pivot; rake away from the load" + root["collateral_key"] = "corroded_post" + root["collateral_value"] = CORRODED_POST_COLLATERAL + root["collateral_label"] = "the corroded post" + root["breakable"] = True + root["corrosion_note"] = ("the tell is load-bearing: e.test pins rust " + "area, shaft dullness and the eye droop β€” a " + "clean-steel rebuild at the same hint goes red") + + # THE AXIS TRAP (E's swing_set precedent, fourth application). The droop + # is an ORIENTATION CLAIM, and orientation claims in this repo are the + # ones that rot: the exporter maps Blender (x,y,z) β†’ three.js (x,z,βˆ’y), so + # a docstring saying "-y" and a mesh leaning +Z agree only by luck. These + # extras are stated in THREE.JS coords and e.test recomputes them off the + # exported geometry β€” so note and mesh can only ever lie together. + # Blender ring_c (0, βˆ’sinDΒ·ARM, WELD_Z+cosDΒ·ARM) + # β†’ three.js (0, WELD_Z+cosDΒ·ARM, +sinDΒ·ARM) + # i.e. the eye hangs toward +Z, the yard side, the face the pergola + # presents β€” so the sag is pointed at the player who has to judge it. + root["padeye_droop_deg"] = DROOP_DEG + root["padeye_arm_m"] = EYE_ARM + root["padeye_droop_dir_threejs"] = "+Z" + root["padeye_weld_y_threejs"] = WELD_Z # blender z β†’ three.js y + root["padeye_droop_note"] = ( + "top_anchor sits EYE_ARM from the weld line, pitched DROOP_DEG off " + "vertical toward three.js +Z (the yard). e.test derives the angle " + "back out of the exported anchor position and pins it to this number: " + "flatten the eye and the note goes red with it") + return root + + +def build_sail_post_corroded_wrecked(name): + """The corroded post after a loaded corner won: FOLDED at the deep-rust + line just above the footing (where the intact variant wears its worst + band β€” the wreck fails exactly where the tell said it would), stub + standing plumb with a torn rim, the rest of the post flat on the grass + reaching ~3.8 m into the yard (-y here, three.js +Z), pad eye still + drooped at the far end with nothing left to hold. Same footing, same + numbers, same price as its twin (the carport/gutter chain, application + six). No anchor empty survives β€” an anchor that outlives its structure + is the free-failure bug in a costume (fascia rule, fourth application).""" + root = add_empty(name) + steel = get_material("Mat_SteelWeathered", PAL["steel_weathered"], 0.6, + metallic=0.55) + dark = get_material("Mat_SteelDark", PAL["steel_dark"], 0.45, metallic=0.8) + rust = get_material("Mat_Rust", PAL["rust_bloom"], 0.9, metallic=0.1) + rust_d = get_material("Mat_RustDeep", PAL["rust_deep"], 0.95, metallic=0.05) + conc = get_material("Mat_Concrete", PAL["concrete"], 0.95) + H, R = 4.0, 0.048 + FOLD = 0.46 # it let go at the rust line, not the weld: + # the fold is where the section was thinnest + + join_group([ + add_cyl(f"{name}_collar", 0.26, 0.14, (0, 0, 0.05), conc, verts=14), + add_cyl(f"{name}_collar_top", 0.22, 0.04, (0, 0, 0.13), conc, verts=14), + add_cyl(f"{name}_stain", 0.23, 0.012, (0, 0, 0.148), rust_d, verts=14), + ], "footing", root) + + # The stub: plumb (the footing held β€” that half of the 0.55 was honest), + # deep rust at the fold, torn rim leaning the way the post went. + join_group([ + add_cyl(f"{name}_stub", R, FOLD, (0, 0, 0.14 + FOLD / 2), steel, verts=12), + add_cyl(f"{name}_base_plate", 0.11, 0.02, (0, 0, 0.13), dark, verts=12), + add_cyl(f"{name}_stub_rust", R + 0.007, 0.14, (0, 0, 0.14 + FOLD - 0.07), + rust_d, verts=12), + add_cone(f"{name}_tear", R + 0.004, 0.012, 0.09, + (0.005, -0.02, 0.14 + FOLD + 0.035), rust_d, verts=10, + rot=(math.radians(18), 0, 0)), + ], "post_stub", root) + + # The fallen length, resting on its radius, slightly yawed β€” nothing real + # falls on axis. Blooms ride along it where the intact bands were. + L = H - FOLD + yaw = math.radians(7) + rotv = (math.radians(90), 0, yaw) + + def along(s): # a point s metres from the break end, down the fallen shaft + return (-math.sin(yaw) * (0.30 + s), -math.cos(yaw) * (0.30 + s), R) + + down = [ + add_cyl(f"{name}_shaft_down", R, L, along(L / 2), steel, verts=12, rot=rotv), + add_cyl(f"{name}_down_break", R + 0.007, 0.18, along(0.10), rust_d, + verts=12, rot=rotv), + add_cyl(f"{name}_down_mid", R + 0.004, 0.30, along(L * 0.55), rust, + verts=12, rot=rotv), + add_cyl(f"{name}_down_head", R + 0.004, 0.26, along(L - 0.35), rust, + verts=12, rot=rotv), + add_cyl(f"{name}_down_cap", R * 1.2, 0.02, along(L - 0.01), dark, + verts=12, rot=rotv), + ] + join_group(down, "post_down", root) + + # The pad eye at the far end, on the grass, still drooped, still rusty β€” + # the one part of this post that was never going to hold anything again. + ex, ey, _ = along(L - 0.10) + eye = [add_box(f"{name}_padeye_down", (0.012, 0.09, 0.07), + (ex + 0.09, ey, 0.035), rust_d, + rot=(0, math.radians(80), 0)), + add_arc_tube(f"{name}_eye_down", 0.026, 0.008, 0, math.tau, rust, + segs=10, center=(ex + 0.16, ey, 0.012), plane='XY')] + join_group(eye, "pad_eye_down", root) + + stamp(root, name, "structure") + root["broken_variant_of"] = "sail_post_corroded" + root["collateral_key"] = "corroded_post" + root["collateral_value"] = CORRODED_POST_COLLATERAL + root["collateral_label"] = "the corroded post" + return root + + def build_ladder_01(name): root = add_empty(name) alu = get_material("Mat_Steel", PAL["steel_gal"], 0.35, metallic=0.85) @@ -3544,6 +3830,19 @@ ASSETS = [ dims=((5.4, 5.9), (4.1, 4.8), (1.12, 1.32)), nodes=["pool_shell", "pool_water", "fence_rails", "pool_gate", "fence_post_01", "fence_post_14"]), + # SPRINT17 gate 3.1 β€” the corroded tier. The intact variant must stand + # POST height (its lie is "I'm a sail post", so it stands like one; only + # the colour and the drooped eye say otherwise). The wreck's z ceiling is + # under a metre because the post FOLDED at the rust line β€” a corroded + # wreck still standing tall is a wreck that never told the truth β€” and + # its y floor is past 3.5 because the fallen shaft is out in the yard. + dict(name="sail_post_corroded", fn=build_sail_post_corroded, + dims=((0.40, 0.60), (0.40, 0.65), (3.90, 4.15)), + nodes=["footing", "post", "rust", "pad_eye", "top_anchor", + "rake_pivot"]), + dict(name="sail_post_corroded_wrecked", fn=build_sail_post_corroded_wrecked, + dims=((0.40, 0.80), (3.50, 4.40), (0.45, 0.95)), + nodes=["footing", "post_stub", "post_down", "pad_eye_down"]), ] diff --git a/tools/blender/contact_sheet.png b/tools/blender/contact_sheet.png index 5e12d2a..963346c 100644 Binary files a/tools/blender/contact_sheet.png and b/tools/blender/contact_sheet.png differ diff --git a/web/world/js/anchor_ratings.gen.js b/web/world/js/anchor_ratings.gen.js index cc9f21f..a669582 100644 --- a/web/world/js/anchor_ratings.gen.js +++ b/web/world/js/anchor_ratings.gen.js @@ -73,6 +73,13 @@ export const FACTORY_ANCHOR_RATINGS = Object.freeze({ "carport_01/post_anchor_02" ] }, + "corroded_post": { + "collateral": "corroded_post", + "rating_hint": 0.55, + "sources": [ + "sail_post_corroded/top_anchor" + ] + }, "house": { "collateral": "gutter", "rating_hint": 0.35, diff --git a/web/world/js/contracts.js b/web/world/js/contracts.js index 989fd0b..772cd43 100644 --- a/web/world/js/contracts.js +++ b/web/world/js/contracts.js @@ -89,9 +89,21 @@ export const PHASES = ['forecast', 'prep', 'storm', 'aftermath']; * loose-on-grass (0.45), but the deck flexes and the timber cracks before * concrete would notice (1.00). One rating for the whole type, per the * manifest rule (THREADS [E] S15): unambiguous types stay node-less-safe. + * + * `corroded_post` (SPRINT17 gate 3.1, E): a sail post gone rusty β€” the pool + * yard's "corroded cheap hardware" thesis as an anchor word. NOT a `post`, + * and this time the word isn't about the footing (the footing is fine β€” + * concrete, same as ever): it's about the STEEL. "post" promises sound + * galvanised tube; this is eaten tube whose visible bloom is a floor on the + * damage, not a ceiling. rating_hint 0.55 β€” above the swing frame (footed + * beats loose), below the pergola (sound flexing timber beats steel you + * can't inspect the inside of). One rating for the whole type, per the + * manifest rule. The GLB wears the tell (rust, a sagging pad eye); the type + * string is the player's pre-rig read of the same fact. */ export const ANCHOR_TYPE = Object.freeze([ 'house', 'tree', 'post', 'carport', 'carport_post', 'swing_frame', 'pergola', + 'corroded_post', ]); // --------------------------------------------------------------------------- diff --git a/web/world/js/tests/e.test.js b/web/world/js/tests/e.test.js index 714e921..fc71306 100644 --- a/web/world/js/tests/e.test.js +++ b/web/world/js/tests/e.test.js @@ -116,6 +116,16 @@ const ASSETS = [ { name: 'pool_kit_01', h: [1.12, 1.32], nodes: ['pool_shell', 'pool_water', 'fence_rails', 'pool_gate', 'fence_post_01', 'fence_post_14'] }, + // SPRINT17 gate 3.1 β€” the corroded tier. The intact variant stands POST + // height on purpose: its lie is "I'm a sail post", so it must stand like + // one, and only the colour and the drooped eye say otherwise. The wreck's + // ceiling is under a metre because the post FOLDED at the rust line β€” a + // corroded wreck still standing 4 m tall is a wreck that never told the + // truth about where it was weakest. + { name: 'sail_post_corroded', h: [3.90, 4.15], + nodes: ['footing', 'post', 'rust', 'pad_eye', 'top_anchor', 'rake_pivot'] }, + { name: 'sail_post_corroded_wrecked', h: [0.45, 0.95], + nodes: ['footing', 'post_stub', 'post_down', 'pad_eye_down'] }, ]; function sizeOf(gltf) { @@ -1050,6 +1060,273 @@ export default async function run(t) { + 'lie the invoice exists to kill; when debris can take a panel out, price it THEN'); }); + // ------------------------------------------------------------------------- + // SPRINT17 gate 3.1 β€” THE CORRODED TIER. The pool kit is fourteen tie-offs + // that don't exist; this is the one that exists and shouldn't be trusted. + // The design claim is "corrosion a player can't see is a trap with no tell, + // and this repo doesn't ship those" β€” so the TELL itself is pinned as + // numbers (area, lightness, sag), not adjectives. An untelled corroded post + // (same 0.55 hint, clean steel) passes the factory, passes RULE 4a/4b, and + // passes every dims/nodes check β€” this test is the ONLY thing that goes red + // on it, which is exactly why it exists. (Proven red in the S17 negative + // controls; receipts in THREADS.) + // ------------------------------------------------------------------------- + + /** Total triangle area of every mesh under a GLB scene, split by predicate + * on the mesh's material. World transforms applied; area is exact. */ + const areaBy = (scene, pred) => { + scene.updateMatrixWorld(true); + let hit = 0, total = 0; + const a = new THREE.Vector3(), b = new THREE.Vector3(), c = new THREE.Vector3(); + scene.traverse((o) => { + if (!o.isMesh) return; + const pos = o.geometry.attributes.position; + const idx = o.geometry.index; + const n = idx ? idx.count : pos.count; + let area = 0; + for (let i = 0; i < n; i += 3) { + const ia = idx ? idx.getX(i) : i, ib = idx ? idx.getX(i + 1) : i + 1, + ic = idx ? idx.getX(i + 2) : i + 2; + a.fromBufferAttribute(pos, ia).applyMatrix4(o.matrixWorld); + b.fromBufferAttribute(pos, ib).applyMatrix4(o.matrixWorld); + c.fromBufferAttribute(pos, ic).applyMatrix4(o.matrixWorld); + b.sub(a); c.sub(a); + area += b.cross(c).length() / 2; + } + total += area; + if (pred(o.material)) hit += area; + }); + return { hit, total }; + }; + + /** Rust, as a colour band: strongly saturated orange-brown, darker than + * mid. Nothing else in this palette is saturated in that hue (weathered + * steel and concrete are near-grey, s < 0.15; timber is lighter). Colours + * are read in the loader's working space β€” thresholds chosen with margin + * on both sides (rust s β‰ˆ 0.8, everything else ≀ 0.15). */ + const isRust = (m) => { + const hsl = { h: 0, s: 0, l: 0 }; + (m.color ?? new THREE.Color(0xffffff)).getHSL(hsl); + return hsl.h <= 0.12 && hsl.s >= 0.35 && hsl.l <= 0.45; + }; + + // The three tells are three SEPARATE tests on purpose. Shipped as one + // block, the first failing assert masked the other two β€” the S17 M1 + // negative control (clean steel, flat eye) went red on rust area alone and + // never reached the lightness or droop checks, so their strength was + // unproven. Split, each tell is independently red-checkable, which is the + // only way "mutation-checked" means anything. + t.test('the corroded post TELLS (1/3): rust reads as AREA, and the honest post stays clean', () => { + const g = loaded.get('sail_post_corroded'); + const honest = loaded.get('sail_post'); + assert(g && honest, 'both post variants must load β€” the tell is a comparison'); + + // (a) The at-a-glance read, as a number: rust-coloured surface area. 5% + // of a 4 m post is roughly the base band + head bloom + streaks β€” under + // that and the corrosion is a caption, not a tell. (Shipped value ~19%.) + const { hit, total } = areaBy(g.scene, isRust); + assert(total > 0, 'no measurable surface at all'); + const frac = hit / total; + assert(frac >= 0.05, + `rust covers ${(frac * 100).toFixed(1)}% of the corroded post's surface β€” under 5% ` + + 'is corrosion nobody can read from the lawn, a trap with no tell'); + // And the honest post must NOT read rusty β€” the tell only works if the + // clean one is clean. (Also guards the colour thresholds themselves.) + const h = areaBy(honest.scene, isRust); + assert(h.hit === 0, + `the HONEST sail post carries ${(h.hit / h.total * 100).toFixed(1)}% rust-coloured area β€” ` + + 'either the post rusted or the rust predicate is reading galvanised steel as rust'); + }); + + t.test('the corroded post TELLS (2/3): the shaft is NOT galvanised β€” base colour and PBR both moved', () => { + const g = loaded.get('sail_post_corroded'); + const honest = loaded.get('sail_post'); + assert(g && honest, 'both post variants must load β€” the tell is a comparison'); + + // (b) The 20 m read. This assert shipped in the S17 WIP saying the + // corroded shaft "reads darker at yard distance"; I measured the actual + // render before endorsing it and the SIGN IS BACKWARDS. Sampled off + // look.html's canvas at cam z=20 m, 1280Γ—720, over the mid-shaft band: + // + // honest rgb(49,51,49) lum 50 sat 0.031 Rβˆ’B 0 + // corroded rgb(98,94,86) lum 94 sat 0.126 Rβˆ’B +12 + // + // The corroded shaft renders TWICE AS BRIGHT, not darker. Base colour is + // genuinely darker (0.524 vs 0.737) but it does not drive the pixels: + // galvanised is metalness 0.90 / roughness 0.35 and, with no environment + // map in this renderer, a near-mirror metal has almost nothing to mirror + // and goes near-black. The weathered shaft at 0.55 / 0.60 is far more + // diffuse, so it actually catches the sun. The separation is real and + // large β€” it is just made of METALNESS, not lightness. + // + // So the test now pins both halves, and the comment states which one the + // player is actually seeing. An assert whose stated reason is the reverse + // of the mechanism is a docstring waiting to be "fixed" in the wrong + // direction by whoever tunes this next. + const shaftOf = (gl) => { + let best = null; + gl.scene.getObjectByName('post').traverse((o) => { + if (!o.isMesh || isRust(o.material)) return; + const hsl = { h: 0, s: 0, l: 0 }; + o.material.color.getHSL(hsl); + if (!best || hsl.l > best.l) best = { l: hsl.l, m: o.material }; + }); + return best; + }; + const sHonest = shaftOf(honest), sCorroded = shaftOf(g); + assert(sHonest && sCorroded, 'could not find shaft materials on the post nodes'); + + assert(sCorroded.l < sHonest.l - 0.08, + `corroded shaft base lightness ${sCorroded.l.toFixed(3)} vs galvanised ${sHonest.l.toFixed(3)} β€” ` + + 'the corroded shaft is wearing galvanised paint, so nothing distinguishes them but the rust'); + + // The half that actually reaches the screen. Weathered steel is rougher + // and less metallic than gal; if a future edit "tidies" these back to the + // galvanised values the post keeps its rust and loses its 20 m silhouette. + assert(sCorroded.m.roughness > sHonest.m.roughness + 0.15, + `corroded shaft roughness ${sCorroded.m.roughness} vs galvanised ${sHonest.m.roughness} β€” ` + + 'weathered steel that is as polished as gal loses the long-range read (this is the ' + + 'property that actually drove lum 94 vs 50 at 20 m, measured)'); + assert(sCorroded.m.metalness < sHonest.m.metalness - 0.15, + `corroded shaft metalness ${sCorroded.m.metalness} vs galvanised ${sHonest.m.metalness} β€” ` + + 'corroded steel must be the more diffuse of the two or the yard-distance contrast inverts'); + }); + + t.test('the corroded post TELLS (3/3): the pad eye DROOPS β€” angle derived from geometry, pinned to the baked note', () => { + const g = loaded.get('sail_post_corroded'); + const honest = loaded.get('sail_post'); + assert(g && honest, 'both post variants must load β€” the tell is a comparison'); + g.scene.updateMatrixWorld(true); + honest.scene.updateMatrixWorld(true); + + const root = g.scene.getObjectByName('sail_post_corroded'); + const anchor = g.scene.getObjectByName('top_anchor'); + assert(root && anchor, 'root and top_anchor must both survive the export'); + const u = root.userData; + + // THE AXIS TRAP (E's swing_set precedent). The builder writes the droop + // as a claim in THREE.JS coords; this derives the same numbers back out + // of the exported anchor position. Note and mesh can only lie together. + // + // Why not "the corroded anchor sits lower than the honest one" β€” the + // assert this REPLACES? Because the eye hangs off a weld line 160 mm + // down the post, so that comparison reads 0.06 m at ZERO droop and sails + // past a `> 0.05` threshold. It measured the weld offset, not the sag: + // an assert that cannot fail on the thing it names is decoration. This + // one drives the derived angle to 0Β° when the eye is flat. + const baked = u.padeye_droop_deg; + const arm = u.padeye_arm_m; + const weldY = u.padeye_weld_y_threejs; + assert(typeof baked === 'number' && typeof arm === 'number' && typeof weldY === 'number', + 'the droop must be BAKED as numbers on the root (deg, arm, weld y in three.js) β€” ' + + 'an orientation claim that lives only in a docstring is the axis trap waiting to happen'); + assert(baked > 0, `padeye_droop_deg is ${baked} β€” a corroded eye that does not droop has no tell`); + + const p = anchor.getWorldPosition(new THREE.Vector3()); + // The post axis is x=z=0; the eye hangs off the weld line at (0, weldY, 0). + const horiz = Math.hypot(p.x, p.z); // out from the axis + const vert = p.y - weldY; // up from the weld + const armMeasured = Math.hypot(horiz, vert); + assert(Math.abs(armMeasured - arm) < 0.004, + `the eye sits ${armMeasured.toFixed(4)} m off the weld line but the note says ${arm} m β€” ` + + 'the baked arm and the exported geometry disagree'); + + const derivedDeg = Math.atan2(horiz, vert) * 180 / Math.PI; + assert(Math.abs(derivedDeg - baked) < 1.5, + `the exported pad eye droops ${derivedDeg.toFixed(1)}Β° but the root claims ${baked}Β° β€” ` + + 'the mesh and its own note disagree about the sag (flatten the eye and this is what you see)'); + + // DIRECTION, measured β€” not asserted by reading the string. Blender βˆ’y + // maps to three.js +Z, so the eye must hang toward the YARD, the face the + // player judges it from. A sag pointing into the fence is a tell nobody sees. + assert(u.padeye_droop_dir_threejs === '+Z', + `the baked droop direction is ${u.padeye_droop_dir_threejs} β€” this test only knows how to check +Z`); + assert(p.z > 0.5 * horiz, + `the eye hangs toward (x=${p.x.toFixed(3)}, z=${p.z.toFixed(3)}) β€” the note says +Z (the yard side), ` + + 'so either the exporter axis mapping moved or the note is wrong'); + assert(Math.abs(p.x) < 0.006, + `the eye is displaced ${p.x.toFixed(3)} m on X β€” the droop is meant to be purely +Z`); + + // And the weaker claim, kept as a sanity check now that it is no longer + // carrying the argument: the drooped anchor does sit below the honest one. + const sag = honest.scene.getObjectByName('top_anchor') + .getWorldPosition(new THREE.Vector3()).y - p.y; + assert(sag > 0.05, + `the corroded top_anchor sits only ${sag.toFixed(3)} m below the honest one`); + }); + + t.test('corroded steel is a RUNG: above the swing frame, below sound timber, resolvable node-less', () => { + const T = FACTORY_ANCHOR_RATINGS.types; + const entry = T.corroded_post; + assert(entry, 'corroded_post is not in the ratings manifest β€” the type cannot resolve node-less'); + assert(!FACTORY_ANCHOR_RATINGS.ambiguous.corroded_post, + 'corroded_post is manifest-AMBIGUOUS β€” the one-rating-per-type rule (THREADS [E] S15) broke'); + // The ladder claims, pinned against the MANIFEST, never against literals + // (a literal pin would stay green while the whole ladder reshuffled): + assert(T.swing_frame.rating_hint < entry.rating_hint, + `corroded post (${entry.rating_hint}) must out-rate the swing frame (${T.swing_frame.rating_hint}) β€” ` + + 'a real concrete footing beats sound steel standing loose on grass'); + assert(entry.rating_hint < T.pergola.rating_hint, + `corroded post (${entry.rating_hint}) must rate under the pergola (${T.pergola.rating_hint}) β€” ` + + 'sound flexing timber beats steel whose bloom outside means pitting inside'); + assert(entry.rating_hint < T.post.rating_hint, + 'a corroded post rating at or above the honest post erases the tier entirely'); + // It fails through the client's property: collateral named, priced, and + // priced UNDER the gutter β€” the bill is the make-safe, not the steel; the + // real price of trusting rust is the corner you lose mid-storm. + assert(entry.collateral === 'corroded_post', + 'the manifest must carry the collateral so node-less anchors bill on failure'); + const root = g('sail_post_corroded'); + const gutter = g('house_yardside'); + assert(typeof root.userData.collateral_value === 'number' && root.userData.collateral_value > 0, + 'the corroded post names collateral nothing prices β€” free failure, the gutter bug'); + assert(root.userData.collateral_value < gutter.userData.collateral_value, + `a snapped corroded post ($${root.userData.collateral_value}) must bill under the gutter ` + + `($${gutter.userData.collateral_value}) β€” condemned steel is a make-safe charge, not a rebuild`); + + function g(name) { + const glb = loaded.get(name); + assert(glb, `${name} did not load`); + return glb.scene.getObjectByName(name); + } + }); + + t.test('the corroded wreck folded at the rust line: stub stands, shaft in the yard, nothing left to tie to', () => { + const w = loaded.get('sail_post_corroded_wrecked'); + assert(w, 'sail_post_corroded_wrecked did not load'); + const box = new THREE.Box3().setFromObject(w.scene); + // It FELL: nothing stands past the stub, and the fallen shaft reaches + // into the yard on +Z (the same face the pergola presents β€” placement + // facts in THREADS). Both directions measured, not asserted by name. + assert(box.max.y < 1.0, + `the wreck stands ${box.max.y.toFixed(2)} m β€” a corroded post that still stands never folded`); + assert(box.max.z > 3.2, + `the fallen shaft reaches z=${box.max.z.toFixed(2)} β€” it is meant to be ~3.5 m out in the yard (+Z)`); + // No anchor survives, nothing rates, nothing enrols β€” an anchor that + // outlives its structure is the free-failure bug in a costume (the + // fascia rule; fourth application). + assert(!w.scene.getObjectByName('top_anchor'), + 'top_anchor survives the wreck β€” you cannot clip to a post that is lying on the grass'); + const offers = []; + w.scene.traverse((o) => { + if (typeof o.userData?.rating_hint === 'number' || o.userData?.anchor_type !== undefined) { + offers.push(o.name); + } + }); + assert(offers.length === 0, + `the wreck offers [${offers.join(', ')}] β€” a wreck must offer nothing`); + // Twin-priced, and it names its twin β€” the carport/gutter chain, sixth + // application, compared between the GLBs and never against a literal. + const wr = w.scene.getObjectByName('sail_post_corroded_wrecked'); + const ir = loaded.get('sail_post_corroded')?.scene.getObjectByName('sail_post_corroded'); + assert(wr?.userData?.broken_variant_of === 'sail_post_corroded', + 'the wreck must name its intact twin so A can pair the swap'); + assert(wr?.userData?.collateral_value === ir?.userData?.collateral_value, + 'the wrecked post must be priced the same as the one it used to be'); + assert(wr?.userData?.collateral_key === 'corroded_post', + 'the wreck must keep the collateral key or the swap changes what the bill is about'); + }); + // One GLB carries three wilt states as siblings; Lane A toggles .visible // rather than reloading, so all three have to be present at once. t.test('garden_bed carries all 3 damage states in one GLB', () => { diff --git a/web/world/models/sail_post_corroded_v1.glb b/web/world/models/sail_post_corroded_v1.glb new file mode 100644 index 0000000..7453546 Binary files /dev/null and b/web/world/models/sail_post_corroded_v1.glb differ diff --git a/web/world/models/sail_post_corroded_wrecked_v1.glb b/web/world/models/sail_post_corroded_wrecked_v1.glb new file mode 100644 index 0000000..69be741 Binary files /dev/null and b/web/world/models/sail_post_corroded_wrecked_v1.glb differ