diff --git a/THREADS.md b/THREADS.md index b14211f..6b6852e 100644 --- a/THREADS.md +++ b/THREADS.md @@ -7543,3 +7543,166 @@ anchors are your GLB), but the tooling is now waiting, not TODO. flagged, levers named) β€” file the feel, we retune the numbers, not the mechanism. Pushed lane/a: bfe8763 (seam contract), 71278da (the ledger), this entry. +[E] 2026-07-20 β€” πŸ— **GATE 5 LANDED: THE NEXT TEMPTATIONS β€” three props, three different ways of + saying no. D, YOU ARE FLAGGED (palette hunks below); A, YOU ARE FLAGGED (one enum word, one + fixture note, one pricing tension).** Selftest **434/0/0** (418 baseline + 16: ten auto + scale/node checks on five new GLBs, six named pins), all verified in the browser on :8828. + Everything on lane/e; nothing placeable until A applies the palette hunks β€” by design, the + _v1 lesson: this time the palette strings ship IN THIS ENTRY, ready to paste. + + **WHAT SHIPPED (gate 5 says two of three; here's the two, and the third is the rung):** + Β· **glasshouse_01 (+ wrecked)** β€” the collateral apex. 14 translucent panes on an aluminium + frame, ridge at 2.30 m. NOTHING about it may adopt: zero rated nodes, zero enum-typed nodes + on either variant (pinned from all three directions), and the ridge β€” the most tie-off- + shaped bar in the game β€” carries `tie_off:false` with its reasoning in words. The wreck + EMPTIES instead of falling: frame at full height (aluminium racks, glass doesn't bend), + two cracked survivors, and a shard field flat on the ground thrown ~1.7 m wider than the + building ever stood. Both claims are measured pins, not adjectives. + Β· **pool_kit_01** β€” pool + compliant fence ring (AS 1926.1 flavour: 1.22 m, non-climbable + pickets, self-closing outward gate left ajar). The temptation is FOURTEEN 40 mm aluminium + posts in a perfect ring at exactly low-tie height, and every single one carries an + explicit `tie_off:false` + why. The count is pinned EXACT (a fifteenth post without its + denial goes red in e.test, not in a shipped yard) β€” and this pin guards ground RULE 1 + cannot: `fence_post_07` doesn't match the `*_anchor` regex, so per-post assertion is the + only tooth over these names (proven red, control M3 below). + Β· **pergola_01 (+ wrecked)** β€” the ladder's middle rung, D's endorsement carried to steel: + two bolted post-beam corners, `rating_hint 0.65`, typed **`pergola`** in its own enum word + (a deck is not concrete; the type string is the player's pre-rig read β€” the swing_frame + lesson, third application). ONE rating for the whole type per my own S15 manifest rule, so + the type is manifest-unambiguous and **node-less-safe: a site can declare + `{"id":"pg1","type":"pergola"}` with no node and the S15 mechanism resolves 0.65 + + collateral through `factoryExtras()` β€” this is the first new asset built ON the gate-1.2 + wire, which was the point of building the wire.** The wreck is the 0.65 story told twice: + bolts hold, timber cracks β€” front beam torn down, rafters hinged to the deck lean-to + style, back half and deck standing, and the two decks are pinned byte-close so the + shared-helper can't fork (the drift bug, pre-killed). + Β· **NOT the trampoline + tie-down kit, and here's the reasoning, not a shrug:** the tie-down + is a PREP-PHASE MECHANIC β€” it needs A's prep surface and C's opening-projectile event in + the same sprint, or it's a sim feature no key reaches (ROADMAP principle 2, the fabric-bet + lesson). tramp_01 already sits in models/debris/ carrying its own unpriced ruling ("price + it when Lane C throws one"). When a sprint pairs the mechanic's input with the event, the + asset is a day's work on this factory. ⚠️ **A, the fixture note:** a.test's unknown-type + probe uses the literal string `'trampoline'` as its canonically-bogus type β€” the day the + trampoline enters ANCHOR_TYPE, that fixture needs a new fake word first. + + **PRICES PROPOSED (A rules; reasoning baked beside every number in the factory):** + Β· pergola **$120**, key `pergola` β€” partial failure bills under total failure: a beam and a + snapped post (bolts hold) beats the gutter's $90 run of one trade's work and loses to the + swing's $140 whole-frame-on-the-grass. Ladder: 25 < 90 < **120** < 140 < 180 < 320. + Β· glasshouse **$320**, key `glasshouse` β€” the apex must top the carport by a distance (14 + panes vs one sheet of Colorbond; glass all goes at once) and stay under a six-night week's + gross (~$540 in fees), so one catastrophe is a week worked for nothing, not auto-BROKE. + **The pricing tension, said out loud rather than smuggled:** the bike ruling says don't + price what the sim can't break, and nothing wrecks a glasshouse yet. This ships priced ON + THE SPEC'S OWN ORDER ("priced high, wreck variant") because arc 2's glasshouse yard needs + the chain testable before the yard is authored; until an event lands the value is inert β€” + main.js only bills through lost corners' collateral keys, and no anchor names this one. + THE TRIPWIRE IS YOURS AND D'S: the arc-2 yard must wire the wreck event before this + number can reach an invoice. If you'd rather it ship `priced:False` until then, say so + and I'll flip it β€” the disagreement is one line and I hold it loosely. + Β· pool kit **unpriced BY RULING** (`priced:False` + why in extras) β€” the bike rule, applied + not dodged: nothing can bend a pool fence yet, and an unpriced prop with no reason is + indistinguishable from a forgotten one, so the reason ships in the GLB. + Β· Both priced props: wreck carries the identical price + `broken_variant_of`, pinned β€” the + carport/gutter chain, applications four and five. + + **RECEIPTS:** + Β· Factory: 36/36 PASS (dims, tri budget, node round-trip). New tris: pergola 276/300, + glasshouse 356/680, pool 2,780 β€” all far under budget. + Β· **Determinism, measured:** two full back-to-back runs, `shasum -c` over all 44 outputs + (36 GLBs incl. debris copies, report, contact sheet, manifest) β€” zero mismatches. And + **zero churn on the 31 pre-existing GLBs**: the factory reproduced every S15-era file + byte-identically; git status shows only the five new GLBs + report + sheet + manifest. + Β· Manifest diff is EXACTLY the pergola entry (`0.65 / "pergola"`, two sources) β€” nothing + else moved; `tree` stays honestly ambiguous. + Β· **Negative controls, all red-then-green in the real chain:** + **M1** β€” pergola_anchor_02 typed but silent: the factory tooth bit first, as promised + (`RuntimeError: pergola_01/pergola_anchor_02 carries anchor_type='pergola' with no + rating_hint and no tie_off denial β€” RULE 1`), manifest left unwritten. ⚠️ honest gotcha: + `blender -b -P` still EXITS 0 on a raise β€” the tooth is the traceback + the stale + manifest, not a shell code. So I shipped the mutated GLB to the browser anyway: **triple + red** (RULE 1 silent-anchor, my pergola pin, RULE 4a GLBβ‰ manifest) β€” 3 failed / 431 + passed. Ignoring the traceback cannot ship this. + **M2** β€” ridge_anchor rated 0.9 on purpose: **double red** with the right words β€” RULE + 1's "both rates itself and denies β€” pick one" AND the apex pin's "glasshouse_01 carries + rating_hint on [ridge_anchor] β€” a glasshouse node with a rating is adoptable". 2/432. + **M3** β€” fence_post_07 shipped without its denial: the factory build SUCCEEDED β€” 36/36 + PASS, zero tracebacks β€” because the post carries no anchor_type and no `*_anchor` name, + so NEITHER the build tooth NOR RULE 1 can see it. In the browser: exactly ONE red, + "fence_post_07 does not deny being a tie-off β€” silence here adopts at rating 1.0" (1 + failed / 433 passed). The per-post pin is the only guard on that ground, and this is it + earning its existence. + Every control restored by full factory re-run, `shasum -c` against the clean-run + snapshot: 0 mismatches both times β€” hash-verified, not eyeballed. Final green after the + last restore: **434/0/0** in the browser. + Β· **LOOKED AT** (assetcheck/look.html, the real renderer, capsule beside each): the trio + reads at a glance β€” pergola is a deck with an open roof at 2.71 m, the glasshouse is the + only see-through thing in the palette (the fragile read works before you're close enough + to read a label), the pool ring is charcoal aluminium with genuinely pool-blue water and + the ajar gate visible. The wrecks tell their stories cold: the pergola's rafters lean-to + down onto the deck with the back beam still up and two snapped stubs at the front; the + wrecked glasshouse is a bare skeleton at full height over a glitter field of shards β€” + "it emptied" is legible from across a yard, which was the design claim. + + **D β€” THE PALETTE HUNKS (editor.js is A's file; A or the integrator applies verbatim β€” + the `_v1` suffixes are present and load-bearing, per your S14 find):** + ```js + { + // SPRINT16 gate 5, E. The ladder's middle rung, D-endorsed in S15: bolted + // to a deck β€” better than loose-on-grass (0.45), worse than concrete + // (1.00). One type, one rating, so the manifest resolves it node-less. + kind: 'structure', label: 'Pergola (the middle rung)', model: 'pergola_01_v1', + requires: ['pergola'], + hint: 'two bolted corners at 0.65 β€” the honest step between swing frame and post', + make: (id, x, z) => ({ + id, model: 'pergola_01_v1', wreckedModel: 'pergola_01_wrecked_v1', + x, z, rotYDeg: 0, solid: true, + collateralKey: 'pergola', + collateralValue: 120, collateralLabel: 'the pergola', + anchors: [1, 2].map((i) => ({ + id: `${id}_a${i}`, node: `pergola_anchor_0${i}`, type: 'pergola', work: 'cloth', + })), + }), + }, + { + // The collateral apex. NO anchors, on purpose β€” nothing about a glasshouse + // may adopt (the ridge carries tie_off:false in the GLB). collateralKey is + // EXPLICIT so the second one placed still bills (the carport lesson). + kind: 'structure', label: 'Glasshouse (the apex, $320)', model: 'glasshouse_01_v1', + hint: 'no anchors at all; $320 of glass β€” the thing you keep the whole rig away from', + make: (id, x, z) => ({ + id, model: 'glasshouse_01_v1', wreckedModel: 'glasshouse_01_wrecked_v1', + x, z, rotYDeg: 0, solid: true, + collateralKey: 'glasshouse', + collateralValue: 320, collateralLabel: 'the glasshouse', + anchors: [], + }), + }, + { + // The compliance ring. Fourteen posts, every one tie_off:false in the GLB; + // unpriced BY RULING (the bike rule β€” price it when debris can bend it). + kind: 'structure', label: 'Pool + fence ring', model: 'pool_kit_01_v1', + hint: 'fourteen perfect-looking posts, every one an honest no β€” the legal tie-offs are elsewhere', + make: (id, x, z) => ({ + id, model: 'pool_kit_01_v1', x, z, rotYDeg: 0, solid: true, + anchors: [], + }), + }, + ``` + **D β€” placement facts, measured in three.js coords (the axis trap, paid for in advance):** + Β· pergola: anchors on the **+Z face** (the yard side); wreck spills the torn beam ~0.13 m + past the deck on +X β€” footprint 3.33 Γ— 2.60, standing 2.71 m. + Β· glasshouse: wreck shard field reaches the plan out to **4.34 Γ— 2.94 m** (intact 2.65 Γ— + 1.95), biased +X/downwind β€” leave that apron clear or shards clip through a fence line. + Β· pool kit: gate is on the **+Z run**, ajar and swinging outward (+Z) β€” 5.64 Γ— 4.52 m + including the swing; ring proper is 5.6 Γ— 4.2. + Β· The glasshouse and pergola are `solid: true` in the hunks β€” carport precedent. + + **A β€” your three flags in one place:** (1) `'pergola'` added to ANCHOR_TYPE in contracts.js + on lane/e β€” your file, my edit, the swing_frame precedent, revert-and-tell-me if you'd + rather land it yourself; (2) a.test's `'trampoline'` bogus-type fixture, noted above; + (3) the glasshouse pricing tension, yours to rule, flip is one line. + + Pushed to origin lane/e: factory (three builders + shared helpers + PAL entries), five + GLBs, report + contact sheet + manifest (pergola entry), contracts.js widening, sixteen + e.test pins, this entry. diff --git a/tools/blender/asset_report.json b/tools/blender/asset_report.json index 043f3fe..87ceeac 100644 --- a/tools/blender/asset_report.json +++ b/tools/blender/asset_report.json @@ -719,6 +719,193 @@ "anchors": [], "status": "PASS", "problems": [] + }, + { + "name": "pergola_01", + "dims": [ + 3.2, + 2.6, + 2.71 + ], + "tris": 276, + "nodes": [ + "beams", + "deck", + "pergola_01", + "pergola_anchor_01", + "pergola_anchor_02", + "posts", + "rafters" + ], + "anchors": [ + { + "node": "pergola_anchor_01", + "anchor_type": "pergola", + "rating_hint": 0.65, + "collateral": "pergola" + }, + { + "node": "pergola_anchor_02", + "anchor_type": "pergola", + "rating_hint": 0.65, + "collateral": "pergola" + } + ], + "status": "PASS", + "problems": [] + }, + { + "name": "pergola_01_wrecked", + "dims": [ + 3.3288, + 2.6, + 2.6648 + ], + "tris": 300, + "nodes": [ + "beam_down", + "beams", + "deck", + "pergola_01_wrecked", + "posts", + "rafters_down" + ], + "anchors": [], + "status": "PASS", + "problems": [] + }, + { + "name": "glasshouse_01", + "dims": [ + 2.645, + 1.945, + 2.3025 + ], + "tris": 356, + "nodes": [ + "door", + "frame", + "glasshouse_01", + "panes", + "ridge_anchor" + ], + "anchors": [ + { + "node": "ridge_anchor", + "tie_off": false + } + ], + "status": "PASS", + "problems": [] + }, + { + "name": "glasshouse_01_wrecked", + "dims": [ + 4.3356, + 2.9387, + 2.3025 + ], + "tris": 680, + "nodes": [ + "door", + "frame", + "glasshouse_01_wrecked", + "panes_left", + "shards" + ], + "anchors": [], + "status": "PASS", + "problems": [] + }, + { + "name": "pool_kit_01", + "dims": [ + 5.645, + 4.5207, + 1.22 + ], + "tris": 2780, + "nodes": [ + "fence_post_01", + "fence_post_02", + "fence_post_03", + "fence_post_04", + "fence_post_05", + "fence_post_06", + "fence_post_07", + "fence_post_08", + "fence_post_09", + "fence_post_10", + "fence_post_11", + "fence_post_12", + "fence_post_13", + "fence_post_14", + "fence_rails", + "pool_gate", + "pool_kit_01", + "pool_shell", + "pool_water" + ], + "anchors": [ + { + "node": "fence_post_01", + "tie_off": false + }, + { + "node": "fence_post_02", + "tie_off": false + }, + { + "node": "fence_post_03", + "tie_off": false + }, + { + "node": "fence_post_04", + "tie_off": false + }, + { + "node": "fence_post_05", + "tie_off": false + }, + { + "node": "fence_post_06", + "tie_off": false + }, + { + "node": "fence_post_07", + "tie_off": false + }, + { + "node": "fence_post_08", + "tie_off": false + }, + { + "node": "fence_post_09", + "tie_off": false + }, + { + "node": "fence_post_10", + "tie_off": false + }, + { + "node": "fence_post_11", + "tie_off": false + }, + { + "node": "fence_post_12", + "tie_off": false + }, + { + "node": "fence_post_13", + "tie_off": false + }, + { + "node": "fence_post_14", + "tie_off": false + } + ], + "status": "PASS", + "problems": [] } ], "debris": [] diff --git a/tools/blender/build_yard_assets.py b/tools/blender/build_yard_assets.py index 2108280..32b673c 100644 --- a/tools/blender/build_yard_assets.py +++ b/tools/blender/build_yard_assets.py @@ -137,6 +137,11 @@ 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 + "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 + # supposed to look inviting + "deck_board": "#A8845C", # decking oil, a shade lighter than fence timber } # Rainwater caught in a sail is not swimming-pool blue. It's shallow, murky, it @@ -1522,6 +1527,544 @@ def build_swing_set_01_wrecked(name): return root +# --------------------------------------------------------------------------- +# THE PERGOLA (SPRINT16 gate 5 β€” the honest middle rung, D-endorsed) +# --------------------------------------------------------------------------- +# One shape, stated once, used by the intact pergola AND the wreck β€” the +# _swing_frame precedent, kept on purpose: a wreck built from re-typed numbers +# drifts from its twin one edit at a time. +PERGOLA = dict( + DECK_W=3.2, # deck span, x + DECK_D=2.6, # deck depth, y + DECK_H=0.30, # two steps up β€” the deck IS the footing, that's the point + POST=0.09, # 90x90 treated pine + POST_H=2.20, # above the deck + INSET=0.18, # posts in from the deck edge + BEAM_H=0.14, # 140x45 beams along x on front/back post pairs + N_RAFTERS=7, # 70x35 rafters spanning y, the open roof +) + +# What it costs when the timber cracks. Proposal β€” Lane A owns the number, the +# ladder it slots into is gnome 25 < gutter 90 < PERGOLA 120 < swing 140 < +# carport 180 (< glasshouse 320, below): +# Β· the failure is PARTIAL by design: the bolts hold, the timber breaks β€” a +# torn-off beam and a snapped post on a structure that stays standing. The +# bill is one beam, one post and the labour to re-bolt, not a rebuild; +# Β· under ~90 it undercuts the gutter, and a structural timber repair does +# not cost less than a run of guttering; +# Β· over ~140 it outbids the swing set, whose failure is the WHOLE frame on +# the grass β€” a worse day than a cracked beam, and the prices must agree +# with the wrecks the player actually sees. +PERGOLA_COLLATERAL = 120 + + +def _pergola(name, root, deck_m, timber_m, dark_m, *, wrecked=False): + """Build the pergola. Returns the two anchor points (Blender coords) so the + caller hangs anchors on the geometry's own numbers, not a second copy of + the arithmetic. Intact and wrecked share every dimension through PERGOLA. + + HOW IT FAILS, and why that is the 0.65 story told twice: the posts are + BOLTED to the deck β€” real M12s through the joists, and the bolts hold. + What gives is the timber and the deck's own stiffness: the loaded front + beam tears off its post tops, the front posts snap at half height above + the racking bolts, and the rafters β€” still fixed at the back beam β€” hinge + down like a lean-to until their front ends hit the deck. The back half + stands. A pergola fails PARTIALLY, which is exactly why its rating (0.65) + sits above the unbolted swing frame (0.45) and below concrete (1.00), and + why its bill (a beam and a post) sits below the swing's (a whole frame). + """ + P = PERGOLA + hw, hd = P["DECK_W"] / 2, P["DECK_D"] / 2 + px, py = hw - P["INSET"], hd - P["INSET"] # post centres + top = P["DECK_H"] + P["POST_H"] # post top z + beam_z = top + P["BEAM_H"] / 2 # beam centre z + + # --- the deck: joists + boards. Identical in the wreck β€” it was never the + # weak part; it is the footing the rating is ABOUT. + deck = [add_box(f"{name}_deck_slab", (P["DECK_W"], P["DECK_D"], 0.045), + (0, 0, P["DECK_H"] - 0.0225), deck_m)] + for i in range(4): + y = -hd + 0.12 + i * (P["DECK_D"] - 0.24) / 3 + deck.append(add_box(f"{name}_joist_{i}", (P["DECK_W"] - 0.1, 0.045, P["DECK_H"] - 0.05), + (0, y, (P["DECK_H"] - 0.05) / 2), dark_m)) + # Board grooves read as lines; three thin gaps of darker timber. + for i in range(5): + x = -hw + 0.3 + i * (P["DECK_W"] - 0.6) / 4 + deck.append(add_box(f"{name}_board_gap_{i}", (0.015, P["DECK_D"] - 0.02, 0.008), + (x, 0, P["DECK_H"] + 0.004), dark_m)) + join_group(deck, "deck", root) + + # --- posts. Front pair (βˆ’y, the yard side, where the anchors live) snaps + # in the wreck; back pair always stands. + posts = [] + for sx in (-1, 1): + posts.append(add_box(f"{name}_post_b_{sx}", (P["POST"], P["POST"], P["POST_H"]), + (sx * px, py, P["DECK_H"] + P["POST_H"] / 2), timber_m)) + if wrecked: + # Snapped at 55% height: the bolted stub stands plumb, the top + # half lies on the deck with its beam end, split face up. + stub = P["POST_H"] * 0.55 + posts.append(add_box(f"{name}_post_f_{sx}", (P["POST"], P["POST"], stub), + (sx * px, -py, P["DECK_H"] + stub / 2), timber_m)) + posts.append(add_box(f"{name}_post_top_{sx}", + (P["POST"], P["POST"], P["POST_H"] - stub), + (sx * (px - 0.35), -py + 0.42, + P["DECK_H"] + P["POST"] / 2), timber_m, + rot=(math.radians(88), 0, math.radians(20 * sx)))) + else: + posts.append(add_box(f"{name}_post_f_{sx}", (P["POST"], P["POST"], P["POST_H"]), + (sx * px, -py, P["DECK_H"] + P["POST_H"] / 2), timber_m)) + join_group(posts, "posts", root) + + # --- beams. Back beam always up; front beam is the one that tears off. + beams = [add_box(f"{name}_beam_b", (P["DECK_W"] - 0.1, 0.045, P["BEAM_H"]), + (0, py, beam_z), timber_m)] + if not wrecked: + beams.append(add_box(f"{name}_beam_f", (P["DECK_W"] - 0.1, 0.045, P["BEAM_H"]), + (0, -py, beam_z), timber_m)) + join_group(beams, "beams", root) + if wrecked: + # The torn beam, flat on the deck along the front edge, slightly askew. + # Rolled 90Β° about its own long axis so the 140 mm face lies on the + # boards (a beam at rest lies on its side; on edge it would balance). + join_group([add_box(f"{name}_beam_down", (P["DECK_W"] - 0.1, 0.045, P["BEAM_H"]), + (0.18, -py + 0.15, P["DECK_H"] + 0.03), + timber_m, rot=(math.radians(90), 0, math.radians(6)))], + "beam_down", root) + + # --- rafters: up in the intact roof; hinged down at the back beam in the + # wreck, front ends resting on the deck β€” the lean-to collapse. + rafters = [] + for i in range(P["N_RAFTERS"]): + x = -hw + 0.35 + i * (P["DECK_W"] - 0.7) / (P["N_RAFTERS"] - 1) + if wrecked: + drop = math.atan2(top + P["BEAM_H"] - (P["DECK_H"] + 0.05), P["DECK_D"] - 0.3) + L = math.hypot(top + P["BEAM_H"] - (P["DECK_H"] + 0.05), P["DECK_D"] - 0.3) + rafters.append(add_box(f"{name}_rafter_{i}", (0.035, L, 0.07), + (x, py - L / 2 * math.cos(drop), + (top + P["BEAM_H"] + P["DECK_H"] + 0.05) / 2), + timber_m, rot=(-drop, 0, 0))) + else: + rafters.append(add_box(f"{name}_rafter_{i}", (0.035, P["DECK_D"] - 0.15, 0.07), + (x, 0, top + P["BEAM_H"] + 0.035), timber_m)) + join_group(rafters, "rafters_down" if wrecked else "rafters", root) + + # Anchor points: where the front beam meets the post tops β€” the bolted + # corner an author would genuinely strap to. + return [(-px, -py, top + P["BEAM_H"] * 0.6), (px, -py, top + P["BEAM_H"] * 0.6)] + + +def build_pergola_01(name): + """A timber pergola on a bolted deck β€” the ladder's missing middle rung. + + SPRINT16 gate 5, proposed in S15 and carried on D's playtester endorsement: + "the ladder jumps 0.45-on-grass straight to 1.00-in-concrete; a 0.65 + bolted-to-deck mid-rung turns fascia-or-nothing into a real decision." + + WHY 0.65, and why one rating for the whole type (the manifest rule my own + S15 entry proposed: new types ship ONE rating per type, so a node-less + JSON anchor can resolve it unambiguously): the post-beam corner is real + 90x90 timber with real M12 bolts through a deck β€” genuinely better than a + welded frame standing loose on grass (swing_frame 0.45), because the load + path reaches something fixed. And genuinely worse than 300 mm of concrete + (post 1.00), because what it reaches is a DECK: the deck flexes, the + timber cracks before the bolts let go, and 0.65 is that sentence as a + number. Typed `pergola` in its own enum word β€” calling it a post would + promise concrete the deck cannot deliver (the swing_frame lesson, kept). + """ + root = add_empty(name) + deck_m = get_material("Mat_Deck", PAL["deck_board"], 0.8) + timber_m = get_material("Mat_Timber", PAL["timber"], 0.75) + dark_m = get_material("Mat_TimberDark", PAL["timber_dark"], 0.8) + + anchors = _pergola(name, root, deck_m, timber_m, dark_m, wrecked=False) + for i, ap in enumerate(anchors): + e = add_empty(f"pergola_anchor_{i + 1:02d}", ap, root, size=0.18) + e["anchor_type"] = "pergola" + e["rating_hint"] = 0.65 + e["collateral"] = "pergola" + e["why"] = ("bolted post-beam corner: the bolts are real, the deck is " + "the footing β€” better than loose-on-grass, worse than " + "concrete, and the number argues exactly that") + + stamp(root, name, "structure") + root["collateral_key"] = "pergola" + root["collateral_value"] = PERGOLA_COLLATERAL + root["collateral_label"] = "the pergola" + root["breakable"] = True + return root + + +def build_pergola_01_wrecked(name): + """The pergola after a loaded corner won: front beam torn off, front posts + snapped above their bolts, rafters hinged down onto the deck like a + lean-to. The back half β€” and the deck, and every bolt β€” still standing, + because a bolted structure fails PARTIALLY, which is the whole 0.65 story. + Same origin, same PERGOLA numbers, one flag different, so intact and + wrecked cannot drift (the _swing_frame pattern).""" + root = add_empty(name) + deck_m = get_material("Mat_Deck", PAL["deck_board"], 0.8) + timber_m = get_material("Mat_Timber", PAL["timber"], 0.75) + dark_m = get_material("Mat_TimberDark", PAL["timber_dark"], 0.8) + + _pergola(name, root, deck_m, timber_m, dark_m, wrecked=True) + # No pergola_anchor_* survives β€” you cannot re-bolt to a snapped post from + # inside a storm, and an anchor that outlives its structure is the + # free-failure bug in a costume (fascia rule, third application). + stamp(root, name, "structure") + root["broken_variant_of"] = "pergola_01" + root["collateral_key"] = "pergola" + root["collateral_value"] = PERGOLA_COLLATERAL + root["collateral_label"] = "the pergola" + return root + + +# --------------------------------------------------------------------------- +# THE GLASSHOUSE (SPRINT16 gate 5 β€” the collateral apex) +# --------------------------------------------------------------------------- +GLASSHOUSE = dict( + W=2.6, # x β€” ridge runs along x + D=1.9, # y + WALL_H=1.70, + RIDGE_H=2.28, + BAR=0.045, # aluminium extrusion section +) + +# The apex, priced as one. Proposal β€” Lane A owns the number: +# Β· it must top the carport (180) by a distance, or "apex" is a word: the +# carport is one sheet of Colorbond; this is ~14 panes of horticultural +# glass, and glass does not bend, it all goes at once. 320 is the panes, +# the reglaze labour, and a racked door that never closes right again; +# Β· it must stay under a six-night week's gross (~$540 in fees alone), so +# one catastrophe is a week worked for nothing, not an automatic BROKE β€” +# the carport ruling ("the worst thing on the bill and still a week you +# can dig out of"), scaled to the thing that costs a week; +# Β· unlike the bike (unpriced, nothing can break it) this ships priced ON +# THE SPEC'S OWN ORDER: arc 2's glasshouse yard is the event's home, and +# the price chain has to exist before that yard can be authored against +# it. Until an event lands, nothing bills it β€” the value is inert data, +# not a lie on an invoice. The tripwire is written in THREADS: the arc-2 +# yard must wire the wreck event before this number reaches a bill. +GLASSHOUSE_COLLATERAL = 320 + + +def _glasshouse_frame(name, root, alu): + """The aluminium skeleton, shared by intact and wreck β€” the frame is the + part that SURVIVES (aluminium racks, glass shatters), which is why the + wreck stands at full height and reads as emptied, not felled.""" + G = GLASSHOUSE + hw, hd, B = G["W"] / 2, G["D"] / 2, G["BAR"] + parts = [] + # Corner + mid posts. + for x in (-hw, 0, hw): + for sy in (-1, 1): + parts.append(add_box(f"{name}_post_{x:.1f}_{sy}", (B, B, G["WALL_H"]), + (x, sy * hd, G["WALL_H"] / 2), alu)) + # Eave beams (long sides) + end sills. + for sy in (-1, 1): + parts.append(add_box(f"{name}_eave_{sy}", (G["W"], B, B), + (0, sy * hd, G["WALL_H"]), alu)) + for sx in (-1, 1): + parts.append(add_box(f"{name}_end_{sx}", (B, G["D"], B), + (sx * hw, 0, G["WALL_H"]), alu)) + # Gable rafters up to the ridge. + for sy in (-1, 1): + p0 = (sx * hw, sy * hd, G["WALL_H"]) + p1 = (sx * hw, 0, G["RIDGE_H"]) + parts.append(add_tube_between(f"{name}_gable_{sx}_{sy}", p0, p1, + B * 0.45, alu, verts=6)) + # The ridge β€” the straightest, most anchor-looking bar in any yard, which + # is exactly why it gets a named node and a denial (below, on the empty). + parts.append(add_box(f"{name}_ridge", (G["W"], B, B), (0, 0, G["RIDGE_H"]), alu)) + join_group(parts, "frame", root) + + # Door frame on the -x gable end. + join_group([ + add_box(f"{name}_door_l", (B * 0.7, B * 0.7, 1.55), (-hw - 0.001, -0.36, 0.775), alu), + add_box(f"{name}_door_r", (B * 0.7, B * 0.7, 1.55), (-hw - 0.001, 0.36, 0.775), alu), + add_box(f"{name}_door_t", (B * 0.7, 0.76, B * 0.7), (-hw - 0.001, 0, 1.55), alu), + ], "door", root) + + +def build_glasshouse_01(name): + """A backyard glasshouse β€” the collateral apex, and NOT an anchor. + + SPRINT16 gate 5. The palette's other structures are temptations because + they offer bad steel; this one is a temptation because it offers NO steel + while being ringed in members that look like it β€” an aluminium ridge bar + at 2.28 m is the most tie-off-shaped object in the game. Every reading of + it must come back "don't": the ridge carries an explicit tie_off denial, + and NO node anywhere on either variant carries a rating_hint, which + e.test pins β€” nothing about a glasshouse may adopt as an anchor, because + 30 kg of glass in a storm is not a thing you add load to, it is a thing + you keep everything away from. + + The panes read fragile at a glance because they are TRANSLUCENT β€” the only + see-through walls in the palette β€” and that read survives export as a + material fact (transparent + alpha), which e.test also pins: a repaint + that turns the glass opaque has silently deleted the tell. + """ + root = add_empty(name) + alu = get_material("Mat_AluFrame", PAL["alu_frame"], 0.35, metallic=0.7) + glass = get_material("Mat_Glass", PAL["glass"], 0.05, metallic=0.0, opacity=0.30) + G = GLASSHOUSE + hw, hd = G["W"] / 2, G["D"] / 2 + + _glasshouse_frame(name, root, alu) + + panes = [] + # Wall panes: long sides + gable ends (leaving the door opening). + for sy in (-1, 1): + panes.append(add_box(f"{name}_wall_{sy}", (G["W"] - 0.06, 0.012, G["WALL_H"] - 0.08), + (0, sy * (hd - 0.012), G["WALL_H"] / 2), glass)) + panes.append(add_box(f"{name}_endwall_e", (0.012, G["D"] - 0.06, G["WALL_H"] - 0.08), + (hw - 0.012, 0, G["WALL_H"] / 2), glass)) + for sy in (-1, 1): # west end: two strips beside the door + panes.append(add_box(f"{name}_endwall_w_{sy}", (0.012, (G["D"] - 0.8) / 2 - 0.05, G["WALL_H"] - 0.08), + (-hw + 0.012, sy * (0.4 + (G["D"] - 0.8) / 4), G["WALL_H"] / 2), glass)) + # Roof panes: two slopes from eave to ridge. + slope = math.atan2(G["RIDGE_H"] - G["WALL_H"], hd) + run = math.hypot(G["RIDGE_H"] - G["WALL_H"], hd) + for sy in (-1, 1): + panes.append(add_box(f"{name}_roof_{sy}", (G["W"] - 0.04, run - 0.03, 0.012), + (0, sy * hd / 2, (G["WALL_H"] + G["RIDGE_H"]) / 2 + 0.01), + glass, rot=(sy * slope, 0, 0))) + # Gable triangles, approximated as narrow strips under the rafters. + for sx in (-1, 1): + panes.append(add_box(f"{name}_gable_pane_{sx}", (0.012, G["D"] * 0.5, (G["RIDGE_H"] - G["WALL_H"]) * 0.8), + (sx * (hw - 0.012), 0, G["WALL_H"] + (G["RIDGE_H"] - G["WALL_H"]) * 0.42), glass)) + join_group(panes, "panes", root) + + # The temptation, denied ON the data. Same mechanism as the swing set's + # crossbar: the most anchor-looking member carries the explicit no. + not_a_tie_off( + add_empty("ridge_anchor", (0, 0, G["RIDGE_H"]), root, size=0.2), + "the ridge bar β€” the straightest steel-looking line in the yard", + "a 45 mm aluminium extrusion whose whole job is holding glass; load a " + "sail corner on it and you are not testing the bar, you are testing " + "fourteen panes at once") + + stamp(root, name, "structure") + root["collateral_key"] = "glasshouse" + root["collateral_value"] = GLASSHOUSE_COLLATERAL + root["collateral_label"] = "the glasshouse" + root["fragile"] = True + root["pane_count"] = 14 + return root + + +def build_glasshouse_01_wrecked(name): + """The glasshouse after the storm found it. The frame is aluminium β€” it + stays up, near enough at full height β€” but the glass is GONE: two cracked + panes still hanging, the rest a glitter field of shards on the ground, + thrown wider than the footprint ever was. A glasshouse doesn't fall, it + empties; a wreck that read as a collapsed building would be lying about + what glass does. Same frame helper as the intact one, so the two cannot + drift.""" + rng = rng_for(name) + root = add_empty(name) + alu = get_material("Mat_AluFrame", PAL["alu_frame"], 0.35, metallic=0.7) + glass = get_material("Mat_Glass", PAL["glass"], 0.05, metallic=0.0, opacity=0.30) + G = GLASSHOUSE + hw, hd = G["W"] / 2, G["D"] / 2 + + _glasshouse_frame(name, root, alu) + + # Two survivors, one visibly askew in its frame. + join_group([ + add_box(f"{name}_pane_left_a", (0.012, G["D"] * 0.4, G["WALL_H"] * 0.5), + (hw - 0.012, -0.3, G["WALL_H"] * 0.35), glass), + add_box(f"{name}_pane_left_b", (G["W"] * 0.3, 0.012, G["WALL_H"] * 0.45), + (0.4, hd - 0.012, G["WALL_H"] * 0.3), glass, + rot=(0, math.radians(8), 0)), + ], "panes_left", root) + + # The shard field. Deterministic scatter, thrown mostly downwind (+x, the + # storm convention every other wreck in the palette follows) and WIDER + # than the standing footprint β€” the measured claim e.test pins. + shards = [] + for i in range(26): + a = rng.uniform(0, math.tau) + r = rng.uniform(0.2, 1.0) + x = math.cos(a) * (hw + r * 0.9) * (1.25 if math.cos(a) > 0 else 0.8) + y = math.sin(a) * (hd + r * 0.6) + s = rng.uniform(0.06, 0.22) + shards.append(add_box(f"{name}_shard_{i}", (s, s * rng.uniform(0.4, 0.9), 0.008), + (x, y, 0.006), glass, + rot=(0, 0, rng.uniform(0, math.pi)))) + # A few inside on the beds' footprint too β€” the glass fell in as well as out. + for i in range(8): + x, y = rng.uniform(-hw * 0.8, hw * 0.8), rng.uniform(-hd * 0.7, hd * 0.7) + s = rng.uniform(0.05, 0.16) + shards.append(add_box(f"{name}_shard_in_{i}", (s, s * 0.7, 0.008), + (x, y, 0.006), glass, rot=(0, 0, rng.uniform(0, math.pi)))) + join_group(shards, "shards", root) + + stamp(root, name, "structure") + root["broken_variant_of"] = "glasshouse_01" + root["collateral_key"] = "glasshouse" + root["collateral_value"] = GLASSHOUSE_COLLATERAL + root["collateral_label"] = "the glasshouse" + return root + + +# --------------------------------------------------------------------------- +# THE POOL KIT (SPRINT16 gate 5 β€” the compliant ring whose steel all says no) +# --------------------------------------------------------------------------- +POOL_KIT = dict( + RING_W=5.6, # fence ring, x + RING_D=4.2, # fence ring, y + FENCE_H=1.22, # AS 1926.1 minimum is 1.2 m β€” compliance is the flavour + POOL_W=3.6, + POOL_D=2.2, + COPING_H=0.18, # raised fibreglass lip; keeps every vertex above grade + POST=0.045, # 40 mm aluminium tube posts +) + + +def _pool_fence_posts(): + """Post centres round the ring, deterministic order. Module-level so the + builder and the ASSETS node list can never disagree about the count.""" + P = POOL_KIT + hx, hy = P["RING_W"] / 2, P["RING_D"] / 2 + pts = [] + for x in (-hx, -hx / 2, 0, hx / 2, hx): # long sides, 5 each + for sy in (-1, 1): + pts.append((x, sy * hy)) + for y in (-hy / 2, hy / 2): # short sides, 2 each + for sx in (-1, 1): + pts.append((sx * hx, y)) + return pts # 14 posts + + +def build_pool_kit_01(name): + """A backyard pool inside its compliant fence ring β€” the yard the palette + could not dress, and the puzzle is the fence. + + SPRINT16 gate 5. Fourteen 40 mm aluminium posts stand in a perfect ring + at exactly the height a low tie line wants, core-mounted, evenly spaced β€” + they LOOK like the most generous anchor field in the game. Every single + one carries an explicit `tie_off: false`, because silence is not neutral: + `adoptAnchor` reads `rating_hint ?? 1`, so an unrated node a site names + becomes the best steel in the game by omission (the S14 audit lesson, and + the reason the spec says "honest on every one"). A pool fence holds a + child OUT; it does not hold a sail DOWN β€” it is 1.2 m of child-proofing + on core-drilled tube, and a loaded corner would have the whole panel out + of the ground before the shackle noticed. + + That denial field is the level design: the pool yard's whole puzzle (arc + 2) is that the legal tie-offs are all in the wrong places, and the ring + of honest nos in the middle of the yard is what makes it true. + + UNPRICED, and that is a statement (the bike/jacaranda ruling, not the + glasshouse's spec order): nothing in the sim can bend a pool fence or + crack a coping yet, and billing an event the player cannot see is the lie + the invoice exists to kill. `priced: False` + the reason ride in extras; + price it the day debris can take a panel out. + """ + root = add_empty(name) + alu = get_material("Mat_AluFrame", PAL["alu_frame"], 0.35, metallic=0.7) + conc = get_material("Mat_Concrete", PAL["concrete"], 0.95) + water = get_material("Mat_PoolWater", PAL["pool_water"], 0.08, opacity=0.88) + P = POOL_KIT + hx, hy = P["RING_W"] / 2, P["RING_D"] / 2 + pw, pd = P["POOL_W"] / 2, P["POOL_D"] / 2 + + # --- the pool: raised coping ring + water just below the lip ----------- + join_group([ + add_box(f"{name}_coping_n", (P["POOL_W"] + 0.3, 0.15, P["COPING_H"]), + (0, pd + 0.075, P["COPING_H"] / 2), conc), + add_box(f"{name}_coping_s", (P["POOL_W"] + 0.3, 0.15, P["COPING_H"]), + (0, -pd - 0.075, P["COPING_H"] / 2), conc), + add_box(f"{name}_coping_e", (0.15, P["POOL_D"], P["COPING_H"]), + (pw + 0.075, 0, P["COPING_H"] / 2), conc), + add_box(f"{name}_coping_w", (0.15, P["POOL_D"], P["COPING_H"]), + (-pw - 0.075, 0, P["COPING_H"] / 2), conc), + ], "pool_shell", root) + join_group([add_box(f"{name}_water", (P["POOL_W"], P["POOL_D"], 0.02), + (0, 0, P["COPING_H"] - 0.045), water)], "pool_water", root) + + # --- the fence: rails + pickets, joined; POSTS as individual named nodes + # so a site (or the editor's node list, if it ever offers per-node + # anchors) meets the denial ON the node it would name. RULE 1's regex + # only guards *_anchor names; these are guarded by their own pin in + # e.test β€” same rule, wider net. + gate_span = (0.25, 1.15) # gate opening on the south run, x range + rails = [] + for sy in (-1, 1): + for z in (0.12, P["FENCE_H"] - 0.03): + if sy < 0: # south run has the gate opening + rails.append(add_box(f"{name}_rail_s{z:.2f}_a", (gate_span[0] + hx, 0.04, 0.04), + ((gate_span[0] - hx) / 2, -hy, z), alu)) + rails.append(add_box(f"{name}_rail_s{z:.2f}_b", (hx - gate_span[1], 0.04, 0.04), + ((gate_span[1] + hx) / 2, -hy, z), alu)) + else: + rails.append(add_box(f"{name}_rail_n{z:.2f}", (P["RING_W"], 0.04, 0.04), + (0, hy, z), alu)) + for sx in (-1, 1): + for z in (0.12, P["FENCE_H"] - 0.03): + rails.append(add_box(f"{name}_rail_{'e' if sx > 0 else 'w'}{z:.2f}", + (0.04, P["RING_D"], 0.04), (sx * hx, 0, z), alu)) + # Pickets every ~0.1 m β€” the non-climbable read. + for sy in (-1, 1): + n = int(P["RING_W"] / 0.105) + for i in range(1, n): + x = -hx + i * P["RING_W"] / n + if sy < 0 and gate_span[0] < x < gate_span[1]: + continue + rails.append(add_box(f"{name}_pk_y{sy}_{i}", (0.014, 0.014, P["FENCE_H"] - 0.14), + (x, sy * hy, (P["FENCE_H"] - 0.14) / 2 + 0.1), alu)) + for sx in (-1, 1): + n = int(P["RING_D"] / 0.105) + for i in range(1, n): + y = -hy + i * P["RING_D"] / n + rails.append(add_box(f"{name}_pk_x{sx}_{i}", (0.014, 0.014, P["FENCE_H"] - 0.14), + (sx * hx, y, (P["FENCE_H"] - 0.14) / 2 + 0.1), alu)) + join_group(rails, "fence_rails", root) + + # The posts β€” fourteen honest nos, each its own node. + for i, (x, y) in enumerate(_pool_fence_posts()): + post = join_group([add_cyl(f"{name}_postmesh_{i}", P["POST"] / 2, P["FENCE_H"], + (x, y, P["FENCE_H"] / 2), alu, verts=8)], + f"fence_post_{i + 1:02d}", root) + not_a_tie_off( + post, "pool fence post β€” compliance steel, not rigging steel", + "40 mm core-mounted aluminium tube holding a child-proof panel; " + "it resists a climbing toddler, not a loaded sail corner β€” the " + "ring LOOKS like fourteen perfect anchors and every one says no") + + # The gate: self-closing, opens outward, latch high β€” the compliance + # tells, in geometry and in data. + gx0, gx1 = gate_span + gate = join_group([ + add_box(f"{name}_gate_frame_l", (0.03, 0.03, P["FENCE_H"] - 0.1), + (gx0 + 0.03, -hy - 0.06, (P["FENCE_H"] - 0.1) / 2 + 0.05), alu, + rot=(0, 0, math.radians(-14))), + add_box(f"{name}_gate_frame_r", (0.03, 0.03, P["FENCE_H"] - 0.1), + (gx1 - 0.15, -hy - 0.28, (P["FENCE_H"] - 0.1) / 2 + 0.05), alu, + rot=(0, 0, math.radians(-14))), + add_box(f"{name}_gate_top", (gx1 - gx0 - 0.1, 0.03, 0.03), + ((gx0 + gx1) / 2 - 0.06, -hy - 0.17, P["FENCE_H"] - 0.08), alu, + rot=(0, 0, math.radians(-14))), + *[add_box(f"{name}_gate_pk_{i}", (0.014, 0.014, P["FENCE_H"] - 0.22), + (gx0 + 0.1 + i * (gx1 - gx0 - 0.2) / 7, + -hy - 0.08 - i * 0.028, (P["FENCE_H"] - 0.22) / 2 + 0.08), alu) + for i in range(8)], + ], "pool_gate", root) + gate["self_closing"] = True + gate["opens"] = "outward, ajar β€” someone left it swinging, which is the tell" + gate["latch_height_m"] = 1.5 + + stamp(root, name, "structure") + root["priced"] = False + root["unpriced_why"] = ("nothing in the sim can bend a pool fence or crack " + "a coping yet β€” billing an unseen event is the lie " + "the invoice exists to kill (the bike ruling); " + "price it when debris can take a panel out") + root["compliance"] = "AS 1926.1 flavour: 1.2 m, non-climbable, self-closing outward gate" + return root + + def build_shed_01(name): """Colorbond garden shed, skillion roof. Spare hardware lives in here.""" root = add_empty(name) @@ -2969,6 +3512,38 @@ ASSETS = [ dict(name="fence_panel_snapped", fn=build_fence_panel_snapped, dims=((2.38, 2.60), (0.03, 1.05), (1.70, 1.90)), nodes=["palings", "rails", "debris_palings"]), + # SPRINT16 gate 5 β€” the next temptations. The pergola is the ladder's + # honest middle rung (D-endorsed, S15 THREADS): TALLER than the swing set + # it out-rates, and its z floor is above the swing's 2.15 ceiling on + # purpose β€” if it ever measures swing-height, someone shrank the rung. + dict(name="pergola_01", fn=build_pergola_01, + dims=((3.1, 3.4), (2.4, 2.8), (2.55, 2.85)), + nodes=["deck", "posts", "beams", "rafters", + "pergola_anchor_01", "pergola_anchor_02"]), + # The wreck keeps the deck footprint but spills the torn beam past the + # edge (x floor ABOVE the intact deck's 3.2) and its roof line is the + # rafters hinged down β€” near intact height at the surviving back beam. + dict(name="pergola_01_wrecked", fn=build_pergola_01_wrecked, + dims=((3.2, 3.6), (2.4, 2.8), (2.55, 2.85)), + nodes=["deck", "posts", "beams", "beam_down", "rafters_down"]), + # The collateral apex. Narrow bands on z because the ridge IS the claim: + # a glasshouse that grew would stop reading as the small fragile thing. + dict(name="glasshouse_01", fn=build_glasshouse_01, + dims=((2.5, 2.8), (1.8, 2.1), (2.15, 2.40)), + nodes=["frame", "panes", "door", "ridge_anchor"]), + # Emptied, not felled: SAME height band as the intact one (the frame is + # aluminium and stands), but the shard field throws the plan dims WIDE β€” + # the x/y floors sit above the intact ceilings, which is the "it emptied + # outward" claim as a number. + 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"]), + # 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, + 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"]), ] diff --git a/tools/blender/contact_sheet.png b/tools/blender/contact_sheet.png index 3f17747..5e12d2a 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 bcaec8a..cc9f21f 100644 --- a/web/world/js/anchor_ratings.gen.js +++ b/web/world/js/anchor_ratings.gen.js @@ -82,6 +82,14 @@ export const FACTORY_ANCHOR_RATINGS = Object.freeze({ "house_yardside/fascia_anchor_03" ] }, + "pergola": { + "collateral": "pergola", + "rating_hint": 0.65, + "sources": [ + "pergola_01/pergola_anchor_01", + "pergola_01/pergola_anchor_02" + ] + }, "post": { "rating_hint": 1.0, "sources": [ diff --git a/web/world/js/contracts.js b/web/world/js/contracts.js index b8e4485..989fd0b 100644 --- a/web/world/js/contracts.js +++ b/web/world/js/contracts.js @@ -81,9 +81,17 @@ export const PHASES = ['forecast', 'prep', 'storm', 'aftermath']; * concreted steel. This is a welded joint on a frame standing on grass: good * steel, no footing, rating_hint 0.45. Same lesson as the carport, which was * also nearly smuggled in as a post. + * + * `pergola` (SPRINT16 gate 5, E): a pergola's bolted post-beam corner β€” the + * ladder's middle rung (0.65), D-endorsed in S15. Not a `post` for the same + * reason the swing frame isn't: the word promises the footing, and this one's + * footing is a DECK β€” real bolts into real joists, so better than + * 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. */ export const ANCHOR_TYPE = Object.freeze([ - 'house', 'tree', 'post', 'carport', 'carport_post', 'swing_frame', + 'house', 'tree', 'post', 'carport', 'carport_post', 'swing_frame', 'pergola', ]); // --------------------------------------------------------------------------- diff --git a/web/world/js/tests/e.test.js b/web/world/js/tests/e.test.js index 1b78c20..714e921 100644 --- a/web/world/js/tests/e.test.js +++ b/web/world/js/tests/e.test.js @@ -100,6 +100,22 @@ const ASSETS = [ nodes: ['frame', 'crossbar', 'swings', 'frame_anchor_01', 'frame_anchor_02'] }, { name: 'swing_set_01_wrecked', h: [0.80, 1.05], nodes: ['frame', 'crossbar', 'swings'] }, + // SPRINT16 gate 5 β€” the next temptations. Height floors carry design claims: + // the pergola's 2.55 floor sits ABOVE the swing set's 2.15 ceiling (the rung + // it out-rates must stand taller than the rung below it), and the pool + // fence's band brackets 1.2 m because the compliance height IS the flavour. + { name: 'pergola_01', h: [2.55, 2.85], + nodes: ['deck', 'posts', 'beams', 'rafters', + 'pergola_anchor_01', 'pergola_anchor_02'] }, + { name: 'pergola_01_wrecked', h: [2.55, 2.85], + nodes: ['deck', 'posts', 'beams', 'beam_down', 'rafters_down'] }, + { name: 'glasshouse_01', h: [2.15, 2.40], + nodes: ['frame', 'panes', 'door', 'ridge_anchor'] }, + { name: 'glasshouse_01_wrecked', h: [2.15, 2.40], + nodes: ['frame', 'door', 'panes_left', 'shards'] }, + { name: 'pool_kit_01', h: [1.12, 1.32], + nodes: ['pool_shell', 'pool_water', 'fence_rails', 'pool_gate', + 'fence_post_01', 'fence_post_14'] }, ]; function sizeOf(gltf) { @@ -502,7 +518,9 @@ export default async function run(t) { for (const [intact, broken] of [['garden_gnome_01', 'garden_gnome_01_broken'], ['fence_panel', 'fence_panel_snapped'], ['swing_set_01', 'swing_set_01_wrecked'], - ['house_yardside', 'house_yardside_wrecked']]) { + ['house_yardside', 'house_yardside_wrecked'], + ['pergola_01', 'pergola_01_wrecked'], + ['glasshouse_01', 'glasshouse_01_wrecked']]) { for (const n of [intact, broken]) { const box = new THREE.Box3().setFromObject(loaded.get(n).scene); assert(Math.abs(box.min.y) < 0.03, @@ -792,6 +810,246 @@ export default async function run(t) { `gum is ${g.y.toFixed(2)} m tall and ${Math.max(g.x, g.z).toFixed(2)} m across β€” the control is broken`); }); + // ------------------------------------------------------------------------- + // SPRINT16 gate 5 β€” THE NEXT TEMPTATIONS. Three props, three different ways + // of saying no: the pergola says "yes, at 0.65" (the ladder's middle rung), + // the glasshouse says "no, and here is the bill if you make me" (the + // collateral apex), the pool ring says "no" fourteen times in a perfect + // circle. Every claim below is a number read off the shipped GLB. + // ------------------------------------------------------------------------- + + // The pergola's rating is pinned against the MANIFEST, not a literal β€” the + // gate-1.2 lesson applied to the asset that gate's mechanism was built FOR: + // a site declaring a node-less `pergola` anchor must resolve these exact + // extras, so the manifest entry existing (and being unambiguous) IS the + // feature. A pin that hard-coded 0.65 would stay green while the manifest + // silently dropped the type. + t.test('the pergola is the ladder\'s middle rung, and the manifest can resolve it', () => { + const types = FACTORY_ANCHOR_RATINGS.types; + const p = types.pergola; + assert(p, 'the manifest does not rate "pergola" β€” either the bake is missing or the ' + + 'type went AMBIGUOUS (its nodes disagree), and a node-less pergola anchor would ' + + 'fall back to the default hint: the sail_post bug shape, on the asset built to bury it'); + assert(p.collateral === 'pergola', `manifest collateral is ${JSON.stringify(p.collateral)}, want "pergola"`); + const swing = types.swing_frame.rating_hint, post = types.post.rating_hint; + assert(p.rating_hint > swing && p.rating_hint < post, + `pergola rates ${p.rating_hint} β€” the rung must sit strictly between the swing frame ` + + `(${swing}, bolted to nothing) and the post (${post}, bolted to the planet); at either ` + + 'end the ladder loses the rung D endorsed and fascia-or-nothing is back'); + + const g = loaded.get('pergola_01'); + assert(g, 'pergola_01 did not load'); + for (const n of ['pergola_anchor_01', 'pergola_anchor_02']) { + const o = g.scene.getObjectByName(n); + assert(o, `${n} missing`); + assert(o.userData?.anchor_type === 'pergola', + `${n} is typed ${JSON.stringify(o.userData?.anchor_type)} β€” a pergola is not a post; ` + + 'the type string is the player\'s pre-rig read and "post" promises concrete'); + assert(o.userData?.rating_hint === p.rating_hint, + `${n} bakes ${o.userData?.rating_hint} but the manifest says ${p.rating_hint}`); + assert(o.userData?.collateral === 'pergola', `${n} must name what it takes with it`); + } + + // The price ladder, read off the GLBs so site JSON and factory can't tell + // two stories: gutter < pergola < swing set < carport. The pergola bills + // UNDER the swing set because its failure is partial (a beam and a post; + // the bolts hold) where the swing's is total (the whole frame on the + // grass) β€” prices must agree with the wrecks the player actually sees. + const price = (a) => loaded.get(a)?.scene.getObjectByName(a)?.userData?.collateral_value; + const cost = price('pergola_01'); + assert(typeof cost === 'number', 'pergola_01 carries no collateral_value'); + const root = g.scene.getObjectByName('pergola_01'); + assert(root.userData?.collateral_key === 'pergola', + 'collateral_key must name the string the anchors carry, or the price prices nothing'); + assert(cost > price('house_yardside') && cost < price('swing_set_01'), + `the pergola (${cost}) must sit between the gutter (${price('house_yardside')}) and the ` + + `swing set (${price('swing_set_01')}) β€” a partial timber repair beats a run of guttering ` + + 'and loses to a whole frame replacement'); + + const w = loaded.get('pergola_01_wrecked')?.scene.getObjectByName('pergola_01_wrecked'); + assert(w?.userData?.collateral_value === cost, + 'the wrecked pergola must be priced the same as the one it used to be'); + assert(w?.userData?.broken_variant_of === 'pergola_01', + 'the wreck must name its intact twin so A can pair the swap'); + }); + + // The 0.65 story told by the wreck: a bolted structure fails PARTIALLY. + // The front beam and the rafters come down; the deck, the bolts and the + // back half stand. Measured, because "partial" is exactly the kind of word + // that erodes into "rubble" one art pass at a time. + t.test('the pergola fails partially: rafters hinge down, the deck stands, no anchor survives', () => { + const w = loaded.get('pergola_01_wrecked'); + assert(w, 'pergola_01_wrecked did not load'); + for (const n of ['pergola_anchor_01', 'pergola_anchor_02']) { + assert(!w.scene.getObjectByName(n), + `${n} survives the wreck β€” you cannot re-bolt a snapped post mid-storm, and an anchor ` + + 'that outlives its structure is the free-failure bug in a costume'); + } + // The lean-to read: rafters still reach the surviving back beam AND rest + // on the deck β€” high at one end, low at the other, in the same node. + const down = new THREE.Box3().setFromObject(w.scene.getObjectByName('rafters_down')); + assert(down.max.y > 2.3, + `rafters_down tops out at y=${down.max.y.toFixed(2)} β€” they must still hang from the back beam`); + assert(down.min.y < 0.5, + `rafters_down bottoms out at y=${down.min.y.toFixed(2)} β€” the front ends should rest on the deck`); + const beamDown = new THREE.Box3().setFromObject(w.scene.getObjectByName('beam_down')); + assert(beamDown.max.y < 0.55, + `beam_down tops out at y=${beamDown.max.y.toFixed(2)} β€” the torn beam lies on the deck`); + // Intact control: the rafters were UP, or "down" above means nothing. + const up = new THREE.Box3().setFromObject(loaded.get('pergola_01').scene.getObjectByName('rafters')); + assert(up.min.y > 2.3, + `intact rafters bottom out at y=${up.min.y.toFixed(2)} β€” the control is broken`); + + // The wreck does not stand taller, and the DECK did not move: it is the + // footing the rating is about, and the shared helper builds it once β€” + // byte-drift between the twins' decks means someone forked the helper. + const hi = new THREE.Box3().setFromObject(loaded.get('pergola_01').scene); + const hw = new THREE.Box3().setFromObject(w.scene); + assert(hw.max.y <= hi.max.y + 0.02, + `the wreck stands ${hw.max.y.toFixed(2)} m vs the intact ${hi.max.y.toFixed(2)} β€” taller is a bug`); + const deckA = new THREE.Box3().setFromObject(loaded.get('pergola_01').scene.getObjectByName('deck')); + const deckB = new THREE.Box3().setFromObject(w.scene.getObjectByName('deck')); + assert(deckA.min.distanceTo(deckB.min) < 0.01 && deckA.max.distanceTo(deckB.max) < 0.01, + 'the two decks differ β€” intact and wrecked no longer share the helper, and they will drift'); + }); + + // The glasshouse's one rule, pinned from every direction: NOTHING about it + // may adopt as an anchor. Not the ridge (denied in words), not any node by + // silence (a rating_hint anywhere on either variant is a red), not via the + // enum (no node enrols an anchor_type). The ridge bar is the most + // tie-off-shaped object in the game and that is exactly why the denial is + // load-bearing rather than decorative. + t.test('the glasshouse offers NOTHING: no rated node, no typed node, and the ridge denies', () => { + for (const name of ['glasshouse_01', 'glasshouse_01_wrecked']) { + const g = loaded.get(name); + assert(g, `${name} did not load`); + const rated = [], typed = []; + g.scene.traverse((o) => { + if (typeof o.userData?.rating_hint === 'number') rated.push(o.name); + if (o.userData?.anchor_type !== undefined) typed.push(o.name); + }); + assert(rated.length === 0, + `${name} carries rating_hint on [${rated.join(', ')}] β€” a glasshouse node with a rating ` + + 'is adoptable, and 30 kg of glass is not a thing you add load to'); + assert(typed.length === 0, + `${name} enrols [${typed.join(', ')}] in the anchor enum β€” nothing here is an anchor of any type`); + } + const ridge = loaded.get('glasshouse_01').scene.getObjectByName('ridge_anchor'); + assert(ridge, 'ridge_anchor missing β€” the temptation must exist to be denied'); + assert(ridge.userData?.tie_off === false, + 'the ridge must carry tie_off:false β€” the most anchor-looking member says no ON the data'); + assert(typeof ridge.userData?.why === 'string' && ridge.userData.why.length > 0, + 'the denial must carry its reasoning β€” a bare flag is a rule, a why is a lesson'); + }); + + // The apex is a price claim, so it is pinned as one: strictly the worst + // bill in the palette, keyed, and identical on the wreck. The billing + // EVENT is arc 2's (nothing in the sim wrecks a glasshouse yet β€” the value + // is inert data until then, which is why pricing it is not the bike bug); + // this pin is the chain being ready the day that yard is authored. + t.test('the glasshouse is the collateral apex: priced above everything, keyed, twin-priced', () => { + const g = loaded.get('glasshouse_01')?.scene.getObjectByName('glasshouse_01'); + assert(g, 'glasshouse_01 root missing'); + const cost = g.userData?.collateral_value; + assert(typeof cost === 'number', 'glasshouse carries no collateral_value'); + assert(g.userData?.collateral_key === 'glasshouse', + 'collateral_key must be explicit β€” the carport was one editor click from being FREE ' + + 'because its price resolved off a coincidence of naming'); + const carport = loaded.get('carport_01')?.scene.getObjectByName('carport_01')?.userData?.collateral_value; + assert(cost > carport, + `the glasshouse (${cost}) must top the carport (${carport}) by construction β€” fourteen panes ` + + 'of glass against one sheet of Colorbond, or "apex" is just a word'); + const w = loaded.get('glasshouse_01_wrecked')?.scene.getObjectByName('glasshouse_01_wrecked'); + assert(w?.userData?.collateral_value === cost, + 'the wrecked glasshouse must be priced the same as the one it used to be'); + assert(w?.userData?.broken_variant_of === 'glasshouse_01', + 'the wreck must name its intact twin so A can pair the swap'); + }); + + // What glass DOES, measured: the intact panes are translucent (the fragile + // read is a material fact, not a vibe β€” turn the glass opaque and the tell + // is deleted), and the wreck EMPTIES rather than falls (frame at full + // height, shard field flat on the ground and thrown wider than the + // building ever stood). + t.test('the glasshouse reads fragile, and its wreck empties instead of falling', () => { + const panes = loaded.get('glasshouse_01')?.scene.getObjectByName('panes'); + assert(panes, 'panes node missing'); + let translucent = false; + panes.traverse((o) => { + const m = o.material; + if (m && m.transparent === true && m.opacity < 0.6) translucent = true; + }); + assert(translucent, + 'no pane material is transparent with opacity < 0.6 β€” the glass has gone opaque, and ' + + 'the only see-through walls in the palette no longer read as glass'); + + const hi = new THREE.Box3().setFromObject(loaded.get('glasshouse_01').scene); + const wreck = loaded.get('glasshouse_01_wrecked'); + const hw = new THREE.Box3().setFromObject(wreck.scene); + assert(Math.abs(hi.max.y - hw.max.y) < 0.05, + `wreck ridge at y=${hw.max.y.toFixed(2)} vs intact ${hi.max.y.toFixed(2)} β€” the aluminium frame ` + + 'survives; a felled glasshouse is lying about what glass does'); + const shards = new THREE.Box3().setFromObject(wreck.scene.getObjectByName('shards')); + assert(shards.max.y < 0.05, + `shards top out at y=${shards.max.y.toFixed(2)} β€” glass on the ground lies flat`); + const spreadX = (hw.max.x - hw.min.x) - (hi.max.x - hi.min.x); + const spreadZ = (hw.max.z - hw.min.z) - (hi.max.z - hi.min.z); + assert(spreadX > 0.6 && spreadZ > 0.6, + `the shard field adds only ${spreadX.toFixed(2)}/${spreadZ.toFixed(2)} m in plan β€” it must ` + + 'throw glass clear of the footprint or the wreck reads as a dirty window, not a catastrophe'); + assert(wreck.scene.getObjectByName('panes_left'), + 'panes_left missing β€” a wreck with zero surviving panes reads as demolition, not storm'); + }); + + // The pool ring: fourteen posts, fourteen explicit nos. RULE 1's regex only + // guards *_anchor names, and these posts don't match it β€” which is exactly + // how they'd slip through as silent 1.0s the day a site names one (the + // `?? 1` default is generous, and silence is not neutral). So the denial is + // pinned per-post, by name, with the count exact: a fifteenth post added + // without its denial goes red here, not in a shipped yard. + t.test('the pool ring is fourteen honest nos β€” every fence post denies, nothing rates', () => { + const g = loaded.get('pool_kit_01'); + assert(g, 'pool_kit_01 did not load'); + const posts = []; + g.scene.traverse((o) => { if (/^fence_post_\d+$/.test(o.name)) posts.push(o); }); + assert(posts.length === 14, + `found ${posts.length} fence posts, want exactly 14 β€” a post this count doesn't know ` + + 'is a post whose denial nobody checked'); + for (const p of posts) { + assert(p.userData?.tie_off === false, + `${p.name} does not deny being a tie-off β€” silence here adopts at rating 1.0, the best ` + + 'steel in the game conjured out of a missing field'); + assert(p.userData?.rating_hint === undefined, + `${p.name} carries a rating_hint, which makes it adoptable β€” the ring's whole design is ` + + 'that its steel is legally useless'); + assert(typeof p.userData?.why === 'string' && p.userData.why.length > 0, + `${p.name} denies without reasoning`); + } + // Nothing anywhere on the kit rates or enrols β€” same sweep as the glasshouse. + const rated = [], typed = []; + g.scene.traverse((o) => { + if (typeof o.userData?.rating_hint === 'number') rated.push(o.name); + if (o.userData?.anchor_type !== undefined) typed.push(o.name); + }); + assert(rated.length === 0 && typed.length === 0, + `pool_kit_01 offers [${[...rated, ...typed].join(', ')}] β€” the kit must offer nothing`); + + // Compliance is the flavour, so its numbers are pinned as facts: 1.2 m + // posts, a self-closing gate, and no price until something can break it. + const post1 = new THREE.Box3().setFromObject(g.scene.getObjectByName('fence_post_01')); + assert(post1.max.y >= 1.15 && post1.max.y <= 1.32, + `fence stands ${post1.max.y.toFixed(2)} m β€” AS 1926.1 says 1.2, and the height IS the flavour`); + const gate = g.scene.getObjectByName('pool_gate'); + assert(gate?.userData?.self_closing === true, 'the gate must be self-closing β€” compliance is data'); + const root = g.scene.getObjectByName('pool_kit_01'); + assert(root?.userData?.priced === false && typeof root?.userData?.unpriced_why === 'string', + 'the kit must say OUT LOUD that it is unpriced and why (the bike ruling) β€” an unpriced ' + + 'prop with no reason is indistinguishable from a forgotten one'); + assert(root?.userData?.collateral_value === undefined, + 'pool_kit_01 carries a collateral_value β€” pricing an event the sim cannot produce is the ' + + 'lie the invoice exists to kill; when debris can take a panel out, price it THEN'); + }); + // 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/glasshouse_01_v1.glb b/web/world/models/glasshouse_01_v1.glb new file mode 100644 index 0000000..706149d Binary files /dev/null and b/web/world/models/glasshouse_01_v1.glb differ diff --git a/web/world/models/glasshouse_01_wrecked_v1.glb b/web/world/models/glasshouse_01_wrecked_v1.glb new file mode 100644 index 0000000..9306a62 Binary files /dev/null and b/web/world/models/glasshouse_01_wrecked_v1.glb differ diff --git a/web/world/models/pergola_01_v1.glb b/web/world/models/pergola_01_v1.glb new file mode 100644 index 0000000..1e6d29d Binary files /dev/null and b/web/world/models/pergola_01_v1.glb differ diff --git a/web/world/models/pergola_01_wrecked_v1.glb b/web/world/models/pergola_01_wrecked_v1.glb new file mode 100644 index 0000000..eed4ff6 Binary files /dev/null and b/web/world/models/pergola_01_wrecked_v1.glb differ diff --git a/web/world/models/pool_kit_01_v1.glb b/web/world/models/pool_kit_01_v1.glb new file mode 100644 index 0000000..1d08869 Binary files /dev/null and b/web/world/models/pool_kit_01_v1.glb differ