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 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;
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
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