Add AI Director batch runner

This commit is contained in:
wuwenbo 2026-06-29 15:10:56 +08:00
parent dac07774b6
commit ee49cab50c
9 changed files with 1329 additions and 2 deletions

View File

@ -0,0 +1,112 @@
---
name: th1-ai-director
description: TH1 project workflow for the new AI Director architecture, AI kernel switching, AI action generation, AI Director diagnostics, JSONL log analysis, infinite-loop/card/hero-task/action-repeat triage, and iterative fixes to the simplified non-behavior-tree AI. Use when working on TH1 新AI, AI Director, AI_Director_Diagnostics logs, AI卡死/死循环, AI行为不聪明, AI action filtering, AI replay/testing loops, or replacing/maintaining behavior-tree AI.
---
# TH1 AI Director
## Scope
Use this skill for the new non-behavior-tree AI under `Unity/Assets/Scripts/TH1_Logic/AI/Director`, AI kernel registration, AI action-pool filtering, and local diagnostic loops.
Always layer these project skills first when editing code:
- `th1-base` for broad Unity/hotfix constraints.
- `th1-action-logic` for action execution, `CheckCan`, `CompleteExecute`, AI action generation, and replay/network implications.
- `th1-hero-implementation` if changing hero task semantics or hero gameplay, not just AI filtering.
## Key Files
- `Unity/Assets/Scripts/TH1_Logic/AI/Kernel/` - AI kernel abstraction and switching.
- `Unity/Assets/Scripts/TH1_Logic/AI/Director/` - new AI Director decision/cache/index/diagnostics implementation.
- `Unity/Assets/Scripts/TH1_Logic/AI/AIActionGenerator.cs` - shared AI action candidate generation used by Director and legacy AI paths.
- `Unity/Assets/Scripts/TH1_Logic/AI/AILogic.cs` - AI update loop and action execution guard.
- `Unity/Assets/Scripts/TH1_Logic/Core/GameLogic.cs` - AI update caller.
- `MD/GameMDFramework/18-AI导演系统策划文档.md` - planning/design intent.
- `MD/GameMDFramework/19-AI导演系统逻辑语言.md` - pseudocode/logic-language source.
- `MD/GameMDFramework/20-AI导演系统测试与诊断流程.md` - logging and testing process.
- `Unity/Logs/AI_Director_Diagnostics/*.jsonl` - local debug diagnostic output.
## Fast Log Triage
Run the bundled analyzer before reading huge JSONL files manually:
```powershell
python .codex/skills/th1-ai-director/scripts/analyze_ai_director_log.py
```
Useful options:
```powershell
python .codex/skills/th1-ai-director/scripts/analyze_ai_director_log.py --log Unity/Logs/AI_Director_Diagnostics/ai_director_YYYYMMDD_HHMMSS.jsonl
python .codex/skills/th1-ai-director/scripts/analyze_ai_director_log.py --last 50 --top 30
python .codex/skills/th1-ai-director/scripts/analyze_ai_director_log.py --json
```
Read the analyzer output in this order:
1. Latest log path and event counts.
2. Executions by player and max actions per turn.
3. Decision/execution action distributions.
4. Repeated stable keys.
5. No-effect successful actions.
6. Last execution rows.
## Infinite Loop Triage
Classify the repeated action before patching:
- If `netActionDelta=1` and all gameplay deltas are zero, the action is probably not a valid AI autonomous action. Filter it from `AIActionGenerator` and, if needed, from `AIDirectorActionIndex.IsDangerousAction`.
- If the action mutates state but remains repeatedly high priority, fix the Director lane priority or add once-per-turn/action-budget gating.
- If `CompleteExecute` returns true but `CheckCan` remains true forever because the action's own state is not consumed, inspect the action layer. Do not change authoritative action semantics unless the action is actually wrong for player/network/replay use.
- If an action is player-only, UI-only, debug-only, or a hidden milestone/card/task helper, prefer excluding it from AI candidate generation.
- If the action should be AI-usable long term but currently has missing scoring conditions, temporarily filter it and leave a concise code comment; then update 18/19 before re-enabling.
Known temporary filters from this iteration:
- `CommonActionType.BuyCultureCard` is disabled for AI generation.
- `UnitActionType.ToggleShenlan` is filtered because it is a debug/visual toggle with no action-point cost.
- `PlayerActionType.FinishHeroTask` is filtered because it can execute repeatedly without observable AI turn progress.
## Candidate Filtering Rules
Keep filters near shared AI generation when the action should not be available to either Director or legacy behavior-tree AI.
Use `AIDirectorActionIndex.IsDangerousAction` as a second safety net for Director-specific indexing and fallback selection.
Do not fix AI loops by changing `CheckCan` unless the action is invalid for all callers. Player/UI, network, replay, and scripted effects share the action layer.
## Diagnostic Expectations
A useful AI diagnostic log should explain:
- Session and player turn boundaries.
- Decision lane, priority, reason, fallback flag, stable action key.
- Action pool counts by category.
- World cache snapshot: posture, city threats, fronts, development targets, local battles, hero states, diplomacy.
- Execution before/after/delta snapshots.
- Guard/forced-stop reason when action count budget is exceeded.
When adding diagnostics, keep them editor/debug gated. Do not add game-facing text.
## Verification
After AI or action filtering changes, run:
```powershell
dotnet build Unity/TH1.Hotfix.csproj --no-restore
```
For log-only script changes, run:
```powershell
python .codex/skills/th1-ai-director/scripts/analyze_ai_director_log.py --top 10 --last 10
```
For local autonomous smoke runs, close the Unity Editor first, then run:
```powershell
Tools/RunAIDirectorBatch.ps1 -Games 1 -Players 2 -Turns 1 -TimeoutSeconds 60
```
The runner writes `batch_summary.json` under `Unity/Logs/AI_Batch/<timestamp>/`. Use `-Games 10`, higher `-Turns`, and the normal 17 players for larger AI quality loops.

View File

@ -0,0 +1,4 @@
interface:
display_name: "TH1 AI Director"
short_description: "新AI Director诊断、卡死排查与迭代"
default_prompt: "Use $th1-ai-director to analyze TH1 AI Director logs and fix AI decision loops."

View File

@ -0,0 +1,220 @@
#!/usr/bin/env python3
import argparse
import json
from collections import Counter, defaultdict
from pathlib import Path
ZERO_DELTA_FIELDS = (
"coinDelta",
"techPointDelta",
"cultureDelta",
"cultureCardDelta",
"scoreDelta",
"cityDelta",
"unitDelta",
"heroDelta",
"criticalCityThreatDelta",
"cityThreatDelta",
"unitHealthDelta",
"targetUnitHealthDelta",
"cityLevelDelta",
"targetCityLevelDelta",
)
def repo_root() -> Path:
return Path(__file__).resolve().parents[4]
def find_latest_log(root: Path) -> Path:
log_dir = root / "Unity" / "Logs" / "AI_Director_Diagnostics"
logs = sorted(log_dir.glob("*.jsonl"), key=lambda p: p.stat().st_mtime, reverse=True)
if not logs:
raise FileNotFoundError(f"No AI Director logs found under {log_dir}")
return logs[0]
def load_rows(path: Path):
rows = []
with path.open("r", encoding="utf-8-sig") as f:
for line_no, line in enumerate(f, 1):
line = line.strip()
if not line:
continue
try:
rows.append(json.loads(line))
except json.JSONDecodeError as exc:
raise ValueError(f"{path}:{line_no}: invalid JSON: {exc}") from exc
return rows
def action_key(action: dict) -> str:
if not action:
return ":"
sub_fields = (
"unitActionType",
"playerActionType",
"cityActionType",
"cityLevelUpActionType",
"gridMiscActionType",
"techType",
"cultureCardType",
"resourceType",
"unitType",
"giantType",
"wonderType",
"skillType",
)
sub = ""
for field in sub_fields:
value = action.get(field)
if value and value not in ("None", "NONE"):
sub = value
break
return f"{action.get('actionType', '')}:{sub}"
def stable_key(action: dict) -> str:
return action.get("stableKey") or action_key(action)
def has_no_effect_delta(execution: dict) -> bool:
if not execution or not execution.get("executed"):
return False
delta = execution.get("delta") or {}
if delta.get("netActionDelta") != 1:
return False
if delta.get("unitMoved") or delta.get("unitDied") or delta.get("targetUnitDied"):
return False
if delta.get("cityOwnerChanged") or delta.get("targetCityOwnerChanged"):
return False
for field in ZERO_DELTA_FIELDS:
if delta.get(field, 0) != 0:
return False
float_fields = ("selfMilitaryDelta", "enemyMilitaryDelta", "maxCityDangerScoreDelta")
return all(abs(float(delta.get(field, 0.0))) <= 0.0001 for field in float_fields)
def short_action_row(row: dict, execution_mode: bool) -> dict:
block_name = "execution" if execution_mode else "decision"
block = row.get(block_name) or {}
action = block.get("action") or {}
before = block.get("before") or {}
after = block.get("after") or {}
delta = block.get("delta") or {}
return {
"seq": row.get("eventSequence", 0),
"idx": row.get("actionIndexInTurn", 0),
"player": row.get("playerId", 0),
"netBefore": before.get("netActionCount", row.get("netActionCount", 0)),
"netDelta": delta.get("netActionDelta", 0),
"action": action_key(action),
"unit": action.get("unitId", 0),
"grid": action.get("gridId", 0),
"targetUnit": action.get("targetUnitId", 0),
"beforeGrid": before.get("unitGridId", 0),
"afterGrid": after.get("unitGridId", 0),
"coinDelta": delta.get("coinDelta", 0),
"cultureDelta": delta.get("cultureDelta", 0),
"scoreDelta": delta.get("scoreDelta", 0),
"unitMoved": delta.get("unitMoved", False),
"executed": block.get("executed", False),
}
def summarize(rows, top: int, last: int):
event_counts = Counter(row.get("eventType", "") for row in rows)
decisions = [row for row in rows if row.get("eventType") == "Decision"]
executions = [row for row in rows if row.get("eventType") == "Execution"]
decision_actions = Counter(action_key((row.get("decision") or {}).get("action") or {}) for row in decisions)
execution_actions = Counter(action_key((row.get("execution") or {}).get("action") or {}) for row in executions)
executions_by_player = Counter(row.get("playerId", 0) for row in executions)
repeated_stable = Counter(stable_key((row.get("execution") or {}).get("action") or {}) for row in executions)
per_turn = Counter()
for row in executions:
per_turn[(row.get("playerId", 0), row.get("playerTurn", 0))] += 1
no_effect = [row for row in executions if has_no_effect_delta(row.get("execution") or {})]
no_effect_actions = Counter(action_key((row.get("execution") or {}).get("action") or {}) for row in no_effect)
return {
"event_counts": event_counts,
"decision_count": len(decisions),
"execution_count": len(executions),
"executions_by_player": executions_by_player,
"max_actions_per_player_turn": per_turn.most_common(top),
"decision_actions": decision_actions.most_common(top),
"execution_actions": execution_actions.most_common(top),
"repeated_stable_keys": [(k, v) for k, v in repeated_stable.most_common(top) if v > 1],
"no_effect_actions": no_effect_actions.most_common(top),
"last_executions": [short_action_row(row, True) for row in executions[-last:]],
}
def print_counter(title: str, items):
print(title)
if not items:
print(" <none>")
return
for item in items:
if isinstance(item, tuple) and len(item) == 2:
key, count = item
print(f" {count:>5} {key}")
else:
print(f" {item}")
def main():
parser = argparse.ArgumentParser(description="Analyze TH1 AI Director JSONL diagnostics.")
parser.add_argument("--log", type=Path, help="Specific AI Director JSONL log path.")
parser.add_argument("--top", type=int, default=20, help="Number of top items to print.")
parser.add_argument("--last", type=int, default=30, help="Number of last execution rows to print.")
parser.add_argument("--json", action="store_true", help="Emit JSON summary.")
args = parser.parse_args()
root = repo_root()
log_path = args.log if args.log else find_latest_log(root)
if not log_path.is_absolute():
log_path = root / log_path
rows = load_rows(log_path)
summary = summarize(rows, args.top, args.last)
if args.json:
serializable = {}
for key, value in summary.items():
if isinstance(value, Counter):
serializable[key] = value.most_common(args.top)
else:
serializable[key] = value
serializable["log"] = str(log_path)
serializable["row_count"] = len(rows)
print(json.dumps(serializable, ensure_ascii=False, indent=2))
return
print(f"Log: {log_path}")
print(f"Rows: {len(rows)}")
print_counter("Event counts:", summary["event_counts"].most_common(args.top))
print_counter("Executions by player:", summary["executions_by_player"].most_common(args.top))
print_counter("Max actions per player turn:", summary["max_actions_per_player_turn"])
print_counter("Decision actions:", summary["decision_actions"])
print_counter("Execution actions:", summary["execution_actions"])
print_counter("Repeated stable keys:", summary["repeated_stable_keys"])
print_counter("No-effect successful actions:", summary["no_effect_actions"])
print("Last executions:")
for row in summary["last_executions"]:
print(
" "
f"seq={row['seq']} idx={row['idx']} player={row['player']} "
f"net={row['netBefore']}+{row['netDelta']} action={row['action']} "
f"unit={row['unit']} grid={row['grid']} targetUnit={row['targetUnit']} "
f"unitGrid={row['beforeGrid']}->{row['afterGrid']} "
f"coin={row['coinDelta']} culture={row['cultureDelta']} score={row['scoreDelta']} "
f"moved={row['unitMoved']}"
)
if __name__ == "__main__":
main()

View File

@ -0,0 +1,157 @@
param(
[string]$UnityPath = $env:UNITY_EXE,
[string]$ProjectPath,
[int]$Games = 1,
[int]$Players = 17,
[int]$Width = 30,
[int]$Height = 30,
[int]$Turns = 100,
[int]$TimeoutSeconds = 1800,
[int]$MaxActions = 20000,
[int]$MaxActionsPerPlayerTurn = 260,
[string]$OutDir,
[string]$Difficulty = "LUNATIC",
[switch]$KeepGoing,
[switch]$AllowProjectAlreadyOpen,
[switch]$DryRun
)
$ErrorActionPreference = "Stop"
$repoRoot = Split-Path -Parent $PSScriptRoot
if ([string]::IsNullOrWhiteSpace($ProjectPath)) {
$ProjectPath = Join-Path $repoRoot "Unity"
}
$ProjectPath = (Resolve-Path $ProjectPath).Path
if ([string]::IsNullOrWhiteSpace($OutDir)) {
$stamp = Get-Date -Format "yyyyMMdd_HHmmss"
$OutDir = Join-Path $ProjectPath "Logs\AI_Batch\$stamp"
}
$OutDir = [System.IO.Path]::GetFullPath($OutDir)
[System.IO.Directory]::CreateDirectory($OutDir) | Out-Null
function Get-ProjectEditorVersion([string]$PathValue) {
$versionFile = Join-Path $PathValue "ProjectSettings\ProjectVersion.txt"
if (!(Test-Path $versionFile)) { return $null }
foreach ($line in Get-Content -LiteralPath $versionFile) {
$match = [regex]::Match($line, '^m_EditorVersion:\s*(\S+)')
if ($match.Success) { return $match.Groups[1].Value }
}
return $null
}
if ([string]::IsNullOrWhiteSpace($UnityPath)) {
$hubRoot = "C:\Program Files\Unity\Hub\Editor"
if (Test-Path $hubRoot) {
$projectEditorVersion = Get-ProjectEditorVersion $ProjectPath
if (![string]::IsNullOrWhiteSpace($projectEditorVersion)) {
$projectUnityPath = Join-Path $hubRoot "$projectEditorVersion\Editor\Unity.exe"
if (Test-Path $projectUnityPath) {
$UnityPath = $projectUnityPath
}
}
if ([string]::IsNullOrWhiteSpace($UnityPath)) {
$UnityPath = Get-ChildItem -Path $hubRoot -Directory |
Where-Object { $_.Name -match '^2022\.3\.(\d+)' } |
ForEach-Object {
[pscustomobject]@{
Path = Join-Path $_.FullName "Editor\Unity.exe"
Patch = [int]$Matches[1]
Name = $_.Name
}
} |
Where-Object { Test-Path $_.Path } |
Sort-Object Patch, Name -Descending |
Select-Object -ExpandProperty Path -First 1
}
}
}
if ([string]::IsNullOrWhiteSpace($UnityPath) -or !(Test-Path $UnityPath)) {
throw "Unity.exe not found. Pass -UnityPath or set UNITY_EXE."
}
function Normalize-ProjectPath([string]$PathValue) {
if ([string]::IsNullOrWhiteSpace($PathValue)) { return "" }
return [System.IO.Path]::GetFullPath($PathValue).TrimEnd('\', '/').ToLowerInvariant()
}
function Get-ProjectPathFromUnityCommandLine([string]$CommandLine) {
if ([string]::IsNullOrWhiteSpace($CommandLine)) { return $null }
$match = [regex]::Match($CommandLine, '(?i)-projectpath\s+(?:"([^"]+)"|([^\s]+))')
if (!$match.Success) { return $null }
if ($match.Groups[1].Success) { return $match.Groups[1].Value }
return $match.Groups[2].Value
}
function Quote-Argument([string]$Value) {
if ($null -eq $Value) { return '""' }
if ($Value -notmatch '[\s"]') { return $Value }
return '"' + ($Value -replace '"', '\"') + '"'
}
$logFile = Join-Path $OutDir "unity_batch.log"
$failFast = if ($KeepGoing) { "false" } else { "true" }
$args = @(
"-batchmode",
"-projectPath", $ProjectPath,
"-executeMethod", "TH1_Logic.Editor.AIDirectorBatchRunner.Run",
"-logFile", $logFile,
"-aiBatchGames", $Games,
"-aiBatchPlayers", $Players,
"-aiBatchWidth", $Width,
"-aiBatchHeight", $Height,
"-aiBatchTurns", $Turns,
"-aiBatchTimeoutSeconds", $TimeoutSeconds,
"-aiBatchMaxActions", $MaxActions,
"-aiBatchMaxActionsPerPlayerTurn", $MaxActionsPerPlayerTurn,
"-aiBatchDifficulty", $Difficulty,
"-aiBatchFailFast", $failFast,
"-aiBatchOut", $OutDir
)
Write-Host "[AI.Batch] Unity: $UnityPath"
Write-Host "[AI.Batch] Project: $ProjectPath"
Write-Host "[AI.Batch] Output: $OutDir"
Write-Host "[AI.Batch] Log: $logFile"
Write-Host "[AI.Batch] Args: $($args -join ' ')"
if ($DryRun) {
exit 0
}
$normalizedProjectPath = Normalize-ProjectPath $ProjectPath
$openEditors = Get-CimInstance Win32_Process -Filter "Name = 'Unity.exe'" |
Where-Object {
$openProject = Get-ProjectPathFromUnityCommandLine $_.CommandLine
(Normalize-ProjectPath $openProject) -eq $normalizedProjectPath
}
if ($openEditors -and !$AllowProjectAlreadyOpen) {
$ids = ($openEditors | Select-Object -ExpandProperty ProcessId) -join ", "
Write-Host "[AI.Batch] ERROR: Unity project is already open by process id(s): $ids. Close the editor first, or pass -AllowProjectAlreadyOpen if you intentionally want Unity to fail fast." -ForegroundColor Red
exit 20
}
$argumentLine = ($args | ForEach-Object { Quote-Argument ([string]$_) }) -join " "
$unityProcess = Start-Process -FilePath $UnityPath -ArgumentList $argumentLine -Wait -PassThru -WindowStyle Hidden
$unityExitCode = if ($null -ne $unityProcess.ExitCode) { $unityProcess.ExitCode } else { 0 }
$summaryPath = Join-Path $OutDir "batch_summary.json"
if (!(Test-Path $summaryPath)) {
Write-Host "[AI.Batch] ERROR: AI batch did not produce $summaryPath. Check $logFile." -ForegroundColor Red
if ($unityExitCode -ne 0) { exit $unityExitCode }
exit 21
}
if ($unityExitCode -ne 0) {
Write-Host "[AI.Batch] ERROR: Unity exited with code $unityExitCode. Check $logFile." -ForegroundColor Red
exit $unityExitCode
}
Write-Host "[AI.Batch] Summary: $summaryPath"
exit 0

View File

@ -103,7 +103,7 @@ namespace Logic.AI
#if ENABLE_SPEEDUP #if ENABLE_SPEEDUP
if (AILogicState == AILogicState.Pausing) if (AILogicState == AILogicState.Pausing)
#else #else
if (AILogicState == AILogicState.Pausing && !PresentationManager.Busy) if (AILogicState == AILogicState.Pausing && (AIDirectorBatchRuntime.SkipPresentationWait || !PresentationManager.Busy))
#endif #endif
{ {
_targetTime -= Time.deltaTime; _targetTime -= Time.deltaTime;

View File

@ -15,6 +15,17 @@ namespace Logic.AI
Finished Finished
} }
public static class AIDirectorBatchRuntime
{
#if UNITY_EDITOR
public static bool ForceAllPlayersAi;
public static bool SkipPresentationWait;
#else
public const bool ForceAllPlayersAi = false;
public const bool SkipPresentationWait = false;
#endif
}
public readonly struct AIKernelUpdate public readonly struct AIKernelUpdate
{ {
public readonly AIKernelUpdateResult Result; public readonly AIKernelUpdateResult Result;

View File

@ -155,7 +155,7 @@ namespace Logic
if (Main.MapData.CurPlayer == null) return; if (Main.MapData.CurPlayer == null) return;
#if !GAME_AUTO_DEBUG #if !GAME_AUTO_DEBUG
if (!NeedAI()) return; if (!AIDirectorBatchRuntime.ForceAllPlayersAi && !NeedAI()) return;
#endif #endif
if (_aiLogic.PlayerData == null || _aiLogic.PlayerData != Main.MapData.CurPlayer) if (_aiLogic.PlayerData == null || _aiLogic.PlayerData != Main.MapData.CurPlayer)

View File

@ -0,0 +1,812 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
using Logic;
using Logic.AI;
using Logic.CrashSight;
using RuntimeData;
using TH1_Core.Managers;
using TH1_Logic.Core;
using TH1_Logic.MatchConfig;
using TH1_Logic.Net;
using TH1Renderer;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
namespace TH1_Logic.Editor
{
[InitializeOnLoad]
public static class AIDirectorBatchRunner
{
private const string PendingKey = "TH1.AIDirectorBatch.Pending";
private const string OptionsKey = "TH1.AIDirectorBatch.Options";
private const string GameIndexKey = "TH1.AIDirectorBatch.GameIndex";
private const string ResultsKey = "TH1.AIDirectorBatch.Results";
static AIDirectorBatchRunner()
{
if (SessionState.GetBool(PendingKey, false))
{
EditorApplication.playModeStateChanged -= OnPlayModeStateChanged;
EditorApplication.playModeStateChanged += OnPlayModeStateChanged;
}
}
[MenuItem("Tools/TH1/AI Director/Run Batch")]
public static void RunFromMenu()
{
StartBatch(ParseOptionsFromCommandLine());
}
public static void Run()
{
StartBatch(ParseOptionsFromCommandLine());
}
private static void StartBatch(BatchOptions options)
{
try
{
options.Normalize();
Directory.CreateDirectory(options.OutputDirectory);
SessionState.SetString(OptionsKey, JsonUtility.ToJson(options));
SessionState.SetInt(GameIndexKey, 0);
SessionState.SetString(ResultsKey, JsonUtility.ToJson(new BatchResultList()));
SessionState.SetBool(PendingKey, true);
EditorApplication.playModeStateChanged -= OnPlayModeStateChanged;
EditorApplication.playModeStateChanged += OnPlayModeStateChanged;
if (!EditorApplication.isPlaying)
{
OpenStartupScene(options.ScenePath);
EditorApplication.EnterPlaymode();
return;
}
StartPlayModeRunner();
}
catch (Exception e)
{
Debug.LogError($"[AI.Batch] Start failed:\n{e}");
ExitEditor(1);
}
}
private static void OnPlayModeStateChanged(PlayModeStateChange state)
{
if (!SessionState.GetBool(PendingKey, false)) return;
if (state != PlayModeStateChange.EnteredPlayMode) return;
EditorApplication.playModeStateChanged -= OnPlayModeStateChanged;
StartPlayModeRunner();
}
private static void StartPlayModeRunner()
{
EditorApplication.update -= WaitForMainAndStartCoroutine;
EditorApplication.update += WaitForMainAndStartCoroutine;
}
private static void WaitForMainAndStartCoroutine()
{
if (!EditorApplication.isPlaying) return;
if (Main.Instance == null) return;
EditorApplication.update -= WaitForMainAndStartCoroutine;
Main.Instance.StartCoroutine(RunChecked(RunBatchCoroutine(), HandleFatalBatchException));
}
private static IEnumerator RunBatchCoroutine()
{
var options = ReadOptions();
var gameIndex = SessionState.GetInt(GameIndexKey, 0);
var results = ReadResults();
while (gameIndex < options.Games)
{
var result = new BatchGameResult
{
gameIndex = gameIndex,
startedAt = DateTime.UtcNow.ToString("o", CultureInfo.InvariantCulture)
};
var gameDirectory = Path.Combine(options.OutputDirectory, $"game_{gameIndex + 1:000}");
Directory.CreateDirectory(gameDirectory);
Exception gameException = null;
yield return RunChecked(RunGameCoroutine(options, result, gameIndex), e => gameException = e);
if (gameException != null)
{
result.success = false;
result.reason = $"Exception: {gameException.GetType().Name}: {gameException.Message}";
result.stackTrace = gameException.ToString();
Debug.LogError($"[AI.Batch] Game {gameIndex + 1} failed:\n{gameException}");
}
#if UNITY_EDITOR
AIDirectorBatchRuntime.ForceAllPlayersAi = false;
AIDirectorBatchRuntime.SkipPresentationWait = false;
#endif
CompleteResult(result, gameDirectory);
results.Add(result);
WriteGameSummary(gameDirectory, result);
WriteBatchSummary(options, results);
SessionState.SetString(ResultsKey, JsonUtility.ToJson(new BatchResultList { results = results }));
gameIndex++;
SessionState.SetInt(GameIndexKey, gameIndex);
if (!result.success && options.FailFast) break;
if (gameIndex < options.Games)
{
yield return ResetForNextGame();
}
}
var hasFailure = results.Any(item => !item.success);
CleanupSessionState();
Debug.Log($"[AI.Batch] Finished. games={results.Count}, failed={results.Count(item => !item.success)}, output={options.OutputDirectory}");
ExitEditor(hasFailure ? 1 : 0);
}
private static IEnumerator RunGameCoroutine(BatchOptions options, BatchGameResult result, int gameIndex)
{
yield return WaitForRuntimeReady(options);
ConfigureDebugRuntime(options);
AILogic.UseDirectorKernel();
AIDirectorBatchRuntime.ForceAllPlayersAi = true;
AIDirectorBatchRuntime.SkipPresentationWait = true;
var main = Main.Instance;
main.MapConfig = BuildMapConfig(options, gameIndex);
result.playerCount = (int)main.MapConfig.PlayerCount;
result.width = (int)main.MapConfig.Width;
result.height = (int)main.MapConfig.Height;
result.aiKernel = AIKernelRegistry.CurrentKernelType.ToString();
Debug.Log($"[AI.Batch] Start game {gameIndex + 1}/{options.Games}: players={result.playerCount}, size={result.width}x{result.height}");
main.StartMatch();
yield return null;
yield return RunOneGame(options, result);
}
private static IEnumerator RunChecked(IEnumerator root, Action<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;
#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;
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 4ea05ed6538d4a439ba76df86c185fe4
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: