using System; using System.Collections; using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; using System.Text; using Logic; using Logic.AI; using Logic.CrashSight; using RuntimeData; using TH1_Core.Managers; using TH1_Logic.Core; using TH1_Logic.MatchConfig; using TH1_Logic.Net; using TH1Renderer; using UnityEditor; using UnityEditor.SceneManagement; using UnityEngine; namespace TH1_Logic.Editor { [InitializeOnLoad] public static class AIDirectorBatchRunner { private const string PendingKey = "TH1.AIDirectorBatch.Pending"; private const string OptionsKey = "TH1.AIDirectorBatch.Options"; private const string GameIndexKey = "TH1.AIDirectorBatch.GameIndex"; private const string ResultsKey = "TH1.AIDirectorBatch.Results"; static AIDirectorBatchRunner() { if (SessionState.GetBool(PendingKey, false)) { EditorApplication.playModeStateChanged -= OnPlayModeStateChanged; EditorApplication.playModeStateChanged += OnPlayModeStateChanged; } } [MenuItem("Tools/TH1/AI Director/Run Batch")] public static void RunFromMenu() { StartBatch(ParseOptionsFromCommandLine()); } public static void Run() { StartBatch(ParseOptionsFromCommandLine()); } private static void StartBatch(BatchOptions options) { try { options.Normalize(); Directory.CreateDirectory(options.OutputDirectory); SessionState.SetString(OptionsKey, JsonUtility.ToJson(options)); SessionState.SetInt(GameIndexKey, 0); SessionState.SetString(ResultsKey, JsonUtility.ToJson(new BatchResultList())); SessionState.SetBool(PendingKey, true); EditorApplication.playModeStateChanged -= OnPlayModeStateChanged; EditorApplication.playModeStateChanged += OnPlayModeStateChanged; if (!EditorApplication.isPlaying) { OpenStartupScene(options.ScenePath); EditorApplication.EnterPlaymode(); return; } StartPlayModeRunner(); } catch (Exception e) { Debug.LogError($"[AI.Batch] Start failed:\n{e}"); ExitEditor(1); } } private static void OnPlayModeStateChanged(PlayModeStateChange state) { if (!SessionState.GetBool(PendingKey, false)) return; if (state != PlayModeStateChange.EnteredPlayMode) return; EditorApplication.playModeStateChanged -= OnPlayModeStateChanged; StartPlayModeRunner(); } private static void StartPlayModeRunner() { EditorApplication.update -= WaitForMainAndStartCoroutine; EditorApplication.update += WaitForMainAndStartCoroutine; } private static void WaitForMainAndStartCoroutine() { if (!EditorApplication.isPlaying) return; if (Main.Instance == null) return; EditorApplication.update -= WaitForMainAndStartCoroutine; Main.Instance.StartCoroutine(RunChecked(RunBatchCoroutine(), HandleFatalBatchException)); } private static IEnumerator RunBatchCoroutine() { var options = ReadOptions(); var gameIndex = SessionState.GetInt(GameIndexKey, 0); var results = ReadResults(); while (gameIndex < options.Games) { var result = new BatchGameResult { gameIndex = gameIndex, startedAt = DateTime.UtcNow.ToString("o", CultureInfo.InvariantCulture) }; var gameDirectory = Path.Combine(options.OutputDirectory, $"game_{gameIndex + 1:000}"); Directory.CreateDirectory(gameDirectory); Exception gameException = null; yield return RunChecked(RunGameCoroutine(options, result, gameIndex), e => gameException = e); if (gameException != null) { result.success = false; result.reason = $"Exception: {gameException.GetType().Name}: {gameException.Message}"; result.stackTrace = gameException.ToString(); Debug.LogError($"[AI.Batch] Game {gameIndex + 1} failed:\n{gameException}"); } #if UNITY_EDITOR AIDirectorBatchRuntime.ForceAllPlayersAi = false; AIDirectorBatchRuntime.SkipPresentationWait = false; #endif CompleteResult(result, gameDirectory); results.Add(result); WriteGameSummary(gameDirectory, result); WriteBatchSummary(options, results); SessionState.SetString(ResultsKey, JsonUtility.ToJson(new BatchResultList { results = results })); gameIndex++; SessionState.SetInt(GameIndexKey, gameIndex); if (!result.success && options.FailFast) break; if (gameIndex < options.Games) { yield return ResetForNextGame(); } } var hasFailure = results.Any(item => !item.success); CleanupSessionState(); Debug.Log($"[AI.Batch] Finished. games={results.Count}, failed={results.Count(item => !item.success)}, output={options.OutputDirectory}"); ExitEditor(hasFailure ? 1 : 0); } private static IEnumerator RunGameCoroutine(BatchOptions options, BatchGameResult result, int gameIndex) { yield return WaitForRuntimeReady(options); ConfigureDebugRuntime(options); AILogic.UseDirectorKernel(); AIDirectorBatchRuntime.ForceAllPlayersAi = true; AIDirectorBatchRuntime.SkipPresentationWait = true; var main = Main.Instance; main.MapConfig = BuildMapConfig(options, gameIndex); result.playerCount = (int)main.MapConfig.PlayerCount; result.width = (int)main.MapConfig.Width; result.height = (int)main.MapConfig.Height; result.aiKernel = AIKernelRegistry.CurrentKernelType.ToString(); Debug.Log($"[AI.Batch] Start game {gameIndex + 1}/{options.Games}: players={result.playerCount}, size={result.width}x{result.height}"); main.StartMatch(); yield return null; yield return RunOneGame(options, result); } private static IEnumerator RunChecked(IEnumerator root, Action onException) { var stack = new Stack(); stack.Push(root); while (stack.Count > 0) { object current; try { var top = stack.Peek(); if (!top.MoveNext()) { stack.Pop(); continue; } current = top.Current; } catch (Exception e) { onException?.Invoke(e); yield break; } if (current is IEnumerator nested) { stack.Push(nested); continue; } yield return current; } } private static void HandleFatalBatchException(Exception exception) { Debug.LogError($"[AI.Batch] Fatal failure:\n{exception}"); #if UNITY_EDITOR AIDirectorBatchRuntime.ForceAllPlayersAi = false; AIDirectorBatchRuntime.SkipPresentationWait = false; #endif try { var options = ReadOptionsSafe(); var results = ReadResultsSafe(); var gameIndex = SessionState.GetInt(GameIndexKey, 0); var gameDirectory = Path.Combine(options.OutputDirectory, $"game_{gameIndex + 1:000}"); Directory.CreateDirectory(gameDirectory); var result = new BatchGameResult { gameIndex = gameIndex, success = false, reason = $"FatalException:{exception.GetType().Name}:{exception.Message}", stackTrace = exception.ToString(), startedAt = DateTime.UtcNow.ToString("o", CultureInfo.InvariantCulture) }; CompleteResult(result, gameDirectory); results.Add(result); WriteGameSummary(gameDirectory, result); WriteBatchSummary(options, results); } catch (Exception writeException) { Debug.LogError($"[AI.Batch] Failed to write fatal summary:\n{writeException}"); } finally { CleanupSessionState(); ExitEditor(1); } } private static IEnumerator WaitForRuntimeReady(BatchOptions options) { var deadline = EditorApplication.timeSinceStartup + options.StartupTimeoutSeconds; while (EditorApplication.timeSinceStartup <= deadline) { var main = Main.Instance; if (main != null && main.GameLogic != null && Main.PlayerLogic != null && Main.CityLogic != null && Main.UnitLogic != null && UIManager.Instance != null) { yield break; } yield return null; } throw new TimeoutException($"Runtime startup timeout after {options.StartupTimeoutSeconds} seconds."); } private static IEnumerator ResetForNextGame() { var main = Main.Instance; if (main != null) { main.Clear(); yield return null; while (PresentationManager.Busy) yield return null; } } private static IEnumerator RunOneGame(BatchOptions options, BatchGameResult result) { var deadline = EditorApplication.timeSinceStartup + options.TimeoutSeconds; var lastNetActionCount = GetNetActionCount(); var lastTurnKey = string.Empty; var actionsThisPlayerTurn = 0; var stagnantFrames = 0; while (EditorApplication.timeSinceStartup <= deadline) { yield return null; result.frames++; var map = Main.MapData; if (map == null) { result.success = false; result.reason = "Main.MapData is null."; yield break; } result.netActions = GetNetActionCount(); result.maxPlayerTurn = GetMaxPlayerTurn(map); result.curPlayerId = map.CurPlayer?.Id ?? 0; result.curPlayerTurn = map.CurPlayer?.Turn ?? 0; result.survivingPlayers = CountSurvivingPlayers(map); result.gameState = Main.Instance.GameLogic?.GetCurState().ToString() ?? string.Empty; if (map.CheckIfGameEnd(out var isWin) || Main.Instance.GameLogic?.GetCurState() == GameState.Finished) { result.success = true; result.reason = isWin ? "GameEnd:SelfOrTeamWin" : "GameEnd"; yield break; } if (options.MaxTurns > 0 && result.maxPlayerTurn >= options.MaxTurns) { result.success = true; result.reason = $"ReachedMaxTurns:{options.MaxTurns}"; yield break; } if (options.MaxActions > 0 && result.netActions >= options.MaxActions) { result.success = false; result.reason = $"ReachedMaxActions:{options.MaxActions}"; yield break; } var turnKey = $"{result.curPlayerId}:{result.curPlayerTurn}"; if (turnKey != lastTurnKey) { lastTurnKey = turnKey; actionsThisPlayerTurn = 0; } if (result.netActions > lastNetActionCount) { actionsThisPlayerTurn += result.netActions - lastNetActionCount; lastNetActionCount = result.netActions; stagnantFrames = 0; } else { stagnantFrames++; } if (options.MaxActionsPerPlayerTurn > 0 && actionsThisPlayerTurn > options.MaxActionsPerPlayerTurn) { result.success = false; result.reason = $"ActionsPerPlayerTurnGuard:{actionsThisPlayerTurn}>{options.MaxActionsPerPlayerTurn}"; yield break; } if (options.StagnantFrameLimit > 0 && stagnantFrames > options.StagnantFrameLimit) { result.success = false; result.reason = $"StagnantFrameGuard:{stagnantFrames}"; yield break; } } result.success = false; result.reason = $"Timeout:{options.TimeoutSeconds}s"; } private static MapConfig BuildMapConfig(BatchOptions options, int gameIndex) { var config = new MapConfig((uint)options.Width, (uint)options.Height, (uint)options.Players, 0, 0, options.Difficulty) { GameMode = GameMode.DOMINATION, MatchSettlement = MatchSettlementType.Normal, IsLimitTime = false, DisableNearbySpawnPoints = options.DisableNearbySpawnPoints, WaterType = options.WaterType }; config.SetPlayerCount((uint)options.Players, NetMode.Single); var used = new HashSet(); for (var i = 0; i < options.Players; i++) { var civId = PickCivId(i + gameIndex, used); used.Add(civId); if (!config.SetSinglePlayerSlotCiv(i, civId, civId)) { config.SetPlayerSlotRandomCiv(i, NetMode.Single); } } config.EnsurePlayerSlots(NetMode.Single); return config; } private static uint PickCivId(int preferredIndex, HashSet used) { const int defaultCivCount = 17; uint fallback = 0; var hasFallback = false; for (var offset = 0; offset < defaultCivCount; offset++) { var civId = (uint)((preferredIndex + offset) % defaultCivCount); if (!ContentGate.CanUseEmpire(civId, civId)) continue; if (!hasFallback) { fallback = civId; hasFallback = true; } if (!used.Contains(civId)) return civId; } return hasFallback ? fallback : 0; } private static void ConfigureDebugRuntime(BatchOptions options) { var main = Main.Instance; if (main != null) { main.NoAI = false; main.FullSight = true; main.AIActionTime = 0f; main.AnimationSpeed = Mathf.Max(1f, options.AnimationSpeed); main.DebugMode = true; main.DebugHideCenterMessage = true; } if (DebugCenter.Instance == null) return; DebugCenter.Instance.DebugNoAI = false; DebugCenter.Instance.DebugSelfPlayerAllSight = true; DebugCenter.Instance.DebugAIActionTime = 0f; DebugCenter.Instance.AnimationSpeed = Mathf.Max(1f, options.AnimationSpeed); DebugCenter.Instance.DebugMode = true; DebugCenter.Instance.DebugHideCenterMessage = true; } private static BatchOptions ParseOptionsFromCommandLine() { var options = new BatchOptions(); options.Games = GetIntArg("-aiBatchGames", options.Games); options.Players = GetIntArg("-aiBatchPlayers", options.Players); options.Width = GetIntArg("-aiBatchWidth", options.Width); options.Height = GetIntArg("-aiBatchHeight", options.Height); options.MaxTurns = GetIntArg("-aiBatchTurns", options.MaxTurns); options.TimeoutSeconds = GetIntArg("-aiBatchTimeoutSeconds", options.TimeoutSeconds); options.StartupTimeoutSeconds = GetIntArg("-aiBatchStartupTimeoutSeconds", options.StartupTimeoutSeconds); options.MaxActions = GetIntArg("-aiBatchMaxActions", options.MaxActions); options.MaxActionsPerPlayerTurn = GetIntArg("-aiBatchMaxActionsPerPlayerTurn", options.MaxActionsPerPlayerTurn); options.StagnantFrameLimit = GetIntArg("-aiBatchStagnantFrames", options.StagnantFrameLimit); options.AnimationSpeed = GetFloatArg("-aiBatchAnimationSpeed", options.AnimationSpeed); options.FailFast = GetBoolArg("-aiBatchFailFast", options.FailFast); options.DisableNearbySpawnPoints = GetBoolArg("-aiBatchDisableNearbySpawnPoints", options.DisableNearbySpawnPoints); var outputDirectory = GetStringArg("-aiBatchOut"); if (!string.IsNullOrWhiteSpace(outputDirectory)) options.OutputDirectory = outputDirectory; var scenePath = GetStringArg("-aiBatchScene"); if (!string.IsNullOrWhiteSpace(scenePath)) options.ScenePath = scenePath; var difficulty = GetStringArg("-aiBatchDifficulty"); if (!string.IsNullOrWhiteSpace(difficulty) && Enum.TryParse(difficulty, true, out AIDifficult parsedDifficulty)) options.Difficulty = parsedDifficulty; var waterType = GetStringArg("-aiBatchWaterType"); if (!string.IsNullOrWhiteSpace(waterType) && Enum.TryParse(waterType, true, out MapWaterType parsedWaterType)) options.WaterType = parsedWaterType; return options; } private static BatchOptions ReadOptions() { var json = SessionState.GetString(OptionsKey, string.Empty); var options = string.IsNullOrWhiteSpace(json) ? new BatchOptions() : JsonUtility.FromJson(json); options.Normalize(); return options; } private static List ReadResults() { var json = SessionState.GetString(ResultsKey, string.Empty); if (string.IsNullOrWhiteSpace(json)) return new List(); if (json.TrimStart().StartsWith("[", StringComparison.Ordinal)) return new List(); var list = JsonUtility.FromJson(json); return list?.results ?? new List(); } private static BatchOptions ReadOptionsSafe() { try { return ReadOptions(); } catch { var options = ParseOptionsFromCommandLine(); options.Normalize(); return options; } } private static List ReadResultsSafe() { try { return ReadResults(); } catch { return new List(); } } private static void CompleteResult(BatchGameResult result, string gameDirectory) { result.endedAt = DateTime.UtcNow.ToString("o", CultureInfo.InvariantCulture); result.summaryPath = Path.Combine(gameDirectory, "batch_game_summary.json").Replace('\\', '/'); var map = Main.MapData; if (map == null) return; result.mapId = map.MapID; result.netActions = GetNetActionCount(); result.maxPlayerTurn = GetMaxPlayerTurn(map); result.curPlayerId = map.CurPlayer?.Id ?? 0; result.curPlayerTurn = map.CurPlayer?.Turn ?? 0; result.survivingPlayers = CountSurvivingPlayers(map); result.players = BuildPlayerResults(map); } private static List BuildPlayerResults(MapData map) { var results = new List(); var players = map.PlayerMap?.PlayerDataList; if (players == null) return results; foreach (var player in players) { if (player == null) continue; results.Add(new BatchPlayerResult { id = player.Id, civId = player.PlayerCivId, forceId = player.PlayerForceId, turn = player.Turn, alive = player.Alive, score = player.PlayerScore, coin = player.PlayerCoin, techPoint = player.PlayerTechPoint, cityCount = CountPlayerCities(map, player.Id), unitCount = CountPlayerUnits(map, player.Id), isWin = map.MatchSettlement?.IsWin(player.Id) ?? false }); } return results; } private static int CountPlayerCities(MapData map, uint playerId) { var cities = map.CityMap?.CityList; if (cities == null) return 0; var count = 0; foreach (var city in cities) { if (city != null && map.CheckCityIdBelongPlayerId(city.Id, playerId)) count++; } return count; } private static int CountPlayerUnits(MapData map, uint playerId) { var units = map.UnitMap?.UnitList; if (units == null) return 0; var count = 0; foreach (var unit in units) { if (unit != null && map.GetPlayerIdByUnitId(unit.Id, out var ownerId) && ownerId == playerId && unit.IsAlive()) count++; } return count; } private static int CountSurvivingPlayers(MapData map) { var players = map.PlayerMap?.PlayerDataList; if (players == null) return 0; var count = 0; foreach (var player in players) { if (player != null && player.IsSurvival) count++; } return count; } private static uint GetMaxPlayerTurn(MapData map) { var players = map.PlayerMap?.PlayerDataList; if (players == null || players.Count == 0) return 0; var maxTurn = 0u; foreach (var player in players) { if (player != null && player.Turn > maxTurn) maxTurn = player.Turn; } return maxTurn; } private static int GetNetActionCount() { return Main.MapData?.Net?.Actions?.Count ?? 0; } private static void WriteGameSummary(string gameDirectory, BatchGameResult result) { var path = Path.Combine(gameDirectory, "batch_game_summary.json"); File.WriteAllText(path, JsonUtility.ToJson(result, true), Encoding.UTF8); } private static void WriteBatchSummary(BatchOptions options, List results) { var summary = new BatchSummary { options = options, results = results, generatedAt = DateTime.UtcNow.ToString("o", CultureInfo.InvariantCulture), totalGames = results.Count, failedGames = results.Count(item => !item.success) }; var path = Path.Combine(options.OutputDirectory, "batch_summary.json"); File.WriteAllText(path, JsonUtility.ToJson(summary, true), Encoding.UTF8); } private static void OpenStartupScene(string scenePath) { if (string.IsNullOrWhiteSpace(scenePath)) { var scene = EditorBuildSettings.scenes.FirstOrDefault(item => item.enabled); scenePath = scene?.path; } if (string.IsNullOrWhiteSpace(scenePath)) return; if (!File.Exists(Path.Combine(ProjectRoot, scenePath))) { throw new FileNotFoundException($"Startup scene not found: {scenePath}"); } EditorSceneManager.OpenScene(scenePath); } private static string GetStringArg(string name) { var args = Environment.GetCommandLineArgs(); for (var i = 0; i < args.Length - 1; i++) { if (!string.Equals(args[i], name, StringComparison.OrdinalIgnoreCase)) continue; return args[i + 1]; } return null; } private static int GetIntArg(string name, int fallback) { var value = GetStringArg(name); return int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) ? parsed : fallback; } private static float GetFloatArg(string name, float fallback) { var value = GetStringArg(name); return float.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed) ? parsed : fallback; } private static bool GetBoolArg(string name, bool fallback) { var value = GetStringArg(name); if (string.IsNullOrWhiteSpace(value)) return fallback; if (bool.TryParse(value, out var parsed)) return parsed; return value == "1" || value.Equals("yes", StringComparison.OrdinalIgnoreCase); } private static void CleanupSessionState() { SessionState.EraseBool(PendingKey); SessionState.EraseString(OptionsKey); SessionState.EraseInt(GameIndexKey); SessionState.EraseString(ResultsKey); } private static void ExitEditor(int code) { if (Application.isBatchMode) { EditorApplication.Exit(code); return; } if (EditorApplication.isPlaying) { EditorApplication.ExitPlaymode(); } } private static string ProjectRoot => Directory.GetParent(Application.dataPath).FullName; } [Serializable] public class BatchOptions { public int Games = 1; public int Players = 17; public int Width = 30; public int Height = 30; public int MaxTurns = 100; public int TimeoutSeconds = 1800; public int StartupTimeoutSeconds = 180; public int MaxActions = 20000; public int MaxActionsPerPlayerTurn = 260; public int StagnantFrameLimit = 3600; public float AnimationSpeed = 100f; public bool FailFast = true; public bool DisableNearbySpawnPoints = false; public string OutputDirectory; public string ScenePath; public AIDifficult Difficulty = AIDifficult.LUNATIC; public MapWaterType WaterType = MapWaterType.Pangea; public void Normalize() { Games = Mathf.Clamp(Games, 1, 1000); Players = Mathf.Clamp(Players, 2, 17); Width = Mathf.Clamp(Width, 8, 80); Height = Mathf.Clamp(Height, 8, 80); MaxTurns = Mathf.Max(0, MaxTurns); TimeoutSeconds = Mathf.Max(30, TimeoutSeconds); StartupTimeoutSeconds = Mathf.Max(30, StartupTimeoutSeconds); MaxActions = Mathf.Max(0, MaxActions); MaxActionsPerPlayerTurn = Mathf.Max(0, MaxActionsPerPlayerTurn); StagnantFrameLimit = Mathf.Max(0, StagnantFrameLimit); AnimationSpeed = Mathf.Max(1f, AnimationSpeed); if (string.IsNullOrWhiteSpace(OutputDirectory)) { var root = Directory.GetParent(Application.dataPath).FullName; OutputDirectory = Path.Combine(root, "Logs", "AI_Batch", DateTime.Now.ToString("yyyyMMdd_HHmmss", CultureInfo.InvariantCulture)); } OutputDirectory = Path.GetFullPath(OutputDirectory); } } [Serializable] public class BatchSummary { public string generatedAt; public int totalGames; public int failedGames; public BatchOptions options; public List results; } [Serializable] public class BatchResultList { public List results = new(); } [Serializable] public class BatchGameResult { public int gameIndex; public bool success; public string reason; public string startedAt; public string endedAt; public string summaryPath; public string stackTrace; public string aiKernel; public string gameState; public uint mapId; public int playerCount; public int width; public int height; public int frames; public int netActions; public uint maxPlayerTurn; public uint curPlayerId; public uint curPlayerTurn; public int survivingPlayers; public List players = new(); } [Serializable] public class BatchPlayerResult { public uint id; public uint civId; public uint forceId; public uint turn; public bool alive; public bool isWin; public int score; public int coin; public int techPoint; public int cityCount; public int unitCount; } }