diff --git a/tools/blender/asset_report.json b/tools/blender/asset_report.json index e3b97bd..35e0e67 100644 --- a/tools/blender/asset_report.json +++ b/tools/blender/asset_report.json @@ -59,6 +59,29 @@ "status": "PASS", "problems": [] }, + { + "name": "tree_jacaranda_01", + "dims": [ + 7.4789, + 6.5035, + 6.0535 + ], + "tris": 632, + "nodes": [ + "branch_anchor_01", + "branch_anchor_02", + "branch_anchor_03", + "canopy", + "canopy_01", + "canopy_02", + "canopy_03", + "canopy_04", + "tree_jacaranda_01", + "trunk" + ], + "status": "PASS", + "problems": [] + }, { "name": "fence_post", "dims": [ @@ -198,6 +221,42 @@ "status": "PASS", "problems": [] }, + { + "name": "swing_set_01", + "dims": [ + 2.3416, + 0.9469, + 2.0741 + ], + "tris": 220, + "nodes": [ + "crossbar", + "frame", + "frame_anchor_01", + "frame_anchor_02", + "swing_set_01", + "swings" + ], + "status": "PASS", + "problems": [] + }, + { + "name": "swing_set_01_wrecked", + "dims": [ + 2.3416, + 2.4842, + 0.9325 + ], + "tris": 220, + "nodes": [ + "crossbar", + "frame", + "swing_set_01_wrecked", + "swings" + ], + "status": "PASS", + "problems": [] + }, { "name": "shed_01", "dims": [ diff --git a/tools/blender/build_yard_assets.py b/tools/blender/build_yard_assets.py index 4f71e63..fd26e8e 100644 --- a/tools/blender/build_yard_assets.py +++ b/tools/blender/build_yard_assets.py @@ -121,6 +121,9 @@ PAL = { "bristle": "#C9A659", # broom straw "hail_ice": "#DCEAF2", # hailstone "window_warm": "#FFC98A", # someone is home + "bark_jac": "#A79C90", # jacaranda: thin grey bark, browner than a gum + "leaf_jac": "#8C7FC0", # in flower — lilac, and unmistakable at 30 m + "leaf_jac_2": "#6E5FA8", "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 @@ -328,6 +331,30 @@ def add_empty(name, location=(0, 0, 0), parent=None, size=0.15): return obj +def not_a_tie_off(empty, role, why): + """Mark an `*_anchor` empty that is NOT something you can strap a sail to. + + SPRINT14 palette audit. `world.js:adoptAnchor` reads `rating_hint` off the + node and falls back to **1** when it is absent — so any empty whose name + ends in `_anchor` and carries no hint is, the moment a site names it, the + BEST tie-off in the game: better than a gum fork (1.0 is the ceiling), a + perfect anchor conjured out of a missing field. Four of those were sitting + in the palette (`door_anchor`, `pickup_anchor`, `grip_anchor`, + `window_light_anchor`), every one of them a carry point or a light hint. + Silence read as "flawless steel"; that is the free-failure bug inverted, + and the editor is about to offer these nodes to an author by name. + + So say it in the data. `tie_off: False` is the explicit denial the missing + field only pretended to be; e.test.js pins that every `*_anchor` node in + every GLB carries either a rating_hint or this flag, so a new anchor cannot + arrive silent again. + """ + empty["tie_off"] = False + empty["anchor_role"] = role + empty["why"] = why + return empty + + def join_group(objs, name, parent=None): """Join a list of meshes into one named node. Groups are the sway/animation unit, so this is per-group, NOT per-asset like racks_to_glb.py — Lane A has @@ -593,6 +620,128 @@ def build_tree_gum_02(name): sway_amp=1.20) +def build_tree_jacaranda_01(name): + """The second species — and the point of it is the LADDER, not the leaves. + + SPRINT14 gate 3.1. Both existing trees are gums, and both carry the same + branch ladder: 1.0 / 0.88 / 0.76, twelve points a rung. That ladder is + forgiving by design (Sprint 7) — on a gum you can climb for height and pay + almost nothing for it, so "which tree, and how high" was never really a + question, only "is there a tree". + + A jacaranda answers it differently, and honestly. It forks low and heavy: + the union at 2.4 m is a genuine two-hands-around-it fork, as good as + anything in the yard (0.95). Above that it is a different tree. Jacaranda + wood is famously brittle — fast-grown, light, and the long straight leaders + above the fork are the first things down in any real storm. So the ladder + falls off a cliff instead of stepping down: + + gum 1.00 / 0.88 / 0.76 climb freely, pay 24% + jacaranda 0.95 / 0.52 / 0.40 climb at all, pay 58% + + THAT is the decision the palette was missing. A sail wants height on its + high corner (rain has to run off somewhere, DESIGN.md), and on a gum height + is nearly free. Put a jacaranda in the same spot and the author has bought + a real dilemma for the player: tie low into excellent steel and cut the + sail flat, or reach for the height and rig off a limb rated 0.40. + + It is not priced and has no wreck, deliberately — same ruling as the bike. + Nothing in the sim brings a limb down as an event the player can watch, and + billing collateral for a thing nobody sees break is the lie the invoice + exists to kill. When limb failure is a visible event, price it then. + + Shape follows the ladder rather than decorating it: low fork, three long + leaders, and a wide flat crown (7.2 m of spread on a 6.6 m tree — a + jacaranda is broader than it is tall, which is also why it makes such + good shade and such tempting bad anchors). + """ + rng = rng_for(name) + root = add_empty(name) + height, spread = 6.6, 7.2 + bark = get_material("Mat_BarkJac", PAL["bark_jac"], 0.85) + bark_d = get_material("Mat_BarkShadow", PAL["bark_shadow"], 0.9) + leaf_a = get_material("Mat_LeafJac", PAL["leaf_jac"], 0.8) + leaf_b = get_material("Mat_LeafJac2", PAL["leaf_jac_2"], 0.8) + + # The fork is LOW — 0.36 of the tree's height, where a gum's is 0.62. This + # single number is most of why the two species read differently at a + # glance, and all of why the good anchor is reachable off a ladder. + fork_h = height * 0.36 + r_base, r_fork = height * 0.046, height * 0.027 + lean = rng.uniform(-0.03, 0.03) + parts = [add_cone(f"{name}_trunk", r_base, r_fork, fork_h, + (lean * fork_h * 0.5, 0, fork_h / 2), bark, + verts=10, rot=(0, lean, 0))] + parts.append(add_cone(f"{name}_flare", r_base * 1.4, r_base, + height * 0.045, (0, 0, height * 0.0225), bark_d, + verts=10)) + + # Three leaders off the fork, sweeping out and up. These are the brittle + # part; the anchors on them are what the low fork is being compared to. + fork_pt = (lean * fork_h, 0, fork_h) + leader_tips = [] + for i in range(3): + ang = math.tau * i / 3 + rng.uniform(-0.25, 0.25) + reach = spread * rng.uniform(0.26, 0.34) + tip = (fork_pt[0] + math.cos(ang) * reach, + fork_pt[1] + math.sin(ang) * reach, + fork_h + height * rng.uniform(0.22, 0.34)) + parts.extend(_limb(f"{name}_leader_{i:02d}", fork_pt, tip, + r_fork * 0.72, r_fork * 0.30, bark, segs=4, + sweep=0.45)) + leader_tips.append(tip) + join_group(parts, "trunk", root) + + # Canopy: wide, flat, and low-domed. Blobs are squashed hard on Z (0.30–0.42 + # against a gum's 0.55–0.75) because that flat umbrella IS the silhouette. + top = (fork_pt[0], fork_pt[1], height * 0.68) + canopy_grp = add_empty("canopy", top, root, size=0.7) + canopy_grp["sway_amp"] = 1.05 + canopy_grp["sway_phase"] = round(rng_for(f"{name}:sway").uniform(0, math.tau), 3) + canopy_grp["sway_pivot_y"] = round(height * 0.68, 3) + for i in range(4): + ang = math.tau * i / 4 + rng.uniform(-0.25, 0.25) + off = spread * rng.uniform(0.16, 0.30) + cz = height * 0.68 + height * rng.uniform(0.02, 0.12) + r = spread * rng.uniform(0.22, 0.30) + blob = add_ico(f"canopy_{i + 1:02d}", r, + (top[0] + math.cos(ang) * off, top[1] + math.sin(ang) * off, cz), + leaf_a if i % 2 == 0 else leaf_b, subdiv=2, + scale=(1.0, 1.0, rng.uniform(0.30, 0.42)), + jitter=r * 0.10, rng=rng) + parent_keep_transform(blob, canopy_grp) + blob["sway_amp"] = round(0.6 + 0.4 * (cz / height), 3) + + # The ladder, as data. branch_anchor_01 is the FORK — the one piece of + # honest steel this tree has — and 02/03 are out on the leaders. + LADDER = [ + (fork_pt, 0.95, + "the main fork at 2.4 m: a two-hands-around-it union, and the best " + "thing this tree will ever offer you"), + (leader_tips[0], 0.52, + "a jacaranda leader — fast-grown, light, brittle; it is holding " + "itself up and not much else"), + (leader_tips[1], 0.40, + "further out on the same kind of limb; this is the rung that ends " + "the night"), + ] + for i, (pt, hint, why) in enumerate(LADDER): + e = add_empty(f"branch_anchor_{i + 1:02d}", pt, root, size=0.25) + e["anchor_type"] = "tree" + e["rating_hint"] = hint + e["why"] = why + + stamp(root, name, "tree") + root["canopy_count"] = 4 + root["species"] = "jacaranda" + root["branch_ladder"] = "0.95/0.52/0.40" + root["ladder_note"] = ("steeper than the gums' 1.0/0.88/0.76 on purpose — " + "on this tree, height costs you") + root["priced"] = False + root["unpriced_why"] = "no limb-failure event exists for the player to see" + return root + + def build_fence_post(name): root = add_empty(name) timber = get_material("Mat_Timber", PAL["timber"], 0.85) @@ -759,6 +908,9 @@ def _house_facade(name, root): e = add_empty("window_light_anchor", (win_x, -D / 2 - 0.35, win_z + win_h / 2), root, size=0.2) e["light_hint"] = "warm PointLight ~2700K, spills onto the grass under the eave" + not_a_tie_off(e, "where Lane A hangs the warm window light", + "a lighting hint at a window pane — the fascia anchors are " + "the house's tie-offs, and they rate 0.35 for a reason") # The roof and its eave. The eave overhangs 0.55 into the yard (-Y). eave_y = -0.55 @@ -1023,6 +1175,20 @@ def build_carport_01(name): # but nothing said what a carport COSTS, so Lane A's aftermath had no number # to reach for. main.js already reads world.gnome.collateralValue — same # shape, same place. + # + # SPRINT14 palette audit — `collateral_key` added, and the new audit assert + # is what found it. The price used to resolve only because site_02 happens + # to name its structure "carport", matching the string on the anchors: + # `collateralFor(key)` looks for a STRUCTURE whose site-JSON id === key. + # That held for exactly one yard. The moment the editor places a second one + # — and it will generate "carport_2" for uniqueness, because it must — the + # anchors still say collateral:"carport", no structure carries that id, and + # collateralFor returns null: the carport becomes a FREE failure, which is + # the gutter bug reborn in the sprint meant to bury it. The GLB now names + # which collateral string its price answers to, exactly as the house does + # for "gutter". Lane A: the runtime half is yours — collateralFor could + # fall back to `glb.userData.collateral_key` when no structure id matches. + root["collateral_key"] = "carport" root["collateral_value"] = CARPORT_COLLATERAL root["collateral_label"] = "the carport" return root @@ -1099,10 +1265,253 @@ def build_carport_01_wrecked(name): stamp(root, name, "structure") root["broken_variant_of"] = "carport_01" + root["collateral_key"] = "carport" # SPRINT14 audit — see the intact twin root["collateral_value"] = CARPORT_COLLATERAL return root +# --------------------------------------------------------------------------- +# THE SWING SET (SPRINT14 gate 3.1 — a temptation prop for D's palette) +# --------------------------------------------------------------------------- +# One shape, stated once, used by the intact set AND the wreck. The carport and +# the house both taught this: a wreck built from re-typed numbers drifts away +# from its twin one edit at a time, and the swap starts needing a fudge offset. +SWING = dict( + SPAN=2.30, # crossbar, apex to apex (a two-seat domestic A-frame) + SPLAY=0.45, # each leg foot this far fore/aft of the apex + H=2.05, # top rail height — a shade over head height, which is + # exactly why it reads as a tie-off + R_LEG=0.024, # 48 mm OD galvanised tube + R_RAIL=0.019, # 38 mm OD top rail + SEAT_Z=0.45, + SEAT_X=(-0.55, 0.55), +) + +# What it costs to bend the client's swing set. Proposal — Lane A owns the +# number, same as the carport's 180 and the gutter's 90: +# · the band is a ladder now, and this has to slot into it honestly: +# gnome 25 (ornament) < gutter 90 (a run of one trade's work) < SWING 140 +# < carport 180 (a structure with a roof on it); +# · what actually fails is the two apex junctions and the legs under them — +# bent tube, not matchwood. That's a frame replacement and a re-hang on a +# set that costs $250–350 new, so 140 is the repair, not the receipt; +# · under ~90 it's cheaper than the gutter, which would say a whole item of +# the kid's play equipment is worth less than a length of guttering — +# the client would not agree and neither would the bill; +# · over ~180 it outbids the carport, and a swing set is a toy: the carport +# must stay the worst thing on any yard's bill or the corner block's +# lesson gets quietly outranked by a prop. +# It is priced (unlike the bike) because the sim CAN destroy it: the frame +# anchors carry collateral="swing_set", so losing that corner bills it through +# exactly the chain the carport already proved, and the wreck is the thing the +# player sees. +SWING_COLLATERAL = 140 + + +def _swing_frame(name, root, steel, seat_m, chain_m, *, wrecked=False): + """Build the set. `wrecked=False` is the standing one; True tips it over. + + Returns the two apex points (Blender coords) so the caller hangs the + anchors on the exact points the geometry ended up at, rather than on a + second copy of the arithmetic. + + HOW IT FAILS, and why the wreck is a transform rather than a second model: + an A-frame swing set is not bolted to anything. The feet sit on the grass + (the ground pegs are in the shed; they always are). Load a corner of a + 25 m² sail onto it and nothing snaps — the frame walks, then it goes over + sideways in one piece. + + So the wreck is the same triangle, rotated about the foot line it tips + OVER (y = −SPLAY), by 100° — past horizontal. That angle is not a look, it + is the resting position: the frame comes to rest on that foot rail and on + the crossbar, which puts the far pair of legs in the air at ~0.89 m, + pointing up. That is what a fallen A-frame looks like in every yard I have + ever seen one in, and it is unmistakable at a glance from across a yard — + which is the job, because the player has to read "I did that" instantly. + + Tipping about the far foot instead (the obvious first try) drives the near + feet 0.88 m through the lawn — the same class of error as a wreck that + stands taller than its twin, and the reason both are asserted. + """ + S = SWING + half = S["SPAN"] / 2 + apex_l, apex_r = (-half, 0.0, S["H"]), (half, 0.0, S["H"]) + + if wrecked: + th = math.radians(100.0) + c, s = math.cos(th), math.sin(th) + y0 = -S["SPLAY"] # the foot line it goes over + # It rests ON its tubes, so lift by a leg radius: without this the + # capped ends of the now-near-horizontal legs sit ~24 mm under the + # grass, and "broken variants sit on the ground" is a real assert. + lift = S["R_LEG"] + 0.002 + + def R(p): + x, y, z = p + yr = y - y0 + return (x, yr * c - z * s + y0, yr * s + z * c + lift) + else: + def R(p): + return p + + legs, feet = [], [] + for sx in (-1, 1): + ax = sx * half + for sy in (-1, 1): + legs.append(add_tube_between( + f"{name}_leg_{sx}_{sy}", R((ax, sy * S["SPLAY"], 0.0)), + R((ax, 0.0, S["H"])), S["R_LEG"], steel, verts=6)) + # The foot rail — the bit that is supposed to be pegged down and is + # not. It survives the wreck: it is the part that dragged. + feet.append(add_tube_between( + f"{name}_footrail_{sx}", R((ax, -S["SPLAY"], 0.03)), + R((ax, S["SPLAY"], 0.03)), S["R_LEG"] * 0.8, steel, verts=6)) + join_group(legs + feet, "frame", root) + + # The crossbar gets its OWN node, and that is a gameplay decision, not a + # modelling one: it is the single most tempting-looking thing on the prop + # (a straight steel rail at 2.05 m, dead level, right where a sail corner + # wants to be) and it is not an anchor. Keeping it separate means the data + # can say so on the node itself, and means the wreck can lay it on the + # grass as one recognisable piece. + rail = join_group([add_tube_between( + f"{name}_rail", R(apex_l), R(apex_r), S["R_RAIL"], steel, verts=8)], + "crossbar", root) + not_a_tie_off( + rail, "the top rail — a swing hangs off it, a sail does not", + "38 mm tube spanning 2.3 m between two unpegged A-frames: it holds a " + "child in bending, and a sail corner pulls it sideways, which is the " + "one direction nothing here resists") + + swing_parts = [] + for i, sx in enumerate(S["SEAT_X"]): + if wrecked: + # Chains do not stay rigid when the frame they hang from is on the + # grass. The rail is down at (y≈−2.55, z≈0.09); the seats ended up + # just past it, flat, with the chains slack across the lawn. + hang = R((sx, 0.0, S["H"])) + seat_at = (sx, hang[1] - 0.28, 0.03) + for sy in (-1, 1): + swing_parts.append(add_tube_between( + f"{name}_chain_{i}_{sy}", (hang[0] + sy * 0.05, hang[1], hang[2]), + (seat_at[0] + sy * 0.07, seat_at[1], seat_at[2] + 0.02), + 0.006, chain_m, verts=4)) + swing_parts.append(add_box( + f"{name}_seat_{i}", (0.44, 0.16, 0.03), seat_at, seat_m, + rot=(0, 0, math.radians(7 * (1 if i else -1))))) + else: + for sy in (-1, 1): + swing_parts.append(add_tube_between( + f"{name}_chain_{i}_{sy}", (sx, 0.0, S["H"]), + (sx, sy * 0.09, S["SEAT_Z"]), 0.006, chain_m, verts=4)) + swing_parts.append(add_box( + f"{name}_seat_{i}", (0.44, 0.16, 0.03), (sx, 0.0, S["SEAT_Z"]), + seat_m)) + join_group(swing_parts, "swings", root) + return [R(apex_l), R(apex_r)] + + +def build_swing_set_01(name): + """A two-seat backyard swing set — the palette's honest middle option. + + SPRINT14 gate 3.1. D needs things worth placing, and "worth placing" means + the author has a real decision to make. The palette had a ceiling (a gum + fork, 1.0), a floor (the carport beam, 0.22, a pure trap) and almost + nothing in between — so every yard was either "there is good steel here" or + "there is a lie here". The swing set is the middle: it genuinely holds, and + it costs you if you lean on it. + + THE TEMPTATION IS THE CROSSBAR. A dead-level steel rail at 2.05 m, spanning + 2.3 m, at the exact height a sail corner wants — it is the most anchor- + looking object I have built. It is not an anchor, and the data says so + (`tie_off: False` on the `crossbar` node). What IS offered is the two apex + junctions, where four legs and the rail all meet a welded corner casting: + `frame_anchor_01/02`, rating_hint 0.45, typed `swing_frame`. + + WHY 0.45, and why its own enum type. The junction itself is sound steel — + better than the house fascia (0.35) and much better than the carport beam + (0.22). What it is NOT is anchored: the whole set stands on four feet on + grass, with the ground pegs still in the shed. So it holds a moderate pull + and then the SET moves, which is a completely different failure from a + post pulling out of concrete. That is also why it is not typed `post`: the + enum string is what the player reads before they commit (MANUAL, "the enum + gives it its pre-rig read"), and calling this a post would promise 4 m of + concreted steel. It is a swing frame. It says swing frame. + + Priced at SWING_COLLATERAL, with a wreck, because the sim can actually do + it: `collateral="swing_set"` on both anchors, the value keyed on the root, + the same chain the carport proved. (Compare the bike, which stays unpriced + because nothing can knock it over yet.) + """ + root = add_empty(name) + steel = get_material("Mat_Steel", PAL["steel_gal"], 0.4, metallic=0.85) + seat_m = get_material("Mat_SwingSeat", PAL["bike_kid"], 0.75) + chain_m = get_material("Mat_SteelDark", PAL["steel_dark"], 0.45, metallic=0.8) + + apexes = _swing_frame(name, root, steel, seat_m, chain_m, wrecked=False) + + for i, ap in enumerate(apexes): + e = add_empty(f"frame_anchor_{i + 1:02d}", ap, root, size=0.18) + e["anchor_type"] = "swing_frame" + e["rating_hint"] = 0.45 + e["collateral"] = "swing_set" + e["why"] = ("welded apex casting — sound steel on a frame that is " + "standing on grass, not pegged into it") + + stamp(root, name, "prop") + root["collateral_key"] = "swing_set" + root["collateral_value"] = SWING_COLLATERAL + root["collateral_label"] = "the swing set" + root["breakable"] = True + root["mass_hint"] = 38.0 + + # ** WHICH WAY IT FALLS — a placement fact, MEASURED, not reasoned. ** + # In Blender the wreck goes over toward −Y. You do not work in Blender: the + # exporter maps (x, y, z) → (x, z, −y), so in three.js it lands on +Z. I am + # only willing to write that down because I loaded the GLB in the browser + # and read the crossbar's world box: centre (0.00, 0.11, +2.55), footprint + # z = 0.45 … 2.93. My bike docstring lied about exactly this axis and only a + # browser-coords assert caught it, so these two extras are pinned by one in + # e.test.js as well — the claim and the geometry now go red together. + # + # Lane A / D: leave ~3 m clear on the prop's +Z side or the wreck lays + # itself through whatever is standing there. The intact footprint is only + # ~0.95 m deep, so the editor cannot infer this from the standing bounds. + root["wreck_falls_toward"] = "+Z" + root["wreck_clearance_m"] = 3.0 + return root + + +def build_swing_set_01_wrecked(name): + """The swing set after you tied a sail corner to it. + + Same origin, same parts, one number different (`wrecked=True` racks the + frame 62° about the ground line) — so intact and wrecked cannot drift, and + the swap is mesh-for-mesh like the carport's and the house's. + + It is racked over, not flattened: the rail is on the grass with both seats + still hanging off it and the feet have dragged. A swing set that came apart + into pieces would read as vandalism; one lying on its side with the swings + tangled reads as exactly what it is, which is a thing you pulled over. + """ + root = add_empty(name) + steel = get_material("Mat_Steel", PAL["steel_gal"], 0.4, metallic=0.85) + seat_m = get_material("Mat_SwingSeat", PAL["bike_kid"], 0.75) + chain_m = get_material("Mat_SteelDark", PAL["steel_dark"], 0.45, metallic=0.8) + + _swing_frame(name, root, steel, seat_m, chain_m, wrecked=True) + + # No frame_anchor_* survives, for the same reason no fascia_anchor survives + # the torn eave: you cannot re-tie to a frame lying on the grass, and an + # anchor that outlives its structure is the free-failure bug in a costume. + stamp(root, name, "prop") + root["broken_variant_of"] = "swing_set_01" + root["collateral_key"] = "swing_set" + root["collateral_value"] = SWING_COLLATERAL + root["collateral_label"] = "the swing set" + return root + + def build_shed_01(name): """Colorbond garden shed, skillion roof. Spare hardware lives in here.""" root = add_empty(name) @@ -1134,7 +1543,10 @@ def build_shed_01(name): add_box(f"{name}_door_r", (W / 2 - 0.06, 0.02, H - fall - 0.16), (W / 4, -D / 2 - 0.03, 0.08 + (H - fall - 0.16) / 2), dark), ], "doors", root) - add_empty("door_anchor", (0, -D / 2 - 0.6, 0.9), root, size=0.2) + not_a_tie_off(add_empty("door_anchor", (0, -D / 2 - 0.6, 0.9), root, size=0.2), + "stand point in front of the shed doors", + "a Colorbond door skin on a sheet-metal shed; there is no " + "steel here to strap to, only 0.5 mm of cladding") stamp(root, name, "structure") return root @@ -1157,7 +1569,9 @@ def build_shed_table(name): legs.append(add_box(f"{name}_shelf", (W - 0.16, D - 0.12, 0.03), (0, 0, 0.22), timber)) join_group(legs, "table_frame", root) - add_empty("pickup_anchor", (0, 0, H + 0.05), root, size=0.2) + not_a_tie_off(add_empty("pickup_anchor", (0, 0, H + 0.05), root, size=0.2), + "where spare hardware sits and where the hold-E prompt lands", + "a bench top, not a bollard — the table would come with you") stamp(root, name, "prop") return root @@ -1416,6 +1830,16 @@ def build_tramp_01(name): join_group(legs, "legs", root) stamp(root, name, "debris") root["mass_hint"] = 45.0 + # SPRINT14 audit — the trampoline is UNPRICED, and that is a statement, not + # an omission. It carries no anchor node (nothing here is a tie-off: a rim + # on six unpegged legs is the least trustworthy steel in any yard), and + # nothing in the runtime spawns it yet, so no player-visible event can + # destroy it. Same ruling as the bike: billing collateral for a thing the + # player never sees break is the lie the invoice exists to kill. If Lane C + # ever throws one, price it THEN — the number is easy, the event is the + # hard part. + root["priced"] = False + root["unpriced_why"] = "no anchor, and nothing in the sim can wreck it yet" return root @@ -1784,6 +2208,9 @@ def build_broom_01(name): g = add_empty("grip_anchor", (0, 0, 0.95), root, size=0.12) g["carry_type"] = "broom" + not_a_tie_off(g, "where the player's hand takes the broom", + "a carry point on a 1.2 kg tool; it is the thing that blows " + "away, not the thing that holds") p = add_empty("poke_tip", (0, 0, 0.02), root, size=0.12) p["use"] = "push the pond up from under the sail; soft end, won't hole the cloth" stamp(root, name, "tool") @@ -2407,6 +2834,13 @@ ASSETS = [ dims=((2.0, 5.5), (2.0, 5.5), (5.0, 6.5)), nodes=["trunk", "canopy", "canopy_01", "canopy_02", "branch_anchor_01", "branch_anchor_02"]), + # The second species (SPRINT14). Broader than it is tall — if x/y ever + # measure under the height, someone has quietly turned it back into a gum. + dict(name="tree_jacaranda_01", fn=build_tree_jacaranda_01, + dims=((7.0, 8.2), (6.1, 7.2), (5.7, 6.5)), + nodes=["trunk", "canopy", "canopy_01", "canopy_02", "canopy_03", + "canopy_04", "branch_anchor_01", "branch_anchor_02", + "branch_anchor_03"]), dict(name="fence_post", fn=build_fence_post, dims=((0.10, 0.16), (0.10, 0.16), (1.95, 2.10)), nodes=["post"]), @@ -2441,6 +2875,18 @@ ASSETS = [ dict(name="carport_01_wrecked", fn=build_carport_01_wrecked, dims=((4.5, 6.6), (5.2, 5.8), (1.9, 2.45)), nodes=["footings", "posts", "beams", "roof_down"]), + # The temptation prop (SPRINT14). Spans the crossbar on x, splays on y, + # and stands a shade over head height — the three numbers that make the + # rail look like an anchor. + dict(name="swing_set_01", fn=build_swing_set_01, + dims=((2.30, 2.45), (0.85, 1.05), (2.00, 2.15)), + nodes=["frame", "crossbar", "swings", + "frame_anchor_01", "frame_anchor_02"]), + # Over on its side: it reaches FURTHER on y than it ever stood on z. A + # wreck that still measures ~2.05 tall is a wreck that never fell. + dict(name="swing_set_01_wrecked", fn=build_swing_set_01_wrecked, + dims=((2.30, 2.45), (2.30, 3.20), (0.80, 1.05)), + nodes=["frame", "crossbar", "swings"]), 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 fa6ef91..3f17747 100644 Binary files a/tools/blender/contact_sheet.png and b/tools/blender/contact_sheet.png differ diff --git a/web/world/js/contracts.js b/web/world/js/contracts.js index bd2db25..b8e4485 100644 --- a/web/world/js/contracts.js +++ b/web/world/js/contracts.js @@ -74,9 +74,16 @@ export const PHASES = ['forecast', 'prep', 'storm', 'aftermath']; * (main.js: `type === 'tree'`). Ladder work is `work`; hardware rating is E's * `rating_hint`. Keep it that way — a rule keyed on a type a future site * doesn't fit is the exact shape of the bug this widening fixed. + * + * `swing_frame` (SPRINT14, E): a swing set's apex junction. It is NOT a `post` + * and the difference is the whole reason it got its own word — the type string + * is what the player reads before they commit, and "post" promises 4 m of + * 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. */ export const ANCHOR_TYPE = Object.freeze([ - 'house', 'tree', 'post', 'carport', 'carport_post', + 'house', 'tree', 'post', 'carport', 'carport_post', 'swing_frame', ]); // --------------------------------------------------------------------------- diff --git a/web/world/js/tests/e.test.js b/web/world/js/tests/e.test.js index c2b1692..4e801cd 100644 --- a/web/world/js/tests/e.test.js +++ b/web/world/js/tests/e.test.js @@ -20,6 +20,7 @@ import * as THREE from '../../vendor/three.module.js'; import { assert } from '../testkit.js'; +import { ANCHOR_TYPE } from '../contracts.js'; // GLTFLoader is imported DYNAMICALLY, below, and that is deliberate. // @@ -90,6 +91,13 @@ const ASSETS = [ nodes: ['footings', 'posts', 'beams', 'roof_down'] }, { name: 'bike_kid_01', h: [0.60, 0.84], nodes: ['wheel_rear', 'wheel_front', 'frame', 'bars'] }, + { name: 'tree_jacaranda_01', h: [5.7, 6.5], + nodes: ['trunk', 'canopy', 'canopy_01', 'canopy_02', 'canopy_03', 'canopy_04', + 'branch_anchor_01', 'branch_anchor_02', 'branch_anchor_03'] }, + { name: 'swing_set_01', h: [2.00, 2.15], + nodes: ['frame', 'crossbar', 'swings', 'frame_anchor_01', 'frame_anchor_02'] }, + { name: 'swing_set_01_wrecked', h: [0.80, 1.05], + nodes: ['frame', 'crossbar', 'swings'] }, ]; function sizeOf(gltf) { @@ -491,6 +499,7 @@ export default async function run(t) { t.test('broken variants sit on the same ground plane as their intact twin', () => { 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']]) { for (const n of [intact, broken]) { const box = new THREE.Box3().setFromObject(loaded.get(n).scene); @@ -522,6 +531,265 @@ export default async function run(t) { 'window_glow must carry hidden_by_default or the house is lit at noon'); }); + // ------------------------------------------------------------------------- + // SPRINT14 gate 3.1 — THE PALETTE AUDIT, as three rules that can go red. + // + // The editor is about to offer these GLBs to an author by name, which turns + // every silent field in them into a trap for whoever places one. These three + // tests are the audit made permanent: run them, not a spreadsheet. + // ------------------------------------------------------------------------- + + /** Every node in every loaded GLB, as [assetName, node] pairs. */ + const allNodes = () => { + const out = []; + for (const [name, gltf] of loaded) { + gltf.scene.traverse((o) => out.push([name, o])); + } + return out; + }; + + // RULE 1 — no silent anchors. `world.js:adoptAnchor` does + // `anchor.ratingHint = node.userData?.rating_hint ?? 1`, so a node named + // `*_anchor` with no hint is not "unrated", it is RATED PERFECT: better than + // a gum fork, conjured out of a missing field. Four were sitting in the + // palette before this sprint (door_anchor, pickup_anchor, grip_anchor, + // window_light_anchor) — all of them carry points or lighting hints, none of + // them steel. They now deny it explicitly (`tie_off: false`). + // + // The failure this prevents is the nastiest kind: a site author picks + // `door_anchor` off the editor's node list, the yard boots, the sail holds, + // and a shed door quietly outperforms a concreted post for the rest of the + // game. Nothing crashes. Nothing looks wrong. The site is just a lie. + t.test('no silent anchors: every *_anchor node rates itself or denies being one', () => { + const silent = []; + for (const [asset, o] of allNodes()) { + if (!/_anchor(_\d+)?$/.test(o.name)) continue; + const u = o.userData ?? {}; + const rated = typeof u.rating_hint === 'number'; + const denied = u.tie_off === false; + if (!rated && !denied) silent.push(`${asset}/${o.name}`); + assert(!(rated && denied), + `${asset}/${o.name} both rates itself and denies being a tie-off — pick one`); + } + assert(silent.length === 0, + `these nodes adopt at rating_hint 1 (the best steel in the game) purely by ` + + `saying nothing: ${silent.join(', ')}. Give them a rating_hint, or ` + + `not_a_tie_off() them in build_yard_assets.py`); + }); + + // RULE 2 — no unpriced collateral. The gutter was a FREE FAILURE for two + // sprints: the fascia anchors said collateral:"gutter" from Sprint 6 and + // nothing anywhere priced one, so collateralFor('gutter') returned null and + // ripping the eave off the client's house cost the player a shackle. Fixing + // that one string is not the fix; the fix is that a new asset cannot repeat + // it. Every collateral string an anchor names must resolve to a number + // somewhere in the palette, by construction, before it can ship. + t.test('no unpriced collateral: every collateral string an anchor names has a price', () => { + const priced = new Map(); + for (const [, o] of allNodes()) { + const u = o.userData ?? {}; + if (typeof u.collateral_value !== 'number') continue; + priced.set(u.collateral_key ?? u.shades_asset ?? o.name, u.collateral_value); + } + const orphans = []; + for (const [asset, o] of allNodes()) { + const key = o.userData?.collateral; + if (typeof key !== 'string') continue; + if (typeof priced.get(key) !== 'number') orphans.push(`${asset}/${o.name} → "${key}"`); + } + assert(orphans.length === 0, + `anchors name collateral nothing prices, so breaking these is FREE: ` + + `${orphans.join(', ')}. Priced keys are [${[...priced.keys()].join(', ')}]`); + }); + + // RULE 3 — the baked type is the checked type. `anchor_type` in a GLB and + // ANCHOR_TYPE in contracts.js have to be the same vocabulary or the site's + // enum check is validating a different thing than the asset says. The + // carport needed the list widened (SPRINT11); the swing frame needed it + // widened again this sprint. Widening it is the intended move — inventing a + // word only the GLB knows is not. + t.test('baked anchor_type strings are all in the checked ANCHOR_TYPE enum', () => { + for (const [asset, o] of allNodes()) { + const ty = o.userData?.anchor_type; + if (ty === undefined) continue; + assert(ANCHOR_TYPE.includes(ty), + `${asset}/${o.name} is typed "${ty}", which no site may declare — ` + + `add it to ANCHOR_TYPE in contracts.js or use one of [${ANCHOR_TYPE.join(', ')}]`); + } + }); + + // --- the swing set: the temptation is the crossbar ------------------------ + // The prop exists to put an honest middle rung in a palette that only had a + // ceiling (gum fork 1.0) and a trap (carport beam 0.22). Its whole design + // rests on the rail NOT being an anchor while looking exactly like one, so + // that is the thing pinned hardest. + t.test('the swing set offers two anchors and a crossbar that is not one', () => { + const g = loaded.get('swing_set_01'); + assert(g, 'swing_set_01 did not load'); + + const rail = g.scene.getObjectByName('crossbar'); + assert(rail, 'crossbar node missing — it must stay its own node so the data can refuse it'); + assert(rail.userData?.tie_off === false, + 'the crossbar must carry tie_off:false — it is the most anchor-looking object in the palette'); + assert(rail.userData?.rating_hint === undefined, + 'the crossbar carries a rating_hint, which makes it adoptable — that is the whole thing this prop is about'); + + // Exactly two, and no third one hiding on the rail or the seats. + const anchors = []; + g.scene.traverse((o) => { if (typeof o.userData?.rating_hint === 'number') anchors.push(o); }); + assert(anchors.length === 2, + `swing_set_01 offers ${anchors.length} rated anchors (${anchors.map((a) => a.name).join(', ')}) — want exactly the two frame apexes`); + + const CARPORT_BEAM = 0.22, FASCIA = 0.35, GUM_FORK = 1.0; + for (const n of ['frame_anchor_01', 'frame_anchor_02']) { + const o = g.scene.getObjectByName(n); + assert(o, `${n} missing`); + assert(o.userData?.anchor_type === 'swing_frame', + `${n} is typed "${o.userData?.anchor_type}" — a swing frame is not a post, and the type string is what the player reads before committing`); + const r = o.userData?.rating_hint; + assert(r > FASCIA && r < GUM_FORK, + `${n} rates ${r} — the frame junction is sound steel on an unpegged frame: better than the fascia (${FASCIA}), nowhere near a fork (${GUM_FORK})`); + assert(r > CARPORT_BEAM, + `${n} rates ${r} — if it is worse than the carport beam it is a second trap, not the palette's honest middle`); + assert(o.userData?.collateral === 'swing_set', + `${n} must name what it takes with it (collateral="swing_set")`); + } + + // Priced, in band, and the wreck agrees — the carport/gutter chain again. + const root = g.scene.getObjectByName('swing_set_01'); + const cost = root?.userData?.collateral_value; + const carport = loaded.get('carport_01')?.scene.getObjectByName('carport_01')?.userData?.collateral_value; + const gutter = loaded.get('house_yardside')?.scene.getObjectByName('house_yardside')?.userData?.collateral_value; + assert(typeof cost === 'number', 'swing_set_01 carries no collateral_value'); + assert(root.userData?.collateral_key === 'swing_set', + 'collateral_key must name the string the anchors carry, or the price prices nothing'); + assert(cost > gutter && cost < carport, + `the swing set (${cost}) must sit between the gutter (${gutter}) and the carport (${carport}) — ` + + 'a toy costs more than a run of guttering and less than a structure with a roof'); + + const w = loaded.get('swing_set_01_wrecked')?.scene.getObjectByName('swing_set_01_wrecked'); + assert(w?.userData?.collateral_value === cost, + 'the wrecked set must be priced the same as the one it used to be'); + assert(w?.userData?.broken_variant_of === 'swing_set_01', + 'the wreck must name its intact twin so A can pair the swap'); + }); + + // Same rule as the torn fascia: you cannot re-tie to a frame lying on the + // grass. An anchor that survives its structure is the free-failure bug in a + // costume — world.anchors would keep offering the tie-off after the swap. + t.test('the wrecked swing set is over, and offers nothing to tie to', () => { + const w = loaded.get('swing_set_01_wrecked'); + assert(w, 'swing_set_01_wrecked did not load'); + for (const n of ['frame_anchor_01', 'frame_anchor_02']) { + assert(!w.scene.getObjectByName(n), `${n} survives the wreck — the frame it was welded to is on the ground`); + } + + // It went OVER, not down: measured, because "wrecked" has to mean a + // specific pose or the next edit quietly turns it into rubble. The rail + // used to be the highest thing on the prop; now it is on the grass, and + // the far legs are the highest thing instead. + const intact = loaded.get('swing_set_01'); + const railUp = new THREE.Box3().setFromObject(intact.scene.getObjectByName('crossbar')); + assert(railUp.max.y > 2.0, + `the intact rail tops out at y=${railUp.max.y.toFixed(2)} — the control is broken, nothing below can mean anything`); + const railDown = new THREE.Box3().setFromObject(w.scene.getObjectByName('crossbar')); + assert(railDown.max.y < 0.25, + `the wrecked rail tops out at y=${railDown.max.y.toFixed(2)} — it is meant to be lying on the grass`); + + // And it reaches further along the ground than it ever stood tall. + const box = new THREE.Box3().setFromObject(w.scene); + const size = box.getSize(new THREE.Vector3()); + assert(size.z > size.y * 2, + `the wreck is ${size.z.toFixed(2)} m deep and ${size.y.toFixed(2)} m tall — a set that fell over lies down`); + const hi = new THREE.Box3().setFromObject(intact.scene); + assert(size.y < hi.getSize(new THREE.Vector3()).y * 0.6, + 'the wreck stands too close to full height — it did not fall, it sagged'); + }); + + // THE AXIS TRAP, third time paid for. The wreck goes over toward Blender −Y, + // which arrives here as +Z, and the placement note baked in the GLB says so + // in words. Words cannot fail; this can. It pins the CLAIM against the + // GEOMETRY, so the extras and the mesh have to lie in the same direction at + // the same time or the suite says which one moved. + // + // This matters at placement, not just in a docstring: standing, the set is + // 0.95 m deep; fallen, it reaches 2.9 m to +Z. Nothing about the intact + // bounds tells the editor that, so a swing set backed onto a fence looks + // fine until the night it lays itself through the palings. + t.test('the swing set falls toward +Z, and the GLB says so in the same direction', () => { + const intact = loaded.get('swing_set_01'); + const root = intact.scene.getObjectByName('swing_set_01'); + const claim = root?.userData?.wreck_falls_toward; + assert(claim === '+Z', `the GLB claims it falls toward ${JSON.stringify(claim)} — this assert only knows how to check +Z, so if the geometry really changed, change both`); + + const rail = new THREE.Box3() + .setFromObject(loaded.get('swing_set_01_wrecked').scene.getObjectByName('crossbar')) + .getCenter(new THREE.Vector3()); + assert(rail.z > 2.0, + `the wrecked rail centres at z=${rail.z.toFixed(2)} — the claim says +Z and the mesh disagrees`); + assert(Math.abs(rail.x) < 0.1, + `the wrecked rail centres at x=${rail.x.toFixed(2)} — it should go over sideways, not slide along its own span`); + + // And the clearance number is the reach, not a guess someone typed. + const box = new THREE.Box3().setFromObject(loaded.get('swing_set_01_wrecked').scene); + const clear = root?.userData?.wreck_clearance_m; + assert(typeof clear === 'number' && box.max.z <= clear, + `the wreck reaches z=${box.max.z.toFixed(2)} but the GLB tells the editor to leave ${clear} m — the advice must cover the wreckage`); + }); + + // --- the second species: the ladder IS the feature ------------------------ + // Both gums carry 1.0/0.88/0.76 — forgiving by design, so height up a gum is + // nearly free and "which tree" was never a real question. The jacaranda's + // ladder collapses instead of stepping down, which is what makes tree choice + // a decision. That claim is a NUMBER, so it gets an assert rather than a + // docstring: flatten the ladder and this goes red. + t.test('the jacaranda ladder falls away far harder than the gum ladder', () => { + const rungs = (asset) => { + const g = loaded.get(asset); + assert(g, `${asset} did not load`); + return ['branch_anchor_01', 'branch_anchor_02', 'branch_anchor_03'] + .map((n) => g.scene.getObjectByName(n)?.userData?.rating_hint) + .filter((v) => typeof v === 'number'); + }; + const gum = rungs('tree_gum_01'), jac = rungs('tree_jacaranda_01'); + assert(gum.length === 3 && jac.length === 3, + `need three rungs each; got gum=${gum.length}, jac=${jac.length}`); + + // Rung 1: the jacaranda's low fork is genuinely good steel — that is the + // trade, not a consolation. It must stay close to the gum's. + assert(jac[0] > 0.9, `jacaranda fork rates ${jac[0]} — the low fork is meant to be the best thing on the tree`); + assert(jac[0] <= gum[0], `jacaranda fork (${jac[0]}) must not out-rate a gum fork (${gum[0]})`); + + // ...and then it must actually fall away. Twice the gum's drop is the + // design claim, stated as the threshold it has to clear. + const dGum = gum[0] - gum[2], dJac = jac[0] - jac[2]; + assert(dJac > dGum * 2, + `the jacaranda drops ${dJac.toFixed(2)} across its ladder and the gum drops ${dGum.toFixed(2)} — ` + + 'if height costs the same on both species, the second tree is just a repaint'); + assert(jac[2] < 0.5, + `the jacaranda's top rung rates ${jac[2]} — the whole point is that the high anchor is a gamble`); + + // Unpriced BY RULING, same as the bike: no limb-failure event exists for + // a player to watch, and billing an unseen event is the lie the invoice + // exists to kill. Price it when the limb can come down. + const root = loaded.get('tree_jacaranda_01').scene.getObjectByName('tree_jacaranda_01'); + assert(root?.userData?.collateral_value === undefined, + 'the jacaranda is unpriced BY RULING — build the limb failure first'); + }); + + // The silhouette carries the species read at 30 m, which is what an author + // in the editor actually picks on. A jacaranda is broader than it is tall; + // a gum is the other way round. If this flips, the palette has two trees + // that look the same and one of them is lying about its ladder. + t.test('the jacaranda reads as a different tree: broader than tall, unlike the gums', () => { + const size = (n) => new THREE.Box3().setFromObject(loaded.get(n).scene).getSize(new THREE.Vector3()); + const j = size('tree_jacaranda_01'), g = size('tree_gum_01'); + assert(Math.max(j.x, j.z) > j.y, + `jacaranda is ${Math.max(j.x, j.z).toFixed(2)} m across and ${j.y.toFixed(2)} m tall — it should be broader than tall`); + assert(g.y > Math.max(g.x, g.z), + `gum is ${g.y.toFixed(2)} m tall and ${Math.max(g.x, g.z).toFixed(2)} m across — the control is broken`); + }); + // 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/broom_01_v1.glb b/web/world/models/broom_01_v1.glb index 2b3b9a7..75259e1 100644 Binary files a/web/world/models/broom_01_v1.glb and b/web/world/models/broom_01_v1.glb differ diff --git a/web/world/models/carport_01_v1.glb b/web/world/models/carport_01_v1.glb index ea8fe43..bcec19e 100644 Binary files a/web/world/models/carport_01_v1.glb and b/web/world/models/carport_01_v1.glb differ diff --git a/web/world/models/carport_01_wrecked_v1.glb b/web/world/models/carport_01_wrecked_v1.glb index 653fc62..4b8d5d3 100644 Binary files a/web/world/models/carport_01_wrecked_v1.glb and b/web/world/models/carport_01_wrecked_v1.glb differ diff --git a/web/world/models/debris/tramp_01_v1.glb b/web/world/models/debris/tramp_01_v1.glb index 1794d76..b979151 100644 Binary files a/web/world/models/debris/tramp_01_v1.glb and b/web/world/models/debris/tramp_01_v1.glb differ diff --git a/web/world/models/house_yardside_v1.glb b/web/world/models/house_yardside_v1.glb index ffe2101..6daf5bf 100644 Binary files a/web/world/models/house_yardside_v1.glb and b/web/world/models/house_yardside_v1.glb differ diff --git a/web/world/models/house_yardside_wrecked_v1.glb b/web/world/models/house_yardside_wrecked_v1.glb index 748a024..49ea885 100644 Binary files a/web/world/models/house_yardside_wrecked_v1.glb and b/web/world/models/house_yardside_wrecked_v1.glb differ diff --git a/web/world/models/shed_01_v1.glb b/web/world/models/shed_01_v1.glb index 8cceea5..f067333 100644 Binary files a/web/world/models/shed_01_v1.glb and b/web/world/models/shed_01_v1.glb differ diff --git a/web/world/models/shed_table_v1.glb b/web/world/models/shed_table_v1.glb index 1c6f619..75064dd 100644 Binary files a/web/world/models/shed_table_v1.glb and b/web/world/models/shed_table_v1.glb differ diff --git a/web/world/models/swing_set_01_v1.glb b/web/world/models/swing_set_01_v1.glb new file mode 100644 index 0000000..aa716d7 Binary files /dev/null and b/web/world/models/swing_set_01_v1.glb differ diff --git a/web/world/models/swing_set_01_wrecked_v1.glb b/web/world/models/swing_set_01_wrecked_v1.glb new file mode 100644 index 0000000..2fdcb6a Binary files /dev/null and b/web/world/models/swing_set_01_wrecked_v1.glb differ diff --git a/web/world/models/tree_jacaranda_01_v1.glb b/web/world/models/tree_jacaranda_01_v1.glb new file mode 100644 index 0000000..54c196d Binary files /dev/null and b/web/world/models/tree_jacaranda_01_v1.glb differ