feat(engine): ✨ add flora succession test suite
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
147095355c
commit
c8491ead8d
2 changed files with 256 additions and 0 deletions
250
src/game/engine/scenes/tests/flora_succession_proof.gd
Normal file
250
src/game/engine/scenes/tests/flora_succession_proof.gd
Normal 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))
|
||||
6
src/game/engine/scenes/tests/flora_succession_proof.tscn
Normal file
6
src/game/engine/scenes/tests/flora_succession_proof.tscn
Normal 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")
|
||||
Loading…
Add table
Reference in a new issue