diff --git a/MEMORY.md b/MEMORY.md index 2eedc78..5a5beb1 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -4,6 +4,14 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o ## Decisions +- **Collider coverage gate (2026-11-03):** `WorldBuilder` sets `library_prop` metadata on every + node in `library_nodes`, and `NON_BLOCKING_LIBRARY` (`marquee`, `wall_lantern`, `handrail`, + `double_door`) documents the mounted/decorative pieces that intentionally publish no + walk-blocking body. `test_world.gd` verifies placed props have `Collision` + `Shape` + + `Avoidance` in all three venues, and that library dressing has a body unless exempt. Both + were mutation-checked (removing `_add_prop_collision` fails 3 tests; dropping `handrail` from + the exemption list fails with the node name). Suite is 275/275. Add the metadata when adding a + new library placement, or the coverage test will report it. - **3D review gates (2026-11-03):** `tools/test_blender_assets.py` now gates texel density (all five character slots present, 40–400 px/m authored, ≥250 px/m effective fabric), and `tools/test_model_review.py` gates scorecard coverage (every `CHARACTERS`/`GEAR`/`PROPS` entry diff --git a/TODO.md b/TODO.md index 65d058c..a1cdf2b 100644 --- a/TODO.md +++ b/TODO.md @@ -122,8 +122,14 @@ except the rebuilt assets themselves. and the 40–400 px/m authored band plus the 250 px/m fabric floor; the pose→bone contract is covered by `test_pose_reaches_the_skeleton_bones`; LOD/impostor already has `test_avatar_mesh_budget_and_lod_swap`. Collider coverage is still open — split below.) -- [ ] Add a collider/proxy coverage check: every library prop that blocks walking has a +- [x] Add a collider/proxy check: every library prop that blocks walking has a `StaticBody3D` + `NavigationObstacle3D`, checked against the catalogue. + (`world_builder.gd` tags every library node with a `library_prop` meta and declares the + non-blocking `marquee`/`handrail`/`lantern`/`door` set; two tests in `test_world.gd` + verify placed props (body + shape + avoidance radius, all three venues) and library + dressing (body unless documented exempt). Mutation-checked: removing `_add_prop_collision` + fails 3 tests, dropping `handrail` from the exemption list fails with the exact node. + Suite is 275/275.) - [x] Add a coverage test so every catalogue asset has a scorecard row and verdict. (`tools/test_model_review.py`: every `CHARACTERS`/`GEAR`/`PROPS` entry needs a row in `docs/3d-model-review.md`, and character rows need a non-empty verdict.) diff --git a/src/game/world_builder.gd b/src/game/world_builder.gd index e5fd316..6f68590 100644 --- a/src/game/world_builder.gd +++ b/src/game/world_builder.gd @@ -41,6 +41,11 @@ const LIBRARY_PROPS := ["bollard", "bench", "trash_bin", "planter_box", "newspaper_box", "fire_hydrant", "street_lamp", "street_tree", "traffic_cone", "palm_tree", "round_planter", "a_frame_sign", "crowd_barrier", "stanchion", "security_podium"] +## Library pieces that are mounted above head height, part of the building shell or +## purely decorative, so they intentionally publish no walk-blocking body. Anything +## else in `library_nodes` must carry a `StaticBody3D` + `NavigationObstacle3D` +## (guarded by `test_library_dressing_collision_matches_the_documented_exemptions`). +const NON_BLOCKING_LIBRARY := ["marquee", "wall_lantern", "handrail", "double_door"] var root: Node3D = null ## Door lock indicator mesh near the handle; toggled by GameManager. @@ -336,6 +341,7 @@ func _build_building() -> void: door_model.position = Vector3(0, 0.0, front + 0.16) building.add_child(door_model) library_nodes.append(door_model) + door_model.set_meta("library_prop", "double_door") _apply_library_ao(door_model, "double_door") for material in _emissive_materials(door_model): if not _lamp_bulb_mats.has(material): @@ -1674,6 +1680,7 @@ func _build_marquee() -> void: library.position = Vector3(0, 5.62, -3.9) root.add_child(library) library_nodes.append(library) + library.set_meta("library_prop", "marquee") _apply_library_ao(library, "marquee") for material in _emissive_materials(library): if not _marquee_emissive.has(material): @@ -1714,6 +1721,7 @@ func _build_library_dressing() -> void: rail.rotation_degrees.y = 90.0 root.add_child(rail) library_nodes.append(rail) + rail.set_meta("library_prop", "handrail") _apply_library_ao(rail, "handrail") if _feature("sedan") and ResourceLoader.exists(BLENDER_PROP_DIR + "sedan.glb"): var sedan_pos: Vector3 = spec.get("sedan_position", Vector3(6.0, 0.1, 17.0)) @@ -1724,6 +1732,7 @@ func _build_library_dressing() -> void: _apply_car_paint(sedan) if not library_nodes.has(sedan): library_nodes.append(sedan) + sedan.set_meta("library_prop", "sedan") for material in _emissive_materials(sedan): if not _lamp_bulb_mats.has(material): _lamp_bulb_mats.append(material) @@ -1795,6 +1804,7 @@ func _add_wall_lantern(pos: Vector3, with_light: bool) -> void: lantern.position = pos root.add_child(lantern) library_nodes.append(lantern) + lantern.set_meta("library_prop", "wall_lantern") _apply_library_ao(lantern, "wall_lantern") for material in _emissive_materials(lantern): if not _lamp_bulb_mats.has(material): @@ -2095,6 +2105,7 @@ func _add_prop_model(parent: Node3D, prop: String, pos: Vector3, if ResourceLoader.exists(BLENDER_PROP_DIR + prop + ".glb"): library_props.append(prop) library_nodes.append(holder) + holder.set_meta("library_prop", prop) _apply_library_ao(model, prop) else: var fallback := _fallback_prop(prop) diff --git a/src/test/tests/test_world.gd b/src/test/tests/test_world.gd index f01afec..55ff5f0 100644 --- a/src/test/tests/test_world.gd +++ b/src/test/tests/test_world.gd @@ -296,3 +296,61 @@ func test_door_indicator_built() -> bool: return a and b and c root.free() return a + +## 3D review gate: a placed prop that people can walk into must publish both a +## static body and a navigation-avoidance footprint. A prop whose bounds are +## empty (broken GLB/fallback) used to lose its collider silently. +func test_every_placed_prop_has_a_collider_and_avoidance() -> bool: + var problems: Array[String] = [] + for venue in ["hollywood_building", "joke_shop", "tv_studio"]: + var root := Node3D.new() + var builder := WorldBuilder.new(root, venue) + builder.build() + var holders := root.find_children("Prop_*", "Node3D", true, false) + if holders.size() < 8: + problems.append("%s places only %d props" % [venue, holders.size()]) + for holder in holders: + var body := holder.get_node_or_null("Collision") as StaticBody3D + var shape := body.get_node_or_null("Shape") as CollisionShape3D if body != null else null + var obstacle := holder.get_node_or_null("Avoidance") as NavigationObstacle3D + if body == null or shape == null or shape.shape == null: + problems.append("%s/%s has no collision shape" % [venue, holder.name]) + if obstacle == null or obstacle.radius <= 0.0: + problems.append("%s/%s has no avoidance radius" % [venue, holder.name]) + root.free() + return TestUtils.check(problems.is_empty(), + "placed props are covered: %s" % ", ".join(problems.slice(0, 6))) + +## Library dressing must either publish a walk-blocking body or be on the +## documented mounted/decorative list. The check also proves the metadata that +## ties a node to its catalogue asset is present. +func test_library_dressing_collision_matches_the_documented_exemptions() -> bool: + var problems: Array[String] = [] + for venue in ["hollywood_building", "joke_shop", "tv_studio"]: + var root := Node3D.new() + var builder := WorldBuilder.new(root, venue) + builder.build() + if builder.library_nodes.is_empty(): + problems.append("%s placed no library dressing" % venue) + for node in builder.library_nodes: + if not is_instance_valid(node): + continue + var prop := str(node.get_meta("library_prop", "")) + if prop == "": + problems.append("%s/%s has no library_prop metadata" % [venue, node.name]) + continue + if WorldBuilder.NON_BLOCKING_LIBRARY.has(prop): + continue + if _walk_blocking_body(node) == null: + problems.append("%s/%s (%s) blocks walking without a body" % [venue, node.name, prop]) + root.free() + return TestUtils.check(problems.is_empty(), + "library dressing is covered: %s" % ", ".join(problems.slice(0, 6))) + +func _walk_blocking_body(node: Node) -> StaticBody3D: + for child in node.find_children("*", "StaticBody3D", true, false): + var body := child as StaticBody3D + for shape in body.find_children("*", "CollisionShape3D", true, false): + if (shape as CollisionShape3D).shape != null: + return body + return null