Files
security-game/src/test/tests/test_generated_props.gd
T
zwitschi 74dc1fdc1d
Full build / Import + headless tests (push) Successful in 37s
Full build / Full desktop build + optional release (push) Skipped
Map 1: rebuild Hollywood as the El Capitan landmark
Replace the generic boulevard facade with a stylized 6840 Hollywood Blvd block: six Ionic columns, eight stone steps, Masonic frieze/medallions, event banners, side doors, El Capitan Theatre and retail neighbours, Ovation Hollywood arcade, Walk of Fame and four-lane street markings.

Add the Day and Night Hollywood venue-menu rows. Night uses a phase-time offset so a full-length shift stays in LATE while the clock starts at zero; late routine traffic keeps the street populated and the offset round-trips through checkpoint restore.

Retune local-light falloff to linear attenuation, add a cool north sky fill, and use the smooth limestone facade texture. Refresh the staged gallery and map references; add geometry-budget, menu, night-offset and late-traffic tests.
2026-11-02 12:34:48 +01:00

267 lines
12 KiB
GDScript

extends RefCounted
## Headless tests for the generated prop models (tools/model_pipeline.py) and
## their placement in the procedural world.
const WorldBuilder := preload("res://src/game/world_builder.gd")
const MODEL_DIR := "res://assets/models/generated/"
func test_prop_glb_files_exist() -> bool:
var missing: Array[String] = []
for prop in WorldBuilder.MODEL_PROPS:
if not FileAccess.file_exists(MODEL_DIR + str(prop) + ".glb"):
missing.append(str(prop))
return TestUtils.check(missing.is_empty(), "all prop models built (missing: %s)" % str(missing))
func test_prop_glb_files_are_imported_scenes() -> bool:
## `load()` only returns a PackedScene once Godot has imported the `.glb`;
## a null here means the checkout is missing the import or the model is invalid.
var problems: Array[String] = []
for prop in WorldBuilder.MODEL_PROPS:
var path := MODEL_DIR + str(prop) + ".glb"
var packed := ResourceLoader.load(path) as PackedScene
if packed == null:
problems.append("%s not imported as a scene" % prop)
elif not FileAccess.file_exists(path + ".import"):
problems.append("%s has no .import file" % prop)
return TestUtils.check(problems.is_empty(), "prop models import as scenes (%s)" % str(problems))
func test_prop_scenes_instantiate_with_meshes() -> bool:
var problems: Array[String] = []
for prop in WorldBuilder.MODEL_PROPS:
var packed := ResourceLoader.load(MODEL_DIR + str(prop) + ".glb") as PackedScene
if packed == null:
problems.append("%s: no scene" % prop)
continue
var instance := packed.instantiate()
var meshes := instance.find_children("*", "MeshInstance3D", true, false)
if meshes.is_empty():
problems.append("%s: no mesh instances" % prop)
instance.free()
return TestUtils.check(problems.is_empty(), "every prop scene has geometry (%s)" % str(problems))
func test_prop_scenes_share_one_atlas_material() -> bool:
## All parts of a prop share one material backed by the colour atlas, so a
## prop is a single draw material instead of one per part.
var problems: Array[String] = []
for prop in WorldBuilder.MODEL_PROPS:
var packed := ResourceLoader.load(MODEL_DIR + str(prop) + ".glb") as PackedScene
if packed == null:
problems.append("%s: no scene" % prop)
continue
var instance := packed.instantiate()
var meshes := instance.find_children("*", "MeshInstance3D", true, false)
var materials := {}
var surfaces := 0
for node in meshes:
var mesh: Mesh = (node as MeshInstance3D).mesh
if mesh == null:
continue
for index in mesh.get_surface_count():
surfaces += 1
var material := mesh.surface_get_material(index)
if material != null:
materials[material.get_instance_id()] = true
if surfaces == 0:
problems.append("%s: no surfaces" % prop)
elif materials.size() != 1:
problems.append("%s: %d materials (expected 1)" % [prop, materials.size()])
instance.free()
return TestUtils.check(problems.is_empty(), "one atlas material per prop (%s)" % str(problems))
func test_prop_instances_share_the_imported_material() -> bool:
## Instancing a prop must not duplicate its atlas material; every copy uses
## the same resource, so many props stay cheap to draw.
var problems: Array[String] = []
for prop in WorldBuilder.MODEL_PROPS:
var packed := ResourceLoader.load(MODEL_DIR + str(prop) + ".glb") as PackedScene
if packed == null:
problems.append("%s: no scene" % prop)
continue
var first := packed.instantiate()
var second := packed.instantiate()
var a := _first_material(first)
var b := _first_material(second)
if a == null or b == null:
problems.append("%s: missing material" % prop)
elif a.get_instance_id() != b.get_instance_id():
problems.append("%s: material is duplicated per instance" % prop)
first.free()
second.free()
return TestUtils.check(problems.is_empty(), "prop instances share materials (%s)" % str(problems))
func _first_material(instance: Node) -> Material:
for node in instance.find_children("*", "MeshInstance3D", true, false):
var mesh: Mesh = (node as MeshInstance3D).mesh
if mesh != null and mesh.get_surface_count() > 0:
return mesh.surface_get_material(0)
return null
func test_world_places_generated_props() -> bool:
var root := Node3D.new()
var builder := WorldBuilder.new(root)
builder.build()
var detail := root.get_node_or_null("StreetDetail")
var a := TestUtils.check(detail != null, "StreetDetail exists")
var placed: int = detail.get_child_count() if detail else 0
var b := TestUtils.check(placed >= 10, "street furniture placed (%d)" % placed)
var c := TestUtils.check(builder.loaded_models.size() >= WorldBuilder.MODEL_PROPS.size(),
"generated models loaded instead of fallbacks")
var d := TestUtils.check(builder.fallback_props.is_empty(),
"no procedural fallback used (fallbacks: %s)" % str(builder.fallback_props))
root.free()
return a and b and c and d
func test_prop_placements_are_valid() -> bool:
var problems := WorldBuilder.placement_problems()
return TestUtils.check(problems.is_empty(),
"prop placements keep the door, steps and approach lane clear (%s)" % str(problems))
func test_placement_validator_catches_problems() -> bool:
var bad: Array = WorldBuilder.PROP_PLACEMENTS.duplicate(true)
bad.append({"zone": "test", "prop": "bench", "x": 0.0, "z": 2.0})
bad.append({"zone": "test", "prop": "bench", "x": 0.0, "z": 2.0})
var problems := WorldBuilder.placement_problems(bad)
var a := TestUtils.check(not problems.is_empty(), "validator rejects bad placements")
var joined := " ".join(problems)
var b := TestUtils.check(joined.contains("steps and door"),
"validator reports the door/steps exclusion")
var c := TestUtils.check(joined.contains("duplicate"), "validator reports a duplicate")
return a and b and c
func test_prop_variants_are_deterministic() -> bool:
var first := WorldBuilder.variant_for(3, 180.0)
var second := WorldBuilder.variant_for(3, 180.0)
var other := WorldBuilder.variant_for(4, 180.0)
var a := TestUtils.check(is_equal_approx(first["rotation"], second["rotation"])
and is_equal_approx(first["scale"], second["scale"]),
"the same placement always gets the same variant")
var b := TestUtils.check(not is_equal_approx(first["rotation"], other["rotation"]),
"different placements get different rotations")
var c := TestUtils.check(first["scale"] >= 0.95 and first["scale"] <= 1.05,
"variant scale stays subtle")
var d := TestUtils.check(absf(first["rotation"] - 180.0) <= 2.01,
"variant rotation stays near the authored angle")
return a and b and c and d
func test_every_prop_has_a_collider() -> bool:
var root := Node3D.new()
var builder := WorldBuilder.new(root)
builder.build()
var detail := root.get_node_or_null("StreetDetail")
var problems: Array[String] = []
if detail == null:
problems.append("no StreetDetail")
else:
for holder in detail.get_children():
var body := holder.get_node_or_null("Collision")
if body == null or not (body is StaticBody3D):
problems.append("%s has no StaticBody3D" % holder.name)
continue
var collider := body.get_node_or_null("Shape") as CollisionShape3D
if collider == null or collider.shape == null:
problems.append("%s has no collision shape" % holder.name)
continue
var size: Vector3
if collider.shape is BoxShape3D:
size = (collider.shape as BoxShape3D).size
elif collider.shape is CylinderShape3D:
var cylinder := collider.shape as CylinderShape3D
size = Vector3(cylinder.radius * 2.0, cylinder.height, cylinder.radius * 2.0)
else:
problems.append("%s uses an unexpected shape" % holder.name)
continue
if size.x <= 0.05 or size.y <= 0.05 or size.z <= 0.05:
problems.append("%s has a degenerate collider" % holder.name)
if collider.position.y + size.y * 0.5 <= 0.05:
problems.append("%s collider sits below the ground" % holder.name)
if holder.get_node_or_null("Avoidance") == null:
problems.append("%s has no navigation obstacle" % holder.name)
root.free()
return TestUtils.check(problems.is_empty(), "every prop has a static collider (%s)" % str(problems))
func test_fallback_props_get_the_same_collider() -> bool:
## A missing `.glb` must not silently remove collision: fallback primitives go
## through the same bounds fit as the imported models.
var root := Node3D.new()
var builder := WorldBuilder.new(root)
var holder := Node3D.new()
var fallback := builder._fallback_prop("bench")
holder.add_child(fallback)
builder._add_prop_collision(holder, "bench", fallback)
var body := holder.get_node_or_null("Collision") as StaticBody3D
var a := TestUtils.check(body != null, "fallback bench gets a static body")
var collider := body.get_node_or_null("Shape") as CollisionShape3D if body else null
var b := TestUtils.check(collider != null and collider.shape is BoxShape3D,
"fallback bench gets a box shape")
var width: float = (collider.shape as BoxShape3D).size.x if b else 0.0
var c := TestUtils.check(width > 1.0, "fallback collider follows the bench width (%.2f)" % width)
var d := TestUtils.check(holder.get_node_or_null("Avoidance") is NavigationObstacle3D,
"fallback bench publishes a navigation obstacle")
holder.free()
root.free()
return a and b and c and d
func test_world_loads_the_blender_library_props() -> bool:
var root := Node3D.new()
var builder := WorldBuilder.new(root)
builder.build()
var a := TestUtils.check(builder.fallback_props.is_empty(),
"no primitive prop fallback (fallbacks: %s)" % str(builder.fallback_props))
var b := TestUtils.check(builder.library_props.has("street_lamp")
and builder.library_props.has("bollard")
and builder.library_props.has("palm_tree"),
"replacement and new props come from the library (%d)" % builder.library_props.size())
var c := TestUtils.check(root.get_node_or_null("NeighbourBlocks/ElCapitanTheatre") != null
and root.get_node_or_null("OvationHollywood/OvationSign") != null
and root.get_node_or_null("WalkOfFame") != null
and root.get_node_or_null("Building/FriezeInscription") != null,
"the landmark neighbours, opposite block, Walk of Fame and frieze are placed")
var d := TestUtils.check(not builder._lamp_bulb_mats.is_empty()
and not builder._marquee_emissive.is_empty(),
"library lamps/marquee expose emissive materials")
builder.set_night_mode(true)
var lit := (builder._lamp_bulb_mats[0].emission_energy_multiplier > 0.0
and builder._marquee_emissive[0].emission_energy_multiplier > 0.0)
builder.set_night_mode(false)
var dark := builder._lamp_bulb_mats[0].emission_energy_multiplier <= 0.0
var e := TestUtils.check(lit and dark, "night mode drives library emissives")
var ao_ok := false
var detail := root.get_node_or_null("StreetDetail")
if detail != null:
for child in detail.get_children():
if str(child.name).begins_with("Prop_bench"):
for mi in (child as Node3D).find_children("*", "MeshInstance3D", true, false):
for index in (mi as MeshInstance3D).mesh.get_surface_count():
var mat := (mi as MeshInstance3D).get_surface_override_material(index) as StandardMaterial3D
if mat != null and mat.ao_enabled and mat.ao_texture != null:
ao_ok = true
var ao := TestUtils.check(ao_ok, "library prop materials carry the baked AO map")
var triangles := builder.library_triangle_count()
var f := TestUtils.check(triangles > 0
and triangles <= WorldBuilder.LIBRARY_TRIANGLE_BUDGET,
"library prop triangles stay inside the budget (%d)" % triangles)
root.free()
return a and b and c and d and e and f and ao
func test_world_facade_windows_light_at_night() -> bool:
var root := Node3D.new()
var builder := WorldBuilder.new(root)
builder.build()
var a := TestUtils.check(builder._window_mats.size() >= 3, "door transom panes built")
builder.set_night_mode(true)
var lit: bool = builder._window_mats[0].emission_energy_multiplier > 0.0
builder.set_night_mode(false)
var dark: bool = is_zero_approx(builder._window_mats[0].emission_energy_multiplier)
root.free()
return a and TestUtils.check(lit, "windows light up at night") and TestUtils.check(dark, "windows are dark by day")
func test_fallback_prop_is_usable() -> bool:
var root := Node3D.new()
var builder := WorldBuilder.new(root)
var fallback := builder._fallback_prop("bench")
var meshes := fallback.find_children("*", "MeshInstance3D", true, false)
var ok := TestUtils.check(meshes.size() >= 3, "fallback bench has geometry")
fallback.free()
root.free()
return ok