diff --git a/THREADS.md b/THREADS.md index 6a59f03..c01c9e8 100644 --- a/THREADS.md +++ b/THREADS.md @@ -2650,3 +2650,52 @@ Format: `[lane letter] YYYY-MM-DD β€” note` on the record, A's "1 lost" was a miscount, and C found the REAL winnable line (p1,p2,p3,p4 β€” $80 exactly, hp 52, 1 lost) that needs B's porous fabric + C's 0.40 downdraft STACKED. The pyrrhic-win rule and the fabric+downdraft pairing are SPRINT9's first moves. + +[E] 2026-07-18 β€” 🏚️ **`carport_01_v1.glb` β€” site_02's dressing, and its whole design (SPRINT9 Β§gate 2-E).** + 3.24 Γ— 5.60 Γ— 2.69 m, 172 tris. DESIGN.md wants the corner block anchor-poor β€” "nowhere to tie off" β€” + and the interesting way to build that is **not** to give the yard nothing. An empty yard is just a + smaller yard. It's to give it something that LOOKS like four free anchors and isn't. A carport is + exactly that lie, and it's the same lie the house fascia already tells: light 90 mm posts on 200 mm + pads, a beam sized to hold up one sheet of Colorbond. Tie 25 mΒ² to it in a southerly and you don't + break the shackle β€” you take the carport. + So it ships as a trap, wired as data, with the worst numbers in the game: + Β· `beam_anchor_01..02` β€” `rating_hint` **0.22**, `collateral="carport"` (the fascia is 0.35). + Β· `post_anchor_01..02` β€” **0.30**. A pad is better than a beam; it's still a lie. + Β· plus `footings` / `posts` / `beams` / `roof`, and `is_anchor_trap` on the root. + **Lane A/B: these are meant to be TAKEN, and to hurt.** Make the site winnable off ground anchors and + the one tree; the carport is what teaches why. e.test.js pins the ratings *below the fascia's* with the + reason attached β€” if someone ever "fixes" them upward the site stops teaching and becomes a small yard. + **Lane B: audit it before it ships** β€” that's your Β§gate 2-B sweep, and this is precisely the geometry + it exists to catch. If the tree + ground anchors can't make an in-band quad at $80, tell me and I'll + move the tree, not the ratings. + +[E] 2026-07-18 β€” **the pyrrhic ending has a card and words (gate 1-1, "E will want in").** + `models/textures/card_pyrrhic.jpg` β€” same camera, same yard, third outcome, so it slots straight into + the set: dawn light, the bed green, the gnome standing, and **the sail post down across the yard behind + him**. That's the reading in one frame β€” the rig let go, he didn't. Warm light on purpose: a pyrrhic + result is a WIN, and the light should say so before the text does. The wreckage is the price, not the + verdict. + Copy, for you to take/edit/bin, Lane A: + Β· headline **"THE GARDEN MADE IT"** Β· sub *"The sail didn't. That's the trade you took."* + Β· kicker *"A sail is a consumable. A season isn't."* + Β· alternates: "BOUGHT AND PAID FOR" / *"Two corners gone. The bed never knew."* + Β· per-night verdict line, if you want one: *"the sail took it so the bed didn't"*. + `tools/cardpreview/index.html` renders all three now β€” `show('win'|'pyrrhic'|'lose')`. + This is DESIGN.md's actual thesis finally having a picture: the sail was never the point. + +[E] 2026-07-18 β€” 🐞 my own bug, logged because this repo learns from named mistakes: I spent most of this + card chasing a post that "wouldn't render". It rendered fine β€” I'd made **two** of them. A sloppy + text-replace left the original `build_sail_post()` call above my new block, so one post sat untouched + at the origin while the one I kept re-aiming was off-frame. Two of the three "failures" I measured on + the way were real and are now comments in the code (a stood-up post clips off the top β€” its head is + 41.7Β° above the camera axis where the vertical half-FOV is 11.4Β°; `Rx(t)` sends +Z to (0,βˆ’sin t, cos t), + so a negative angle lays it *away* from camera and behind the fence). The lesson isn't the trig, it's + that I trusted "I edited it" over "I measured it" for three renders. The probe took 30 seconds. + +[E] 2026-07-18 β€” housekeeping: selftest **277/0/0**, Lane E is 60 asserts, **39/39 output files + byte-identical** across two full runs (cards excluded by design β€” they're `--cards` art; EEVEE's shadow + pass isn't byte-reproducible, see my Sprint 6 entry). Carried and still yours to close either way, + Lane A: **grass_atlas β€” eight sprints, 0 refs** (bin it, honestly), the screenshot POST, `moon.png`. + Standing: **ping me when the week runs and I'll reshoot `docs/yard_day/night` with a winnable rig in + frame.** And when A's `data/sites/*.json` lands I'll dress site_02 against the real numbers β€” the + factory is site-agnostic and the carport is the only new geometry it needed. diff --git a/tools/blender/asset_report.json b/tools/blender/asset_report.json index f52e9df..e8178c2 100644 --- a/tools/blender/asset_report.json +++ b/tools/blender/asset_report.json @@ -133,6 +133,28 @@ "status": "PASS", "problems": [] }, + { + "name": "carport_01", + "dims": [ + 3.24, + 5.5959, + 2.6945 + ], + "tris": 172, + "nodes": [ + "beam_anchor_01", + "beam_anchor_02", + "beams", + "carport_01", + "footings", + "post_anchor_01", + "post_anchor_02", + "posts", + "roof" + ], + "status": "PASS", + "problems": [] + }, { "name": "shed_01", "dims": [ diff --git a/tools/blender/build_yard_assets.py b/tools/blender/build_yard_assets.py index b486060..0f2c1ef 100644 --- a/tools/blender/build_yard_assets.py +++ b/tools/blender/build_yard_assets.py @@ -781,6 +781,86 @@ def build_house_yardside(name): return root +def build_carport_01(name): + """A single-car carport β€” the corner block's whole personality (SPRINT9 Β§E). + + DESIGN.md says that site is anchor-poor: "nowhere to tie off". The interesting + way to build that is NOT to give the yard nothing β€” an empty yard is just a + smaller yard. It's to give it something that LOOKS like four free anchors and + isn't. A carport is exactly that lie, and it's the same lie the house fascia + tells: light C-section posts on shallow pads, a roof beam sized to hold up a + sheet of Colorbond and precisely nothing else. Tie a 25 mΒ² sail to it in a + southerly and you don't break the shackle, you take the carport. + + So the anchors ship with honest, terrible numbers: + Β· `beam_anchor_01..02` β€” rating_hint 0.22, the worst in the game (the house + fascia is 0.35). collateral="carport": pull these and the roof goes. + Β· `post_anchor_01..02` β€” 0.30. Better, because a post at least stands on a + pad, but it's still a 90 mm post in 200 mm of concrete. + Lane A/B: these are meant to be TAKEN and to hurt. The site is winnable off + ground anchors and the one tree; the carport is the trap that teaches why. + """ + root = add_empty(name) + steel = get_material("Mat_Steel", PAL["steel_gal"], 0.4, metallic=0.85) + dark = get_material("Mat_SteelDark", PAL["steel_dark"], 0.45, metallic=0.8) + sheet = get_material("Mat_Colorbond", PAL["colorbond"], 0.45, metallic=0.5) + conc = get_material("Mat_Concrete", PAL["concrete"], 0.95) + + W, D = 3.0, 5.4 # one car, tight β€” it's a corner block + H_HI, H_LO = 2.45, 2.20 # skillion, falling away from the street + + posts, pads = [], [] + for sx in (-1, 1): + for sy in (-1, 1): + x, y = sx * (W / 2 - 0.09), sy * (D / 2 - 0.09) + h = H_HI if sy < 0 else H_LO + pads.append(add_box(f"{name}_pad_{sx}_{sy}", (0.26, 0.26, 0.09), + (x, y, 0.045), conc)) + # 90 mm box section. Light, and meant to look it. + posts.append(add_box(f"{name}_post_{sx}_{sy}", (0.09, 0.09, h), + (x, y, h / 2 + 0.09), steel)) + join_group(pads, "footings", root) + join_group(posts, "posts", root) + + beams = [] + for sy in (-1, 1): + h = H_HI if sy < 0 else H_LO + beams.append(add_box(f"{name}_beam_{sy}", (W, 0.05, 0.14), + (0, sy * (D / 2 - 0.09), h + 0.09), dark)) + for sx in (-1, 1): + beams.append(add_tube_between( + f"{name}_rafter_{sx}", + (sx * (W / 2 - 0.09), -D / 2 + 0.09, H_HI + 0.16), + (sx * (W / 2 - 0.09), D / 2 - 0.09, H_LO + 0.16), 0.035, dark, verts=6)) + join_group(beams, "beams", root) + + fall = math.atan2(H_HI - H_LO, D) + join_group([add_box(f"{name}_roof", (W + 0.24, D + 0.20, 0.04), + (0, 0, (H_HI + H_LO) / 2 + 0.22), sheet, + rot=(fall, 0, 0))], "roof", root) + + # The trap, wired as data. Numbers are deliberately the worst in the game. + for i, sx in enumerate((-1, 1)): + e = add_empty(f"beam_anchor_{i + 1:02d}", + (sx * (W / 2 - 0.09), 0.0, H_LO + 0.16), root, size=0.18) + e["anchor_type"] = "carport" + e["rating_hint"] = 0.22 # worse than the house fascia's 0.35 + e["collateral"] = "carport" + e["why"] = "sized for one sheet of roofing; a loaded sail takes the lot" + for i, sy in enumerate((-1, 1)): + e = add_empty(f"post_anchor_{i + 1:02d}", + (-(W / 2 - 0.09), sy * (D / 2 - 0.09), 1.75), root, size=0.18) + e["anchor_type"] = "carport_post" + e["rating_hint"] = 0.30 + e["collateral"] = "carport" + e["why"] = "90 mm post on a 200 mm pad β€” better than the beam, still a lie" + + stamp(root, name, "structure") + root["site_hint"] = "corner_block" + root["is_anchor_trap"] = True + return root + + def build_shed_01(name): """Colorbond garden shed, skillion roof. Spare hardware lives in here.""" root = add_empty(name) @@ -1750,42 +1830,67 @@ def build_moon_texture(): # ============================================================================ # END CARDS β€” rendered from the game's own props, not drawn (SPRINT6 Β§Lane E-2) # ============================================================================ -def _card_scene(broken): +def _card_scene(state): """One vignette, built from the same yard objects the player spent the week protecting β€” that's the whole idea. A gradient with a font on it says nothing; the gnome you failed to protect, lying in pieces, says it without a - word.""" + word. + + Three endings, one camera (SPRINT9 gate 1 adds the pyrrhic): + Β· win β€” everything stands, dawn. You got away with it. + Β· pyrrhic β€” the bed is green and the gnome is fine, but a post is on its + face and the fence is gone. DESIGN.md's actual thesis: the sail is not + the point, the garden is, and a rig that dies saving it did its job. + Warm light, because you WON β€” the wreckage is the price, not the verdict. + Β· lose β€” gnome in bits, bed dead, flat grey. Nothing was saved. + """ reset_to_empty() - grass = get_material("Mat_Grass", "#4A6B36" if broken else "#5C8A3A", 1.0) + lost = state == "lose" + wrecked = state != "win" # the fence goes in both bad endings + grass = get_material("Mat_Grass", "#4A6B36" if lost else "#5C8A3A", 1.0) add_box("ground", (60, 60, 0.4), (0, 0, -0.2), grass) - if broken: + if lost: gnome = build_garden_gnome_01_broken("gnome") - gnome.location = (0.82, -0.78, 0) gnome.rotation_mode = 'XYZ' gnome.rotation_euler = (0, 0, math.radians(-24)) - fence = build_fence_panel_snapped("fence") - fence.location = (-0.35, 2.6, 0) - bin_ = build_wheelie_bin_01("bin") # blown over, lid flung open - bin_.location = (2.9, 1.5, 0.34) - bin_.rotation_mode = 'XYZ' - bin_.rotation_euler = (math.radians(-96), 0, math.radians(38)) - bed = build_garden_bed("bed") - bed.location = (-3.4, 1.2, 0) - for o in bpy.data.objects: # a loss shows the dead bed + else: + gnome = build_garden_gnome_01("gnome") + gnome.location = (0.82, -0.78, 0) + + fence = (build_fence_panel_snapped if wrecked else build_fence_panel)("fence") + fence.location = (-0.35, 2.6, 0) + + bed = build_garden_bed("bed") + bed.location = (-3.4, 1.2, 0) + if lost: + for o in bpy.data.objects: # only a loss shows the dead bed if o.name == "plants_full": o.hide_render = True elif o.name == "plants_dead": o.hide_render = False - else: - gnome = build_garden_gnome_01("gnome") - gnome.location = (0.82, -0.78, 0) - fence = build_fence_panel("fence") - fence.location = (-0.35, 2.6, 0) - post = build_fence_post("post") - post.location = (0.85, 2.6, 0) - bed = build_garden_bed("bed") - bed.location = (-3.4, 1.2, 0) + + if state == "pyrrhic": + # The post came out of the ground rather than the shackle letting go β€” + # DESIGN.md's "a post in wet ground pulls out slowly, with creaking". + # Rotating the ROOT (not rake_pivot) tips the footing with it, which is + # exactly right here: the concrete is out of the dirt. + # + # Placement is the whole trick, and three measured failures got here: + # stood up, the head sits 41.7 deg above the camera axis when the + # vertical half-FOV is 11.4, so it clips off the top; based at the fence + # line the footing hides behind the palings; and Rx(t) sends +Z to + # (0, -sin t, cos t), so a NEGATIVE angle lays it AWAY from camera and + # out of shot. Flat, forward of the fence, crossing behind the gnome: + # the rig let go, he didn't. + post = build_sail_post("post_down") + post.location = (2.60, 2.00, 0.05) + post.rotation_mode = 'XYZ' + post.rotation_euler = (math.radians(88), 0, math.radians(-60)) + + elif state == "win": + p = build_fence_post("post") + p.location = (0.85, 2.6, 0) shed = build_shed_01("shed") shed.location = (3.6, 2.2, 0) @@ -1808,9 +1913,11 @@ def build_end_cards(): other lanes rely on. """ out = [] - for name, broken, warm in (("card_win", False, True), - ("card_gameover", True, False)): - _card_scene(broken) + for name, state in (("card_win", "win"), + ("card_pyrrhic", "pyrrhic"), + ("card_gameover", "lose")): + _card_scene(state) + warm = state != "lose" scn = bpy.context.scene scn.render.engine = 'BLENDER_EEVEE' scn.render.resolution_x, scn.render.resolution_y = 1200, 675 @@ -1821,7 +1928,8 @@ def build_end_cards(): bg = scn.world.node_tree.nodes.get("Background") if bg: # Dawn after a night you survived, vs the flat grey of a morning you - # have to explain to a client. + # have to explain to a client. The pyrrhic card gets the dawn: it is + # a win, and the light should say so before the text does. bg.inputs[0].default_value = ((0.92, 0.55, 0.32, 1.0) if warm else (0.34, 0.37, 0.41, 1.0)) # Low strength on purpose. The world is a huge ambient fill, and at @@ -1843,11 +1951,11 @@ def build_end_cards(): cam = _active() cam.data.lens = 50 scn.camera = cam - # IDENTICAL camera on both cards, deliberately. They're a diptych: same - # yard, same framing, and the only thing that changed overnight is what - # happened to it. A player who sees both reads the difference instantly, - # which no amount of headline text would do as fast. Low and close, so - # the gnome is the subject; the left third stays empty for Lane A's text. + # IDENTICAL camera on every card, deliberately. They're a set: same yard, + # same framing, and the only thing that changed overnight is what happened + # to it. A player who has seen one reads the next instantly, which no + # amount of headline text would do as fast. Low and close, so the gnome is + # the subject; the left third stays empty for Lane A's text. cam.location = (2.00, -2.86, 0.94) cam.rotation_mode = 'XYZ' target = Vector((0.66, -0.42, 0.30)) @@ -1940,6 +2048,12 @@ ASSETS = [ nodes=["wall", "door", "window", "roof", "fascia", "gutter", "window_glow", "window_light_anchor", "fascia_anchor_01", "fascia_anchor_02", "fascia_anchor_03"]), + # site_02 (corner block): the anchor trap, not just dressing. + dict(name="carport_01", fn=build_carport_01, + dims=((3.0, 3.5), (5.2, 5.8), (2.4, 2.8)), + nodes=["footings", "posts", "beams", "roof", + "beam_anchor_01", "beam_anchor_02", + "post_anchor_01", "post_anchor_02"]), dict(name="shed_01", fn=build_shed_01, dims=((2.4, 2.7), (1.8, 2.1), (1.95, 2.25)), nodes=["shell", "roof", "doors", "door_anchor"]), diff --git a/tools/blender/contact_sheet.png b/tools/blender/contact_sheet.png index 9c0cf4f..2e517f8 100644 Binary files a/tools/blender/contact_sheet.png and b/tools/blender/contact_sheet.png differ diff --git a/tools/cardpreview/index.html b/tools/cardpreview/index.html index 9242fb7..1b686b1 100644 --- a/tools/cardpreview/index.html +++ b/tools/cardpreview/index.html @@ -19,8 +19,9 @@ font: 14px/1.55 ui-monospace, SFMono-Regular, Menlo, monospace; color: #fff; } - .endcard.win { background-image: url('/web/world/models/textures/card_win.jpg'); } - .endcard.lose { background-image: url('/web/world/models/textures/card_gameover.jpg'); } + .endcard.win { background-image: url('/web/world/models/textures/card_win.jpg'); } + .endcard.pyrrhic { background-image: url('/web/world/models/textures/card_pyrrhic.jpg'); } + .endcard.lose { background-image: url('/web/world/models/textures/card_gameover.jpg'); } /* The art was framed with its left third deliberately empty. This scrim only deepens what's already there, so the text has something to sit on without @@ -35,7 +36,8 @@ margin: 0 0 10px; font-size: clamp(30px, 4.4vw, 52px); line-height: .98; letter-spacing: .06em; font-weight: 800; } - .endcard.win h1 { color: #ffd9a3; text-shadow: 0 2px 26px rgba(255,150,60,.45); } + .endcard.win h1, + .endcard.pyrrhic h1 { color: #ffd9a3; text-shadow: 0 2px 26px rgba(255,150,60,.45); } .endcard.lose h1 { color: #e8edf2; text-shadow: 0 2px 22px rgba(0,0,0,.7); } .endcard .sub { margin: 0 0 18px; font-size: clamp(14px, 1.5vw, 18px); color: #dfe6ec; } .endcard .kicker { @@ -60,6 +62,7 @@
+
@@ -86,6 +89,15 @@ const COPY = { ['hardware lost', '2 shackles'], ['collateral', 'βˆ’$25 (gnome)'], ['bank', '$164', true]], }, + pyrrhic: { + h: 'THE GARDEN MADE IT', + s: "The sail didn't. That's the trade you took.", + k: 'A sail is a consumable. A season isn\'t.', + b: 'NEXT WEEK', + rows: [['nights survived', '5 / 5'], ['garden, worst night', '52%'], + ['corners lost', '2 (t2, p3)'], ['hardware lost', '1 shackle, 1 carabiner'], + ['bank', '$88', true]], + }, lose: { h: 'OFF THE JOB', s: 'Broke, with the week still running.', diff --git a/web/world/js/tests/e.test.js b/web/world/js/tests/e.test.js index f2914c6..f34753c 100644 --- a/web/world/js/tests/e.test.js +++ b/web/world/js/tests/e.test.js @@ -80,6 +80,9 @@ const ASSETS = [ { name: 'broom_01', h: [1.35, 1.50], nodes: ['handle', 'head', 'bristles', 'grip_anchor', 'poke_tip'] }, { name: 'hail_stone_01', h: [0.012, 0.028], nodes: ['stone'] }, + { name: 'carport_01', h: [2.4, 2.8], + nodes: ['footings', 'posts', 'beams', 'roof', + 'beam_anchor_01', 'beam_anchor_02', 'post_anchor_01', 'post_anchor_02'] }, ]; function sizeOf(gltf) { @@ -144,6 +147,31 @@ export default async function run(t) { }); } + // site_02's whole personality is "nowhere to tie off", and it lives in these + // four numbers rather than in any geometry. The carport is meant to LOOK like + // four free anchors and be the worst steel in the game β€” worse even than the + // house fascia, which DESIGN.md already calls a lie. If someone quietly + // "fixes" these ratings upward the site stops teaching anything and just + // becomes a smaller yard, so they're pinned here with the reason attached. + t.test('carport anchors stay a trap β€” the corner block has no good tie-off', () => { + const g = loaded.get('carport_01'); + assert(g, 'carport_01 did not load'); + const FASCIA = 0.35; // house_yardside's, the previous worst + for (const n of ['beam_anchor_01', 'beam_anchor_02']) { + const o = g.scene.getObjectByName(n); + assert(o, `${n} missing`); + assert(o.userData?.rating_hint < FASCIA, + `${n} rates ${o.userData?.rating_hint} β€” the carport beam must be worse than the fascia (${FASCIA})`); + assert(o.userData?.collateral === 'carport', + `${n} must carry collateral="carport" β€” taking it takes the roof`); + } + for (const n of ['post_anchor_01', 'post_anchor_02']) { + const o = g.scene.getObjectByName(n); + assert(o?.userData?.rating_hint < FASCIA, + `${n} rates ${o?.userData?.rating_hint} β€” still a 90 mm post on a small pad`); + } + }); + // These five numbers are load-bearing for the whole repo: Lane A's winning // line rigs off t2, and every balance figure B and C measured assumes the // branch anchors sit exactly here. I reshaped the limbs in Sprint 7 for looks diff --git a/web/world/models/carport_01_v1.glb b/web/world/models/carport_01_v1.glb new file mode 100644 index 0000000..0d8cd41 Binary files /dev/null and b/web/world/models/carport_01_v1.glb differ diff --git a/web/world/models/textures/card_gameover.jpg b/web/world/models/textures/card_gameover.jpg index d14da12..24b6d5f 100644 Binary files a/web/world/models/textures/card_gameover.jpg and b/web/world/models/textures/card_gameover.jpg differ diff --git a/web/world/models/textures/card_pyrrhic.jpg b/web/world/models/textures/card_pyrrhic.jpg new file mode 100644 index 0000000..30262b7 Binary files /dev/null and b/web/world/models/textures/card_pyrrhic.jpg differ diff --git a/web/world/models/textures/card_win.jpg b/web/world/models/textures/card_win.jpg index 665e461..8d6d5ec 100644 Binary files a/web/world/models/textures/card_win.jpg and b/web/world/models/textures/card_win.jpg differ diff --git a/web/world/models/textures/grass_atlas.png b/web/world/models/textures/grass_atlas.png new file mode 100644 index 0000000..2a28c31 Binary files /dev/null and b/web/world/models/textures/grass_atlas.png differ