diff --git a/src/game/engine/scenes/tests/ai_sanity_proof.gd b/src/game/engine/scenes/tests/ai_sanity_proof.gd index e04b2527..6b750910 100644 --- a/src/game/engine/scenes/tests/ai_sanity_proof.gd +++ b/src/game/engine/scenes/tests/ai_sanity_proof.gd @@ -109,7 +109,6 @@ func _initialize() -> void: add_child(_mock_tm) _setup_game_state() - (_tech_web as TechWebScript).initialize() var game_map: RefCounted = GameState.get_game_map() var map_w: int = (game_map as GameMapScript).width if game_map is GameMapScript else 0 @@ -202,13 +201,10 @@ func _collect_start_positions( 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 + var player: RefCounted = PlayerScript.new(-1, p_name, race_id) if "clan_id" in player: player.clan_id = clan_id - player.is_player_controlled = false - player.science_per_turn = 3 + player.is_human = false player.gold = 50 player.gold_per_turn = 5 player.happiness = 5 @@ -220,19 +216,9 @@ func _create_ai_player( func _create_founder(owner_idx: int, pos: Vector2i) -> RefCounted: - var unit: UnitScript = UnitScript.new() + var unit: UnitScript = UnitScript.new("dwarf_founder", owner_idx, pos) 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.apply_data(data) - unit.owner = owner_idx - unit.position = pos - unit.id = "founder_%d" % owner_idx return unit @@ -357,9 +343,9 @@ func _draw() -> void: ) 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 has_win: bool = stats.has("win_rate") and stats["win_rate"] != null var win_str: String = ( - "--" if win_rate_any < 0.0 else "%.1f%%" % (win_rate_any * 100.0) + "%.1f%%" % (float(stats["win_rate"]) * 100.0) if has_win else "--" ) var values: Array[String] = [ p.player_name, @@ -379,49 +365,11 @@ func _draw() -> void: 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(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 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(): y += lh draw_string(font, Vector2(24, y), - "ERRORS (%d)" % _error_log.size(), + "ERRORS (%d) — see text summary for full detail" % _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: @@ -430,24 +378,102 @@ func _capture_and_quit() -> void: _captured = true var exit_code: int = 0 if _error_log.is_empty() else 1 - if DisplayServer.get_name() == "headless": - get_tree().quit(exit_code) - return - var image: Image = get_viewport().get_texture().get_image() - if image == null: - get_tree().quit(exit_code) - return - - DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(OUTPUT_DIR)) - var timestamp: String = Time.get_datetime_string_from_system().replace(":", "-").replace("T", "_") - var rel_path: String = "%s/%s_%s.png" % [OUTPUT_DIR, _screenshot_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("Screenshot: %dx%d saved to %s" % [image.get_width(), image.get_height(), abs_path]) - else: - push_error("AISanityProof: Save failed: %s" % error_string(err)) - + var abs_path: String = _save_viewport_or_summary() + if abs_path.is_empty(): + exit_code = maxi(1, exit_code) get_tree().quit(exit_code) + + +## Save an image of the overlay if a real display backs the viewport, OR a +## deterministic text summary when the process is fully headless (dummy +## rendering driver returns null textures). Returns the path written, or +## "" on failure. Both outputs land under OUTPUT_DIR with the same stem so +## Phase Gate review pipelines can pick up whichever exists. +func _save_viewport_or_summary() -> String: + DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(OUTPUT_DIR)) + var timestamp: String = ( + Time.get_datetime_string_from_system().replace(":", "-").replace("T", "_") + ) + var img_rel: String = "%s/%s_%s.png" % [OUTPUT_DIR, _screenshot_name, timestamp] + var img_abs: String = ProjectSettings.globalize_path(img_rel) + + var texture: ViewportTexture = get_viewport().get_texture() + var image: Image = texture.get_image() if texture != null else null + if image != null and image.get_width() > 0: + var err: Error = image.save_png(img_abs) + if err == OK: + print("SCREENSHOT_PATH:%s" % img_abs) + print("Screenshot: %dx%d saved to %s" % [ + image.get_width(), image.get_height(), img_abs, + ]) + return img_abs + push_error("AISanityProof: PNG save failed: %s" % error_string(err)) + + # Headless-fallback text summary — same filename stem, .txt suffix. The + # simulation results are what matters for the Phase Gate review; the + # dummy renderer only prevents the overlay bitmap, not the data. + var txt_rel: String = "%s/%s_%s.txt" % [OUTPUT_DIR, _screenshot_name, timestamp] + var txt_abs: String = ProjectSettings.globalize_path(txt_rel) + var summary: String = _build_text_summary() + var f: FileAccess = FileAccess.open(txt_abs, FileAccess.WRITE) + if f == null: + push_error("AISanityProof: text summary open failed") + return "" + f.store_string(summary) + f.close() + print("SCREENSHOT_PATH:%s" % txt_abs) + print("Text summary: %s (headless fallback)" % txt_abs) + return txt_abs + + +func _build_text_summary() -> String: + var lines: PackedStringArray = PackedStringArray() + lines.append("AI Sanity Proof -- 5 clans, %d turns, seed %d" % [TOTAL_TURNS, MAP_SEED]) + lines.append("Status: %s" % ("PASS" if _error_log.is_empty() else "FAIL")) + lines.append("") + var header: String = ( + "| Clan | Cities | Units | Pop | Gold | Techs " + + "| MCTS Path | Rollouts | Win% | Action |" + ) + lines.append(header) + lines.append( + "|---------------------|--------|-------|-----|------|-------" + + "|------------|----------|--------|-------------|" + ) + for p: RefCounted in _players: + var stats: Dictionary = AiTurnBridgeScript.get_last_mcts_stats( + _final_turn - 1, p.index + ) + var has_win: bool = stats.has("win_rate") and stats["win_rate"] != null + var win_str: String = ( + "%.1f%%" % (float(stats["win_rate"]) * 100.0) if has_win else "--" + ) + lines.append( + "| %-19s | %6d | %5d | %3d | %4d | %5d | %-10s | %8d | %-6s | %-11s |" % [ + p.player_name, p.cities.size(), p.units.size(), + _get_total_pop(p), p.gold, p.researched_techs.size(), + String(stats.get("path", "--")), + int(stats.get("rollouts", 0)), + win_str, + String(stats.get("action", "--")), + ] + ) + lines.append("") + lines.append("Cities by clan:") + for p: RefCounted in _players: + var parts: PackedStringArray = PackedStringArray() + for city: CityScript in p.cities: + if city == null: + continue + parts.append("%s@%s pop=%d" % [city.city_name, str(city.position), city.population]) + var line: String = " %s: %s" % [ + p.player_name, + ", ".join(parts) if not parts.is_empty() else "(no cities founded)", + ] + lines.append(line) + if not _error_log.is_empty(): + lines.append("") + lines.append("Errors:") + for err: String in _error_log: + lines.append(" - %s" % err) + return "\n".join(lines) + "\n"