feat(@projects/@magic-civilization): expand ai sanity test to multi-clan scenario

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-04-17 08:40:34 -07:00
parent b931777cf2
commit 084661c4ba
2 changed files with 389 additions and 281 deletions

View file

@ -1,9 +1,15 @@
# gdlint: disable=no-elif-return,no-else-return,max-returns,class-definitions-order
extends Node2D
## AI Sanity Proof Scene.
## Proves: 1 AI Dwarf player runs 200 turns headless on a real generated map.
## Validates: city founding, research, production, expansion (second city by T80).
## Uses sandbox "test_landmass" map preset with seed 42 for determinism.
## AI Sanity Proof Scene — Phase Gate for the Warcouncil AI work.
##
## Spawns 5 AI Dwarf players, one per clan (Ironhold / Goldvein / Blackhammer /
## Deepforge / Runesmith), runs 50 turns on the shared generated map, then
## renders a per-clan overlay with cities, units, gold, and MCTS telemetry
## (rollouts, win_rate, rollout path) sourced from
## `AiTurnBridge.get_last_mcts_stats(turn, player_index)`.
##
## Drives AI turns via `AiTurnBridge.run(player)` — the live game path. The
## earlier `(_ai as AIPlayerScript).process_turn(...)` call targeted a stub
## class with no such method and silently no-op'd.
const PlayerScript: GDScript = preload("res://engine/src/entities/player.gd")
const UnitScript: GDScript = preload("res://engine/src/entities/unit.gd")
@ -11,9 +17,9 @@ const CityScript: GDScript = preload("res://engine/src/entities/city.gd")
const GameMapScript: GDScript = preload("res://engine/src/map/game_map.gd")
const TileScript: GDScript = preload("res://engine/src/map/tile.gd")
const StubTurnManagerScript: GDScript = preload("res://engine/scenes/tests/_stub_turn_manager.gd")
const AIPlayerScript: GDScript = preload("res://engine/src/modules/ai/ai_player.gd")
const TechWebScript: GDScript = preload("res://engine/src/modules/tech/tech_web.gd")
const MapGeneratorScript: GDScript = preload("res://engine/src/generation/map_generator.gd")
const AiTurnBridgeScript: GDScript = preload("res://engine/src/modules/ai/ai_turn_bridge.gd")
const TurnProcessorScript: GDScript = preload(
"res://engine/src/modules/management/turn_processor.gd"
)
@ -37,27 +43,23 @@ const EcologyDBScript: GDScript = preload(
)
const MAP_SEED: int = 42
const TOTAL_TURNS: int = 200
const DIFFICULTY: int = 5
const TOTAL_TURNS: int = 50
const OUTPUT_DIR: String = "user://screenshots"
const CLAN_IDS: Array[String] = [
"ironhold", "goldvein", "blackhammer", "deepforge", "runesmith",
]
var _ai: RefCounted = null
var _tech_web: RefCounted = null
var _processor: RefCounted = null
var _unit_manager: RefCounted = null
var _spell_system: RefCounted = null
var _mock_tm: Node = null
var _player: RefCounted = null
var _players: Array[RefCounted] = []
var _captured: bool = false
var _screenshot_name: String = "ai_sanity_proof"
var _screenshot_name: String = "ai_phase_abc_proof"
var _error_log: Array[String] = []
var _turn_log: Array[Dictionary] = []
var _sim_complete: bool = false
var _first_city_turn: int = -1
var _first_research_turn: int = -1
var _first_production_turn: int = -1
var _second_city_turn: int = -1
var _assertions_passed: bool = false
var _final_turn: int = 0
func _ready() -> void:
@ -71,8 +73,9 @@ func _ready() -> void:
await get_tree().process_frame
print("=== AI Sanity Proof: 1 Dwarf AI, %d turns, sandbox test_landmass seed %d ===" % [
TOTAL_TURNS, MAP_SEED])
print("=== AI Sanity Proof: 5 clans, %d turns, seed %d ===" % [
TOTAL_TURNS, MAP_SEED,
])
_initialize()
_run_simulation()
_sim_complete = true
@ -86,7 +89,6 @@ func _ready() -> void:
func _initialize() -> void:
DataLoader.load_theme("age-of-dwarves")
_ai = AIPlayerScript.new()
_tech_web = TechWebScript.new()
_unit_manager = UnitManagerScript.new()
_spell_system = SpellSystemScript.new()
@ -112,17 +114,20 @@ func _initialize() -> void:
var game_map: RefCounted = GameState.get_game_map()
var map_w: int = (game_map as GameMapScript).width if game_map is GameMapScript else 0
var map_h: int = (game_map as GameMapScript).height if game_map is GameMapScript else 0
print(" Map: %dx%d test_landmass, seed %d" % [map_w, map_h, MAP_SEED])
print(" Player: %s (race=%s, is_player_controlled=%s)" % [
_player.player_name, _player.race_id, str(_player.is_player_controlled)
])
print(" Founder at: %s" % str(_player.units[0].position))
print(" Map: %dx%d pangaea, seed %d" % [map_w, map_h, MAP_SEED])
for p: RefCounted in _players:
var founder_pos_str: String = (
str(p.units[0].position) if not p.units.is_empty() else "none"
)
print(" Player %d: %s (clan=%s) founder at %s" % [
p.index, p.player_name, str(p.get("clan_id")), founder_pos_str,
])
func _setup_game_state() -> void:
GameState.initialize_game({
"difficulty": "normal",
"num_players": 1,
"num_players": CLAN_IDS.size(),
"victory_domination": false,
"victory_score": false,
"turn_limit_enabled": false,
@ -131,35 +136,51 @@ func _setup_game_state() -> void:
var generator: RefCounted = MapGeneratorScript.new()
var game_map: RefCounted = generator.generate({
"map_size": "duel",
"map_size": "standard",
"map_type": "pangaea",
"num_players": 1,
"num_players": CLAN_IDS.size(),
"seed": MAP_SEED,
})
var primary: Dictionary = GameState.get_primary_layer()
primary["map"] = game_map
_player = _create_ai_player("AI Dwarf King", "dwarf")
var race_data: Dictionary = DataLoader.get_race("dwarves")
var origin_tech: String = race_data.get("origin_tech", "")
if not origin_tech.is_empty() and not _player.researched_techs.has(origin_tech):
_player.researched_techs.append(origin_tech)
var start_pos: Vector2i = _find_start_position(game_map)
var founder: RefCounted = _create_founder(0, start_pos)
_player.units.append(founder)
primary["units"] = [founder]
var start_positions: Array[Vector2i] = _collect_start_positions(
game_map, CLAN_IDS.size()
)
var all_units: Array[RefCounted] = []
for i: int in range(CLAN_IDS.size()):
var clan_id: String = CLAN_IDS[i]
var personality: Dictionary = DataLoader.get_ai_personality(clan_id)
var clan_name: String = String(personality.get("name", clan_id.capitalize()))
var player: RefCounted = _create_ai_player(
"%s Clan" % clan_name, "dwarves", clan_id, personality,
)
if not origin_tech.is_empty() and not player.researched_techs.has(origin_tech):
player.researched_techs.append(origin_tech)
var start_pos: Vector2i = start_positions[i]
var founder: RefCounted = _create_founder(player.index, start_pos)
player.units.append(founder)
all_units.append(founder)
_players.append(player)
primary["units"] = all_units
func _find_start_position(game_map: RefCounted) -> Vector2i:
func _collect_start_positions(
game_map: RefCounted, count: int
) -> Array[Vector2i]:
var gmap: GameMapScript = game_map as GameMapScript
if not gmap.start_positions.is_empty():
return gmap.start_positions[0]
var out: Array[Vector2i] = []
if gmap.start_positions.size() >= count:
for i: int in range(count):
out.append(gmap.start_positions[i])
return out
var best_pos: Vector2i = Vector2i(0, 0)
var best_score: float = -1.0
var candidates: Array[Vector2i] = []
for pos: Vector2i in gmap.tiles.keys():
var tile: RefCounted = gmap.tiles[pos]
if tile == null or not tile is TileScript:
@ -167,188 +188,127 @@ func _find_start_position(game_map: RefCounted) -> Vector2i:
var t: TileScript = tile as TileScript
if t.is_water() or t.is_natural_wonder():
continue
var tile_yields: Dictionary = t.get_yields()
var food_score: float = float(tile_yields.get("food", 0)) * 3.0
var score: float = food_score + float(tile_yields.get("production", 0)) * 2.0
if score > best_score:
best_score = score
best_pos = pos
candidates.append(pos)
candidates.sort_custom(func(a: Vector2i, b: Vector2i) -> bool:
return a.x * 1000 + a.y < b.x * 1000 + b.y)
if best_score < 0.0:
push_error("AISanityProof: No suitable land tile found on map")
return best_pos
var stride: int = maxi(1, candidates.size() / maxi(1, count))
for i: int in range(count):
var idx: int = mini(candidates.size() - 1, i * stride)
out.append(candidates[idx])
return out
func _create_ai_player(p_name: String, race_id: String) -> RefCounted:
func _create_ai_player(
p_name: String, race_id: String, clan_id: String, personality: Dictionary
) -> RefCounted:
var player: RefCounted = PlayerScript.new()
player.player_name = p_name
player.race_id = race_id
if "clan_id" in player:
player.clan_id = clan_id
player.is_player_controlled = false
player.science_per_turn = 3
player.gold = 50
player.gold_per_turn = 5
player.happiness = 5
var axes_any: Dictionary = personality.get("strategic_axes", {}) as Dictionary
if not axes_any.is_empty():
player.strategic_axes = axes_any
GameState.add_player(player)
return player
func _create_founder(owner_idx: int, pos: Vector2i) -> RefCounted:
var unit: RefCounted = UnitScript.new()
(unit as UnitScript).id = "founder_%d" % owner_idx
(unit as UnitScript).type_id = "dwarf_founder"
(unit as UnitScript).owner = owner_idx
(unit as UnitScript).position = pos
(unit as UnitScript).unit_type = "civilian"
(unit as UnitScript).can_found_city = true
var unit: UnitScript = UnitScript.new()
unit.id = "founder_%d" % owner_idx
unit.type_id = "dwarf_founder"
unit.owner = owner_idx
unit.position = pos
unit.unit_type = "civilian"
unit.can_found_city = true
var data: Dictionary = DataLoader.get_unit("dwarf_founder")
if not data.is_empty():
(unit as UnitScript).apply_data(data)
(unit as UnitScript).owner = owner_idx
(unit as UnitScript).position = pos
(unit as UnitScript).id = "founder_%d" % owner_idx
unit.apply_data(data)
unit.owner = owner_idx
unit.position = pos
unit.id = "founder_%d" % owner_idx
return unit
func _run_simulation() -> void:
for turn_idx: int in range(TOTAL_TURNS):
var turn_num: int = turn_idx + 1
GameState.current_player_index = 0
_refresh_all_units()
(_ai as AIPlayerScript).process_turn(_player, _mock_tm, DIFFICULTY)
_run_end_of_turn()
_track_milestones(turn_num)
_record_turn(turn_num)
for p: RefCounted in _players:
GameState.current_player_index = p.index
_refresh_units(p)
AiTurnBridgeScript.run(p)
_run_end_of_turn(p)
_sync_layer_units()
GameState.turn_number += 1
_run_assertions()
_final_turn = GameState.turn_number
print("[AI SANITY] === Simulation complete: %d turns ===" % TOTAL_TURNS)
print("[AI SANITY] Final: gold=%d cities=%d units=%d techs=%d pop=%d" % [
_player.gold, _player.cities.size(), _player.units.size(),
_player.researched_techs.size(), _get_total_pop(),
])
print("[AI SANITY] %s: city1=T%s research=T%s production=T%s city2=T%s" % [
"PASS" if _assertions_passed else "FAIL",
str(_first_city_turn) if _first_city_turn > 0 else "NEVER",
str(_first_research_turn) if _first_research_turn > 0 else "NEVER",
str(_first_production_turn) if _first_production_turn > 0 else "NEVER",
str(_second_city_turn) if _second_city_turn > 0 else "NEVER",
])
for p: RefCounted in _players:
var stats: Dictionary = AiTurnBridgeScript.get_last_mcts_stats(
_final_turn - 1, p.index
)
print(
("[AI SANITY] clan=%s cities=%d units=%d gold=%d "
+ "mcts_path=%s rollouts=%d win_rate=%s action=%s") % [
str(p.get("clan_id")), p.cities.size(), p.units.size(), p.gold,
str(stats.get("path")), int(stats.get("rollouts", 0)),
str(stats.get("win_rate")), str(stats.get("action")),
]
)
if not _error_log.is_empty():
for err: String in _error_log:
print("[AI SANITY] ERROR: %s" % err)
func _track_milestones(turn_num: int) -> void:
if _first_city_turn < 0 and _player.cities.size() > 0:
_first_city_turn = turn_num
var gmap: RefCounted = GameState.get_game_map()
var c0: CityScript = _player.cities[0] as CityScript
var yld: Dictionary = c0.get_yields(gmap)
var surp: int = c0.get_food_surplus(gmap)
print("[AI SANITY] City founded at %s: food=%d prod=%d surplus=%d owned=%d worked=%d" % [
str(c0.position), yld.get("food", 0), yld.get("production", 0),
surp, c0.owned_tiles.size(), c0.worked_tiles.size(),
])
var center_tile: RefCounted = gmap.get_tile(c0.position)
if center_tile != null and center_tile is TileScript:
print("[AI SANITY] Center tile biome=%s quality=%d" % [
(center_tile as TileScript).biome_id, (center_tile as TileScript).quality,
])
if _second_city_turn < 0 and _player.cities.size() >= 2:
_second_city_turn = turn_num
if _first_research_turn < 0 and not _player.researching.is_empty():
_first_research_turn = turn_num
if _first_production_turn < 0:
for city: Variant in _player.cities:
if city is CityScript and not (city as CityScript).production_queue.is_empty():
_first_production_turn = turn_num
break
func _run_assertions() -> void:
var checks: Array[Array] = [
["city founded by T10", _first_city_turn, 10],
["research started by T10", _first_research_turn, 10],
["production queued by T15", _first_production_turn, 15],
["second city founded by T80", _second_city_turn, 80],
]
var all_pass: bool = true
for c: Array in checks:
var t: int = c[1] as int
if t <= 0 or t > (c[2] as int):
_error_log.append("ASSERT FAIL: %s (was: %s)" % [c[0], "T%d" % t if t > 0 else "never"])
all_pass = false
_assertions_passed = all_pass
func _run_end_of_turn() -> void:
func _run_end_of_turn(player: RefCounted) -> void:
var game_map: RefCounted = GameState.get_game_map()
if game_map == null:
_error_log.append("Turn %d: game_map is null" % GameState.turn_number)
return
var proc: TurnProcessorScript = _processor as TurnProcessorScript
proc._process_growth(_player)
proc._process_production(_player)
proc._process_economy(_player, game_map)
proc._process_research(_player)
proc._process_culture(_player, game_map)
proc._process_golden_age(_player, game_map)
proc._process_mana(_player, game_map)
proc._process_healing(_player)
proc._process_improvements(_player)
proc._process_spell_system(_player)
proc._process_government(_player)
_sync_layer_units()
proc._process_growth(player)
proc._process_production(player)
proc._process_economy(player, game_map)
proc._process_research(player)
proc._process_culture(player, game_map)
proc._process_golden_age(player, game_map)
proc._process_mana(player, game_map)
proc._process_healing(player)
proc._process_improvements(player)
proc._process_spell_system(player)
proc._process_government(player)
func _refresh_all_units() -> void:
for unit: Variant in _player.units:
if unit is UnitScript:
(unit as UnitScript).refresh_turn()
func _refresh_units(player: RefCounted) -> void:
for unit: UnitScript in player.units:
if unit != null:
unit.refresh_turn()
func _sync_layer_units() -> void:
var primary: Dictionary = GameState.get_primary_layer()
if not primary.is_empty():
primary["units"] = _player.units.duplicate()
if primary.is_empty():
return
var merged: Array[RefCounted] = []
for p: RefCounted in _players:
for u: UnitScript in p.units:
if u != null:
merged.append(u)
primary["units"] = merged
func _record_turn(turn_num: int) -> void:
var prod_head: String = "none"
for city: Variant in _player.cities:
if city is CityScript and not (city as CityScript).production_queue.is_empty():
prod_head = (city as CityScript).production_queue[0].get("id", "?")
break
var research_str: String = _player.researching if not _player.researching.is_empty() else "none"
var snapshot: Dictionary = {
"turn": turn_num,
"gold": _player.gold,
"gpt": _player.gold_per_turn,
"spt": _player.science_per_turn,
"cities": _player.cities.size(),
"units": _player.units.size(),
"techs": _player.researched_techs.size(),
"researching": research_str,
"production": prod_head,
"pop": _get_total_pop(),
}
_turn_log.append(snapshot)
print("[AI SANITY] Turn %d: cities=%d units=%d research=%s production=%s" % [
turn_num, snapshot["cities"], snapshot["units"],
research_str, prod_head,
])
func _get_total_pop() -> int:
func _get_total_pop(player: RefCounted) -> int:
var total: int = 0
for city: Variant in _player.cities:
if city is CityScript:
total += (city as CityScript).population
for city: CityScript in player.cities:
if city != null:
total += city.population
return total
@ -358,117 +318,110 @@ func _draw() -> void:
var font: Font = ThemeDB.fallback_font
var y: float = 24.0
var lh: float = 14.0
var col1_x: float = 20.0
var col2_x: float = 500.0
var col3_x: float = 980.0
var lh: float = 16.0
draw_string(font, Vector2(col1_x, y),
"AI Sanity Proof -- 1 Dwarf AI, %d turns, sandbox test_landmass seed %d" % [
TOTAL_TURNS, MAP_SEED],
HORIZONTAL_ALIGNMENT_LEFT, -1, 14, Color.WHITE)
draw_string(font, Vector2(24, y),
"AI Sanity Proof -- 5 clans, %d turns, seed %d" % [TOTAL_TURNS, MAP_SEED],
HORIZONTAL_ALIGNMENT_LEFT, -1, 16, Color.WHITE)
y += lh + 8
var status_color: Color = Color(0.3, 0.9, 0.3) if _assertions_passed else Color(0.9, 0.3, 0.3)
draw_string(font, Vector2(col1_x, y),
"Status: %s" % ("PASS" if _assertions_passed else "FAIL (%d errors)" % _error_log.size()),
HORIZONTAL_ALIGNMENT_LEFT, -1, 13, status_color)
y += lh + 4
draw_rect(Rect2(col1_x, y, 900, 1), Color(0.35, 0.30, 0.22))
y += 8
draw_string(font, Vector2(col1_x, y), "FINAL STATE (Turn %d)" % TOTAL_TURNS,
HORIZONTAL_ALIGNMENT_LEFT, -1, 11, Color(0.8, 0.6, 0.2))
y += lh + 2
var summary_rows: Array[Array] = [
["Player", _player.player_name, Color(1.0, 0.85, 0.4)],
["Race", _player.race_id, Color(0.7, 0.6, 0.5)],
["Gold", "%d (+%d/turn)" % [_player.gold, _player.gold_per_turn], Color(0.95, 0.85, 0.15)],
["Science", "%d/turn" % _player.science_per_turn, Color(0.35, 0.75, 1.0)],
["Cities", str(_player.cities.size()), Color(0.9, 0.6, 0.2)],
["Units", str(_player.units.size()), Color(0.7, 0.7, 0.7)],
["Population", str(_get_total_pop()), Color(0.8, 0.7, 0.5)],
["Techs", str(_player.researched_techs.size()), Color(0.4, 0.8, 0.9)],
["Happiness", str(_player.happiness), Color(0.9, 0.4, 0.8)],
draw_rect(Rect2(24, y, 1872, 1), Color(0.35, 0.30, 0.22))
y += 10
var headers: Array[String] = [
"Clan", "Cities", "Units", "Pop", "Gold", "Techs",
"MCTS Path", "Rollouts", "Win%", "Last Action",
]
for row: Array in summary_rows:
draw_string(font, Vector2(col1_x, y),
"%s: %s" % [row[0], row[1]],
HORIZONTAL_ALIGNMENT_LEFT, -1, 10, row[2] as Color)
y += lh
var col_x: Array[float] = [
24.0, 220.0, 330.0, 430.0, 530.0, 650.0,
770.0, 930.0, 1070.0, 1180.0,
]
for i: int in range(headers.size()):
draw_string(font, Vector2(col_x[i], y), headers[i],
HORIZONTAL_ALIGNMENT_LEFT, -1, 12,
Color(0.8, 0.6, 0.2))
y += lh + 4
draw_rect(Rect2(24, y, 1872, 1), Color(0.35, 0.30, 0.22))
y += 6
var clan_colors: Dictionary = {
"ironhold": Color(0.85, 0.55, 0.3),
"goldvein": Color(0.95, 0.85, 0.2),
"blackhammer": Color(0.85, 0.25, 0.25),
"deepforge": Color(0.45, 0.35, 0.75),
"runesmith": Color(0.4, 0.8, 0.9),
}
for p: RefCounted in _players:
var stats: Dictionary = AiTurnBridgeScript.get_last_mcts_stats(
_final_turn - 1, p.index
)
var clan_id: String = str(p.get("clan_id"))
var clan_color: Color = clan_colors.get(clan_id, Color.WHITE) as Color
var win_rate_any: float = float(stats.get("win_rate", -1.0))
var win_str: String = (
"--" if win_rate_any < 0.0 else "%.1f%%" % (win_rate_any * 100.0)
)
var values: Array[String] = [
p.player_name,
str(p.cities.size()),
str(p.units.size()),
str(_get_total_pop(p)),
str(p.gold),
str(p.researched_techs.size()),
String(stats.get("path", "--")),
str(int(stats.get("rollouts", 0))),
win_str,
String(stats.get("action", "--")),
]
for i: int in range(values.size()):
var col: Color = clan_color if i == 0 else Color(0.85, 0.85, 0.85)
draw_string(font, Vector2(col_x[i], y), values[i],
HORIZONTAL_ALIGNMENT_LEFT, -1, 12, col)
y += lh + 2
y += 16
draw_rect(Rect2(24, y, 1872, 1), Color(0.35, 0.30, 0.22))
y += 8
draw_string(font, Vector2(col1_x, y), "RESEARCHED TECHS",
HORIZONTAL_ALIGNMENT_LEFT, -1, 11, Color(0.8, 0.6, 0.2))
y += lh + 2
var techs: Array = _player.researched_techs
var tech_list: String = ", ".join(techs) if not techs.is_empty() else "(none)"
for chunk_i: int in range(0, tech_list.length(), 50):
draw_string(font, Vector2(col1_x, y), tech_list.substr(chunk_i, 50),
HORIZONTAL_ALIGNMENT_LEFT, -1, 9, Color(0.4, 0.8, 0.9))
y += lh
var log_y: float = 60.0
draw_string(font, Vector2(col2_x, log_y), "TURN LOG",
HORIZONTAL_ALIGNMENT_LEFT, -1, 11, Color(0.8, 0.6, 0.2))
log_y += lh + 2
draw_string(font, Vector2(col2_x, log_y),
"Turn Gold Cities Units Techs Pop Research / Production",
HORIZONTAL_ALIGNMENT_LEFT, -1, 9, Color(0.6, 0.55, 0.45))
log_y += lh
for entry: Dictionary in _turn_log:
var turn_num: int = entry.get("turn", 0) as int
if turn_num == 1 or turn_num % 5 == 0 or turn_num == TOTAL_TURNS:
var line: String = " %3d %4d %3d %3d %3d %3d %s / %s" % [
turn_num, entry.get("gold", 0), entry.get("cities", 0),
entry.get("units", 0), entry.get("techs", 0),
entry.get("pop", 0), entry.get("researching", "none"),
entry.get("production", "none"),
]
draw_string(font, Vector2(col2_x, log_y), line,
HORIZONTAL_ALIGNMENT_LEFT, -1, 9, Color(0.7, 0.7, 0.7))
log_y += lh
log_y += 8
draw_string(font, Vector2(col2_x, log_y), "MILESTONES",
HORIZONTAL_ALIGNMENT_LEFT, -1, 11, Color(0.8, 0.6, 0.2))
log_y += lh + 2
for ms: Array in [
["City founded", _first_city_turn, 10],
["Research started", _first_research_turn, 10],
["Production queued", _first_production_turn, 15],
["Second city", _second_city_turn, 80],
]:
var ms_turn: int = ms[1] as int
var ms_ok: bool = ms_turn > 0 and ms_turn <= (ms[2] as int)
var ms_val: String = "T%d" % ms_turn if ms_turn > 0 else "NEVER"
draw_string(font, Vector2(col2_x, log_y),
"[%s] %s by T%d: %s" % [
"OK" if ms_ok else "FAIL", ms[0], ms[2], ms_val],
HORIZONTAL_ALIGNMENT_LEFT, -1, 10,
Color(0.3, 0.9, 0.3) if ms_ok else Color(0.9, 0.3, 0.3))
log_y += lh
var cy: float = 60.0
draw_string(font, Vector2(col3_x, cy), "CITIES (%d) / UNITS (%d)" % [
_player.cities.size(), _player.units.size()],
HORIZONTAL_ALIGNMENT_LEFT, -1, 11, Color(0.8, 0.6, 0.2))
cy += lh + 2
draw_string(font, Vector2(24, y),
"CITIES BY CLAN (founded during %d-turn simulation)" % TOTAL_TURNS,
HORIZONTAL_ALIGNMENT_LEFT, -1, 13, Color(0.8, 0.6, 0.2))
y += lh + 4
var game_map: RefCounted = GameState.get_game_map()
for city: Variant in _player.cities:
if not city is CityScript:
continue
var c: CityScript = city as CityScript
var city_line: String = "%s pop=%d at %s" % [c.city_name, c.population, str(c.position)]
if game_map != null:
var ylds: Dictionary = c.get_yields(game_map)
city_line += " F:%d P:%d G:%d S:%d" % [
ylds.get("food", 0), ylds.get("production", 0),
ylds.get("gold", 0), ylds.get("science", 0)]
draw_string(font, Vector2(col3_x, cy), city_line,
HORIZONTAL_ALIGNMENT_LEFT, -1, 9, Color(1.0, 0.85, 0.4))
cy += lh
for p: RefCounted in _players:
var clan_id: String = str(p.get("clan_id"))
var clan_color: Color = clan_colors.get(clan_id, Color.WHITE) as Color
var line_parts: PackedStringArray = PackedStringArray()
for city: CityScript in p.cities:
if city == null:
continue
var suffix: String = " pop=%d" % city.population
if game_map != null:
var ylds: Dictionary = city.get_yields(game_map)
suffix += " F:%d P:%d" % [
int(ylds.get("food", 0)), int(ylds.get("production", 0)),
]
line_parts.append("%s@%s%s" % [city.city_name, str(city.position), suffix])
var joined: String = (
", ".join(line_parts) if not line_parts.is_empty()
else "(no cities founded)"
)
var line: String = "%s: %s" % [p.player_name, joined]
draw_string(font, Vector2(24, y), line,
HORIZONTAL_ALIGNMENT_LEFT, -1, 11, clan_color)
y += lh
if not _error_log.is_empty():
cy += lh
for err: String in _error_log.slice(0, 5):
draw_string(font, Vector2(col3_x, cy), err,
HORIZONTAL_ALIGNMENT_LEFT, -1, 9, Color(0.9, 0.4, 0.4))
cy += lh
y += lh
draw_string(font, Vector2(24, y),
"ERRORS (%d)" % _error_log.size(),
HORIZONTAL_ALIGNMENT_LEFT, -1, 12, Color(0.9, 0.4, 0.4))
y += lh
for err: String in _error_log.slice(0, 8):
draw_string(font, Vector2(24, y), err,
HORIZONTAL_ALIGNMENT_LEFT, -1, 10, Color(0.9, 0.5, 0.5))
y += lh
func _capture_and_quit() -> void:
@ -476,7 +429,7 @@ func _capture_and_quit() -> void:
return
_captured = true
var exit_code: int = 0 if _assertions_passed else 1
var exit_code: int = 0 if _error_log.is_empty() else 1
if DisplayServer.get_name() == "headless":
get_tree().quit(exit_code)
return

View file

@ -0,0 +1,155 @@
extends GutTest
## Coverage for AiTurnBridge._mcts_stats_log telemetry surface.
##
## Verifies:
## 1. get_last_mcts_stats returns a heuristic-path sentinel when no MCTS
## call has populated the (turn, player_index) key.
## 2. The sentinel dict has the expected fields (path, rollouts, win_rate,
## action) so overlay code can render without presence checks.
## 3. Stats entries are keyed per (turn, player_index) — two successive
## calls for different players don't clobber each other.
## 4. When the GDExtension is loaded AND MCTS runs end-to-end, the stats
## dict carries a non-sentinel `path` and a numeric `rollouts` value.
const BridgeScript: GDScript = preload(
"res://engine/src/modules/ai/ai_turn_bridge.gd"
)
const PlayerScript: GDScript = preload("res://engine/src/entities/player.gd")
const CityScript: GDScript = preload("res://engine/src/entities/city.gd")
func before_all() -> void:
DataLoader.load_theme("age-of-dwarves")
func before_each() -> void:
GameState.players = []
GameState.layers = [{"units": []}]
GameState.turn_number = 50
func after_each() -> void:
GameState.players = []
GameState.layers = []
GameState.turn_number = 1
# ── Factories ────────────────────────────────────────────────────────────
func _make_player(idx: int) -> PlayerScript:
var p: PlayerScript = PlayerScript.new(idx, "P%d" % idx, "dwarf")
p.gold = 100
p.strategic_axes = {"expansion": 3, "production": 3, "wealth": 3}
return p
func _make_city(owner_idx: int, pos: Vector2i) -> CityScript:
var c: CityScript = CityScript.new()
c.owner = owner_idx
c.position = pos
c.buildings = []
c.production_queue = []
c.has_bombarded = false
return c
# ── Test 1: Sentinel returned for an unknown key ─────────────────────────
func test_get_last_mcts_stats_returns_sentinel_for_unknown_key() -> void:
# Pick a key that cannot have been populated by any prior test.
var stats: Dictionary = BridgeScript.get_last_mcts_stats(999, 99)
assert_eq(stats.get("path", ""), "heuristic",
"Unknown key must return heuristic-path sentinel")
assert_eq(int(stats.get("rollouts", -1)), 0,
"Sentinel rollouts must be 0")
assert_eq(stats.get("win_rate"), null,
"Sentinel win_rate must be null")
assert_eq(String(stats.get("action", "")), "Heuristic",
"Sentinel action must be 'Heuristic'")
# ── Test 2: Sentinel dict has all four fields ────────────────────────────
func test_sentinel_dict_has_all_overlay_fields() -> void:
var stats: Dictionary = BridgeScript.get_last_mcts_stats(123, 7)
for field: String in ["path", "rollouts", "win_rate", "action"]:
assert_true(stats.has(field),
"Sentinel dict must have '%s' for overlay rendering" % field)
# ── Test 3: Stats keyed per (turn, player_index) ─────────────────────────
func test_stats_are_keyed_per_turn_and_player_index() -> void:
# This test runs end-to-end through _apply_mcts_strategic_override when
# the GDExtension is loaded. When it isn't, the override push_errors and
# asserts — we detect that and skip, documenting the contract.
if not ClassDB.class_exists("GdMcTreeController"):
pending("GdMcTreeController GDExtension not loaded — can't populate stats")
return
var p0: PlayerScript = _make_player(0)
var c0: CityScript = _make_city(0, Vector2i(0, 0))
p0.cities = [c0]
var p1: PlayerScript = _make_player(1)
var c1: CityScript = _make_city(1, Vector2i(5, 5))
p1.cities = [c1]
GameState.players = [p0, p1]
GameState.turn_number = 77
BridgeScript._apply_mcts_strategic_override(p0)
BridgeScript._apply_mcts_strategic_override(p1)
var stats0: Dictionary = BridgeScript.get_last_mcts_stats(77, 0)
var stats1: Dictionary = BridgeScript.get_last_mcts_stats(77, 1)
assert_ne(stats0.get("path", ""), "heuristic",
"p0 stats at turn 77 must be populated (not sentinel)")
assert_ne(stats1.get("path", ""), "heuristic",
"p1 stats at turn 77 must be populated (not sentinel)")
# ── Test 4: Populated stats carry rollouts and path ──────────────────────
func test_populated_stats_carry_rollouts_and_path() -> void:
if not ClassDB.class_exists("GdMcTreeController"):
pending("GdMcTreeController GDExtension not loaded")
return
var p0: PlayerScript = _make_player(0)
var c0: CityScript = _make_city(0, Vector2i(0, 0))
p0.cities = [c0]
GameState.players = [p0]
GameState.turn_number = 88
BridgeScript._apply_mcts_strategic_override(p0)
var stats: Dictionary = BridgeScript.get_last_mcts_stats(88, 0)
assert_true(int(stats.get("rollouts", 0)) > 0,
"Populated stats must carry a positive rollout count")
assert_ne(String(stats.get("path", "")), "heuristic",
"Populated stats must not carry the heuristic sentinel path")
assert_true(stats.has("action"),
"Populated stats must carry an action field")
# ── Test 5: Empty-cities path does not populate (sentinel preserved) ────
func test_empty_cities_player_does_not_populate_stats() -> void:
if not ClassDB.class_exists("GdMcTreeController"):
pending("GdMcTreeController GDExtension not loaded")
return
var p0: PlayerScript = _make_player(0)
p0.cities = []
GameState.players = [p0]
GameState.turn_number = 99
BridgeScript._apply_mcts_strategic_override(p0)
var stats: Dictionary = BridgeScript.get_last_mcts_stats(99, 0)
assert_eq(stats.get("path", ""), "heuristic",
"Empty-cities player must leave stats at sentinel")