Add carport_01 for site_02, and the pyrrhic end card

carport_01 is site_02's dressing and its whole design. DESIGN.md wants the
corner block anchor-poor, and the interesting way to build that isn't 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. Light posts on small
pads, a beam sized for one sheet of roofing: tie 25 m2 to it in a southerly and
you take the carport, not the shackle. Ships with the worst ratings in the game
(beam 0.22 vs the fascia's 0.35) and collateral="carport", pinned in e.test.js
below the fascia with the reason attached, so nobody quietly fixes them upward
and turns the site back into a small yard.

The pyrrhic card is the third outcome on the same camera: dawn, bed green, gnome
standing, sail post down across the yard behind him. Warm light on purpose — a
pyrrhic result is a win, and the wreckage is the price, not the verdict. Copy
goes to Lane A in THREADS; cardpreview renders all three.

277/0/0, Lane E 60 asserts, 39/39 output files byte-identical.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
m3ultra 2026-07-17 14:29:08 +10:00
parent 62005c8fd1
commit 4d4f2d7d7f
11 changed files with 260 additions and 35 deletions

View File

@ -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.

View File

@ -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": [

View File

@ -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 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")
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)
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
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"]),

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 MiB

After

Width:  |  Height:  |  Size: 3.0 MiB

View File

@ -20,6 +20,7 @@
color: #fff;
}
.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
@ -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 @@
<!-- Toggle for eyeballing; not part of the snippet. -->
<div style="position:fixed;z-index:60;right:12px;top:12px;display:flex;gap:8px">
<button onclick="show('win')" style="font:12px ui-monospace;padding:8px 12px">win</button>
<button onclick="show('pyrrhic')" style="font:12px ui-monospace;padding:8px 12px">pyrrhic</button>
<button onclick="show('lose')" style="font:12px ui-monospace;padding:8px 12px">game over</button>
</div>
@ -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.',

View File

@ -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

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB