Files
security-game/src/test/tests/test_world.gd
T
zwitschi 93da916f90
Full build / Import + headless tests (push) Successful in 39s
Full build / Full desktop build + optional release (push) Skipped
Gate venue light falloff with a test (physical attenuation = 1.0)
2026-11-03 20:14:23 +01:00

416 lines
20 KiB
GDScript

extends RefCounted
## Headless tests for the procedural world, navigation mesh, and phase lighting.
const WorldBuilder := preload("res://src/game/world_builder.gd")
const Rules := preload("res://src/core/rules.gd")
const GameManager := preload("res://src/game/game_manager.gd")
func test_navmesh_built() -> bool:
var root := Node3D.new()
WorldBuilder.new(root).build()
var region: NavigationRegion3D = root.find_child("NavRegion", true, false) as NavigationRegion3D
if region == null:
return TestUtils.fail("no NavRegion in world")
var mesh: NavigationMesh = region.navigation_mesh
if mesh == null:
return TestUtils.fail("NavRegion has no navigation mesh")
var a := TestUtils.check(mesh.get_polygon_count() > 0, "navmesh has polygons")
var b := TestUtils.check(mesh.get_vertices().size() >= 4, "navmesh has vertices")
root.free()
return a and b
func test_venue_geometry_specs_are_distinct_and_defaulted() -> bool:
var hollywood := WorldBuilder.new(Node3D.new())
var joke := WorldBuilder.new(Node3D.new(), "joke_shop")
var studio := WorldBuilder.new(Node3D.new(), "tv_studio")
var unknown := WorldBuilder.new(Node3D.new(), "not_a_venue")
var a := TestUtils.check(hollywood.venue_id == "hollywood_building",
"the default venue is Hollywood")
var b := TestUtils.check(float(hollywood.spec["facade_width"]) > float(joke.spec["facade_width"]),
"the Joke Shop frontage is slightly narrower than the boulevard")
var c := TestUtils.check(not bool(joke.spec["features"]["skyline"])
and not bool(joke.spec["features"]["marquee"]),
"the Joke Shop disables the skyline and marquee")
var d := TestUtils.check(bool(studio.spec["features"]["bulb_canopy"])
and not bool(studio.spec["features"]["marquee"]),
"the studio uses a bulb canopy instead of the marquee")
var e := TestUtils.check(unknown.venue_id == "hollywood_building",
"an unknown venue falls back to Hollywood")
var root := Node3D.new()
var builder := WorldBuilder.new(root, "joke_shop")
builder.build()
var body := root.find_child("Building", true, false)
var facade := body.get_child(0) as MeshInstance3D if body != null else null
var f := TestUtils.check(facade != null
and str(builder.spec.get("architecture", "")) == "comedy_store"
and (facade.mesh as BoxMesh).size.x > 24.0
and (facade.mesh as BoxMesh).size.x < 30.0,
"the Joke Shop builds its 27 m Sunset frontage")
var g := TestUtils.check(root.find_child("SkylineBackdrop", true, false) == null,
"the Joke Shop builds no skyline ring")
hollywood.root.free()
joke.root.free()
studio.root.free()
unknown.root.free()
root.free()
return a and b and c and d and e and f and g
func test_joke_shop_uses_the_real_sunset_strip_layout() -> bool:
var root := Node3D.new()
var builder := WorldBuilder.new(root, "joke_shop")
builder.build()
var a := TestUtils.check(root.find_child("SignatureCanopy", true, false) != null,
"the signature-covered canopy is built")
var sig_label := root.find_child("CanopySignatures_west", true, false) as Label3D
var a2 := TestUtils.check(sig_label != null
and sig_label.modulate.r > 0.85 and sig_label.modulate.g > 0.85,
"the canopy signature band is white, matching the reference's white celebrity names")
var sign := root.find_child("ComedySignLabel", true, false) as Label3D
var b := TestUtils.check(sign != null and sign.text.contains("JOKE")
and sign.text.contains("SHOP"), "the circular blade sign carries the club wordmark")
var c := TestUtils.check(root.get_node_or_null("SunsetHillside/DryHillside") != null,
"the dry hillside rises behind the club")
var d := TestUtils.check(root.get_node_or_null("SunsetNeighbours/WestMediterraneanBlock") != null
and root.get_node_or_null("SunsetNeighbours/EastCommercialBlock") != null,
"the west and east Sunset neighbours are built")
var e := TestUtils.check(root.find_child("SunsetAcrossStreet", true, false) != null,
"the opposite side of Sunset is built as background context")
var f := TestUtils.check(root.find_child("EntranceStep", true, false) != null,
"the low entrance step is built")
var g := TestUtils.check(root.find_child("AlleyWall", true, false) == null,
"the old alley walls are gone")
# The hall wall uses the dark stucco pick, not the grey sidewalk-paver tile,
# so it reads as the real club's flat black plaster.
var wall := root.find_child("Building", true, false) as Node3D
var wall_mesh := wall.get_child(0) as MeshInstance3D if wall != null and wall.get_child_count() > 0 else null
var wall_mat: Material = wall_mesh.mesh.surface_get_material(0) if wall_mesh != null and wall_mesh.mesh != null else null
var g2 := TestUtils.check(wall_mat is StandardMaterial3D
and (wall_mat as StandardMaterial3D).albedo_texture != null
and (wall_mat as StandardMaterial3D).albedo_texture.resource_path.contains("dark_stucco"),
"the hall wall carries the dark stucco texture")
var placements: Array = builder.spec.get("prop_placements", [])
var h := TestUtils.check(not placements.is_empty()
and WorldBuilder.placement_problems(placements).is_empty(),
"the Joke Shop prop placements stay on the sidewalk and clear the lane")
root.free()
return a and a2 and b and c and d and e and f and g and g2 and h
func test_joke_shop_geometry_stays_inside_the_map_budget() -> bool:
var root := Node3D.new()
var builder := WorldBuilder.new(root, "joke_shop")
builder.build()
var triangles := builder.world_triangle_count()
var meshes := builder.world_mesh_count()
print("joke shop map: %d triangles across %d meshes" % [triangles, meshes])
var a := TestUtils.check(triangles < 150000,
"Joke Shop triangle budget stays under 150k (got %d)" % triangles)
var b := TestUtils.check(meshes < 700,
"Joke Shop draw-call count stays under 700 meshes (got %d)" % meshes)
root.free()
return a and b
func test_hollywood_uses_the_real_landmark_layout() -> bool:
var root := Node3D.new()
var builder := WorldBuilder.new(root)
builder.build()
var columns := root.find_child("IonicColonnade", true, false)
var steps := root.find_child("Steps", true, false)
var a := TestUtils.check(columns != null and columns.get_child_count() >= 12,
"six Ionic columns with bases and capitals are built")
var b := TestUtils.check(steps != null and steps.get_child_count() >= 8,
"eight stone steps are built")
var frieze := root.find_child("FriezeInscription", true, false) as Label3D
var c := TestUtils.check(frieze != null and frieze.text.contains("FREEMASONRY")
and frieze.text.contains("HEARTS OF MEN"), "the Masonic parapet motto is present")
var banners := root.find_child("EventBanners", true, false)
var d := TestUtils.check(banners != null and banners.get_child_count() >= 9,
"three red event banners with their trim are present")
# The event banners stay readable at night: the cloth carries a warm
# self-light that the day/night cycle brightens (not a flat dead panel).
var banner_cloth: Material = banners.get_child(0).material_override \
if banners != null and banners.get_child_count() > 0 else null
var d2 := TestUtils.check(banner_cloth is StandardMaterial3D
and (banner_cloth as StandardMaterial3D).emission_enabled,
"the event banner cloth is emissive for the night balance")
var walk := root.find_child("WalkOfFame", true, false)
var walk_band := walk.get_child(0) as MeshInstance3D if walk != null else null
var walk_material: Material = walk_band.mesh.surface_get_material(0) if walk_band != null else null
var e := TestUtils.check(walk != null and walk_material != null
and walk_material is StandardMaterial3D
and (walk_material as StandardMaterial3D).albedo_texture != null,
"the Hollywood Walk of Fame strip is built with the terrazzo texture")
var f := TestUtils.check(root.get_node_or_null("NeighbourBlocks/ElCapitanTheatre") != null
and root.get_node_or_null("NeighbourBlocks/EastRetailBlock") != null,
"the El Capitan Theatre and east retail neighbours are built")
var g := TestUtils.check(root.find_child("OvationHollywood", true, false) != null,
"the opposite Ovation Hollywood block is built")
var placements: Array = builder.spec.get("prop_placements", [])
var h := TestUtils.check(not placements.is_empty()
and WorldBuilder.placement_problems(placements).is_empty(),
"the landmark prop placements keep the wide steps clear")
root.free()
return a and b and c and d and d2 and e and f and g and h
func test_hollywood_geometry_stays_inside_the_map_budget() -> bool:
var root := Node3D.new()
var builder := WorldBuilder.new(root)
builder.build()
var triangles := builder.world_triangle_count()
var meshes := builder.world_mesh_count()
print("hollywood map: %d triangles across %d meshes" % [triangles, meshes])
var a := TestUtils.check(triangles < 150000,
"landmark triangle budget stays under 150k (got %d)" % triangles)
var b := TestUtils.check(meshes < 600,
"landmark draw-call count stays under 600 meshes (got %d)" % meshes)
root.free()
return a and b
func test_navigation_mesh_matches_each_venue_bounds() -> bool:
var problems: Array[String] = []
for id in ["hollywood_building", "joke_shop", "tv_studio"]:
var root := Node3D.new()
var builder := WorldBuilder.new(root, id)
builder.build()
var region := root.find_child("NavRegion", true, false) as NavigationRegion3D
if region == null or region.navigation_mesh == null:
problems.append("%s has no navmesh" % id)
root.free()
continue
var verts := region.navigation_mesh.get_vertices()
var nav_min: Vector2 = builder.spec["nav_min"]
var nav_max: Vector2 = builder.spec["nav_max"]
var min_x := 999.0
var max_x := -999.0
var min_z := 999.0
var max_z := -999.0
for v in verts:
min_x = minf(min_x, v.x)
max_x = maxf(max_x, v.x)
min_z = minf(min_z, v.z)
max_z = maxf(max_z, v.z)
if absf(min_x - nav_min.x) > 0.01 or absf(max_x - nav_max.x) > 0.01 \
or absf(min_z - nav_min.y) > 0.01 or absf(max_z - nav_max.y) > 0.01:
problems.append("%s navmesh %s does not match spec %s" % [
id, str(Vector4(min_x, max_x, min_z, max_z)),
str(Vector4(nav_min.x, nav_max.x, nav_min.y, nav_max.y))])
root.free()
return TestUtils.check(problems.is_empty(),
"each venue builds its own navigation bounds (%s)" % str(problems))
func test_phase_lighting_config_covered() -> bool:
var ok := true
for phase in [Rules.Phase.EARLY, Rules.Phase.MIDDAY, Rules.Phase.EVENING, Rules.Phase.LATE]:
var cfg: Variant = GameManager.PHASE_LIGHTING.get(phase, null)
if not (cfg is Dictionary):
ok = false
continue
for key in ["sun_energy", "sun_angle", "sun_color", "sky_top", "sky_horizon", "ambient", "night"]:
if not (cfg as Dictionary).has(key):
ok = false
return TestUtils.check(ok, "all four phases have a full lighting config")
func test_street_lamps_and_marquee_built() -> bool:
var root := Node3D.new()
var builder := WorldBuilder.new(root)
builder.build()
var a := TestUtils.check(builder._street_lamps.size() >= 2, "street lamps built")
var b := TestUtils.check(builder.marquee_label != null, "marquee label built")
# Night mode raises bulb emission; day mode drops it.
builder.set_night_mode(true)
var night_on := builder._lamp_bulb_mats[0].emission_energy_multiplier > 1.0
builder.set_night_mode(false)
var day_off := builder._lamp_bulb_mats[0].emission_energy_multiplier <= 0.5
root.free()
return a and b and TestUtils.check(night_on, "lamps brighten at night") and TestUtils.check(day_off, "lamps dim in daylight")
func test_marquee_emissive_balanced_at_night() -> bool:
## The marquee sign face and bulbs are registered with the day/night cycle:
## they brighten after dark and fall back to a faint glow in daylight, so the
## marquee reads as practical lighting instead of a black box.
var root := Node3D.new()
var builder := WorldBuilder.new(root)
builder.build()
var emissive := builder._marquee_emissive
var ok := emissive.size() >= 2
builder.set_night_mode(true)
var night_energy := 0.0
for mat in emissive:
night_energy += mat.emission_energy_multiplier
builder.set_night_mode(false)
var day_energy := 0.0
for mat in emissive:
day_energy += mat.emission_energy_multiplier
root.free()
return ok and TestUtils.check(night_energy > day_energy,
"marquee emissive surfaces brighten at night")
func test_asset_textures_present_and_pow2() -> bool:
var required := [
"res://assets/textures/brick_facade.png",
"res://assets/textures/concrete.png",
"res://assets/textures/asphalt.png",
"res://assets/textures/awning.png",
"res://assets/textures/skyline.png",
"res://assets/textures/vignette.png",
"res://assets/textures/icons/flashlight_on.png",
"res://assets/textures/icons/door_locked.png",
"res://assets/textures/icons/wand.png",
"res://assets/textures/icons/rec_dot.png",
"res://assets/textures/cards/id_card.png",
"res://assets/textures/cards/wristband.png",
"res://assets/textures/cards/ticket.png",
"res://assets/textures/cards/credential.png",
"res://assets/textures/cards/guest_list.png",
]
for path in required:
if not FileAccess.file_exists(path):
return TestUtils.fail("missing generated asset: %s — run tools/generate_textures.py" % path)
var img := Image.new()
for path in required:
if img.load(path) != OK:
return TestUtils.fail("cannot decode asset: %s" % path)
var w := img.get_width()
var h := img.get_height()
if w > 1024 or h > 1024:
return TestUtils.fail("asset too large: %s (%dx%d)" % [path, w, h])
if (w & (w - 1)) != 0 or (h & (h - 1)) != 0:
return TestUtils.fail("asset not power-of-two: %s (%dx%d)" % [path, w, h])
return true
func test_steps_have_colliders() -> bool:
## Steps were visual-only and the guard walked through them (TODO).
var root := Node3D.new()
WorldBuilder.new(root).build()
var steps := root.find_child("Steps", true, false)
var a := TestUtils.check(steps != null, "Steps node exists")
var b := TestUtils.check(steps is StaticBody3D, "Steps are a physics body")
var shape_count := 0
if steps is StaticBody3D:
var body := steps as StaticBody3D
shape_count = body.get_shape_owners().size()
var c := TestUtils.check(shape_count >= 5, "four treads + landing have colliders (got %d)" % shape_count)
root.free()
return a and b and c
func test_skyline_backdrop_uses_alpha() -> bool:
## The old opaque skyline texture made the plane render as a black slab, and
## the scene fog bleached the panorama into a white band. The backdrop is an
## alpha-scissored, fog-free shader with a panorama per phase.
var root := Node3D.new()
var builder := WorldBuilder.new(root)
builder.build()
var backdrop := root.find_child("SkylineBackdrop", true, false)
var a := TestUtils.check(backdrop != null, "SkylineBackdrop exists")
var mat: ShaderMaterial = builder._skyline_mat
var b := TestUtils.check(mat != null and mat.shader != null, "skyline shader material captured")
var code := mat.shader.code if mat != null and mat.shader != null else ""
var c := TestUtils.check(code.contains("ALPHA") and code.contains("fog_disabled")
and code.contains("haze_strength"),
"backdrop shader is alpha-scissored, fog-free and haze-blended")
var haze := float(mat.get_shader_parameter("haze_strength")) if mat != null else 0.0
var c2 := TestUtils.check(haze > 0.0, "skyline haze is enabled")
c = c and c2
var day: Texture2D = mat.get_shader_parameter("sky_texture")
builder.set_phase(Rules.Phase.LATE)
var night: Texture2D = mat.get_shader_parameter("sky_texture")
var d := TestUtils.check(day != null and night != null and day != night,
"the skyline panorama follows the shift phase")
var e := TestUtils.check(builder._street_mat != null
and builder._street_mat.roughness < 0.5, "the night street turns wet")
root.free()
return a and b and c and d and e
func test_door_indicator_built() -> bool:
var root := Node3D.new()
var builder := WorldBuilder.new(root)
builder.build()
var a := TestUtils.check(builder.door_indicator != null, "door indicator mesh exists")
if builder.door_indicator != null:
builder.set_door_locked(true)
var mat: StandardMaterial3D = builder.door_indicator.material_override as StandardMaterial3D
var b := TestUtils.check(mat != null and mat.albedo_color.r > 0.5, "indicator turns red when locked")
builder.set_door_locked(false)
var c := TestUtils.check(mat != null and mat.albedo_color.g > 0.5, "indicator turns green when open")
root.free()
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 test_venue_lights_use_physical_falloff() -> 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()
for light in root.find_children("*", "OmniLight3D", true, false):
if not is_equal_approx((light as OmniLight3D).omni_attenuation, 1.0):
problems.append("%s/%s omni_attenuation=%.2f" % [venue, light.name,
(light as OmniLight3D).omni_attenuation])
for light in root.find_children("*", "SpotLight3D", true, false):
if not is_equal_approx((light as SpotLight3D).spot_attenuation, 1.0):
problems.append("%s/%s spot_attenuation=%.2f" % [venue, light.name,
(light as SpotLight3D).spot_attenuation])
root.free()
return TestUtils.check(problems.is_empty(),
"every venue light uses physical falloff: %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