1392 lines
60 KiB
C#
1392 lines
60 KiB
C#
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.AI.Director;
|
|
using Logic.CrashSight;
|
|
using Newtonsoft.Json.Linq;
|
|
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;
|
|
AIDirectorBatchRuntime.CompactDiagnostics = false;
|
|
AIDirectorBatchRuntime.SuppressGameEnd = false;
|
|
AIDirectorBatchRuntime.RandomSeedOverride = 0;
|
|
#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;
|
|
AIDirectorBatchRuntime.CompactDiagnostics = options.CompactDiagnostics;
|
|
AIDirectorBatchRuntime.SuppressGameEnd = !options.StopOnGameEnd;
|
|
AIDirectorBatchRuntime.RandomSeedOverride = options.Seed == 0 ? 0 : options.Seed + gameIndex;
|
|
#if TH1_AI_DIRECTOR_DIAGNOSTICS || UNITY_EDITOR
|
|
AIDirectorDiagnostics.BeginNewSession();
|
|
#endif
|
|
|
|
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.randomSeed = AIDirectorBatchRuntime.RandomSeedOverride;
|
|
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<Exception> onException)
|
|
{
|
|
var stack = new Stack<IEnumerator>();
|
|
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;
|
|
AIDirectorBatchRuntime.CompactDiagnostics = false;
|
|
AIDirectorBatchRuntime.SuppressGameEnd = false;
|
|
AIDirectorBatchRuntime.RandomSeedOverride = 0;
|
|
#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);
|
|
var gameState = Main.Instance.GameLogic?.GetCurState();
|
|
result.gameState = gameState?.ToString() ?? string.Empty;
|
|
|
|
if (options.StopOnGameEnd && gameState == GameState.Finished)
|
|
{
|
|
result.success = true;
|
|
result.reason = "GameStateFinished";
|
|
yield break;
|
|
}
|
|
|
|
if (options.StopOnGameEnd && map.CheckIfGameEnd(out var isWin))
|
|
{
|
|
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<uint>();
|
|
for (var i = 0; i < options.Players; i++)
|
|
{
|
|
var civId = PickCivId(i + gameIndex, used);
|
|
used.Add(civId);
|
|
config.SetSinglePlayerSlotCiv(i, civId, civId);
|
|
}
|
|
|
|
config.EnsurePlayerSlots(NetMode.Single);
|
|
return config;
|
|
}
|
|
|
|
private static uint PickCivId(int preferredIndex, HashSet<uint> 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 = options.AllSight;
|
|
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 = options.AllSight;
|
|
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.Seed = GetIntArg("-aiBatchSeed", options.Seed);
|
|
options.FailFast = GetBoolArg("-aiBatchFailFast", options.FailFast);
|
|
options.DisableNearbySpawnPoints = GetBoolArg("-aiBatchDisableNearbySpawnPoints", options.DisableNearbySpawnPoints);
|
|
options.CompactDiagnostics = GetBoolArg("-aiBatchCompactDiagnostics", options.CompactDiagnostics);
|
|
options.AllSight = GetBoolArg("-aiBatchAllSight", options.AllSight);
|
|
options.StopOnGameEnd = GetBoolArg("-aiBatchStopOnGameEnd", options.StopOnGameEnd);
|
|
|
|
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<BatchOptions>(json);
|
|
options.Normalize();
|
|
return options;
|
|
}
|
|
|
|
private static List<BatchGameResult> ReadResults()
|
|
{
|
|
var json = SessionState.GetString(ResultsKey, string.Empty);
|
|
if (string.IsNullOrWhiteSpace(json)) return new List<BatchGameResult>();
|
|
if (json.TrimStart().StartsWith("[", StringComparison.Ordinal)) return new List<BatchGameResult>();
|
|
var list = JsonUtility.FromJson<BatchResultList>(json);
|
|
return list?.results ?? new List<BatchGameResult>();
|
|
}
|
|
|
|
private static BatchOptions ReadOptionsSafe()
|
|
{
|
|
try
|
|
{
|
|
return ReadOptions();
|
|
}
|
|
catch
|
|
{
|
|
var options = ParseOptionsFromCommandLine();
|
|
options.Normalize();
|
|
return options;
|
|
}
|
|
}
|
|
|
|
private static List<BatchGameResult> ReadResultsSafe()
|
|
{
|
|
try
|
|
{
|
|
return ReadResults();
|
|
}
|
|
catch
|
|
{
|
|
return new List<BatchGameResult>();
|
|
}
|
|
}
|
|
|
|
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);
|
|
#if TH1_AI_DIRECTOR_DIAGNOSTICS || UNITY_EDITOR
|
|
result.diagnosticsLogPath = AIDirectorDiagnostics.CurrentLogPathOrEmpty;
|
|
#endif
|
|
result.diagnostics = BuildDiagnosticsSummary(result.diagnosticsLogPath);
|
|
}
|
|
|
|
private static List<BatchPlayerResult> BuildPlayerResults(MapData map)
|
|
{
|
|
var results = new List<BatchPlayerResult>();
|
|
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),
|
|
heroEligible = PlayerHasSelectableHero(player),
|
|
selectedHeroCount = player.PlayerHeroData?.HeroCount ?? 0,
|
|
spawnedHeroCount = CountPlayerHeroes(map, player.Id),
|
|
maxHeroCount = player.PlayerHeroData?.MaxHeroCount ?? 0,
|
|
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 bool PlayerHasSelectableHero(PlayerData player)
|
|
{
|
|
if (player?.PlayerHeroData == null) return false;
|
|
var leader = player.PlayerHeroData.GetLeaderGiantType();
|
|
return leader != GiantType.None && ContentGate.CanUseHeroForPlayer(player, leader);
|
|
}
|
|
|
|
private static int CountPlayerHeroes(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 || !unit.IsAlive()) continue;
|
|
if (!map.GetPlayerIdByUnitId(unit.Id, out var ownerId) || ownerId != playerId) continue;
|
|
if (unit.TreatedAsHero(map, unit)) 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<BatchGameResult> 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 BatchDiagnosticSummary BuildDiagnosticsSummary(string diagnosticsLogPath)
|
|
{
|
|
var summary = new BatchDiagnosticSummary();
|
|
if (string.IsNullOrWhiteSpace(diagnosticsLogPath) || !File.Exists(diagnosticsLogPath)) return summary;
|
|
|
|
summary.logPath = diagnosticsLogPath.Replace('\\', '/');
|
|
var decisionMs = new List<float>();
|
|
var actionPoolAll = new List<int>();
|
|
var actionPoolMoves = new List<int>();
|
|
var actionCountByPlayerTurn = new Dictionary<string, int>();
|
|
var executedStableKeysByPlayerTurn = new HashSet<string>();
|
|
var lanes = new Dictionary<string, int>();
|
|
var reasons = new Dictionary<string, int>();
|
|
var selectedActionTypes = new Dictionary<string, int>();
|
|
var executedActionTypes = new Dictionary<string, int>();
|
|
var laneActionTypes = new Dictionary<string, int>();
|
|
var noEffectActionTypes = new Dictionary<string, int>();
|
|
var heroReasons = new Dictionary<string, int>();
|
|
var heroActionTypes = new Dictionary<string, int>();
|
|
var heroExecutedActionTypes = new Dictionary<string, int>();
|
|
var fallbackActionTypes = new Dictionary<string, int>();
|
|
var fallbackReasons = new Dictionary<string, int>();
|
|
var fallbackExecutedActionTypes = new Dictionary<string, int>();
|
|
var fallbackNoEffectActionTypes = new Dictionary<string, int>();
|
|
var defenseReasons = new Dictionary<string, int>();
|
|
var defenseActionTypes = new Dictionary<string, int>();
|
|
var heroStyleBuckets = new Dictionary<string, int>();
|
|
var heroStyleReasons = new Dictionary<string, int>();
|
|
var heroStyleActionTypes = new Dictionary<string, int>();
|
|
var unitSkillActionTypes = new Dictionary<string, int>();
|
|
var unitSkillChangedActionTypes = new Dictionary<string, int>();
|
|
var actorSkillSignatures = new Dictionary<string, int>();
|
|
var decisionLaneByAction = new Dictionary<string, string>();
|
|
var decisionReasonByAction = new Dictionary<string, string>();
|
|
var lastTurnSummaryByPlayer = new Dictionary<uint, JToken>();
|
|
|
|
try
|
|
{
|
|
foreach (var line in File.ReadLines(diagnosticsLogPath))
|
|
{
|
|
if (string.IsNullOrWhiteSpace(line)) continue;
|
|
var record = JObject.Parse(line);
|
|
var eventType = record.Value<string>("eventType");
|
|
if (eventType == "Decision")
|
|
{
|
|
summary.decisions++;
|
|
var decision = record["decision"];
|
|
if (decision == null) continue;
|
|
|
|
decisionMs.Add(decision.Value<float?>("decideMs") ?? 0f);
|
|
var pool = record["actionPool"];
|
|
if (pool != null)
|
|
{
|
|
actionPoolAll.Add(pool.Value<int?>("all") ?? 0);
|
|
actionPoolMoves.Add(pool.Value<int?>("moves") ?? 0);
|
|
}
|
|
|
|
if (!(decision.Value<bool?>("hasAction") ?? false))
|
|
{
|
|
summary.noActionDecisions++;
|
|
continue;
|
|
}
|
|
|
|
var lane = decision.Value<string>("lane") ?? string.Empty;
|
|
var reason = decision.Value<string>("reason") ?? string.Empty;
|
|
var action = decision["action"];
|
|
var actionType = action?.Value<string>("actionType") ?? string.Empty;
|
|
var actionKey = BuildActionMetricKey(action);
|
|
var turnActionKey = BuildTurnActionKey(record, action);
|
|
Increment(lanes, lane);
|
|
Increment(reasons, reason);
|
|
Increment(selectedActionTypes, actionType);
|
|
Increment(laneActionTypes, $"{lane}:{actionType}");
|
|
if (!string.IsNullOrEmpty(turnActionKey))
|
|
{
|
|
decisionLaneByAction[turnActionKey] = lane;
|
|
decisionReasonByAction[turnActionKey] = reason;
|
|
}
|
|
|
|
if (lane == "Emergency")
|
|
{
|
|
summary.emergencyDecisions++;
|
|
Increment(defenseReasons, reason);
|
|
Increment(defenseActionTypes, actionKey);
|
|
}
|
|
|
|
if (lane == "HeroManagement")
|
|
{
|
|
summary.heroManagementDecisions++;
|
|
Increment(heroReasons, reason);
|
|
Increment(heroActionTypes, actionKey);
|
|
IncrementHeroStyle(heroStyleBuckets, heroStyleReasons, heroStyleActionTypes, reason, actionKey);
|
|
}
|
|
else if (lane == "HeroPlaybook")
|
|
{
|
|
summary.heroPlaybookDecisions++;
|
|
Increment(heroReasons, reason);
|
|
Increment(heroActionTypes, actionKey);
|
|
IncrementHeroStyle(heroStyleBuckets, heroStyleReasons, heroStyleActionTypes, reason, actionKey);
|
|
if (reason.StartsWith("HeroPlaybook.", StringComparison.Ordinal)) summary.genericHeroDecisions++;
|
|
else summary.heroRuleDecisions++;
|
|
}
|
|
|
|
if (decision.Value<bool?>("isFallback") ?? false)
|
|
{
|
|
summary.fallbackDecisions++;
|
|
Increment(fallbackReasons, reason);
|
|
Increment(fallbackActionTypes, actionKey);
|
|
}
|
|
}
|
|
else if (eventType == "TurnStart")
|
|
{
|
|
var turnSummary = record["turnSummary"];
|
|
if (turnSummary == null) continue;
|
|
|
|
var playerId = record.Value<uint?>("playerId") ?? 0;
|
|
if ((turnSummary.Value<int?>("criticalCityThreatCount") ?? 0) > 0) summary.criticalCityThreatTurnCount++;
|
|
if ((turnSummary.Value<int?>("capitalThreatCount") ?? 0) > 0) summary.capitalThreatTurnCount++;
|
|
var emptyThreatenedCityCount = turnSummary.Value<int?>("emptyThreatenedCityCount") ?? 0;
|
|
if (emptyThreatenedCityCount > 0)
|
|
{
|
|
summary.emptyThreatenedCityTurnCount++;
|
|
summary.emptyThreatenedCityTotal += emptyThreatenedCityCount;
|
|
}
|
|
|
|
if (lastTurnSummaryByPlayer.TryGetValue(playerId, out var previous))
|
|
{
|
|
var previousCityCount = previous.Value<int?>("cityCount") ?? 0;
|
|
var currentCityCount = turnSummary.Value<int?>("cityCount") ?? 0;
|
|
if (currentCityCount < previousCityCount) summary.cityLostCount += previousCityCount - currentCityCount;
|
|
if (currentCityCount > previousCityCount) summary.cityGainedCount += currentCityCount - previousCityCount;
|
|
|
|
var previousCapital = previous.Value<string>("capitalCityIdsSignature") ?? string.Empty;
|
|
var currentCapital = turnSummary.Value<string>("capitalCityIdsSignature") ?? string.Empty;
|
|
if (!string.Equals(previousCapital, currentCapital, StringComparison.Ordinal)) summary.capitalOwnershipChangedCount++;
|
|
}
|
|
|
|
lastTurnSummaryByPlayer[playerId] = turnSummary;
|
|
}
|
|
else if (eventType == "Execution")
|
|
{
|
|
summary.executions++;
|
|
var execution = record["execution"];
|
|
var action = execution?["action"];
|
|
var actionType = action?.Value<string>("actionType") ?? string.Empty;
|
|
var actionKey = BuildActionMetricKey(action);
|
|
Increment(executedActionTypes, actionType);
|
|
|
|
var playerTurnKey = $"{record.Value<uint?>("playerId") ?? 0}:{record.Value<uint?>("playerTurn") ?? 0}";
|
|
actionCountByPlayerTurn.TryGetValue(playerTurnKey, out var actionCount);
|
|
actionCountByPlayerTurn[playerTurnKey] = actionCount + 1;
|
|
|
|
var stableKey = action?.Value<string>("stableKey");
|
|
if (!string.IsNullOrEmpty(stableKey))
|
|
{
|
|
var stableKeyInTurn = $"{playerTurnKey}:{stableKey}";
|
|
if (!executedStableKeysByPlayerTurn.Add(stableKeyInTurn)) summary.repeatedExecutions++;
|
|
}
|
|
|
|
var turnActionKey = BuildTurnActionKey(record, action);
|
|
decisionLaneByAction.TryGetValue(turnActionKey, out var executedLane);
|
|
decisionReasonByAction.TryGetValue(turnActionKey, out var executedReason);
|
|
if (executedLane == "HeroManagement" || executedLane == "HeroPlaybook")
|
|
{
|
|
Increment(heroExecutedActionTypes, actionKey);
|
|
}
|
|
else if (executedLane == "Fallback")
|
|
{
|
|
Increment(fallbackExecutedActionTypes, actionKey);
|
|
}
|
|
|
|
var delta = execution?["delta"];
|
|
if (execution?.Value<bool?>("executed") ?? false)
|
|
{
|
|
summary.heroDelta += delta?.Value<int?>("heroDelta") ?? 0;
|
|
summary.selectedHeroDelta += delta?.Value<int?>("selectedHeroDelta") ?? 0;
|
|
summary.heroTaskDelta += delta?.Value<int?>("heroTaskDelta") ?? 0;
|
|
summary.readyHeroTaskDelta += delta?.Value<int?>("readyHeroTaskDelta") ?? 0;
|
|
summary.forcedHeroTaskDelta += delta?.Value<int?>("forcedHeroTaskDelta") ?? 0;
|
|
summary.heroTaskProgressDelta += delta?.Value<int?>("heroTaskProgressDelta") ?? 0;
|
|
summary.cityThreatResolvedCount += delta?.Value<bool?>("cityThreatResolved") == true ? 1 : 0;
|
|
summary.cityThreatWorsenedCount += delta?.Value<bool?>("cityThreatWorsened") == true ? 1 : 0;
|
|
if (executedLane == "Emergency") summary.emergencyExecutions++;
|
|
if (executedLane == "Emergency" && actionKey == "UnitMove")
|
|
{
|
|
summary.defenderReturnCount++;
|
|
}
|
|
|
|
if (IsHeroAction(action))
|
|
{
|
|
var bucket = ClassifyStyleBucket(executedReason, actionKey);
|
|
if (IsDefensiveStyle(bucket) || executedLane == "Emergency") summary.heroDefensiveUse++;
|
|
}
|
|
|
|
var before = execution?["before"];
|
|
var skillSignature = before?.Value<string>("unitSkillSignature") ?? string.Empty;
|
|
if (!string.IsNullOrEmpty(skillSignature))
|
|
{
|
|
Increment(unitSkillActionTypes, actionKey);
|
|
Increment(actorSkillSignatures, CompactSkillSignature(skillSignature));
|
|
if (delta?.Value<bool?>("unitSkillSignatureChanged") == true
|
|
|| delta?.Value<bool?>("targetUnitSkillSignatureChanged") == true
|
|
|| (delta?.Value<int?>("unitSkillDelta") ?? 0) != 0
|
|
|| (delta?.Value<int?>("targetUnitSkillDelta") ?? 0) != 0)
|
|
{
|
|
Increment(unitSkillChangedActionTypes, actionKey);
|
|
}
|
|
}
|
|
}
|
|
|
|
if ((execution?.Value<bool?>("executed") ?? false) && !HasMeaningfulDelta(delta))
|
|
{
|
|
summary.noEffectExecutions++;
|
|
Increment(noEffectActionTypes, actionType);
|
|
if (executedLane == "Fallback") Increment(fallbackNoEffectActionTypes, actionKey);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
summary.error = e.Message;
|
|
}
|
|
|
|
FillFloatStats(decisionMs, out summary.avgDecideMs, out summary.p95DecideMs, out summary.maxDecideMs);
|
|
FillIntStats(actionPoolAll, out summary.avgActionPoolAll, out summary.p95ActionPoolAll, out summary.maxActionPoolAll);
|
|
FillIntStats(actionPoolMoves, out summary.avgActionPoolMoves, out summary.p95ActionPoolMoves, out summary.maxActionPoolMoves);
|
|
FillIntStats(actionCountByPlayerTurn.Values.ToList(), out summary.avgActionsPerPlayerTurn, out summary.p95ActionsPerPlayerTurn, out summary.maxActionsPerPlayerTurn);
|
|
summary.topLanes = TopCounts(lanes, 12);
|
|
summary.topReasons = TopCounts(reasons, 20);
|
|
summary.topSelectedActionTypes = TopCounts(selectedActionTypes, 12);
|
|
summary.topExecutedActionTypes = TopCounts(executedActionTypes, 12);
|
|
summary.topLaneActionTypes = TopCounts(laneActionTypes, 20);
|
|
summary.noEffectActionTypes = TopCounts(noEffectActionTypes, 12);
|
|
summary.topHeroReasons = TopCounts(heroReasons, 16);
|
|
summary.topHeroActionTypes = TopCounts(heroActionTypes, 12);
|
|
summary.topHeroExecutedActionTypes = TopCounts(heroExecutedActionTypes, 12);
|
|
summary.topDefenseReasons = TopCounts(defenseReasons, 12);
|
|
summary.topDefenseActionTypes = TopCounts(defenseActionTypes, 12);
|
|
summary.topHeroStyleBuckets = TopCounts(heroStyleBuckets, 12);
|
|
summary.topHeroStyleReasons = TopCounts(heroStyleReasons, 16);
|
|
summary.topHeroStyleActionTypes = TopCounts(heroStyleActionTypes, 12);
|
|
summary.topUnitSkillActionTypes = TopCounts(unitSkillActionTypes, 12);
|
|
summary.topUnitSkillChangedActionTypes = TopCounts(unitSkillChangedActionTypes, 12);
|
|
summary.topActorSkillSignatures = TopCounts(actorSkillSignatures, 12);
|
|
summary.topFallbackReasons = TopCounts(fallbackReasons, 12);
|
|
summary.topFallbackActionTypes = TopCounts(fallbackActionTypes, 12);
|
|
summary.topFallbackExecutedActionTypes = TopCounts(fallbackExecutedActionTypes, 12);
|
|
summary.fallbackNoEffectActionTypes = TopCounts(fallbackNoEffectActionTypes, 12);
|
|
summary.defenseOpportunityTurns = summary.criticalCityThreatTurnCount + summary.emptyThreatenedCityTurnCount;
|
|
summary.emergencyResponseRate = summary.defenseOpportunityTurns <= 0
|
|
? 0f
|
|
: Mathf.Clamp01((float)summary.emergencyDecisions / summary.defenseOpportunityTurns);
|
|
summary.defenseScore = summary.cityThreatResolvedCount * 3f
|
|
+ summary.emergencyExecutions
|
|
+ summary.defenderReturnCount
|
|
+ summary.heroDefensiveUse * 2f
|
|
- summary.cityLostCount * 8f
|
|
- summary.capitalThreatTurnCount * 2f
|
|
- summary.emptyThreatenedCityTurnCount * 2f
|
|
- summary.cityThreatWorsenedCount * 2f;
|
|
return summary;
|
|
}
|
|
|
|
private static string BuildTurnActionKey(JToken record, JToken action)
|
|
{
|
|
var stableKey = action?.Value<string>("stableKey");
|
|
if (string.IsNullOrEmpty(stableKey)) return string.Empty;
|
|
return $"{record?.Value<uint?>("playerId") ?? 0}:{record?.Value<uint?>("playerTurn") ?? 0}:{stableKey}";
|
|
}
|
|
|
|
private static string BuildActionMetricKey(JToken action)
|
|
{
|
|
if (action == null) return string.Empty;
|
|
var actionType = action.Value<string>("actionType") ?? string.Empty;
|
|
var subType = actionType switch
|
|
{
|
|
"UnitAction" => action.Value<string>("unitActionType") ?? string.Empty,
|
|
"PlayerAction" => action.Value<string>("playerActionType") ?? string.Empty,
|
|
"CityAction" => action.Value<string>("cityActionType") ?? string.Empty,
|
|
"CityLevelUpAction" => action.Value<string>("cityLevelUpActionType") ?? string.Empty,
|
|
"GridMisc" => action.Value<string>("gridMiscActionType") ?? string.Empty,
|
|
"LearnTech" => action.Value<string>("techType") ?? string.Empty,
|
|
"BuyCultureCard" => action.Value<string>("cultureCardType") ?? string.Empty,
|
|
"Gain" => action.Value<string>("resourceType") ?? string.Empty,
|
|
"TrainUnit" => action.Value<string>("unitType") ?? string.Empty,
|
|
_ => string.Empty
|
|
};
|
|
|
|
return string.IsNullOrEmpty(subType) || subType == "None"
|
|
? actionType
|
|
: $"{actionType}:{subType}";
|
|
}
|
|
|
|
private static void IncrementHeroStyle(
|
|
Dictionary<string, int> buckets,
|
|
Dictionary<string, int> reasons,
|
|
Dictionary<string, int> actions,
|
|
string reason,
|
|
string actionKey)
|
|
{
|
|
var bucket = ClassifyStyleBucket(reason, actionKey);
|
|
Increment(buckets, bucket);
|
|
Increment(reasons, $"{bucket}:{reason}");
|
|
Increment(actions, $"{bucket}:{actionKey}");
|
|
}
|
|
|
|
private static string ClassifyStyleBucket(string reason, string actionKey)
|
|
{
|
|
var text = $"{reason ?? string.Empty} {actionKey ?? string.Empty}";
|
|
if (ContainsAny(text, "LowHp", "Recover", "Heal", "Eirin", "Sanae", "Patchouli", "Absorb", "Revive"))
|
|
return "Recovery";
|
|
if (ContainsAny(text, "Defense", "Defend", "Protect", "Guard", "MoveToCity", "Aunn", "Meiling", "Sakuya", "Reimu", "ShakeOff", "Unsit"))
|
|
return "Defense";
|
|
if (ContainsAny(text, "Kill", "Flandre", "Assassin", "AttackValue", "LocalBattle", "Boom", "Mokou", "Reisen", "Yuugi"))
|
|
return "Burst";
|
|
if (ContainsAny(text, "Ban", "Fear", "Control", "Satori", "Koishi", "Sumireko", "Orb", "Ground"))
|
|
return "Control";
|
|
if (ContainsAny(text, "Summon", "CreateMini", "Suwako", "Snake", "BonePile", "Mini"))
|
|
return "Summon";
|
|
if (ContainsAny(text, "Economy", "CityExp", "Corpse", "KanakoSit", "Rin", "Tewi", "Kaguya"))
|
|
return "Economy";
|
|
if (ContainsAny(text, "MoveAgain", "MoveToFront", "Retreat", "Mobility", "Aya", "Kasen"))
|
|
return "Mobility";
|
|
if (ContainsAny(text, "SelectHero", "SpawnHero", "FinishLowestTask"))
|
|
return "HeroLifecycle";
|
|
return "General";
|
|
}
|
|
|
|
private static bool ContainsAny(string value, params string[] tokens)
|
|
{
|
|
if (string.IsNullOrEmpty(value) || tokens == null) return false;
|
|
foreach (var token in tokens)
|
|
{
|
|
if (!string.IsNullOrEmpty(token) && value.IndexOf(token, StringComparison.OrdinalIgnoreCase) >= 0) return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private static bool IsDefensiveStyle(string bucket)
|
|
{
|
|
return bucket == "Defense" || bucket == "Recovery";
|
|
}
|
|
|
|
private static bool IsHeroAction(JToken action)
|
|
{
|
|
if (action == null) return false;
|
|
var actorUnitType = action.Value<string>("actorUnitType") ?? string.Empty;
|
|
var actorGiantType = action.Value<string>("actorGiantType") ?? string.Empty;
|
|
var giantType = action.Value<string>("giantType") ?? string.Empty;
|
|
return actorUnitType == "Giant"
|
|
|| (!string.IsNullOrEmpty(actorGiantType) && actorGiantType != "None")
|
|
|| (!string.IsNullOrEmpty(giantType) && giantType != "None");
|
|
}
|
|
|
|
private static string CompactSkillSignature(string signature)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(signature)) return string.Empty;
|
|
var parts = signature.Split('|');
|
|
if (parts.Length <= 4) return signature;
|
|
return string.Join("|", parts.Take(4)) + "|...";
|
|
}
|
|
|
|
private static bool HasMeaningfulDelta(JToken delta)
|
|
{
|
|
if (delta == null) return false;
|
|
foreach (var property in delta.Children<JProperty>())
|
|
{
|
|
if (property.Name == "netActionDelta") continue;
|
|
var value = property.Value;
|
|
switch (value.Type)
|
|
{
|
|
case JTokenType.Boolean:
|
|
if (value.Value<bool>()) return true;
|
|
break;
|
|
case JTokenType.Integer:
|
|
case JTokenType.Float:
|
|
if (Math.Abs(value.Value<float>()) > 0.0001f) return true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private static void Increment(Dictionary<string, int> values, string key)
|
|
{
|
|
if (string.IsNullOrEmpty(key)) key = "(empty)";
|
|
values.TryGetValue(key, out var count);
|
|
values[key] = count + 1;
|
|
}
|
|
|
|
private static List<BatchCountMetric> TopCounts(Dictionary<string, int> values, int maxCount)
|
|
{
|
|
return values
|
|
.OrderByDescending(item => item.Value)
|
|
.ThenBy(item => item.Key, StringComparer.Ordinal)
|
|
.Take(maxCount)
|
|
.Select(item => new BatchCountMetric { key = item.Key, count = item.Value })
|
|
.ToList();
|
|
}
|
|
|
|
private static void FillFloatStats(List<float> values, out float average, out float p95, out float max)
|
|
{
|
|
if (values == null || values.Count == 0)
|
|
{
|
|
average = 0f;
|
|
p95 = 0f;
|
|
max = 0f;
|
|
return;
|
|
}
|
|
|
|
values.Sort();
|
|
average = values.Average();
|
|
p95 = values[Mathf.Clamp(Mathf.CeilToInt(values.Count * 0.95f) - 1, 0, values.Count - 1)];
|
|
max = values[^1];
|
|
}
|
|
|
|
private static void FillIntStats(List<int> values, out float average, out int p95, out int max)
|
|
{
|
|
if (values == null || values.Count == 0)
|
|
{
|
|
average = 0f;
|
|
p95 = 0;
|
|
max = 0;
|
|
return;
|
|
}
|
|
|
|
values.Sort();
|
|
average = (float)values.Average();
|
|
p95 = values[Mathf.Clamp(Mathf.CeilToInt(values.Count * 0.95f) - 1, 0, values.Count - 1)];
|
|
max = values[^1];
|
|
}
|
|
|
|
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 bool CompactDiagnostics = true;
|
|
public bool AllSight = false;
|
|
public bool StopOnGameEnd = true;
|
|
public int Seed = 0;
|
|
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);
|
|
Seed = Mathf.Max(0, Seed);
|
|
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<BatchGameResult> results;
|
|
}
|
|
|
|
[Serializable]
|
|
public class BatchResultList
|
|
{
|
|
public List<BatchGameResult> 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 randomSeed;
|
|
public int frames;
|
|
public int netActions;
|
|
public uint maxPlayerTurn;
|
|
public uint curPlayerId;
|
|
public uint curPlayerTurn;
|
|
public int survivingPlayers;
|
|
public string diagnosticsLogPath;
|
|
public BatchDiagnosticSummary diagnostics;
|
|
public List<BatchPlayerResult> 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;
|
|
public bool heroEligible;
|
|
public int selectedHeroCount;
|
|
public int spawnedHeroCount;
|
|
public int maxHeroCount;
|
|
}
|
|
|
|
[Serializable]
|
|
public class BatchDiagnosticSummary
|
|
{
|
|
public string logPath;
|
|
public string error;
|
|
public int decisions;
|
|
public int noActionDecisions;
|
|
public int fallbackDecisions;
|
|
public int heroManagementDecisions;
|
|
public int heroPlaybookDecisions;
|
|
public int heroRuleDecisions;
|
|
public int genericHeroDecisions;
|
|
public int executions;
|
|
public int noEffectExecutions;
|
|
public int repeatedExecutions;
|
|
public int heroDelta;
|
|
public int selectedHeroDelta;
|
|
public int heroTaskDelta;
|
|
public int readyHeroTaskDelta;
|
|
public int forcedHeroTaskDelta;
|
|
public int heroTaskProgressDelta;
|
|
public int criticalCityThreatTurnCount;
|
|
public int capitalThreatTurnCount;
|
|
public int emptyThreatenedCityTurnCount;
|
|
public int emptyThreatenedCityTotal;
|
|
public int defenseOpportunityTurns;
|
|
public int cityThreatResolvedCount;
|
|
public int cityThreatWorsenedCount;
|
|
public int cityLostCount;
|
|
public int cityGainedCount;
|
|
public int capitalOwnershipChangedCount;
|
|
public int emergencyDecisions;
|
|
public int emergencyExecutions;
|
|
public int defenderReturnCount;
|
|
public int heroDefensiveUse;
|
|
public float emergencyResponseRate;
|
|
public float defenseScore;
|
|
public float avgDecideMs;
|
|
public float p95DecideMs;
|
|
public float maxDecideMs;
|
|
public float avgActionPoolAll;
|
|
public int p95ActionPoolAll;
|
|
public int maxActionPoolAll;
|
|
public float avgActionPoolMoves;
|
|
public int p95ActionPoolMoves;
|
|
public int maxActionPoolMoves;
|
|
public float avgActionsPerPlayerTurn;
|
|
public int p95ActionsPerPlayerTurn;
|
|
public int maxActionsPerPlayerTurn;
|
|
public List<BatchCountMetric> topLanes = new();
|
|
public List<BatchCountMetric> topReasons = new();
|
|
public List<BatchCountMetric> topSelectedActionTypes = new();
|
|
public List<BatchCountMetric> topExecutedActionTypes = new();
|
|
public List<BatchCountMetric> topLaneActionTypes = new();
|
|
public List<BatchCountMetric> noEffectActionTypes = new();
|
|
public List<BatchCountMetric> topHeroReasons = new();
|
|
public List<BatchCountMetric> topHeroActionTypes = new();
|
|
public List<BatchCountMetric> topHeroExecutedActionTypes = new();
|
|
public List<BatchCountMetric> topDefenseReasons = new();
|
|
public List<BatchCountMetric> topDefenseActionTypes = new();
|
|
public List<BatchCountMetric> topHeroStyleBuckets = new();
|
|
public List<BatchCountMetric> topHeroStyleReasons = new();
|
|
public List<BatchCountMetric> topHeroStyleActionTypes = new();
|
|
public List<BatchCountMetric> topUnitSkillActionTypes = new();
|
|
public List<BatchCountMetric> topUnitSkillChangedActionTypes = new();
|
|
public List<BatchCountMetric> topActorSkillSignatures = new();
|
|
public List<BatchCountMetric> topFallbackReasons = new();
|
|
public List<BatchCountMetric> topFallbackActionTypes = new();
|
|
public List<BatchCountMetric> topFallbackExecutedActionTypes = new();
|
|
public List<BatchCountMetric> fallbackNoEffectActionTypes = new();
|
|
}
|
|
|
|
[Serializable]
|
|
public class BatchCountMetric
|
|
{
|
|
public string key;
|
|
public int count;
|
|
}
|
|
}
|