diff --git a/THREADS.md b/THREADS.md index b079b16..ddebec5 100644 --- a/THREADS.md +++ b/THREADS.md @@ -10313,3 +10313,291 @@ anchors are your GLB), but the tooling is now waiting, not TODO. lull and the veer fire TOGETHER, which is not a contradiction β€” it is a southerly change, going quiet before it comes back from the other quarter. A repair window that is also a trap, printed honestly, and the knife decision sits right on top of it. +[E] 2026-07-25 β€” πŸͺŸ **GATE 4 LANDED: THE GLASSHOUSE TIER β€” and the gate's premise had already moved.** + **READ THIS FIRST: SPRINT16 ALREADY SHIPPED THE GLASSHOUSE, AND GATE 4'S THREE LITERAL ASKS + WERE ALREADY GREEN.** `glasshouse_01` / `_wrecked` are built, exported, in A's palette, and + pinned by e.test since S16 gate 5: fragility readable at a glance (translucent panes, pinned as + a MATERIAL fact so a repaint that turns the glass opaque goes red), $320 against the gnome's $25 + (12.8Γ—), and a wreck that EMPTIES instead of falling (aluminium frame at full height, 34 shards + thrown wider than the footprint). I checked before building anything, because the cheapest way to + ship nothing is to rebuild what exists. + **THE REAL GAP WAS WRITTEN IN MY OWN TEST FILE, IN ITS OWN WORDS** (e.test, S16, above the apex + pin): *"nothing in the sim wrecks a glasshouse yet β€” the value is inert data"*. `main.js:877` + bills collateral off a **broken rig corner's** baked key (`world.anchor(c.anchorId).collateral`), + and a glasshouse correctly offers **no anchors** β€” the one rule pinned from every direction, and + it should stay that way. So the most expensive object in the game **could not cost anybody a + cent.** That is RULE 2's mirror image: RULE 2 catches a key with no price; nothing caught a price + with no key that can reach it. **It does now, and that assert is the gate.** + **WHAT DESIGN.md LINE 179 ACTUALLY ASKED FOR:** *"Jobs: hail nets and mesh sails rigged OVER + glass, where your own failure is the worst debris of all."* You do not tie to the glasshouse. You + tie to the frame that STRADDLES it. So the tier is one new asset pair, and the glasshouse itself + is untouched (zero churn on both its GLBs β€” the control that proves I only added). + + **`hail_net_frame_01` / `hail_net_frame_01_wrecked` β€” 328 / 480 tris, 3.42 Γ— 3.02 Γ— 2.77 m.** + A galvanised-then-powder-coated portal frame: two portals clear of the gable ends (Β±0.30 m), legs + clear of the long walls (Β±0.45 m), head beams at 2.75 m so a net clears the 2.28 m ridge, eave + rails, a thin tensioning wire down the middle, and the hail net **rolled** along one eave β€” + rolled, because the JOB is to rig it, and a deployed net is a finished yard. + + **THE TELL IS A POSITION, AND THAT IS THE NEW THING.** Every other trap in this game punishes you + with a bad number on the object: the carport beam at 0.22, the fascia at 0.35, the corroded post + at 0.55 whose real capacity you cannot read. **This frame's numbers are GOOD** β€” engineered + portal, four real footings, welded factory eyes β€” and it is the most dangerous anchor in the game + because of WHERE IT STANDS. So the tell is pinned as GEOMETRY off two shipped GLBs, not as paint + on one: the frame's plan footprint **strictly contains** the glasshouse's on both axes + (x Β±1.71 vs Β±1.32, z Β±1.51 vs Β±0.98) and its head **clears the ridge by 0.47 m** (2.766 vs + 2.30), and all four eyes sit **above** the ridge. "It stands over the glass" is a measurement. + **THE 20 M READ IS METALNESS, NOT LIGHTNESS β€” S17's lesson, applied deliberately this time.** + S17 measured that galvanised (metalness 0.90) renders **near-black** at yard distance because a + near-mirror with no environment map has nothing to mirror β€” rgb(49,51,49). So every steel the + player is allowed to trust is DARK. This frame is powder-coat, i.e. **paint**: metalness 0.05, + diffuse, base luminance 0.903 against galvanised 0.734. **Measured off the shipped GLB, then + LOOKED AT in the game's own renderer** (`tools/assetcheck/look.html`, cam at eye height 1.7 m): + at 20 m the honest sail post is a near-black line, the frame is a **white cage**, the glasshouse + is visibly see-through. At 11–16 m the rolled net reads as a hard **dark bar** along one eave + (lum 0.254) β€” which Blender's own contact-sheet view transform had washed out to near-white, the + jacaranda lesson again: a colour judgement made in Blender is a judgement about Blender. + + **THE RUNG β€” 0.80. I OWN IT AS A PROPOSAL, A RULES.** Ladder: 0.22 carport beam < 0.30 carport + post < 0.35 fascia < 0.45 swing frame < 0.55 corroded post < 0.65 pergola < **0.80 hail_net_frame** + < 1.00 sail post. Two halves to the argument: + Β· **It EARNS the highest rung below the post.** A pergola is a deck screwed to a house and it + flexes; the swing frame stands on grass; the corroded post's capacity is unknowable. This is an + engineered frame on four footings and **the only structure in the palette that was built to be + tied to.** De-rating honest steel for looking dangerous is the corroded post's lie inverted. + Β· **It is not 1.00 because of SECTION, not condition.** 32 mm sized for knitted hail mesh β€” + ~0.2 kg/mΒ² and near-zero drag, because mesh *spills* wind. A 25 mΒ² impermeable sail in a + southerly is an order of magnitude more load. 0.80 is a 20% de-rate for "engineered for a load + you are about to exceed", and the reasoning is baked on the root as `rated_for`. + **AND 0.80 IS CHOSEN SO THAT IT USUALLY HOLDS. That is the tier, not a softness in it.** A corner + that lets go one night in five costs you a shackle on a sail post and **$320** here. The player + who learned "0.80 is fine" on every other yard is exactly the player this yard is for. The lesson + is not "don't trust this steel" β€” the steel is fine. It is **"the same failure has a different + price depending on what is underneath it"**, which is the sentence line 179 has been waiting for + an asset to say. + **COLLATERAL: the frame is UNPRICED, BY RULING** (the bike/jacaranda pattern). Its anchors bill + the **glass** at $320 (unchanged, A's number, endorsed). A folded portal frame is obviously also + a repair bill, but one broken corner can name exactly one collateral key, and inventing a second + bill with no mechanism to split it is the guess the invoice exists to refuse. Price it when a + break can bill two things. + **MANIFEST β€” UNAMBIGUOUS.** `anchor_ratings.gen.js` regenerated: `hail_net_frame` β†’ rating 0.80, + `collateral: "glasshouse"`, **four agreeing sources**, not in `ambiguous`. So a yard can place + this **node-less** (`factoryExtras` carries both the 0.80 AND the 'glasshouse' key off the type) + exactly like the corroded post β€” D does not have to name nodes to author a glasshouse yard. + It is the second type whose collateral names a **different** structure than itself; `house` β†’ + `"gutter"` is the precedent, and the shape is the same: **an anchor names what its failure TAKES, + not what it is.** + + **NEGATIVE CONTROLS β€” five, each witnessed RED in the real harness at :8828, my own fronted tab, + then green. Batched only where the mutations hit disjoint tests, so every red is attributable.** + Β· **M4 β€” the anchors stop naming the glass (the PRE-SPRINT state).** Witnessed free, and it is + the headline: on the run where the two GLBs were built but not yet in e.test's own ASSETS list, + the apex pin went red with exactly *"NOTHING in the palette can bill "glasshouse" ($320)"*. + **That is the hole gate 4 was really about, printed by the test that closes it.** + Β· **M1 β€” the frame stops bracketing the glass** (CLEAR_X/Y negative): BRACKETS red, *"the frame + spans x βˆ’0.86..0.86 and the glasshouse βˆ’1.32..1.32"*. **Caught TWICE** β€” the factory's own dims + band rejects the build outright, so this control needed `--no-verify` to even export. Belt and + braces, and I'll take it. + Β· **M2 β€” powder-coat becomes galvanised** (steel_gal at metallic 0.90): METALNESS red, *"the frame + paints at metalness 0.8999…"*. The tell dying **silently** is the whole risk here: the frame + stays white in the palette and goes black on screen. + Β· **M3 β€” the frame CLAIMS the glass's key** (`collateral_key = "glasshouse"` on the root): apex + pin red on its second half. This is gate 5's landmine **on my own asset**, which is why the + omission is commented at the bake site rather than merely done. + Β· **M5 / M6 β€” the gate-5 site controls**, below. + Counts held at 533 through every control run (530 passed + 3 failed), so no test silently + vanished while I was reddening others. + **DETERMINISM β€” the rule I set in S17, held.** Three consecutive full factory runs; runs 2 and 3 + are **56/56 byte-identical** (39 GLBs, 4 verbatim debris copies, 8 textures, `asset_report.json`, + `contact_sheet.png`, `anchor_ratings.gen.js`). + + ⚠️ **BLENDER VERSION IS PART OF THE OUTPUT β€” READ BEFORE YOU BUILD. This box is WRONG.** + The shipped GLBs were exported by **Blender 5.1.2** (glTF I/O v5.1.20); `asset_report.json` + records it in its `blender` field. This box has **5.0.1** (I/O v5.0.21). A full run here rewrote + **all 45 binaries** β€” every GLB and every texture β€” and both differences are cosmetic: + Β· GLB accessor min/max drift in the **7th decimal** (~6eβˆ’8 m, 60 nanometres). Node graph, mesh + topology, materials and every custom prop are **bit-identical** (verified chunk by chunk). + Β· The generated PNGs **lose their sRGB/gAMA/cHRM chunks** β€” exactly 73 bytes each. IDAT pixel + data decompresses **bit-identically**; 5.0.x simply omits the colour-space chunks 5.1.x writes. + **I reverted all 45 and committed only my two new files.** Shipping that churn would have been a + 45-binary diff nobody could review, hiding a real (if small) colour-management regression, to add + two assets β€” my S17 words for this exact shape were *"a corrupt artifact, not a decision"*. It is + now documented at the top of `build_yard_assets.py` so the next E session doesn't ship the + downgrade by accident: if `git status` shows every GLB modified after you added one asset, that is + this. **Better: build on 5.1.2 and the churn is zero.** I did NOT chase a second machine for it β€” + adding a box to the asset pipeline to chase exporter metadata is an unforced variable, and the + difference is provably semantically nil. + + **THE WRECK β€” and I changed it because I LOOKED.** First cut racked the failed portal 0.30 m in + and kicked its feet 0.16 m out. In the render the top frame **still read as a clean level + rectangle**: a 0.43 m drop on a 2.77 m frame of 32 mm tube is invisible in perspective, and a + wreck you cannot see is not a wreck. Now 0.45 each way β€” head IN (dragged by the sail), feet OUT + β€” so the portal **racks into a parallelogram**, the carport's failure mode on a lighter section: + ~22Β° of lean, one foot clear of its pad, one **orphaned footing**, the top frame a visible + trapezoid, and the net off its lashings draped down over the glass and heaped past the footing. + Verified at 16 m in the game's renderer: unmistakably broken at a glance. + The head beam rests **ACROSS the ridge** (2.33) rather than below it, deliberately: the + glasshouse's own wreck keeps its aluminium frame standing at full height, so a beam that ended up + under the ridge line would be *inside* it. **The wreck carries NO GLASS** (pinned): the glasshouse + swaps independently, and shards in both would double-draw the night both swap β€” and would quietly + claim the glass's failure as this asset's. **No anchor survives the wreck** (pinned; fascia rule, + sixth application). Same height band as the intact frame, because a portal frame folds at one end + β€” it does not lie down. + + **PLACEMENT FACTS, three.js coords, measured off the exported GLBs (not off the builder):** + Β· `hail_net_frame_01` β€” footprint **3.42 Γ— 3.02 m**, height **2.766 m**. Four eyes, + `net_anchor_01..04`, all at **y = 2.690**, on the corners of a 3.20 Γ— 2.80 m rectangle: + **(βˆ’1.600, 2.690, +1.400) Β· (+1.600, 2.690, +1.400) Β· (+1.600, 2.690, βˆ’1.400) Β· + (βˆ’1.600, 2.690, βˆ’1.400)**. Sized around `glasshouse_01` (2.65 Γ— 1.95 Γ— 2.30): **place both at + the same x/z and the frame straddles it correctly with no fiddling.** + Β· `hail_net_frame_01_wrecked` β€” **WRECK APRON: reserve ~0.9 m of clear ground on βˆ’X** beyond the + footing line (x reaches βˆ’2.59 against the intact βˆ’1.71; the **net heap** sets that bound, not + the racked feet). Height 2.783 m. It is the far side from the standing portal. + + **πŸ“‹ A / INTEGRATOR β€” EDITOR PALETTE HUNK, APPLY VERBATIM** (editor.js is A's; leadFor precedent, + eighth application). Insert next to the glasshouse. A's own palette test uses `PALETTE.find`, not + a length assert, so this adds no rows to a.test: + +```js + { + // SPRINT18 gate 4, E. The glasshouse tier's LIVE half β€” the frame you rig a + // hail net FROM, straddling the glass you must never rig TO. DESIGN.md line + // 179: "hail nets and mesh sails rigged over glass, where your own failure + // is the worst debris of all." Place it at the same x/z as a glasshouse and + // it straddles correctly; the eyes land at y=2.690, clear of the 2.28 ridge. + // + // 0.80, and it USUALLY HOLDS β€” that is the tier, not a softness in it. Every + // other trap here punishes you with a bad number; this is good steel + // (engineered portal, four footings, welded eyes) and the hazard is WHERE IT + // STANDS. The corner failure that costs a shackle on a sail post costs $320. + // + // ⚠️ `_v1` is LOAD-BEARING and its absence is SILENT: adoptAnchor does + // `rating_hint ?? 1`, so a typo does not fail β€” it rates these four eyes + // 1.00, the best steel in the game, on the highest-collateral anchor there + // is. (S14, D's cold pass.) + // ⚠️ THIS ITEM WRITES NO collateralKey, ON PURPOSE. The anchors bill + // 'glasshouse'; the GLASSHOUSE owns that key. structFor() returns the FIRST + // structure matching a key, so a frame that claimed it would bill the panes + // and then wreck the FRAME β€” both papers agreeing and both wrong. + // ⚠️ PLACE IT OVER A GLASSHOUSE. With no glasshouse in the site, + // collateralFor('glasshouse') returns null and these four corners are a FREE + // failure β€” the gutter bug. e.test pins that per-yard and names the anchor. + kind: 'structure', label: 'Hail-net frame (0.80 β€” over the glass, $320 under it)', + model: 'hail_net_frame_01_v1', + requires: ['hail_net_frame'], + hint: 'four good eyes at 2.7 m, rated 0.80 β€” and everything they drop lands on the glasshouse', + make: (id, x, z) => ({ + id, model: 'hail_net_frame_01_v1', + wreckedModel: 'hail_net_frame_01_wrecked_v1', + x, z, rotYDeg: 0, solid: true, + anchors: [1, 2, 3, 4].map((i) => ({ + id: `${id}_a${i}`, node: `net_anchor_0${i}`, type: 'hail_net_frame', work: 'cloth', + })), + }), + }, +``` + + ⚠️ **A β€” ANCHOR_TYPE WIDENED** (contracts.js is yours; carport / swing_frame / pergola / + corroded_post precedent, fifth application). I added `'hail_net_frame'` with its reasoning beside + it, including why it must NOT be typed `post`: "post" promises a corner whose failure costs a + shackle. Revert-and-tell-me standard; your own "the widened enum is CHECKED" test covers it and + is green. + + **πŸͺŸ A β€” THE TIER IS PLACEABLE, AND THE ONE-VARIABLE LAW IS YOURS TO ENFORCE.** Both GLBs are + landed, deterministic and manifest-resolvable **node-less**, so a glasshouse yard can be authored + now. **Per SPRINT18's own hard gate: this must NOT debut on the same night as the emergency + callout**, however well line 29 pairs them β€” the glasshouse debuts as an ordinary + rig-for-tonight job, and the flogging-sail-over-glass sentence is the payoff, a later week. I am + supplying it and saying so; the ordering is yours. + +[E] 2026-07-25 β€” πŸ”‘ **GATE 5 HALF-LANDED: THE SECOND-RUST-POST LANDMINE β€” my half, and the exact + wiring A needs. DISARMED AT AUTHOR TIME; the runtime fix is one `??` and it is A's.** + D's filing, with D's measured receipt ($115 on the offer card vs $45 on the failure route). The + mechanism, re-read at source and confirmed: `world.js:526` `structFor(key)` iterates + `structures.values()` and **returns the first match**; `main.js:877` dedupes bills by key; + `scorecard.js:355` `collateralExposure` dedupes by key again; `board.js:330` `exposureOf` counts + per **STRUCTURE**. All four are individually correct and they disagree the moment two structures + share a key. **The pool yard dodges it by shipping exactly one corroded post on purpose β€” that is + a yard author remembering, which is not a defence.** + **AND GATE 4 JUST MADE IT LIKELIER, which is why these two gates are one seam.** A glasshouse + yard now carries a structure that **owns** the key and a frame whose anchors **point at** it. Two + glasshouses with a frame each trips this on the first night. So my own asset carries the omission + deliberately (no `collateral_key` on the frame root) with the reasoning commented at the bake. + + **MY HALF β€” two pins in e.test, both witnessed red then green:** + Β· **`no shipped yard gives two structures the same collateral key, and ids are unique`.** Resolves + each structure's key exactly the way `structKey()` does (site JSON β†’ baked GLB key β†’ id as last + resort), across all four shipped yards. **M5 control β€” I added a second corroded post to the + pool yard** and it went red naming both. It also pins **duplicate structure ids**, which is a + separate silent death I found while reading: `structures.set(st.id, …)` means a duplicate id + **silently replaces** the first structure β€” it never renders, never bills, never wrecks, and + nothing anywhere says a word. `validateSite` checks duplicate ANCHOR ids and not structure ids. + Β· **`every collateral an authored anchor names is OWNED by exactly one thing in that yard`.** The + runtime twin of RULE 2: RULE 2 proves a key is priced *somewhere in the palette*, this proves it + is owned *in the yard that names it*. **M6 control β€” I placed the net frame in the pool yard, + which has no glasshouse** β†’ red: *"nf1_a1 will bill "glasshouse" on failure, and 0 things in + this yard own that key"*. **Zero owners is the gutter bug reborn**, and it is now caught before + a yard ships rather than by a player breaking a corner for free. + + **πŸ”§ A β€” THE WIRING, EXACT. Two hunks in world.js (yours) and one in validateSite (yours).** + The reason a site cannot currently just give two posts different keys: `structKey()` already + prefers `spec.collateralKey`, so the STRUCTURE half works today. The **anchor** half does not β€” + `adoptAnchor` takes the key off the baked GLB node, identical for every instance, and + `factoryExtras(type)` does the same for node-less anchors. So renaming the structures' keys makes + it **worse, not better**: both anchors still say `'corroded_post'`, which now matches neither + `'corroded_post_a'` nor `'corroded_post_b'`, `collateralFor` returns null, and both corners + become **FREE**. Hence: + 1. **`world.js:541`** (structure anchor creation) β€” let the site name its own target, applied + AFTER `factoryExtras` so it wins: + ```js + anchors.push(Object.assign( + makeStaticAnchor(a.id, a.type ?? 'post', new THREE.Vector3(st.x, heightAt(st.x, st.z) + 2.4, st.z)), + { work: a.work }, + a.node ? null : factoryExtras(a.type ?? 'post'), + a.collateral ? { collateral: a.collateral } : null, // SPRINT18 gate 5 + )); + ``` + 2. **`world.js:690`** (`adoptAnchor`) β€” one `??`, so the bake is the DEFAULT and never clobbers an + override: + ```js + anchor.collateral = anchor.collateral ?? node.userData?.collateral ?? null; + ``` + Every shipped site is byte-unchanged by both hunks: with no override the chain is identical. + 3. **`validateSite`** β€” the loud author-time refusal, mirroring my pin, next to the existing + duplicate-anchor-id check: reject two structures resolving to one collateral key, and reject + duplicate structure ids. + **WHAT THAT BUYS, PRECISELY.** A yard can then ship two corroded posts: `collateralKey` + `corroded_post_a` / `_b` on the structures, and the matching `collateral` on each structure's + anchors. `collateralFor` resolves each to its own structure (the VALUE lives on the site spec, so + $45 each), the right post wrecks, and **the two harnesses agree**: `exposureOf` counts two + structures = $90, `collateralExposure` sees two keys = $90. **D's $115-vs-$45 split closes to + $115 vs $90** β€” and the residual **$25 is exactly the gnome**, which `collateralExposure` excludes + because no anchor reaches it. That is not a bug, it is **your ruling to make**: does an unreachable + priced prop belong in the offer card's exposure total? I have no vote; I am handing you a number + with one variable left in it instead of two. + **AND THE SAME WIRING IS WHAT THE GLASSHOUSE TIER WILL WANT** for two-glasshouse yards, which is + why I did not invent a second mechanism for it. + +[E] 2026-07-25 β€” ✏️ **CORRECTION TO MY OWN ENTRY, SAME SESSION: I overwrote the field I told you to + trust.** The gate-4 entry above says *"the shipped GLBs were exported by Blender 5.1.2; + `asset_report.json` records it in its `blender` field β€” check it before you build."* **That + instruction is now wrong, and I made it wrong.** My run regenerated the report, so the committed + `asset_report.json` reads **`"blender": "5.0.1"`** β€” this box β€” while 37 of the 39 GLBs beside it + are still the **5.1.2** exports I deliberately reverted. Read literally, the field now tells the + next session "you are on the right Blender" at the exact moment they are not, which is the same + shape as every trap this sprint went hunting: a source of truth that quietly starts answering a + different question. + **What is actually true of the committed tree:** `hail_net_frame_01_v1.glb` and + `hail_net_frame_01_wrecked_v1.glb` were built by **5.0.1**; the other 37 GLBs and all 8 textures + are **5.1.2** artifacts, untouched. The report's per-asset facts (dims, tris, node names, baked + extras) are correct for every one of the 39 either way β€” that is measured, not assumed, and it is + why reverting was safe. + **So `asset_report.json:blender` names THE LAST MACHINE THAT RAN THE FACTORY, not the machine + that built the GLBs you are looking at.** Those are the same thing only after a churn-free run. + The honest check is the one I actually used: build, then `git status` β€” **if files you did not + author come back modified, you are on the wrong Blender**, and the header of + `build_yard_assets.py` tells you what the differences mean. I am leaving the generated file alone + rather than hand-editing it (it says "do NOT hand-edit" for good reasons, and a doctored report is + worse than a mislabelled one); the fix is this paragraph, and the mixed-exporter state going into + the integrator's eyes on purpose. If someone runs the full factory on a 5.1.2 box, the churn will + be **my two files only** and the tree becomes uniform again β€” that is the cheap moment to do it. diff --git a/tools/blender/asset_report.json b/tools/blender/asset_report.json index 900cc5e..14157d5 100644 --- a/tools/blender/asset_report.json +++ b/tools/blender/asset_report.json @@ -1,5 +1,5 @@ { - "blender": "5.1.2", + "blender": "5.0.1", "assets": [ { "name": "ref_capsule", @@ -817,6 +817,73 @@ "status": "PASS", "problems": [] }, + { + "name": "hail_net_frame_01", + "dims": [ + 3.42, + 3.02, + 2.766 + ], + "tris": 328, + "nodes": [ + "footings", + "hail_net_frame_01", + "legs", + "net_anchor_01", + "net_anchor_02", + "net_anchor_03", + "net_anchor_04", + "net_roll", + "top_frame" + ], + "anchors": [ + { + "node": "net_anchor_01", + "anchor_type": "hail_net_frame", + "rating_hint": 0.8, + "collateral": "glasshouse" + }, + { + "node": "net_anchor_02", + "anchor_type": "hail_net_frame", + "rating_hint": 0.8, + "collateral": "glasshouse" + }, + { + "node": "net_anchor_03", + "anchor_type": "hail_net_frame", + "rating_hint": 0.8, + "collateral": "glasshouse" + }, + { + "node": "net_anchor_04", + "anchor_type": "hail_net_frame", + "rating_hint": 0.8, + "collateral": "glasshouse" + } + ], + "status": "PASS", + "problems": [] + }, + { + "name": "hail_net_frame_01_wrecked", + "dims": [ + 4.3007, + 3.02, + 2.7832 + ], + "tris": 480, + "nodes": [ + "footings", + "hail_net_frame_01_wrecked", + "legs", + "net_down", + "top_frame" + ], + "anchors": [], + "status": "PASS", + "problems": [] + }, { "name": "pool_kit_01", "dims": [ @@ -955,5 +1022,42 @@ "problems": [] } ], - "debris": [] + "debris": [ + { + "file": "BlueCrate_v2.glb", + "dims": [ + 0.36, + 0.36, + 0.29 + ], + "sane": true + }, + { + "file": "BlackTub_v2.glb", + "dims": [ + 0.36, + 0.54, + 0.2 + ], + "sane": true + }, + { + "file": "WhiteTub_v2.glb", + "dims": [ + 0.36, + 0.54, + 0.2 + ], + "sane": true + }, + { + "file": "WoodenBin_v2.glb", + "dims": [ + 0.35, + 0.36, + 0.31 + ], + "sane": true + } + ] } \ No newline at end of file diff --git a/tools/blender/build_yard_assets.py b/tools/blender/build_yard_assets.py index 394c399..75d7c30 100644 --- a/tools/blender/build_yard_assets.py +++ b/tools/blender/build_yard_assets.py @@ -20,6 +20,25 @@ Outputs (resolved from this file, so no absolute home paths): tools/blender/contact_sheet.png verification render vs the 1.7 m capsule tools/blender/asset_report.json measured dims / tris / node names +⚠️ BLENDER VERSION IS PART OF THE OUTPUT (SPRINT18, E β€” measured, not guessed). + The shipped GLBs were exported by Blender **5.1.2** (glTF I/O v5.1.20), which + `asset_report.json` records in its `blender` field β€” check it before you + build. On Blender 5.0.1 (I/O v5.0.21) this script is still perfectly + deterministic β€” three consecutive full runs gave 56/56 byte-identical + artifacts β€” but its output DIFFERS from the shipped files in two ways, and + both are cosmetic: + Β· GLB accessor min/max drift in the 7th decimal (~6e-8 m, i.e. 60 nm). + Node graph, mesh topology, materials and every custom prop are + bit-identical; only float formatting of the bounds moves. + Β· The generated PNGs lose their sRGB/gAMA/cHRM chunks β€” 73 bytes per file. + IDAT pixel data decompresses bit-identically; the newer exporter simply + writes colour-space chunks that 5.0.x omits. + So a full run on the wrong Blender rewrites all 45 binaries with no change + anybody asked for, and quietly DROPS the colour-space chunks. If your `git + status` shows every GLB and texture modified after adding one asset, that is + this and not your work: `git checkout --` the ones you did not author, and + commit only the new files. Better: build on 5.1.2 and the churn is zero. + Idiom follows ~/Documents/Destroyulater/3D-STORE/racks_to_glb.py: reset_to_empty() per asset -> build under a root empty AT THE ORIGIN -> join by group -> stamp custom props -> export_scene.gltf(export_yup=True, @@ -150,6 +169,23 @@ PAL = { # a pool is the one water in the game that is # supposed to look inviting "deck_board": "#A8845C", # decking oil, a shade lighter than fence timber + "net_frame_white": "#E6E7E1", # SPRINT18 gate 4 β€” the hail-net frame's + # powder-coat, and the BRIGHTEST entry in this + # palette on purpose. The reason is metalness, + # not lightness β€” S17's measured lesson applied + # deliberately this time. Galvanised ships at + # metallic 0.90, and with no environment map a + # near-mirror has nothing to mirror, so every + # steel you are ALLOWED to trust renders + # near-black at yard distance (measured: rgb + # 49,51,49 at 20 m). Powder-coat is paint β€” + # metallic 0.05, diffuse β€” so this frame reads + # as a white cage standing over the glass from + # the far fence, and can never be misread as + # the galvanised steel of an honest sail post. + "net_mesh": "#3A4340", # knitted HDPE hail mesh: dark, matte, and the + # only large dark mass any yard carries up at + # 2.7 m. Rolled it is a bar; down it is a heap. } # Rainwater caught in a sail is not swimming-pool blue. It's shallow, murky, it @@ -1916,6 +1952,283 @@ def build_glasshouse_01_wrecked(name): return root +# --------------------------------------------------------------------------- +# THE HAIL-NET FRAME (SPRINT18 gate 4 β€” the glasshouse tier's LIVE half) +# --------------------------------------------------------------------------- +# SPRINT16 shipped the glasshouse and priced it at 320. SPRINT18 read the gate +# and found the thing the price was waiting on, written in e.test's own words: +# "nothing in the sim wrecks a glasshouse yet β€” the value is inert data". The +# apex could not be reached. main.js bills collateral off a BROKEN RIG CORNER +# (`world.anchor(c.anchorId).collateral`), and a glasshouse deliberately offers +# no anchors β€” correctly, it is the one rule pinned from every direction β€” so +# the most expensive object in the game could not cost anybody a cent. That is +# the free-failure bug inverted: not a hazard billing nothing by accident, but +# a hazard that cannot bill at all by construction. +# +# DESIGN.md line 179 already named the missing asset: "Jobs: hail nets and mesh +# sails rigged OVER glass, where your own failure is the worst debris of all." +# You do not tie to the glasshouse. You tie to the frame that STRADDLES it β€” +# and every corner of that frame is a corner whose failure drops steel, cloth +# and hardware through fourteen panes. +# +# So this is the tier, and its teaching is new. Every other trap in this game +# punishes you with a bad number: the carport beam at 0.22, the fascia at 0.35, +# the corroded post at 0.55 whose real capacity you cannot read. This one is +# GOOD hardware β€” purpose-built, plumb, on its own footings, factory eyes β€” +# and it is the most dangerous anchor in the game because of WHERE IT STANDS. +# The rating is not the hazard. The position is. +NET_FRAME = dict( + CLEAR_X=0.30, # portal stands this far clear of each gable end + CLEAR_Y=0.45, # legs stand this far outside the long walls, on soil + HEAD_H=2.75, # head-beam height. MUST clear the ridge (2.28) β€” a frame + # that fouled the glass it protects is a joke, and + # e.test measures the clearance off the two shipped GLBs. + TUBE=0.032, # 32 mm net-frame section + PAD=0.22, # footing pad +) + +# The rung. PROPOSAL β€” Lane A rules the number, same as every rung before it. +# +# Ladder as shipped: 0.22 carport beam < 0.30 carport post < 0.35 house fascia +# < 0.45 swing frame < 0.55 corroded post < 0.65 pergola < 1.00 sail post. +# This goes at 0.80, and the argument has two halves: +# +# Β· WHY IT OUT-RATES THE PERGOLA (0.65) BY A DISTANCE. The pergola is a deck +# structure screwed to a house and it flexes; the corroded post's capacity +# is unknowable; the swing frame stands on grass. This is an engineered +# portal frame on four real footings with welded eyes, and it is the only +# structure in the palette that was BUILT to be tied to. Rating it near the +# bad steel would be the lie the corroded post exists to punish, inverted: +# honest steel de-rated for looking dangerous. +# Β· WHY IT IS NOT THE SAIL POST (1.00). The section is 32 mm sized for hail +# mesh β€” a couple of hundred grams per square metre, and near-zero drag +# because mesh SPILLS wind rather than catching it. A 25 mΒ² impermeable +# shade sail in a southerly is a load one order of magnitude larger. 0.80 +# is a 20% de-rate for "engineered for a load you are about to exceed". +# +# And 0.80 is chosen so that IT USUALLY HOLDS. That is the point of the tier, +# not a softness in it: a corner that fails one night in five costs you nothing +# on a sail post and 320 dollars here. The player who learns "0.80 is fine" on +# every other yard is exactly the player this yard is for. The lesson is not +# "don't trust this steel" β€” the steel is fine. It is "the same failure has a +# different price depending on what is underneath it", which is the sentence +# DESIGN.md line 179 has been waiting for an asset to say. +NET_FRAME_RATING = 0.80 + + +def _net_frame(name, root, steel, conc, mesh_m, *, wrecked=False): + """The portal frame, shared by intact and wreck so the two cannot drift. + + The wreck fails at the -x portal ONLY: its feet kick out of their pads and + its head beam comes down onto the glasshouse ridge. +x stands, holding the + eave rails up, which is why the wreck keeps the intact height β€” a hail-net + frame does not fall over, it folds at one end, and the end it folds is the + end that empties the glass.""" + G, N = GLASSHOUSE, NET_FRAME + hx = G["W"] / 2 + N["CLEAR_X"] # 1.60 β€” clear of the gable ends + hy = G["D"] / 2 + N["CLEAR_Y"] # 1.40 β€” clear of the long walls + H, r, pad_h = N["HEAD_H"], N["TUBE"] / 2, 0.06 + + def foot(sx, sy): + """Where the leg meets the ground. Wrecked -x feet are OUT of the pad β€” + a foot beside its own footing is the most legible steel failure there + is, and it costs one offset to say.""" + if wrecked and sx < 0: + return (sx * hx - 0.45, sy * hy, pad_h + (0.14 if sy > 0 else 0.0)) + return (sx * hx, sy * hy, pad_h) + + def head(sx, sy): + """Where the leg meets the top frame. The wrecked -x portal has come + down and is RESTING ON THE RIDGE (2.28) rather than passing through it: + the glasshouse's own wreck keeps its aluminium frame standing at full + height, so a beam that ended up below the ridge line would be inside + it. Lying ACROSS the ridge is both true and non-interpenetrating. + + The head comes IN (+x, dragged by the sail) while the feet kick OUT + (-x) β€” the portal RACKS into a parallelogram, the carport's failure + mode on a lighter section. The offsets are deliberately large (0.45 + each way, ~22Β° of lean over 2.3 m) because I looked at 0.30/0.16 in + the render and the top frame still read as a clean level rectangle: + a 0.43 m drop on a 2.77 m frame of 32 mm tube is invisible in + perspective, and a wreck you cannot see is not a wreck.""" + if wrecked and sx < 0: + return (sx * hx + 0.45, sy * hy * 0.90, G["RIDGE_H"] + 0.05) + return (sx * hx, sy * hy, H) + + def mid(sx): + a, b = head(sx, -1), head(sx, 1) + return ((a[0] + b[0]) / 2, 0.0, (a[2] + b[2]) / 2) + + pads, legs = [], [] + for sx in (-1, 1): + for sy in (-1, 1): + pads.append(add_box(f"{name}_pad_{sx}_{sy}", (N["PAD"], N["PAD"], pad_h), + (sx * hx, sy * hy, pad_h / 2), conc)) + legs.append(add_tube_between(f"{name}_leg_{sx}_{sy}", + foot(sx, sy), head(sx, sy), r, steel, verts=8)) + join_group(pads, "footings", root) + join_group(legs, "legs", root) + + top = [] + for sx in (-1, 1): # portal head beams, spanning y + top.append(add_tube_between(f"{name}_head_{sx}", head(sx, -1), head(sx, 1), + r, steel, verts=8)) + for sy in (-1, 1): # eave rails, spanning x + top.append(add_tube_between(f"{name}_eave_{sy}", head(-1, sy), head(1, sy), + r, steel, verts=8)) + # The tensioning wire down the middle, deliberately thinner than the frame: + # at 20 m the gauge difference is the "made for a net" read, and it is the + # honest reason the whole thing rates 0.80 instead of 1.00. + top.append(add_tube_between(f"{name}_ridge_line", mid(-1), mid(1), + r * 0.35, steel, verts=6)) + join_group(top, "top_frame", root) + + if not wrecked: + # Rolled along the +y eave, because the JOB is to rig it. A deployed + # net would be a finished yard, and this asset exists to be worked on. + join_group([add_tube_between(f"{name}_roll", (-hx, hy, H - 0.11), + (hx, hy, H - 0.11), 0.085, mesh_m, verts=10)], + "net_roll", root) + else: + # YOUR debris, not the storm's. The net has come off its lashings at the + # collapsed end and is draped down over the glass and heaped out past + # the footing β€” deterministic scatter, so the same wreck twice. + rng = rng_for(name) + drape = [] + pts = [(hx * 0.85, hy, H - 0.10), + (hx * 0.10, hy * 0.92, H - 0.78), + (-hx * 0.55, hy * 0.80, G["RIDGE_H"] * 0.55), + (-hx - 0.28, hy * 0.55, 0.24), + (-hx - 0.80, hy * 0.20, 0.06)] + for i in range(len(pts) - 1): + drape.append(add_tube_between(f"{name}_net_sag_{i}", pts[i], pts[i + 1], + 0.055, mesh_m, verts=6)) + for i in range(9): + a = rng.uniform(0, math.tau) + rr = rng.uniform(0.05, 0.45) + drape.append(add_box( + f"{name}_net_heap_{i}", + (rng.uniform(0.22, 0.50), rng.uniform(0.16, 0.40), rng.uniform(0.03, 0.09)), + (-hx - 0.58 + math.cos(a) * rr, hy * 0.20 + math.sin(a) * rr * 0.8, + rng.uniform(0.02, 0.07)), + mesh_m, rot=(0, 0, rng.uniform(0, math.pi)))) + join_group(drape, "net_down", root) + + return hx, hy + + +def build_hail_net_frame_01(name): + """The hail-net frame β€” four good anchors in the worst place in the game. + + The glasshouse tier's live half (SPRINT18 gate 4). This is what DESIGN.md + line 179 means by "hail nets and mesh sails rigged OVER glass": a portal + frame that straddles the glasshouse, clear of it on all four sides, with a + head height that clears the ridge and a rolled mesh net waiting to go up. + + THE TELL, and it is a position rather than a defect. Every reading of every + other trap in this game is a bad number on the object. This object's numbers + are GOOD β€” 0.80, plumb, four footings, welded eyes β€” and what the player has + to read instead is the plan view: the frame BRACKETS the glass. e.test pins + that geometrically off the two shipped GLBs (frame footprint strictly + contains the glasshouse footprint; head height strictly clears the ridge), + because "it stands over the glass" has to be a measurement, not a vibe. + The 20 m read is the powder-coat: white, matte, metallic 0.05, against a + palette where every trustworthy steel is metallic 0.90 and therefore renders + near-black. A white cage over a glass box, visible from the far fence. + + WHAT THE ANCHORS SAY. All four bake collateral="glasshouse". They are the + only nodes in the palette whose collateral names a DIFFERENT structure than + the one they sit on (the house's fasciaβ†’"gutter" is the precedent, and it is + the same shape: the anchor names what its failure TAKES, not what it is). + Break a corner here and the sail, the shackle and the turnbuckle go through + fourteen panes: your own failure is the worst debris of all, wired as data. + + AND WHAT THIS ROOT DELIBERATELY DOES NOT CARRY: collateral_key. See below β€” + it is the whole of gate 5 in one omission. + """ + root = add_empty(name) + # Powder-coat is PAINT, so metallic stays near zero. This is not a style + # choice, it is the S17 measurement: high metalness with no environment map + # renders near-black, and this asset's entire long-range tell is that it is + # the brightest thing in the yard. + steel = get_material("Mat_NetFrame", PAL["net_frame_white"], 0.50, metallic=0.05) + mesh_m = get_material("Mat_HailNet", PAL["net_mesh"], 0.85, metallic=0.0) + conc = get_material("Mat_Concrete", PAL["concrete"], 0.95) + + hx, hy = _net_frame(name, root, steel, conc, mesh_m) + + # The four eyes, at the four corners of the top frame β€” where a net corner + # ties, and therefore where a sail corner will be tied instead. + for i, (sx, sy) in enumerate(((-1, -1), (1, -1), (1, 1), (-1, 1))): + e = add_empty(f"net_anchor_{i + 1:02d}", + (sx * hx, sy * hy, NET_FRAME["HEAD_H"] - 0.06), root, size=0.18) + e["anchor_type"] = "hail_net_frame" + e["rating_hint"] = NET_FRAME_RATING + e["collateral"] = "glasshouse" + e["why"] = ("sound steel, and the most expensive corner on the board: it is " + "rated for hail mesh, and everything it drops lands on glass") + + stamp(root, name, "structure") + root["site_hint"] = "glasshouse" + root["is_anchor_trap"] = True + root["rated_for"] = ("knitted hail mesh, ~0.2 kg/m2, near-zero drag β€” mesh spills " + "wind. A 25 m2 impermeable sail is an order of magnitude more.") + # ⚠️ NO collateral_key ON THIS ROOT, AND THAT IS THE POINT (gate 5). + # + # world.js resolves a collateral key to a structure with structFor(), which + # returns the FIRST structure in site order whose key matches. If this frame + # claimed collateral_key="glasshouse" β€” which looks like the helpful thing + # to do, since its anchors bill that key β€” then in a yard where the frame is + # authored before the glasshouse, every failure would bill "the glasshouse" + # for 320 dollars and then WRECK THE FRAME: the player watches their own + # frame fold while the invoice charges them for panes that are still in + # their rebates. The bill and the wreck would agree with each other and + # both be wrong, which is the only kind of wrong this repo cannot see. + # + # So exactly ONE structure owns a key, and it is the one that owns the + # price. This frame POINTS at the key and never claims it. The pointer + # lives in a field nothing resolves, so it can document intent without + # arming anything. + root["collateral_target"] = "glasshouse" + root["rigs_over"] = "glasshouse_01" + return root + + +def build_hail_net_frame_01_wrecked(name): + """The frame after the corner you trusted let go. + + The -x portal has folded: both its feet are out of their pads, and its head + beam is lying across the glasshouse ridge with the net draped down over the + glass and heaped out past the footing. The +x portal stands, holding the + eave rails at full height β€” so this wreck is the same height as the intact + frame, which is the honest read: a portal frame folds at one end, it does + not lie down. + + It carries NO GLASS. The glasshouse has its own wreck variant and swaps + independently; a net-frame wreck that shipped shards would draw them twice + the moment both swapped on the same night, and would also quietly claim + the glass's failure as its own. e.test pins the absence. + + UNPRICED, BY RULING β€” the bike/jacaranda pattern. A folded portal frame is + obviously a repair bill, but the player is being taught about the glass, and + one broken corner can only name one collateral key. Inventing a second bill + with no mechanism to split it between them is exactly the guess the invoice + exists to refuse. Price it when a break can bill two things.""" + root = add_empty(name) + steel = get_material("Mat_NetFrame", PAL["net_frame_white"], 0.50, metallic=0.05) + mesh_m = get_material("Mat_HailNet", PAL["net_mesh"], 0.85, metallic=0.0) + conc = get_material("Mat_Concrete", PAL["concrete"], 0.95) + + _net_frame(name, root, steel, conc, mesh_m, wrecked=True) + + stamp(root, name, "structure") + root["broken_variant_of"] = "hail_net_frame_01" + # No anchor survives the wreck β€” the fascia rule, sixth application. An + # anchor that outlives its structure is the free-failure bug in a costume. + root["collateral_target"] = "glasshouse" + return root + + # --------------------------------------------------------------------------- # THE POOL KIT (SPRINT16 gate 5 β€” the compliant ring whose steel all says no) # --------------------------------------------------------------------------- @@ -3824,6 +4137,25 @@ ASSETS = [ dict(name="glasshouse_01_wrecked", fn=build_glasshouse_01_wrecked, dims=((3.4, 5.4), (2.6, 3.8), (2.15, 2.40)), nodes=["frame", "door", "panes_left", "shards"]), + # SPRINT18 gate 4 β€” the glasshouse tier's live half. Every band here is a + # design claim about the glass underneath it, which is why they are tight: + # x and y floors must both EXCEED the glasshouse's ceilings (2.8 / 2.1), or + # the frame has stopped straddling the thing it bills; z must clear the + # ridge (2.40 ceiling) or a frame built to protect glass is resting on it. + dict(name="hail_net_frame_01", fn=build_hail_net_frame_01, + dims=((3.30, 3.55), (2.90, 3.15), (2.70, 2.85)), + nodes=["footings", "legs", "top_frame", "net_roll", + "net_anchor_01", "net_anchor_02", "net_anchor_03", "net_anchor_04"]), + # Folded at one end, not felled: the SAME z band as the intact frame, + # because the +x portal stands and holds the eave rails up. If z ever + # collapses, someone has laid a portal frame down flat and it never does + # that. x runs WIDE on the -x side β€” the net heaped out past the footing, + # and the wreck apron a yard author has to keep clear. + # 4.10 floor, not 4.30: the measured span is 4.30 and it is set by the NET + # HEAP, not by the racked feet, so it must not sit on the band boundary. + dict(name="hail_net_frame_01_wrecked", fn=build_hail_net_frame_01_wrecked, + dims=((4.10, 5.30), (2.90, 3.15), (2.70, 2.85)), + nodes=["footings", "legs", "top_frame", "net_down"]), # The compliance ring. z is pinned tight around 1.2 m because the height # IS the flavour (AS 1926.1); y reaches past the ring for the ajar gate. dict(name="pool_kit_01", fn=build_pool_kit_01, diff --git a/tools/blender/contact_sheet.png b/tools/blender/contact_sheet.png index 963346c..8ff9c91 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 a669582..06712eb 100644 --- a/web/world/js/anchor_ratings.gen.js +++ b/web/world/js/anchor_ratings.gen.js @@ -80,6 +80,16 @@ export const FACTORY_ANCHOR_RATINGS = Object.freeze({ "sail_post_corroded/top_anchor" ] }, + "hail_net_frame": { + "collateral": "glasshouse", + "rating_hint": 0.8, + "sources": [ + "hail_net_frame_01/net_anchor_01", + "hail_net_frame_01/net_anchor_02", + "hail_net_frame_01/net_anchor_03", + "hail_net_frame_01/net_anchor_04" + ] + }, "house": { "collateral": "gutter", "rating_hint": 0.35, diff --git a/web/world/js/contracts.js b/web/world/js/contracts.js index 772cd43..07ca54d 100644 --- a/web/world/js/contracts.js +++ b/web/world/js/contracts.js @@ -100,10 +100,26 @@ export const PHASES = ['forecast', 'prep', 'storm', 'aftermath']; * 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. + * + * `hail_net_frame` (SPRINT18 gate 4, E): the frame that straddles a glasshouse + * so a hail net can be rigged OVER the glass (DESIGN.md line 179). It is the + * first anchor word in this enum whose danger is not in its own number. + * rating_hint 0.80 β€” the highest rung below the honest post, and it EARNS it: + * an engineered portal frame on four real footings with welded eyes, the only + * structure in the palette that was built to be tied to. The 20% de-rate is + * for section, not condition (32 mm sized for hail mesh, which spills wind; + * a 25 mΒ² impermeable sail is an order of magnitude more load). + * What makes it the most dangerous anchor in the game is that its baked + * `collateral` names the GLASSHOUSE, not itself β€” the `house`β†’"gutter" + * precedent, and the same shape: an anchor names what its failure TAKES, not + * what it is. Break a corner here and the sail, the shackle and the turnbuckle + * go through fourteen panes at $320. The type string is the player's pre-rig + * read of a hazard that is a POSITION, and the reason it must not be typed + * `post`: "post" promises a corner whose failure costs a shackle. */ export const ANCHOR_TYPE = Object.freeze([ 'house', 'tree', 'post', 'carport', 'carport_post', 'swing_frame', 'pergola', - 'corroded_post', + 'corroded_post', 'hail_net_frame', ]); // --------------------------------------------------------------------------- diff --git a/web/world/js/tests/e.test.js b/web/world/js/tests/e.test.js index fc71306..0506089 100644 --- a/web/world/js/tests/e.test.js +++ b/web/world/js/tests/e.test.js @@ -113,6 +113,16 @@ const ASSETS = [ nodes: ['frame', 'panes', 'door', 'ridge_anchor'] }, { name: 'glasshouse_01_wrecked', h: [2.15, 2.40], nodes: ['frame', 'door', 'panes_left', 'shards'] }, + // SPRINT18 gate 4 β€” the glasshouse tier's live half. The height floor is a + // design claim about the asset next to it: 2.70 sits ABOVE the glasshouse's + // 2.40 ceiling, because a net frame that does not clear the ridge is resting + // on the glass it exists to protect. The wreck shares the band β€” the +x + // portal stands, so a portal frame folds at one end rather than falling. + { name: 'hail_net_frame_01', h: [2.70, 2.85], + nodes: ['footings', 'legs', 'top_frame', 'net_roll', + 'net_anchor_01', 'net_anchor_02', 'net_anchor_03', 'net_anchor_04'] }, + { name: 'hail_net_frame_01_wrecked', h: [2.70, 2.85], + nodes: ['footings', 'legs', 'top_frame', 'net_down'] }, { name: 'pool_kit_01', h: [1.12, 1.32], nodes: ['pool_shell', 'pool_water', 'fence_rails', 'pool_gate', 'fence_post_01', 'fence_post_14'] }, @@ -1436,5 +1446,313 @@ export default async function run(t) { 'vacuous: no node-less declared anchors found across the shipped sites β€” this pin checked nothing'); }); + // ------------------------------------------------------------------------- + // SPRINT18 gate 4 β€” THE GLASSHOUSE TIER. Sprint 16 shipped the glass and + // priced it at 320; the gate asked for the tier, and reading the gate found + // that the price could not be REACHED. The comment two hundred lines up says + // it in this file's own words: "nothing in the sim wrecks a glasshouse yet β€” + // the value is inert data". main.js bills off a broken rig corner's baked + // collateral string, and a glasshouse correctly offers no anchors, so the + // most expensive object in the game could not cost anybody a cent. + // + // DESIGN.md line 179 named the missing asset: hail nets rigged OVER glass. + // You tie to the frame that straddles it. The tier's teaching is new β€” the + // frame's numbers are GOOD, and it is the most dangerous anchor in the game + // because of where it stands. So the tell being pinned below is a POSITION, + // measured off two GLBs, not a defect painted on one. + // ------------------------------------------------------------------------- + + t.test('the hail-net frame BRACKETS the glass β€” the tell is a position, and it is measured', () => { + const frame = loaded.get('hail_net_frame_01'); + const glass = loaded.get('glasshouse_01'); + assert(frame && glass, 'hail_net_frame_01 or glasshouse_01 did not load'); + const f = new THREE.Box3().setFromObject(frame.scene); + const g = new THREE.Box3().setFromObject(glass.scene); + // Both assets are built at the origin, so a shared-origin placement is the + // authored case and these comparisons are the real geometry. + assert(f.min.x < g.min.x && f.max.x > g.max.x, + `the frame spans x ${f.min.x.toFixed(2)}..${f.max.x.toFixed(2)} and the glasshouse ` + + `${g.min.x.toFixed(2)}..${g.max.x.toFixed(2)} β€” the frame must STRADDLE the glass on x. ` + + 'A frame that no longer brackets it is a frame whose anchors bill a building they are ' + + 'not standing over, and the $320 becomes a number with no geometry behind it'); + assert(f.min.z < g.min.z && f.max.z > g.max.z, + `the frame spans z ${f.min.z.toFixed(2)}..${f.max.z.toFixed(2)} and the glasshouse ` + + `${g.min.z.toFixed(2)}..${g.max.z.toFixed(2)} β€” it must straddle on z as well; ` + + 'legs standing ON the glass would be the joke version of this asset'); + assert(f.max.y > g.max.y + 0.35, + `the frame heads at y=${f.max.y.toFixed(2)} and the ridge is at ${g.max.y.toFixed(2)} β€” ` + + 'a net frame built to protect glass must CLEAR the ridge with room for the net to ' + + 'drape, or it is resting on the thing it exists to keep hail off'); + // Every eye is above the ridge: the load path arcs OVER the glass, which is + // the whole reason a failure here lands on panes. + for (const n of ['net_anchor_01', 'net_anchor_02', 'net_anchor_03', 'net_anchor_04']) { + const o = frame.scene.getObjectByName(n); + assert(o, `${n} missing`); + const p = new THREE.Vector3().setFromMatrixPosition(o.matrixWorld); + assert(p.y > g.max.y, + `${n} sits at y=${p.y.toFixed(2)}, below the ridge (${g.max.y.toFixed(2)}) β€” every corner ` + + 'of this frame must be above the glass or the tier is not about the glass'); + } + }); + + t.test('the frame\'s 20 m read is METALNESS, not lightness β€” S17\'s lesson, applied on purpose', () => { + // S17 measured this the hard way on the corroded post: galvanised ships at + // metalness 0.90, and with no environment map a near-mirror has nothing to + // mirror and renders NEAR-BLACK (rgb 49,51,49 at 20 m). So every steel the + // player is allowed to trust is dark at yard distance. This asset's entire + // long-range tell is being the opposite of that, and the mechanism is + // metalness β€” pin the mechanism, or the docstring gets "fixed" the wrong + // way by whoever repaints it next. + const pick = (asset, node) => { + let m = null; + loaded.get(asset)?.scene.getObjectByName(node)?.traverse((o) => { if (o.material) m = o.material; }); + return m; + }; + const paint = pick('hail_net_frame_01', 'top_frame'); + const gal = pick('sail_post', 'post'); + assert(paint && gal, 'could not reach the frame paint or the honest post steel'); + assert(paint.metalness <= 0.15, + `the frame paints at metalness ${paint.metalness} β€” powder-coat is PAINT, and above ~0.15 ` + + 'it starts mirroring an environment that does not exist and goes dark like every other ' + + 'steel. The tell dies silently: the frame is still white in the palette and black on screen'); + assert(gal.metalness >= 0.6, + `the honest post's steel is at metalness ${gal.metalness} β€” the control for this comparison ` + + 'is that trusted steel IS a near-mirror; if that changed, re-measure the whole claim'); + const lum = (c) => c.r * 0.2126 + c.g * 0.7152 + c.b * 0.0722; + assert(lum(paint.color) > lum(gal.color) * 1.3, + `frame paint luminance ${lum(paint.color).toFixed(3)} vs galvanised ${lum(gal.color).toFixed(3)} β€” ` + + 'the powder-coat must also be the brighter base colour, so the two halves of the tell ' + + 'agree instead of fighting'); + }); + + t.test('the hail-net frame is a NEW RUNG at 0.80: good steel, worst place, resolvable node-less', () => { + const T = FACTORY_ANCHOR_RATINGS.types; + const entry = T.hail_net_frame; + assert(entry, 'hail_net_frame is not in the ratings manifest β€” the type cannot resolve ' + + 'node-less, and a site declaring it plainly would play at the default hint (the ' + + 'sail_post bug shape, on the highest-collateral anchor in the game)'); + assert(!FACTORY_ANCHOR_RATINGS.ambiguous.hail_net_frame, + 'hail_net_frame went manifest-AMBIGUOUS β€” its four eyes disagree, and the runtime will ' + + 'refuse to guess; they are one frame and must bake one number'); + // Pinned against the manifest, never a literal β€” a literal would stay green + // through a whole reshuffle of the ladder. + assert(entry.rating_hint > T.pergola.rating_hint, + `the frame rates ${entry.rating_hint} and the pergola ${T.pergola.rating_hint} β€” the frame ` + + 'must out-rate it by a distance: a deck screwed to a house flexes, this is an engineered ' + + 'portal on four footings. De-rating honest steel for looking dangerous is the corroded ' + + 'post\'s lie inverted'); + assert(entry.rating_hint < T.post.rating_hint, + `the frame rates ${entry.rating_hint} against the honest post's ${T.post.rating_hint} β€” at or ` + + 'above the post there is no de-rate left for "32 mm sized for mesh, not for 25 mΒ² of sail"'); + // THE FIELD THAT MAKES IT A TIER: what it takes with it is the GLASS. + assert(entry.collateral === 'glasshouse', + `the manifest says this type bills ${JSON.stringify(entry.collateral)} β€” it must bill ` + + '"glasshouse". That string is the entire tier: the anchor is fine, the thing underneath ' + + 'it is $320, and a node-less declaration has to carry that or the yard is free to break'); + assert(entry.sources.length === 4, + `the manifest cites ${entry.sources.length} eyes; the frame has four corners and a net ` + + 'ties at all of them β€” a missing source means a corner baked something different'); + }); + + // ⚠️ THE GATE, AS ONE MEASUREMENT. Before this sprint this assert was RED by + // construction: no node anywhere in the palette named the glasshouse's key, + // so the collateral apex was unbillable and the 320 was decoration. It is the + // free-failure bug's mirror image β€” RULE 2 catches a key with no price, and + // this catches a price with no key that can reach it. + t.test('⚠️ the collateral APEX is reachable: some anchor in the palette can actually bill the glass', () => { + const g = loaded.get('glasshouse_01')?.scene.getObjectByName('glasshouse_01'); + assert(g, 'glasshouse_01 root missing'); + const key = g.userData?.collateral_key; + assert(typeof key === 'string', 'the glasshouse carries no collateral_key to reach'); + const billers = []; + for (const [asset, o] of allNodes()) { + const u = o.userData ?? {}; + if (u.collateral !== key) continue; + if (typeof u.rating_hint !== 'number' || u.tie_off === false) continue; + billers.push(`${asset}/${o.name}`); + } + assert(billers.length > 0, + `NOTHING in the palette can bill "${key}" ($${g.userData?.collateral_value}). The apex is ` + + 'priced and unreachable: main.js bills collateral off a BROKEN RIG CORNER\'s baked key, ' + + 'a glasshouse rightly offers no anchors, so the most expensive thing in the game costs ' + + 'nobody anything. It needs an adoptable anchor whose collateral names the glass β€” that ' + + 'is what the hail-net frame is for (SPRINT18 gate 4)'); + // ...and the corollary that keeps gate 5 disarmed on my own asset: the + // frame POINTS at the key and must never CLAIM it. structFor() returns the + // FIRST structure matching a key, so a frame carrying collateral_key + // "glasshouse" would, in any yard authored frame-first, bill $320 for the + // panes and then wreck THE FRAME β€” the bill and the wreck agreeing with + // each other and both wrong, which is the only kind of wrong this repo + // cannot see from the outside. + const claimants = []; + for (const [asset, gltf] of loaded) { + const root = gltf.scene.getObjectByName(asset); + if (root?.userData?.collateral_key === key) claimants.push(asset); + } + for (const c of claimants) { + assert(c.startsWith('glasshouse_01'), + `${c} claims collateral_key "${key}", and it is not the glasshouse. Exactly one structure ` + + 'may OWN a key β€” the one that owns the price. structFor() takes the first match, so a ' + + 'second claimant means some yard bills the glass and wrecks something else'); + } + assert(claimants.length > 0, `vacuous: nothing owns "${key}" at all`); + }); + + t.test('the net-frame wreck folds at ONE end: keeps its height, carries no glass, offers nothing', () => { + const w = loaded.get('hail_net_frame_01_wrecked'); + const i = loaded.get('hail_net_frame_01'); + assert(w && i, 'the net frame or its wreck did not load'); + const bw = new THREE.Box3().setFromObject(w.scene); + const bi = new THREE.Box3().setFromObject(i.scene); + assert(Math.abs(bw.max.y - bi.max.y) < 0.10, + `the wreck tops out at y=${bw.max.y.toFixed(2)} vs the intact ${bi.max.y.toFixed(2)} β€” the +x ` + + 'portal stands and holds the eave rails, so a portal frame folds at one end. A wreck lying ' + + 'flat is claiming a failure mode this thing does not have'); + assert((bw.max.x - bw.min.x) > (bi.max.x - bi.min.x) + 0.6, + `the wreck spans ${(bw.max.x - bw.min.x).toFixed(2)} m on x against the intact ` + + `${(bi.max.x - bi.min.x).toFixed(2)} β€” the net has to end up OUT past the footing, or your ` + + 'own debris is tidier than the storm\'s and the tier stops teaching what it is for'); + // No glass here. The glasshouse has its own wreck variant and swaps + // independently; shards in both would draw twice the night both swap, and + // would quietly claim the glass's failure as this asset's. + const glassy = []; + w.scene.traverse((o) => { + const m = o.material; + if (m && m.transparent === true && m.opacity < 0.6) glassy.push(o.name); + }); + assert(glassy.length === 0, + `the net-frame wreck ships translucent geometry on [${glassy.join(', ')}] β€” the glass belongs ` + + 'to glasshouse_01_wrecked, which swaps on its own; shards in both double-draw'); + // No anchor survives the wreck (the fascia rule, sixth application). + 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 still offers [${offers.join(', ')}] β€” you cannot clip to a folded frame, and an ` + + 'anchor that outlives its structure is the free-failure bug in a costume'); + const wr = w.scene.getObjectByName('hail_net_frame_01_wrecked'); + assert(wr?.userData?.broken_variant_of === 'hail_net_frame_01', + 'the wreck must name its intact twin so A can pair the swap'); + // UNPRICED BY RULING β€” the bike/jacaranda pattern. One broken corner can + // only name one collateral key, and the player is being taught about the + // glass. Splitting a bill between the frame and the panes with no mechanism + // to split it is exactly the guess the invoice exists to refuse. + assert(wr?.userData?.collateral_value === undefined, + 'the net frame is UNPRICED BY RULING β€” build the mechanism that can bill two things off ' + + 'one broken corner before pricing the second one'); + }); + + // ------------------------------------------------------------------------- + // SPRINT18 gate 5 β€” THE SECOND-RUST-POST LANDMINE (D's filing, E+A). + // + // Two structures in one yard sharing a collateral key is currently ARMED and + // not firing: world.js's structFor() returns the FIRST structure whose key + // matches, main.js's billing loop dedupes by key, and C's scorecard envelope + // dedupes by key again β€” so the second one is invisible to the bill and to + // the wreck, while board.js's exposureOf() counts per STRUCTURE and prices + // both. D measured the split on a real two-post yard: $115 on the offer card + // against $45 on the failure route. + // + // The pool yard dodges it by shipping exactly one corroded post ON PURPOSE. + // That is a yard author remembering, which is not a defence β€” and gate 4 just + // made it likelier, because a glasshouse yard carries a structure that OWNS + // the key and a frame whose anchors POINT at it. Two glasshouses with a frame + // each trips this on the first night. + // + // So: the asset/manifest half is to make it impossible to SHIP quietly. The + // runtime half (a per-instance key a site can override) is A's and the exact + // wiring is filed in THREADS. + // ------------------------------------------------------------------------- + + const shippedSites = []; + for (const name of ['backyard_01', 'site_02_corner_block', 'site_03_swing_lawn', + 'site_04_pool_yard']) { + shippedSites.push({ name, site: await loadSite(name) }); + } + + t.test('⚠️ no shipped yard gives two structures the same collateral key, and ids are unique', () => { + // Mirrors world.js structKey() exactly: site JSON wins, then the baked + // GLB key, then the structure's own id as a last resort. + const resolvedKey = (s) => { + if (s.collateralKey) return s.collateralKey; + const base = String(s.model ?? '').replace(/_v1$/, ''); + return loaded.get(base)?.scene.getObjectByName(base)?.userData?.collateral_key ?? s.id; + }; + let checked = 0; + for (const { name, site } of shippedSites) { + const structs = site.structures ?? []; + const ids = structs.map((s) => s.id); + const dupeIds = ids.filter((v, i) => ids.indexOf(v) !== i); + assert(dupeIds.length === 0, + `${name}: duplicate structure ids [${[...new Set(dupeIds)].join(', ')}]. world.js keys its ` + + 'structures Map by id, so the second one SILENTLY REPLACES the first β€” it never renders, ' + + 'never bills, and never wrecks, and nothing anywhere says a word'); + const keys = structs.map(resolvedKey); + const dupeKeys = keys.filter((v, i) => keys.indexOf(v) !== i); + assert(dupeKeys.length === 0, + `${name}: two structures resolve to the same collateral key ` + + `"${[...new Set(dupeKeys)].join('", "')}" (keys: [${keys.join(', ')}]). structFor() ` + + 'returns the FIRST match, so the bill and the wreck would both land on the wrong one, ' + + 'agreeing with each other; meanwhile exposureOf() prices per structure and the offer ' + + 'card would quote a total the failure route can never reach (D measured $115 vs $45). ' + + 'Give each instance its own key in the site JSON β€” and until A lands the per-anchor ' + + 'override, the anchors bake the shared key, so the second one cannot be made to bill ' + + 'at all: see THREADS [E] SPRINT18 gate 5'); + checked += keys.length; + } + assert(checked > 0, 'vacuous: no structures found across the shipped yards'); + }); + + // The other half of the same seam, from the asset side: an anchor names the + // collateral it TAKES, and in a real yard something has to own that name. The + // hail-net frame is the first asset where those are different objects, so a + // frame placed in a yard with no glasshouse would bill a building that is not + // there β€” collateralFor() returns null and the corner is FREE. RULE 2 proves + // the key is priced SOMEWHERE in the palette; this proves it is owned in + // every yard that names it. + t.test('⚠️ every collateral an authored anchor names is OWNED by exactly one thing in that yard', () => { + const types = FACTORY_ANCHOR_RATINGS.types; + let checked = 0; + for (const { name, site } of shippedSites) { + const owners = new Map(); + const own = (key, what) => { + if (typeof key !== 'string') return; + owners.set(key, (owners.get(key) ?? 0) + 1); + void what; + }; + const houseBase = String(site.house?.model ?? '').replace(/_v1$/, ''); + own(site.house?.collateralKey + ?? loaded.get(houseBase)?.scene.getObjectByName(houseBase)?.userData?.collateral_key, + 'house'); + for (const s of site.structures ?? []) { + const base = String(s.model ?? '').replace(/_v1$/, ''); + own(s.collateralKey + ?? loaded.get(base)?.scene.getObjectByName(base)?.userData?.collateral_key ?? s.id, s.id); + } + // What every authored anchor on a structure will actually bill: the baked + // node's key when it names a node, else the manifest's key for its type. + for (const s of site.structures ?? []) { + const base = String(s.model ?? '').replace(/_v1$/, ''); + for (const a of s.anchors ?? []) { + const baked = a.node + ? loaded.get(base)?.scene.getObjectByName(a.node)?.userData?.collateral + : types[a.type]?.collateral; + if (typeof baked !== 'string') continue; // bills nothing, by design + assert(owners.get(baked) === 1, + `${name}/${a.id} will bill "${baked}" on failure, and ${owners.get(baked) ?? 0} things ` + + 'in this yard own that key. Zero means collateralFor() returns null and breaking ' + + 'this corner is FREE (the gutter bug, RULE 2\'s runtime twin); more than one means ' + + 'structFor() picks the first and the wreck lands on the wrong structure'); + checked += 1; + } + } + } + assert(checked > 0, + 'vacuous: no structure anchor in any shipped yard names collateral β€” this pin checked nothing'); + }); + for (const { world } of siteWorlds) world.dispose(); } diff --git a/web/world/models/hail_net_frame_01_v1.glb b/web/world/models/hail_net_frame_01_v1.glb new file mode 100644 index 0000000..5d73731 Binary files /dev/null and b/web/world/models/hail_net_frame_01_v1.glb differ diff --git a/web/world/models/hail_net_frame_01_wrecked_v1.glb b/web/world/models/hail_net_frame_01_wrecked_v1.glb new file mode 100644 index 0000000..cc52433 Binary files /dev/null and b/web/world/models/hail_net_frame_01_wrecked_v1.glb differ