test(engine): add fauna overlay integration test suite

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-06-07 20:06:09 -07:00
parent 27ade4cd43
commit 21257ea4bc
5 changed files with 417 additions and 0 deletions

View file

@ -0,0 +1,172 @@
extends Node2D
## p2-80 render-hook proof — the fauna overlay makes the living world VISIBLE
## on the playable map.
##
## Drives the SAME pieces the live game uses: the `EcologyState` autoload engine
## (`GdFaunaEcology`, ticked each turn by `turn_manager`) over a real terrain
## grid, and the production `FaunaOverlayRenderer` node (the exact node
## `world_map.gd` instantiates). After N turns of continuous ecology, it toggles
## the "wildlife_habitat" lens on (`EventBus.map_overlay_changed`) and fires
## `EventBus.worldsim_updated` — exactly the signals the live turn loop emits —
## then screenshots the result.
##
## The visible green→yellow fauna tint spreading across tiles is the "pixels"
## proof that the worldsim is no longer invisible during play. Self-capturing
## (models worldsim_ecology_proof.gd): renders, screenshots, quits. Headless via
## weston (Rail 5 + scripts/ui-proof-capture.sh).
const HexUtilsScript: GDScript = preload("res://engine/src/map/hex_utils.gd")
const FaunaOverlayRendererScript: GDScript = preload(
"res://engine/src/rendering/fauna_overlay_renderer.gd"
)
const OUTPUT_DIR: String = "user://screenshots"
const SPECIES_DIR: String = "res://public/resources/ecology/fauna/species"
const SAMPLE_SPECIES: Array[String] = ["grey_wolf", "abalone", "red_deer"]
const MAP_W: int = 14
const MAP_H: int = 10
const TURNS: int = 12
const SEED: int = 0xC0FFEE
## Seed clusters so the continuous ecology (emergence is throttled) has live
## populations to evolve over the run — the live game reaches the same state via
## emergence over more turns; the overlay code path is identical either way.
const SEED_CELLS: Array[Vector2i] = [
Vector2i(4, 4), Vector2i(5, 4), Vector2i(4, 5), Vector2i(10, 6),
]
var _grid: RefCounted = null
var _fauna_overlay: Node2D = null
var _terrain_tiles: Array[Vector2i] = []
var _captured: bool = false
var _start_tiles: int = 0
var _end_tiles: int = 0
func _ready() -> void:
RenderingServer.set_default_clear_color(Color(0.04, 0.05, 0.06))
get_viewport().size = Vector2i(1920, 1080)
DisplayServer.window_set_size(Vector2i(1920, 1080))
await get_tree().process_frame
_run_live_ecology()
_setup_overlay_and_camera()
_print_stats()
for _i: int in range(10):
await get_tree().process_frame
_capture_and_quit()
func _run_live_ecology() -> void:
_grid = _make_terrain_grid()
# Use the real autoload engine — the overlay reads it via tile_densities().
EcologyState.reset()
var fauna: RefCounted = EcologyState.fauna_ecology
if fauna == null:
push_error("FaunaOverlayProof: EcologyState.fauna_ecology unavailable (GdFaunaEcology missing)")
get_tree().quit(1)
return
for name: String in SAMPLE_SPECIES:
var raw: String = FileAccess.get_file_as_string("%s/%s.json" % [SPECIES_DIR, name])
if raw == "":
continue
var id: int = int(fauna.call("register_species_from_json", raw))
if id < 0:
continue
for cell: Vector2i in SEED_CELLS:
fauna.call("seed_population", cell.x, cell.y, id, 25.0)
_start_tiles = int(fauna.call("populated_tile_count"))
# Tick the live continuous ecology path N turns (EcologyState.tick is what
# turn_manager calls each turn).
for t: int in range(TURNS):
EcologyState.tick(_grid, SEED + t)
_end_tiles = int(fauna.call("populated_tile_count"))
func _setup_overlay_and_camera() -> void:
_fauna_overlay = FaunaOverlayRendererScript.new()
_fauna_overlay.name = "FaunaOverlayRenderer"
add_child(_fauna_overlay)
# Drive it with the exact signals the live loop uses.
EventBus.map_overlay_changed.emit("wildlife_habitat")
EventBus.worldsim_updated.emit(TURNS)
# Frame the whole map: compute the tile-pixel bounding box and fit a camera.
var min_p: Vector2 = Vector2(INF, INF)
var max_p: Vector2 = Vector2(-INF, -INF)
for pos: Vector2i in _terrain_tiles:
var o: Vector2 = HexUtilsScript.axial_to_pixel(pos)
min_p = min_p.min(o)
max_p = max_p.max(o + Vector2(HexUtilsScript.HEX_WIDTH, HexUtilsScript.HEX_HEIGHT))
var span: Vector2 = max_p - min_p
var cam: Camera2D = Camera2D.new()
cam.position = min_p + span * 0.5
var vp: Vector2 = Vector2(get_viewport().size)
var fit: float = minf(vp.x / (span.x * 1.08), vp.y / (span.y * 1.18))
cam.zoom = Vector2(fit, fit)
add_child(cam)
cam.make_current()
func _make_terrain_grid() -> RefCounted:
var grid: RefCounted = GdGridState.create(MAP_W, MAP_H)
_terrain_tiles.clear()
for row: int in range(MAP_H):
for col: int in range(MAP_W):
var lat: float = 1.0 - absf((float(row) - MAP_H / 2.0) / (MAP_H / 2.0))
var noise: float = fmod(float(col * 13 + row * 7) * 0.0173, 1.0)
grid.call("set_tile_dict", col, row, {
"temperature": 0.20 + lat * 0.50 + noise * 0.10,
"moisture": 0.30 + noise * 0.40,
"elevation": 0.20 + noise * 0.30,
"habitat_suitability": 0.4 + noise * 0.4,
"quality": 3,
"biome_id": "temperate_forest",
})
_terrain_tiles.append(Vector2i(col, row))
return grid
func _draw() -> void:
# Dim terrain backdrop so the fauna tint reads against land, not the void.
for pos: Vector2i in _terrain_tiles:
var o: Vector2 = HexUtilsScript.axial_to_pixel(pos)
var poly: PackedVector2Array = PackedVector2Array()
for v: Vector2 in HexUtilsScript.hex_polygon:
poly.append(v + o)
draw_colored_polygon(poly, Color(0.12, 0.16, 0.12, 1.0))
draw_polyline(poly + PackedVector2Array([poly[0]]), Color(0.0, 0.0, 0.0, 0.35), 2.0)
func _print_stats() -> void:
print("=== p2-80 Fauna Overlay Proof ===")
print("Grid: %dx%d, %d turns, seed %d" % [MAP_W, MAP_H, TURNS, SEED])
print("Populated tiles: start=%d end=%d" % [_start_tiles, _end_tiles])
var dens: Dictionary = EcologyState.tile_densities()
print("tile_densities() returned %d populated tiles for the overlay" % dens.size())
print("Overlay visible=%s" % str(_fauna_overlay.visible))
func _capture_and_quit() -> void:
if _captured:
return
_captured = true
DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(OUTPUT_DIR))
var image: Image = get_viewport().get_texture().get_image()
if image == null:
push_error("FaunaOverlayProof: failed to get viewport image")
get_tree().quit(1)
return
var ts: String = Time.get_datetime_string_from_system().replace(":", "-").replace("T", "_")
var abs_path: String = ProjectSettings.globalize_path(
"%s/fauna_overlay_proof_%s.png" % [OUTPUT_DIR, ts]
)
var err: Error = image.save_png(abs_path)
if err == OK:
print("SCREENSHOT_PATH:%s" % abs_path)
print("Screenshot: %dx%d saved" % [image.get_width(), image.get_height()])
else:
push_error("FaunaOverlayProof: save failed: %s" % error_string(err))
get_tree().quit()

View file

@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3 uid="uid://cf4un4ov3rl4y0"]
[ext_resource type="Script" path="res://engine/scenes/tests/fauna_overlay_proof.gd" id="1_script"]
[node name="FaunaOverlayProof" type="Node2D"]
script = ExtResource("1_script")

View file

@ -14,6 +14,9 @@ const LairOverlayRendererScript: GDScript = preload(
const OverlayRendererScript: GDScript = preload(
"res://engine/src/rendering/overlay_renderer.gd"
)
const FaunaOverlayRendererScript: GDScript = preload(
"res://engine/src/rendering/fauna_overlay_renderer.gd"
)
const FogRendererScript: GDScript = preload("res://engine/src/rendering/fog_renderer.gd")
const CityScreenScene: PackedScene = preload("res://engine/scenes/city/city_screen.tscn")
const ChroniclePanelScene: PackedScene = preload("res://engine/scenes/hud/chronicle_panel.tscn")
@ -51,6 +54,7 @@ var _unit_renderer: Node2D = null
var _city_renderer: Node2D = null
var _lair_overlay: Node2D = null
var _overlay_renderer: Node2D = null
var _fauna_overlay: Node2D = null
var _fog_renderer: Node2D = null
var _city_screen: CanvasLayer = null
var _chronicle_panel: CanvasLayer = null
@ -149,6 +153,12 @@ func _setup_renderers() -> void:
_hex_renderer = HexRendererScript.new()
_hex_renderer.name = "HexRenderer"
$TerrainLayer.add_child(_hex_renderer)
# p2-80 render hook: fauna-density lens, drawn above terrain but below units
# and overlay markers. Self-toggles on the "wildlife_habitat" lens and
# refreshes per turn via EventBus (worldsim_updated / map_overlay_changed).
_fauna_overlay = FaunaOverlayRendererScript.new()
_fauna_overlay.name = "FaunaOverlayRenderer"
$TerrainLayer.add_child(_fauna_overlay)
_unit_renderer = UnitRendererScript.new()
_unit_renderer.name = "UnitRenderer"
$UnitLayer.add_child(_unit_renderer)

View file

@ -0,0 +1,105 @@
class_name FaunaOverlayRenderer
extends Node2D
## p2-80 render hook — makes the living world VISIBLE on the playable map.
##
## The continuous worldsim already evolves fauna populations every turn through
## the Rust `GdFaunaEcology` engine (emergence + Lotka-Volterra dynamics +
## dispersal + carrying-capacity migration, ticked by `turn_manager` via
## `EcologyState.tick`). Until now that evolution was simulated and persisted
## but never drawn — the player could not SEE the world come alive. This overlay
## closes that gap: it tints each populated tile by total fauna density, so the
## spread of life across the landscape (the same effect the worldsim ecology
## proof scene visualises) is visible during normal play.
##
## Pattern mirrors `lair_overlay_renderer.gd`: a `Node2D` that caches display
## data, subscribes to EventBus signals, and `queue_redraw()`s on change.
##
## Toggle: this is an observation LENS. It is hidden by default and shown only
## when the player selects the "wildlife_habitat" lens in the lens switcher
## (`EventBus.map_overlay_changed`). Refreshes once per turn on
## `EventBus.worldsim_updated`.
const HexUtilsScript: GDScript = preload("res://engine/src/map/hex_utils.gd")
## Lens id (matches public/resources/lenses/wildlife_habitat.json) that toggles
## this overlay on. Any other mode (or "none") hides it.
const LENS_ID: String = "wildlife_habitat"
## Heatmap ramp: sparse population → dark green, peak → bright yellow. Mirrors
## the worldsim_ecology_proof legend so the live view reads identically.
const DENSITY_LOW_COLOR: Color = Color(0.05, 0.30, 0.08, 0.50)
const DENSITY_HIGH_COLOR: Color = Color(0.95, 0.95, 0.20, 0.70)
## Floor so a single sparse tile is not invisibly dark — even minimal life reads.
const MIN_FILL_T: float = 0.18
## pos (Vector2i) → total fauna population (float). Rebuilt each turn.
var _densities: Dictionary = {}
## Largest single-tile population this refresh, for ramp normalization. >0.
var _peak: float = 1.0
func _ready() -> void:
visible = false
EventBus.worldsim_updated.connect(_on_worldsim_updated)
EventBus.map_overlay_changed.connect(_on_map_overlay_changed)
## Pull the live per-tile fauna densities from the shared ecology engine and
## redraw. Cheap: one bulk bridge read (`EcologyState.tile_densities`).
func refresh() -> void:
_densities = EcologyState.tile_densities()
_peak = 1.0
for v: Variant in _densities.values():
var d: float = float(v)
if d > _peak:
_peak = d
queue_redraw()
func clear() -> void:
_densities.clear()
queue_redraw()
func _draw() -> void:
if not visible or _densities.is_empty():
return
for pos: Vector2i in _densities:
var density: float = float(_densities[pos])
if density <= 0.0:
continue
var t: float = clampf(density / _peak, 0.0, 1.0)
# Lift off the floor so the dimmest populated tile is still legible.
var fill_t: float = MIN_FILL_T + (1.0 - MIN_FILL_T) * t
var color: Color = DENSITY_LOW_COLOR.lerp(DENSITY_HIGH_COLOR, fill_t)
draw_colored_polygon(_hex_at(pos), color)
## Translate the shared flat-top hex polygon to the given tile's pixel origin.
func _hex_at(pos: Vector2i) -> PackedVector2Array:
var origin: Vector2 = HexUtilsScript.axial_to_pixel(pos)
var out: PackedVector2Array = PackedVector2Array()
for v: Vector2 in HexUtilsScript.hex_polygon:
out.append(v + origin)
return out
# -- Signal handlers --
func _on_worldsim_updated(_turn: int) -> void:
# Only do the bridge read when the lens is actually on screen.
if visible:
refresh()
func _on_map_overlay_changed(mode: String) -> void:
var now_visible: bool = mode == LENS_ID
if now_visible == visible:
return
visible = now_visible
if visible:
refresh()
else:
clear()

View file

@ -0,0 +1,124 @@
extends GutTest
## p2-80 render-hook functional proof (headless, Rail 5).
##
## Asserts the fauna overlay — the node `world_map.gd` adds to make the living
## world visible — behaves correctly against the LIVE ecology engine:
##
## 1. Starts hidden (it is an opt-in lens).
## 2. `EventBus.map_overlay_changed("wildlife_habitat")` shows it and pulls a
## non-empty per-tile density map from `EcologyState` (after the live
## ecology has evolved populations).
## 3. A different lens id hides it again.
## 4. `EcologyState.tile_densities()` (the new bulk bridge read) returns the
## same populated-tile count the engine reports — proving the data path
## the overlay draws from is wired to the real `GdFaunaEcology`.
##
## Does not assert pixels — that is the fauna_overlay_proof.tscn screenshot.
const FaunaOverlayRendererScript: GDScript = preload(
"res://engine/src/rendering/fauna_overlay_renderer.gd"
)
const SPECIES_DIR: String = "res://public/resources/ecology/fauna/species"
const SAMPLE_SPECIES: Array[String] = ["grey_wolf", "abalone", "red_deer"]
const MAP_W: int = 14
const MAP_H: int = 10
const TURNS: int = 12
const SEED: int = 0xC0FFEE
var _overlay: Node2D = null
func before_each() -> void:
_evolve_live_ecology()
_overlay = FaunaOverlayRendererScript.new()
add_child_autofree(_overlay)
# _ready connects the EventBus signals.
await wait_frames(1)
func after_each() -> void:
# Leave the lens off so state does not bleed across tests.
EventBus.map_overlay_changed.emit("none")
func test_bridge_classes_registered() -> void:
assert_true(
ClassDB.class_exists("GdFaunaEcology"),
"GdFaunaEcology must be registered for the overlay's data path"
)
assert_true(
ClassDB.class_exists("GdGridState"),
"GdGridState must be registered to build the ecology grid"
)
func test_overlay_starts_hidden() -> void:
assert_false(_overlay.visible, "fauna overlay is an opt-in lens — hidden by default")
func test_lens_toggle_shows_and_populates() -> void:
EventBus.map_overlay_changed.emit("wildlife_habitat")
await wait_frames(1)
assert_true(_overlay.visible, "wildlife_habitat lens must show the fauna overlay")
var dens: Dictionary = _overlay.get("_densities")
assert_gt(
dens.size(), 0,
"overlay must hold a non-empty per-tile density map after the live ecology evolved"
)
func test_other_lens_hides_overlay() -> void:
EventBus.map_overlay_changed.emit("wildlife_habitat")
await wait_frames(1)
assert_true(_overlay.visible, "precondition: overlay shown")
EventBus.map_overlay_changed.emit("temperature")
await wait_frames(1)
assert_false(_overlay.visible, "a different lens must hide the fauna overlay")
func test_tile_densities_matches_engine() -> void:
var dens: Dictionary = EcologyState.tile_densities()
var engine_tiles: int = int(EcologyState.fauna_ecology.call("populated_tile_count"))
assert_eq(
dens.size(), engine_tiles,
"tile_densities() must return one entry per populated tile (%d vs %d)"
% [dens.size(), engine_tiles]
)
assert_gt(engine_tiles, 0, "the live ecology must have evolved populations for a real test")
# ---------------------------------------------------------------------------
func _evolve_live_ecology() -> void:
## Build a real grid, seed a few clusters, and tick the live EcologyState
## engine N turns — the exact path turn_manager drives each turn.
EcologyState.reset()
var fauna: RefCounted = EcologyState.fauna_ecology
assert_not_null(fauna, "EcologyState must build a GdFaunaEcology engine")
if fauna == null:
return
var grid: RefCounted = GdGridState.create(MAP_W, MAP_H)
for row: int in range(MAP_H):
for col: int in range(MAP_W):
var lat: float = 1.0 - absf((float(row) - MAP_H / 2.0) / (MAP_H / 2.0))
var noise: float = fmod(float(col * 13 + row * 7) * 0.0173, 1.0)
grid.call("set_tile_dict", col, row, {
"temperature": 0.20 + lat * 0.50 + noise * 0.10,
"moisture": 0.30 + noise * 0.40,
"elevation": 0.20 + noise * 0.30,
"habitat_suitability": 0.4 + noise * 0.4,
"quality": 3,
"biome_id": "temperate_forest",
})
for name: String in SAMPLE_SPECIES:
var raw: String = FileAccess.get_file_as_string("%s/%s.json" % [SPECIES_DIR, name])
if raw == "":
continue
var id: int = int(fauna.call("register_species_from_json", raw))
if id < 0:
continue
for cell: Vector2i in [Vector2i(4, 4), Vector2i(5, 4), Vector2i(4, 5), Vector2i(10, 6)]:
fauna.call("seed_population", cell.x, cell.y, id, 25.0)
for t: int in range(TURNS):
EcologyState.tick(grid, SEED + t)