Session shell - Startup venue menu (src/game/ui/start_menu.gd): keyboard + mouse, replaces the live-shift boot and V as the player-facing venue path (V stays a debug bind). - Pause overlay on Esc (src/game/ui/pause_overlay.gd): resume, restart shift, settings, quit to menu. - Settings panel (src/game/ui/settings_panel.gd): mouse look sensitivity and HUD font scale, persisted via Save into user://settings.json and loaded on boot. - Shift review "Run another shift" now returns to the venue menu. - New smoke: src/test/smoke_session_shell.gd (menu -> shift -> pause -> settings -> menu), passes headless and windowed. World and presentation - Skyline backdrop restored with a transparent asset and an alpha-scissor unshaded material; fixes the opaque black slab from the earlier attempt and the unimported texture path. - Steps and landing are a StaticBody3D with per-tread colliders, so the guard and NPCs walk up instead of clipping through. - Marquee Label3D scaled up (72 -> 160) so STEP LINE reads from the sidewalk. - Focus torus and name tag enlarged for 2-3 m readability. - Routine traffic despawns after an idle lifetime (NpcAvatar.ephemeral), so the 16-NPC cap no longer fills with standers; authored events stay for the shift. Docs and tests - PLAN.md test/CI section corrected to the custom headless runner and Gitea Actions. - README controls table, smoke checklist, test count, and status lists refreshed. - TODO/DONE/MEMORY updated; completed sections moved to DONE.md. - Headless suite 77 -> 88 passing (settings round-trip, step colliders, skyline alpha, session-shell UI behavior, traffic lifetime).
126 lines
5.2 KiB
GDScript
126 lines
5.2 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_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_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 opaque skyline texture made the plane render as a black slab (TODO).
|
|
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: StandardMaterial3D = builder._skyline_mat
|
|
var b := TestUtils.check(mat != null, "skyline material captured")
|
|
var c := TestUtils.check(
|
|
mat != null and mat.transparency != BaseMaterial3D.TRANSPARENCY_DISABLED,
|
|
"skyline material uses alpha, not an opaque slab")
|
|
root.free()
|
|
return a and b and c
|
|
|
|
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
|