✅ test(engine): ✨ add fauna overlay integration test suite
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
27ade4cd43
commit
21257ea4bc
5 changed files with 417 additions and 0 deletions
172
src/game/engine/scenes/tests/fauna_overlay_proof.gd
Normal file
172
src/game/engine/scenes/tests/fauna_overlay_proof.gd
Normal 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()
|
||||
6
src/game/engine/scenes/tests/fauna_overlay_proof.tscn
Normal file
6
src/game/engine/scenes/tests/fauna_overlay_proof.tscn
Normal 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")
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
105
src/game/engine/src/rendering/fauna_overlay_renderer.gd
Normal file
105
src/game/engine/src/rendering/fauna_overlay_renderer.gd
Normal 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()
|
||||
124
src/game/engine/tests/integration/test_fauna_overlay.gd
Normal file
124
src/game/engine/tests/integration/test_fauna_overlay.gd
Normal 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)
|
||||
Loading…
Add table
Reference in a new issue