TH1/Unity/Assets/Scripts/TH1_Logic/Editor/AIDirectorBatchRunner.cs

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;
}
}