From 21257ea4bcd5db4741851c39afda4ea209f2d95e Mon Sep 17 00:00:00 2001 From: Natalie Date: Sun, 7 Jun 2026 20:06:09 -0700 Subject: [PATCH] =?UTF-8?q?=E2=9C=85=20test(engine):=20=E2=9C=A8=20add=20f?= =?UTF-8?q?auna=20overlay=20integration=20test=20suite?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- .../scenes/tests/fauna_overlay_proof.gd | 172 ++++++++++++++++++ .../scenes/tests/fauna_overlay_proof.tscn | 6 + src/game/engine/scenes/world_map/world_map.gd | 10 + .../src/rendering/fauna_overlay_renderer.gd | 105 +++++++++++ .../tests/integration/test_fauna_overlay.gd | 124 +++++++++++++ 5 files changed, 417 insertions(+) create mode 100644 src/game/engine/scenes/tests/fauna_overlay_proof.gd create mode 100644 src/game/engine/scenes/tests/fauna_overlay_proof.tscn create mode 100644 src/game/engine/src/rendering/fauna_overlay_renderer.gd create mode 100644 src/game/engine/tests/integration/test_fauna_overlay.gd diff --git a/src/game/engine/scenes/tests/fauna_overlay_proof.gd b/src/game/engine/scenes/tests/fauna_overlay_proof.gd new file mode 100644 index 00000000..632523da --- /dev/null +++ b/src/game/engine/scenes/tests/fauna_overlay_proof.gd @@ -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() diff --git a/src/game/engine/scenes/tests/fauna_overlay_proof.tscn b/src/game/engine/scenes/tests/fauna_overlay_proof.tscn new file mode 100644 index 00000000..7365b03a --- /dev/null +++ b/src/game/engine/scenes/tests/fauna_overlay_proof.tscn @@ -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") diff --git a/src/game/engine/scenes/world_map/world_map.gd b/src/game/engine/scenes/world_map/world_map.gd index dac00972..7690dbd5 100644 --- a/src/game/engine/scenes/world_map/world_map.gd +++ b/src/game/engine/scenes/world_map/world_map.gd @@ -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) diff --git a/src/game/engine/src/rendering/fauna_overlay_renderer.gd b/src/game/engine/src/rendering/fauna_overlay_renderer.gd new file mode 100644 index 00000000..689446b5 --- /dev/null +++ b/src/game/engine/src/rendering/fauna_overlay_renderer.gd @@ -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() diff --git a/src/game/engine/tests/integration/test_fauna_overlay.gd b/src/game/engine/tests/integration/test_fauna_overlay.gd new file mode 100644 index 00000000..9e1c1507 --- /dev/null +++ b/src/game/engine/tests/integration/test_fauna_overlay.gd @@ -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)