diff --git a/src/game/engine/scenes/tests/flora_succession_proof.gd b/src/game/engine/scenes/tests/flora_succession_proof.gd new file mode 100644 index 00000000..c44f61d7 --- /dev/null +++ b/src/game/engine/scenes/tests/flora_succession_proof.gd @@ -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)) diff --git a/src/game/engine/scenes/tests/flora_succession_proof.tscn b/src/game/engine/scenes/tests/flora_succession_proof.tscn new file mode 100644 index 00000000..6b2c87e0 --- /dev/null +++ b/src/game/engine/scenes/tests/flora_succession_proof.tscn @@ -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")