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

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