Add AI Director batch runner
This commit is contained in:
parent
dac07774b6
commit
ee49cab50c
112
.codex/skills/th1-ai-director/SKILL.md
Normal file
112
.codex/skills/th1-ai-director/SKILL.md
Normal 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.
|
||||
4
.codex/skills/th1-ai-director/agents/openai.yaml
Normal file
4
.codex/skills/th1-ai-director/agents/openai.yaml
Normal 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."
|
||||
220
.codex/skills/th1-ai-director/scripts/analyze_ai_director_log.py
Normal file
220
.codex/skills/th1-ai-director/scripts/analyze_ai_director_log.py
Normal 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()
|
||||
157
Tools/RunAIDirectorBatch.ps1
Normal file
157
Tools/RunAIDirectorBatch.ps1
Normal 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
|
||||
@ -103,7 +103,7 @@ namespace Logic.AI
|
||||
#if ENABLE_SPEEDUP
|
||||
if (AILogicState == AILogicState.Pausing)
|
||||
#else
|
||||
if (AILogicState == AILogicState.Pausing && !PresentationManager.Busy)
|
||||
if (AILogicState == AILogicState.Pausing && (AIDirectorBatchRuntime.SkipPresentationWait || !PresentationManager.Busy))
|
||||
#endif
|
||||
{
|
||||
_targetTime -= Time.deltaTime;
|
||||
|
||||
@ -15,6 +15,17 @@ namespace Logic.AI
|
||||
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 AIKernelUpdateResult Result;
|
||||
|
||||
@ -155,7 +155,7 @@ namespace Logic
|
||||
if (Main.MapData.CurPlayer == null) return;
|
||||
|
||||
#if !GAME_AUTO_DEBUG
|
||||
if (!NeedAI()) return;
|
||||
if (!AIDirectorBatchRuntime.ForceAllPlayersAi && !NeedAI()) return;
|
||||
#endif
|
||||
|
||||
if (_aiLogic.PlayerData == null || _aiLogic.PlayerData != Main.MapData.CurPlayer)
|
||||
|
||||
812
Unity/Assets/Scripts/TH1_Logic/Editor/AIDirectorBatchRunner.cs
Normal file
812
Unity/Assets/Scripts/TH1_Logic/Editor/AIDirectorBatchRunner.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4ea05ed6538d4a439ba76df86c185fe4
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Loading…
x
Reference in New Issue
Block a user