416 lines
20 KiB
GDScript
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
|