test(mapgen): Add test scene for real-world map generation validation with GDScript logic and TSCN setup

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Claude Code 2026-04-08 15:31:53 -07:00
parent d035d67220
commit 16a4ab16fb
2 changed files with 347 additions and 0 deletions

View file

@ -0,0 +1,341 @@
extends Node2D
## Iter 7g — Real-mapgen + GdTurnProcessor proof scene.
##
## Where iter 7e used a hand-synthesized 24×24 grid with a fixed lair
## pattern, this scene drives the full mc_mapgen pipeline end-to-end:
##
## 1. Instantiate GdMapGenerator, initialize with empty params JSON,
## and call generate(seed, "duel") to produce a real 40×24 GridState
## (the smallest predefined map size — mc-mapgen does not support
## arbitrary 32×32 dimensions). The task brief asked for 32×32, but
## "duel" is the closest canonical size and still fits the 50-turn
## loop comfortably.
## 2. Hand the resulting GdGridState to a fresh GdGameState via the
## new `set_grid_from_gridstate` method (added in iter 7g to mirror
## the existing `create_grid` entry point).
## 3. Stamp 8 lairs across all tier bands (T2-T10) at deterministic
## positions, add a militarist player, and run GdTurnProcessor for
## 50 turns.
## 4. Render the final state on a Label plus a second Label containing
## an ASCII map showing terrain symbols, lair tier digits, and the
## player's city/starter-unit positions.
## 5. Capture a screenshot with the same pattern as iter_7e.
##
## This is the first proof scene where the Godot game consumes REAL
## Rust-generated terrain alongside the Rust turn processor.
const TOTAL_TURNS: int = 50
const MAP_SEED: int = 20260407
const MAP_SIZE_ID: String = "duel" # 40×24 — smallest predefined mc-mapgen size
const OUTPUT_DIR: String = "user://screenshots"
const CITY_COL: int = 20
const CITY_ROW: int = 12
var _state: RefCounted = null
var _processor: RefCounted = null
var _grid: RefCounted = null
var _title: Label = null
var _label: Label = null
var _map_label: Label = null
var _turn: int = 0
var _cumulative_encounters: int = 0
var _cumulative_deaths: int = 0
var _cumulative_t4_t6_enc: int = 0
var _cumulative_t4_t6_kills: int = 0
var _cumulative_t7_t10_enc: int = 0
var _cumulative_t7_t10_kills: int = 0
var _lair_placements: Array = []
var _grid_width: int = 0
var _grid_height: int = 0
var _captured: bool = false
var _sim_complete: bool = false
func _ready() -> void:
_build_ui()
var ok: bool = _instantiate_bridge()
if not ok:
_title.text = "FAIL: GDExtension classes not registered"
_title.add_theme_color_override("font_color", Color.RED)
await get_tree().create_timer(2.0).timeout
_capture_and_quit("iter_7g_real_mapgen_fail")
return
_generate_grid()
_attach_grid_to_state()
_seed_lairs()
_add_player()
_redraw()
await get_tree().create_timer(0.3).timeout
_run_simulation()
_redraw()
await get_tree().create_timer(1.5).timeout
_capture_and_quit("iter_7g_real_mapgen")
func _build_ui() -> void:
DisplayServer.window_set_size(Vector2i(1920, 1080))
get_viewport().size = Vector2i(1920, 1080)
var bg: ColorRect = ColorRect.new()
bg.color = Color(0.08, 0.08, 0.12)
bg.size = Vector2(1920, 1080)
add_child(bg)
_title = Label.new()
_title.text = "Iter 7g — Real mapgen + GdTurnProcessor proof"
_title.position = Vector2(60, 32)
_title.add_theme_font_size_override("font_size", 38)
_title.add_theme_color_override("font_color", Color(0.9, 0.95, 1.0))
add_child(_title)
_label = Label.new()
_label.position = Vector2(60, 100)
_label.add_theme_font_size_override("font_size", 22)
_label.add_theme_color_override("font_color", Color(0.85, 0.85, 0.9))
add_child(_label)
_map_label = Label.new()
_map_label.position = Vector2(60, 560)
_map_label.add_theme_font_size_override("font_size", 16)
_map_label.add_theme_color_override("font_color", Color(0.7, 0.95, 0.7))
add_child(_map_label)
func _instantiate_bridge() -> bool:
_processor = ClassDB.instantiate("GdTurnProcessor") as RefCounted
if _processor == null:
push_error("GdTurnProcessor not registered in GDExtension")
return false
_state = ClassDB.instantiate("GdGameState") as RefCounted
if _state == null:
push_error("GdGameState not registered in GDExtension")
return false
# Disable the advisory victory check so the 50-turn loop never trips it.
_processor.call("set_victory_city_count", 255)
_processor.call("set_max_turns", 999999)
return true
func _generate_grid() -> void:
var mapgen: RefCounted = ClassDB.instantiate("GdMapGenerator") as RefCounted
if mapgen == null:
push_error("GdMapGenerator not registered in GDExtension")
return
mapgen.call("initialize", "{}")
_grid = mapgen.call("generate", MAP_SEED, MAP_SIZE_ID) as RefCounted
if _grid == null:
push_error("GdMapGenerator.generate returned null")
return
_grid_width = int(_grid.call("get_width"))
_grid_height = int(_grid.call("get_height"))
func _attach_grid_to_state() -> void:
if _grid == null or _state == null:
return
_state.call("set_grid_from_gridstate", _grid)
func _seed_lairs() -> void:
# Ten lairs spanning T2-T10. Positions are deterministic and fit
# inside a 40×24 "duel" map. A T10 boss lair sits adjacent to the
# city so the 50-turn loop produces visible high-tier encounter
# pressure on the screenshot.
_lair_placements = [
[4, 4, 2, 101],
[6, 18, 3, 102],
[30, 5, 2, 103],
[10, 9, 5, 201],
[25, 15, 4, 202],
[3, 12, 6, 203],
[21, 12, 10, 901],
[35, 20, 8, 902],
[17, 3, 9, 903],
[4, 20, 7, 904],
]
for p: Array in _lair_placements:
var ok: bool = bool(
_state.call("stamp_lair", p[0], p[1], p[2], p[3])
)
if not ok:
push_warning(
"stamp_lair failed at (%d,%d) tier=%d" % [p[0], p[1], p[2]]
)
func _add_player() -> void:
var pi: int = int(_state.call("add_player_militarist", CITY_COL, CITY_ROW))
if pi != 0:
push_warning("expected player_index 0, got %d" % pi)
func _run_simulation() -> void:
for i: int in range(TOTAL_TURNS):
var result: Dictionary = _processor.call("step", _state)
_turn = int(result.get("turn", 0))
_cumulative_encounters += int(result.get("encounters", 0))
_cumulative_deaths += int(result.get("deaths", 0))
_cumulative_t4_t6_enc += int(result.get("t4_t6_encounters", 0))
_cumulative_t4_t6_kills += int(result.get("t4_t6_deaths", 0))
_cumulative_t7_t10_enc += int(result.get("t7_t10_encounters", 0))
_cumulative_t7_t10_kills += int(result.get("t7_t10_deaths", 0))
_sim_complete = true
func _redraw() -> void:
var cities: int = int(_state.call("city_count", 0))
var units: int = int(_state.call("unit_count", 0))
var gold: int = int(_state.call("gold", 0))
var lairs: int = int(_state.call("lair_count"))
var t4_kr: float = 0.0
if _cumulative_t4_t6_enc > 0:
t4_kr = (
100.0 * float(_cumulative_t4_t6_kills) / float(_cumulative_t4_t6_enc)
)
var t7_kr: float = 0.0
if _cumulative_t7_t10_enc > 0:
t7_kr = (
100.0 * float(_cumulative_t7_t10_kills)
/ float(_cumulative_t7_t10_enc)
)
var sim_state: String = "COMPLETE" if _sim_complete else "running"
var lines: Array[String] = [
"Bridge: GdMapGenerator → GdGridState → GdGameState → GdTurnProcessor",
"Mapgen: seed=%d size=%s (%d×%d real-terrain grid)" % [
MAP_SEED, MAP_SIZE_ID, _grid_width, _grid_height
],
"Player: militarist (exp=2 prod=5 wealth=2 culture=2) @ (%d,%d)" % [
CITY_COL, CITY_ROW
],
"Lairs: %d stamped across T2-T10" % lairs,
"",
"Turn: %d / %d (sim %s)" % [_turn, TOTAL_TURNS, sim_state],
"",
"Player 0 state:",
" cities: %d units: %d gold: %d" % [cities, units, gold],
"",
"Fauna pressure (cumulative):",
" encounters: %d" % _cumulative_encounters,
" unit deaths: %d" % _cumulative_deaths,
" T4-T6: %d / %d = %.1f%% kill rate" % [
_cumulative_t4_t6_kills, _cumulative_t4_t6_enc, t4_kr
],
" T7-T10: %d / %d = %.1f%% kill rate" % [
_cumulative_t7_t10_kills, _cumulative_t7_t10_enc, t7_kr
],
]
_label.text = "\n".join(lines)
_map_label.text = _render_ascii_map()
func _render_ascii_map() -> String:
if _grid == null or _grid_width <= 0 or _grid_height <= 0:
return "(no grid)"
# Build lair lookup keyed on "col,row" → tier.
var lair_lookup: Dictionary = {}
for p: Array in _lair_placements:
var key: String = "%d,%d" % [int(p[0]), int(p[1])]
lair_lookup[key] = int(p[2])
var header: String = "Real-terrain map (#=city u=unit 1-9/A=lair tier .,~,^,*,o=biome)"
var rows: Array[String] = [header, ""]
for row: int in range(_grid_height):
var parts: Array[String] = []
for col: int in range(_grid_width):
var key: String = "%d,%d" % [col, row]
if col == CITY_COL and row == CITY_ROW:
parts.append("#")
continue
if col > CITY_COL and col < CITY_COL + 3 and row == CITY_ROW:
# Starter units spawn here in add_player_militarist.
parts.append("u")
continue
if lair_lookup.has(key):
var tier: int = int(lair_lookup[key])
parts.append(_tier_glyph(tier))
continue
var tile: Dictionary = _grid.call("get_tile_dict", col, row) as Dictionary
var biome_id: String = str(tile.get("biome_id", ""))
parts.append(_biome_glyph(biome_id))
rows.append("".join(parts))
return "\n".join(rows)
func _tier_glyph(tier: int) -> String:
if tier <= 0:
return "."
if tier <= 9:
return str(tier)
return "A" # T10 renders as A
func _biome_glyph(biome_id: String) -> String:
var glyph: String = "."
if biome_id == "":
return glyph
if (
biome_id.contains("ocean")
or biome_id.contains("sea")
or biome_id.contains("water")
):
glyph = "~"
elif biome_id.contains("mountain") or biome_id.contains("hill"):
glyph = "^"
elif (
biome_id.contains("forest")
or biome_id.contains("jungle")
or biome_id.contains("taiga")
):
glyph = "*"
elif biome_id.contains("desert"):
glyph = ":"
elif (
biome_id.contains("tundra")
or biome_id.contains("ice")
or biome_id.contains("snow")
):
glyph = "o"
return glyph
func _capture_and_quit(shot_name: String) -> 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("iter_7g_proof: viewport get_image returned null")
get_tree().quit(1)
return
var timestamp: String = Time.get_datetime_string_from_system().replace(
":", "-"
).replace("T", "_")
var rel_path: String = "%s/%s_%s.png" % [OUTPUT_DIR, shot_name, timestamp]
var abs_path: String = ProjectSettings.globalize_path(rel_path)
var err: Error = image.save_png(abs_path)
if err == OK:
print("SCREENSHOT_PATH:%s" % abs_path)
print("iter_7g_proof: %dx%d saved to %s" % [
image.get_width(), image.get_height(), abs_path
])
else:
push_error("iter_7g_proof: save failed: %s" % error_string(err))
get_tree().quit()

View file

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