feat(engine): add flora succession test suite

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-06-09 22:42:21 -07:00
parent 147095355c
commit c8491ead8d
2 changed files with 256 additions and 0 deletions

View file

@ -0,0 +1,250 @@
extends Node2D
## g2-07 render proof — flora succession is VISIBLE on the world map over N played
## turns. Drives the EXACT production worldsim turn pair the live `turn_manager`
## loop runs — `Climate.process_turn(game_map, t, seed)` (which owns the Rust
## `GdGridState` and runs `mc-climate::EcologyPhysics` flora succession: canopy /
## undergrowth growth per turn) then `EcologyState.tick` — on a REAL worldgen map.
##
## The flora-cover layer (the same `canopy_cover` / flora-cover palette
## `hex_renderer.gd` draws as Layer 2) is captured at an EARLY turn and the FINAL
## turn. The visible delta between the two frames — bare/scrub tiles greening into
## open-grass and closed-canopy as succession advances — IS the proof. The printed
## `succeeded_tiles` count (tiles whose flora-cover class advanced over the run) is
## the success metric; the screenshots only prove the bullet if that number is > 0.
##
## Self-capturing (models fauna_overlay_proof.gd): renders early frame, runs the
## rest of the turns, renders final frame, screenshots each, quits. Headless via
## weston (Rail 5 + scripts/ui-proof-capture.sh).
const MapGeneratorScript: GDScript = preload("res://engine/src/generation/map_generator.gd")
const PlayerScript: GDScript = preload("res://engine/src/entities/player.gd")
const UnitScript: GDScript = preload("res://engine/src/entities/unit.gd")
const HexUtilsScript: GDScript = preload("res://engine/src/map/hex_utils.gd")
const ClimateScript: GDScript = preload("res://engine/src/modules/climate/climate.gd")
const OUTPUT_DIR: String = "user://screenshots"
## Real new-game settings (mirrors fauna_overlay_proof). A small map renders
## legibly; continents gives a mix of forest / grassland / scrub substrate so
## succession has visibly distinct cover classes to move between.
const NEW_GAME: Dictionary = {
"seed": 5, "map_type": "continents", "map_size": "duel", "num_players": 2,
}
## Early snapshot turn (succession barely started) vs the full run (succession
## visibly advanced). 40 turns lets canopy/undergrowth climb several cover classes.
const EARLY_TURN: int = 3
const TURNS: int = 40
## Flora-cover palette — identical keys/colors to hex_renderer.gd Layer 2 so the
## proof shows exactly what the live renderer would.
const FLORA_COVER_COLORS: Dictionary = {
"closed_canopy": Color(0.10, 0.28, 0.10, 0.80),
"open_grass": Color(0.60, 0.78, 0.25, 0.75),
"scrub": Color(0.42, 0.35, 0.18, 0.70),
"aquatic_cover": Color(0.18, 0.48, 0.72, 0.65),
"bare": Color(0.0, 0.0, 0.0, 0.0),
}
## Ordinal rank of each cover class along the succession gradient (bare → canopy).
## Used to count tiles that ADVANCED (not merely changed) between the two frames.
const COVER_RANK: Dictionary = {
"bare": 0, "aquatic_cover": 0, "scrub": 1, "open_grass": 2, "closed_canopy": 3,
}
var _game_map: RefCounted = null
var _climate: RefCounted = null
var _all_positions: Array[Vector2i] = []
var _early_cover: Dictionary = {} # Vector2i → flora_cover_id at EARLY_TURN
var _frame_cover: Dictionary = {} # Vector2i → flora_cover_id currently drawn
var _captured_early: bool = false
func _ready() -> void:
RenderingServer.set_default_clear_color(Color(0.03, 0.04, 0.05))
get_viewport().size = Vector2i(1920, 1080)
DisplayServer.window_set_size(Vector2i(1920, 1080))
await get_tree().process_frame
_build_real_game()
_setup_camera()
# Run to the early snapshot turn, capture the early flora-cover frame.
_run_turns(0, EARLY_TURN)
_early_cover = _read_flora_cover()
_frame_cover = _early_cover
queue_redraw()
for _i: int in range(8):
await get_tree().process_frame
_capture("early", EARLY_TURN)
# Run the rest of the turns, capture the final (succeeded) flora-cover frame.
_run_turns(EARLY_TURN, TURNS)
_frame_cover = _read_flora_cover()
queue_redraw()
for _i: int in range(8):
await get_tree().process_frame
_print_stats()
_capture("final", TURNS)
get_tree().quit()
func _build_real_game() -> void:
DataLoader.load_theme("age-of-dwarves")
DataLoader.load_world("earth")
ThemeAssets.set_theme("age-of-dwarves")
GameState.initialize_game(NEW_GAME)
var gen: RefCounted = MapGeneratorScript.new()
_game_map = gen.generate(NEW_GAME)
if _game_map == null:
push_error("FloraSuccessionProof: MapGenerator returned null")
get_tree().quit(1)
return
GameState.get_primary_layer()["map"] = _game_map
for pos: Vector2i in _game_map.tiles:
_all_positions.append(pos)
for i: int in range(int(NEW_GAME["num_players"])):
var player: PlayerScript = PlayerScript.new()
player.index = i
player.is_human = i == 0
player.player_name = "Clan %d" % (i + 1)
player.race_id = "dwarf"
GameState.players.append(player)
var start: Vector2i = Vector2i.ZERO
if i < _game_map.start_positions.size():
start = _game_map.start_positions[i]
var founder: UnitScript = UnitScript.new("dwarf_founder", i, start)
founder.id = "founder_%d" % i
player.units.append(founder)
## The production per-turn worldsim pair for turns [from, to): climate physics
## (mc-climate flora succession on canopy/undergrowth) then the fauna engine —
## identical to turn_manager's sequence.
func _run_turns(from_turn: int, to_turn: int) -> void:
if _climate == null:
_climate = ClimateScript.new()
EcologyState.reset()
for t: int in range(from_turn, to_turn):
_climate.process_turn(_game_map, t, int(NEW_GAME["seed"]))
var grid: RefCounted = _climate.get("_grid") as RefCounted
if grid == null:
push_error("FloraSuccessionProof: climate built no GdGridState")
get_tree().quit(1)
return
EcologyState.tick(grid, int(NEW_GAME["seed"]) + t)
## Read the current per-tile flora-cover class from the synced GameMap tiles
## (canopy_cover / undergrowth, written back by climate._sync_grid_to_tiles) using
## the same classification the renderer applies. Land tiles only.
func _read_flora_cover() -> Dictionary:
var out: Dictionary = {}
for pos: Vector2i in _all_positions:
var tile: RefCounted = _game_map.get_tile(pos)
if tile == null:
continue
out[pos] = _cover_class(tile)
return out
## Classify a tile's flora cover from its succession state. Canopy dominates
## (closed forest), then undergrowth (grass), else scrub on any vegetated land,
## else bare. Mirrors the canopy/undergrowth-driven flora_cover_id derivation.
func _cover_class(tile: RefCounted) -> String:
var canopy: float = float(tile.get("canopy_cover"))
var under: float = float(tile.get("undergrowth"))
if canopy >= 0.35:
return "closed_canopy"
if under >= 0.30:
return "open_grass"
if canopy > 0.02 or under > 0.02:
return "scrub"
return "bare"
func _setup_camera() -> void:
var min_p: Vector2 = Vector2(INF, INF)
var max_p: Vector2 = Vector2(-INF, -INF)
for pos: Vector2i in _all_positions:
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 _draw() -> void:
for pos: Vector2i in _all_positions:
var o: Vector2 = HexUtilsScript.axial_to_pixel(pos)
var poly: PackedVector2Array = PackedVector2Array()
for v: Vector2 in HexUtilsScript.hex_polygon:
poly.append(v + o)
# Dim land backdrop so the flora-cover tint reads on top.
draw_colored_polygon(poly, Color(0.16, 0.18, 0.16, 1.0))
var cover: String = String(_frame_cover.get(pos, "bare"))
var col: Color = FLORA_COVER_COLORS.get(cover, Color(0, 0, 0, 0))
if col.a > 0.0:
draw_colored_polygon(poly, col)
draw_polyline(poly + PackedVector2Array([poly[0]]), Color(0.08, 0.10, 0.08, 0.5), 1.5)
func _print_stats() -> void:
var counts: Dictionary = {"bare": 0, "scrub": 0, "open_grass": 0, "closed_canopy": 0}
for pos: Vector2i in _frame_cover:
var c: String = String(_frame_cover[pos])
var key: String = c if c != "aquatic_cover" else "bare"
counts[key] = int(counts.get(key, 0)) + 1
var succeeded: int = 0
for pos: Vector2i in _early_cover:
var before: int = int(COVER_RANK.get(String(_early_cover[pos]), 0))
var after: int = int(COVER_RANK.get(String(_frame_cover.get(pos, "bare")), 0))
if after > before:
succeeded += 1
# Diagnostic: max canopy/undergrowth + biome-label histogram so a flat result
# is debuggable (label mismatch vs genuinely-no-growth).
var max_canopy: float = 0.0
var max_under: float = 0.0
var biome_hist: Dictionary = {}
for pos: Vector2i in _all_positions:
var tl: RefCounted = _game_map.get_tile(pos)
if tl == null:
continue
max_canopy = maxf(max_canopy, float(tl.get("canopy_cover")))
max_under = maxf(max_under, float(tl.get("undergrowth")))
var b: String = String(tl.get("biome_id"))
biome_hist[b] = int(biome_hist.get(b, 0)) + 1
print("=== g2-07 Flora Succession Proof (real worldgen, played turns) ===")
print("Map: %s, early=turn %d → final=turn %d, seed %d, %d tiles" % [
NEW_GAME["map_size"], EARLY_TURN, TURNS, int(NEW_GAME["seed"]), _all_positions.size()
])
print("max canopy=%.3f max undergrowth=%.3f" % [max_canopy, max_under])
print("biome histogram: %s" % str(biome_hist))
print("Final flora-cover classes: %s" % str(counts))
print("succeeded_tiles (flora-cover class advanced %d%d turns): %d" % [
EARLY_TURN, TURNS, succeeded
])
func _capture(label: String, turn: int) -> void:
DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(OUTPUT_DIR))
var image: Image = get_viewport().get_texture().get_image()
if image == null:
push_error("FloraSuccessionProof: failed to get viewport image")
return
var abs_path: String = ProjectSettings.globalize_path(
"%s/flora_succession_proof_%s_t%d.png" % [OUTPUT_DIR, label, turn]
)
var err: Error = image.save_png(abs_path)
if err == OK:
print("SCREENSHOT_PATH:%s" % abs_path)
print("Screenshot[%s]: %dx%d saved" % [label, image.get_width(), image.get_height()])
else:
push_error("FloraSuccessionProof: save failed: %s" % error_string(err))

View file

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