Merge branch 'main' of http://10.27.17.121:3000/kawagiri/TH1
This commit is contained in:
commit
6d79d3df46
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()
|
||||
8
.gitignore
vendored
8
.gitignore
vendored
@ -126,6 +126,14 @@ __pycache__/
|
||||
/DOC/marketing/email_outreach/exports/*.eml
|
||||
/DOC/marketing/email_outreach/reports/*first_pass_triage*
|
||||
|
||||
# Local OSS report viewer credentials and download caches.
|
||||
/Tools/PlayerBugViewer/config.local.json
|
||||
/Tools/PlayerBugViewer/Data/
|
||||
/Tools/PlayerMultilingualReportViewer/config.local.json
|
||||
/Tools/PlayerMultilingualReportViewer/Data/
|
||||
/Tools/PlayerQuestionnaireViewer/config.local.json
|
||||
/Tools/PlayerQuestionnaireViewer/Data/
|
||||
|
||||
# Dashboard private runtime config. Community-monitor sqlite is intentionally tracked for now.
|
||||
/Tools/Dashboard/private/*.env
|
||||
/Tools/Dashboard/data/community_monitor/twitter-runs/
|
||||
|
||||
@ -0,0 +1,56 @@
|
||||
# TH1-CI-2026-06-29-001 - AI Director zero-effect action loop
|
||||
|
||||
- Status: Fixed in code; 17-player 10-turn batch passed
|
||||
- First recorded date: 2026-06-29
|
||||
- Severity: Critical
|
||||
|
||||
## Raw Symptom
|
||||
|
||||
AI Director 17-player batch with `-Turns 40` repeatedly hit `AI 行为次数过多,可能进入死循环`.
|
||||
|
||||
Affected paths:
|
||||
|
||||
- `Tools/RunAIDirectorBatch.ps1`
|
||||
- `Unity/Assets/Scripts/TH1_Logic/AI/Director/AIDirectorActionIndex.cs`
|
||||
- `Unity/Assets/Scripts/TH1_Logic/Action/ActionLogic.cs` `BuildWonderAction`
|
||||
|
||||
## Why This Is Recurring
|
||||
|
||||
The new AI can legally execute actions through the shared action layer, but some legal actions have no useful per-turn progress when selected as a generic fallback. Once all higher-value lanes fail, fallback can repeatedly select the same stable action and fill the AI action guard.
|
||||
|
||||
## Root Cause
|
||||
|
||||
Two independent leaks reached the same loop shape:
|
||||
|
||||
- Kanako sit/unsit are free state toggles. HeroPlaybook may use them intentionally, but fallback treated them as ordinary unit actions and alternated them with no strategic progress.
|
||||
- `BuildWonderAction.CheckCan` did not mirror `Execute`'s city/config prerequisites. Some wonder actions passed `CheckCan`, were written to the action log, then returned false during `Execute`, leaving the same invalid action available again.
|
||||
|
||||
## Root-Cause Fix
|
||||
|
||||
- Fallback now skips pure/no-progress management actions such as Examine, Kanako sit/unsit, SelectHero, and FinishHeroTask.
|
||||
- Fallback now prefers attacks, moves, city, grid, and player growth actions before unit actions.
|
||||
- `BuildWonderAction.CheckCan` now verifies the target grid belongs to a city and that the current player's wonder config exists before allowing execution.
|
||||
|
||||
## Guardrail Added
|
||||
|
||||
The AI Director diagnostic analyzer already reports:
|
||||
|
||||
- max actions per player turn
|
||||
- repeated stable action keys
|
||||
- no-effect successful actions
|
||||
|
||||
Batch mode now also disables presentation queue playback, Steam auth warmup, renderer mismatch diagnostics, unavailable-hero warning spam, and full decision cache/lane dumps. These are not part of AI decision quality, but they previously made editor batch runs slow and produced oversized logs.
|
||||
|
||||
## Verification Performed
|
||||
|
||||
- `dotnet build Unity/TH1.Hotfix.csproj --no-restore` passed with existing warnings.
|
||||
- `dotnet build Unity/TH1.Logic.Editor.csproj --no-restore` passed with existing warnings.
|
||||
- `Tools/RunAIDirectorBatch.ps1 -Games 1 -Players 17 -Turns 10 -TimeoutSeconds 600 -MaxActions 10000 -MaxActionsPerPlayerTurn 80` passed.
|
||||
- Result: `failedGames=0`, `reason=ReachedMaxTurns:10`, `netActions=1280`, `maxPlayerTurn=10`.
|
||||
- Analyzer: max actions per player turn was 19, far below the 80 guard.
|
||||
- Log sizes: compact diagnostic JSONL was about 9.3 MB; Unity batch log was about 152 KB.
|
||||
- Log scan found no `Steam auth warmup failed`, `Fragment Timeout`, `Fragement Timeout`, `Blocked unavailable hero select`, `AI 行为次数过多`, `NullReferenceException`, or `Exception:` lines.
|
||||
|
||||
## Remaining Validation Gaps
|
||||
|
||||
Manual Unity Editor playtest still needs to confirm that player-controlled Kanako sit/unsit and valid wonder construction remain usable. A longer 40-turn soak is still useful for balance and late-game behavior, but the previous loop/log/performance blockers are cleared by the 10-turn 17-player batch.
|
||||
@ -6,10 +6,7 @@
|
||||
|
||||
| ID | 日期 | 状态 | 严重度 | 问题 | 根治方向 | 记录 |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| TH1-CI-2026-06-29-002 | 2026-06-29 | Fixed in code; guardrail added; Unity validation pending | High | Lv.3+ Mokou revived from flame/egg could fail to move to a killed target after gaining longer range | Centralize attack move-kill rules and guard revived Mokou's ranged move-kill contract | [record](2026-06-29-mokou-revived-ranged-move-kill.md) |
|
||||
| TH1-CI-2026-06-29-001 | 2026-06-29 | Fixed in code; guardrail added; Unity validation pending | High | Movement/skill displacement sight refresh could fail to reveal the unit's final standing grid, exposed by Suika Lv4 falling splash side landing after a non-lethal 3-grid attack | Make the shared movement sight path include the standing grid and guard Suika landing sight handoff | [record](2026-06-29-movement-displacement-sight-refresh.md) |
|
||||
| TH1-CI-2026-06-28-002 | 2026-06-28 | Fixed in code; guardrail added; Unity validation pending | High | Momiji hunter movement could lose 2 movement when another rule replaced the movement range | Split normal movement from prey-adjacent hunter target movement and ban the old global add-then-payback formula | [record](2026-06-28-momiji-hunter-movement-payback.md) |
|
||||
| TH1-CI-2026-06-28-001 | 2026-06-28 | Fixed in code; guardrail added; Unity validation pending | High | Aunn twin levels could diverge when one body was on a ship and the other upgraded or later landed | Treat Aunn level as a player-hero invariant over all same-player land and ship bodies, synchronized after upgrade and landing | [record](2026-06-28-aunn-ship-level-sync.md) |
|
||||
| TH1-CI-2026-06-29-001 | 2026-06-29 | Fixed in code; 17-player 10-turn batch passed | Critical | AI Director fallback can repeat zero-effect actions until the AI loop guard fires | Filter no-progress fallback actions, align BuildWonder CheckCan with Execute prerequisites, and keep batch diagnostics compact | [record](2026-06-29-ai-director-zero-effect-action-loop.md) |
|
||||
| TH1-CI-2026-06-27-001 | 2026-06-27 | Fixed in code; guardrail added; Unity validation pending | High | Suika Lv4 falling splash water restriction applied to empty grids but not unit targets on water | Share falling-splash water target validation across ground targeting, unit targeting, and `UnitAttack` CheckCan filtering | [record](2026-06-27-suika-falling-splash-water-targeting.md) |
|
||||
| TH1-CI-2026-06-26-002 | 2026-06-26 | Fixed in code; guardrail added; Unity validation pending | Critical | Aunn shared HP ignored `DamageBearer` substitute-damage paths | Treat shared HP as an invariant over the actual mutated unit, `DamageBearer ?? DamageTarget`, with entrypoint fallback and shared-death cleanup | [record](2026-06-26-aunn-shared-health-damage-bearer.md) |
|
||||
| TH1-CI-2026-06-26-001 | 2026-06-26 | Fixed in code; guardrail added; Unity validation pending | High | Suika Lv4 falling splash can move data without complete landing, projectile, and fog/sight presentation after a 3-grid jump | Use a dedicated Suika falling-splash Fragment that owns jump, splash, final landing, and newly opened fog refresh | [record](2026-06-26-suika-falling-splash-landing-animation.md) |
|
||||
|
||||
@ -71,7 +71,7 @@
|
||||
| 3 | **行为系统 (Action)** | 建造 · 训练 · 移动 · 攻击 · 外交 | [03-行为系统](./03-行为系统-Action.md) |
|
||||
| 4 | **逻辑模块 (Logic)** | City/Unit/Player三大逻辑 | [04-逻辑模块](./04-逻辑模块-CityUnitPlayer.md) |
|
||||
| 5 | **技能系统 (Skill)** | 296种技能类型 · 300+实现 | [05-技能系统](./05-技能系统-Skill.md) |
|
||||
| 6 | **AI系统** | 行为树 · 评分 · 决策 · ML推理 | [06-AI系统](./06-AI系统.md) |
|
||||
| 6 | **AI系统** | 行为树 · Director内核 · 决策切换 | [06-AI系统](./06-AI系统.md) |
|
||||
| 7 | **事件系统 (Event)** | EventManager · UIEvents | [07-事件系统](./07-事件系统-Event.md) |
|
||||
| 8 | **UI框架** | ViewController · 5大UI管理器 | [08-UI框架](./08-UI框架.md) |
|
||||
| 9 | **演出与动画** | PresentationManager · Fragment | [09-演出与动画](./09-演出与动画系统.md) |
|
||||
@ -81,7 +81,9 @@
|
||||
| 13 | **配置与数据资产** | Config · DataAssets · Excel | [13-配置与数据资产](./13-配置与数据资产.md) |
|
||||
| 14 | **辅助系统** | 多语言 · 成就 · 漫画 · Wiki等 | [14-辅助系统](./14-辅助系统.md) |
|
||||
| 15 | **服务端** | 阿里云函数 · STS · OSS上传 | [15-服务端](./15-服务端-GameUploadFunction.md) |
|
||||
| 16 | **AI训练管线** | ONNX推理 · 训练数据录制 | [16-AI训练管线](./16-AI训练管线.md) |
|
||||
| 16 | **AI训练管线** | 旧训练方案历史文档 | [16-AI训练管线](./16-AI训练管线.md) |
|
||||
| 18 | **AI导演策划** | 新AI策划结构 · 缓存 · 车道 · HeroPlaybook | [18-AI导演系统策划文档](./18-AI导演系统策划文档.md) |
|
||||
| 19 | **AI导演逻辑语言** | 新AI伪代码 · 逻辑语言 · 接入规则 | [19-AI导演系统逻辑语言](./19-AI导演系统逻辑语言.md) |
|
||||
|
||||
---
|
||||
|
||||
@ -95,7 +97,7 @@
|
||||
| **单例** | Main, AudioManager, ConfigManager 等 | 全局管理器 |
|
||||
| **MVC** | ViewController ↔ Data ↔ Renderer | UI控制器/数据/视图分离 |
|
||||
| **队列序列器** | PresentationManager | 顺序执行UI/动画任务 |
|
||||
| **行为树** | BTNodeCanvas (150+ 节点) | AI决策 |
|
||||
| **AI内核策略** | AILogic / AIKernelRegistry | 行为树与Director内核切换 |
|
||||
| **对象池** | PrefabPoolManager | 特效/Prefab复用 |
|
||||
| **策略模式** | AI评分计算器 | 不同评分策略组合 |
|
||||
|
||||
@ -132,8 +134,8 @@
|
||||
┌─────────────────────────────────────────┐
|
||||
│ AI回合 (OtherPlayerRound) │
|
||||
│ │
|
||||
│ 行为树决策 → 生成合法Action │
|
||||
│ 评分计算 → 选择最优Action │
|
||||
│ AI内核决策(行为树或Director) │
|
||||
│ 生成合法Action → 选择动作 │
|
||||
│ 执行 → 动画 → 下一个AI │
|
||||
└──────────────┬──────────────────────────┘
|
||||
│
|
||||
@ -157,8 +159,7 @@ Scripts/
|
||||
├── TH1_Logic/ # 游戏逻辑(最大模块)
|
||||
│ ├── Core/ # Main.cs · GameLogic.cs 入口
|
||||
│ ├── Action/ # 6种行为逻辑
|
||||
│ ├── AI/ # AI逻辑 · 评分 · 生成器
|
||||
│ ├── AITrain/ # ML推理 · 训练数据
|
||||
│ ├── AI/ # AI逻辑 · 内核切换 · 行为树/Director
|
||||
│ ├── City/ # 城市逻辑
|
||||
│ ├── Unit/ # 部队逻辑
|
||||
│ ├── Player/ # 玩家逻辑
|
||||
|
||||
@ -125,7 +125,7 @@ Steam 预校验响应:
|
||||
▼
|
||||
[5] 生成 Post Policy + HMAC-SHA1 签名
|
||||
│ 限制: objectKey精确匹配
|
||||
│ 限制: 普通数据 1B - 3MB,玩家 Bug 汇报 1B - 10MB,问卷 1B - 512KB
|
||||
│ 限制: 普通数据 1B - 3MB,玩家 Bug 汇报 1B - 10MB,玩家多语言汇报 1B - 1MB,玩家问卷答卷 1B - 512KB
|
||||
│
|
||||
▼
|
||||
[6] 存入 STS 上传凭证缓存
|
||||
@ -179,7 +179,7 @@ Steam 预校验响应:
|
||||
无版本号: questionnaire/common/{steamId}/{timestamp}-{random}.json
|
||||
```
|
||||
|
||||
`questionnaire` 用于玩家主动填写体验反馈问卷。客户端先把答卷保存到本地 `questionnaire_answers.json`,再上传一个 UTF-8 JSON 包到 OSS。该类型每次请求都会生成新的 OSS 对象,不复用 Tablestore 缓存,避免连续提交覆盖同一个文件。
|
||||
`questionnaire` 用于玩家问卷答卷,客户端上传一个 JSON 文件,包含 `schema`、`responseId`、`questionnaireId`、提交时间、SteamID、版本/设备信息、CrashSight 设备 ID 和 `answerSheet`。该类型每次请求都会生成新的 OSS 对象,不复用 Tablestore 缓存,避免连续提交覆盖同一个文件。
|
||||
|
||||
---
|
||||
|
||||
@ -258,7 +258,7 @@ Steam 预校验响应:
|
||||
|------|----|
|
||||
| 服务端口 | 9000 |
|
||||
| 最大请求体 | 1024字节 |
|
||||
| 最大上传文件 | 普通数据 3MB;玩家 Bug 汇报 10MB;玩家多语言汇报 1MB;玩家问卷 512KB |
|
||||
| 最大上传文件 | 普通数据 3MB;玩家 Bug 汇报 10MB;玩家多语言汇报 1MB;玩家问卷答卷 512KB |
|
||||
| STS有效期 | 900秒 (15分钟) |
|
||||
| STS上传凭证缓存 | 5分钟 |
|
||||
| Steam身份缓存 | 10分钟 |
|
||||
@ -295,7 +295,7 @@ Unity客户端
|
||||
├── OSSAccessKeyId = accessKeyId
|
||||
├── signature = signature
|
||||
├── x-oss-security-token = securityToken
|
||||
└── file = 游戏数据(≤3MB;玩家 Bug 汇报≤10MB;玩家问卷≤512KB)
|
||||
└── file = 游戏数据(≤2MB;玩家 Bug 汇报≤10MB)
|
||||
```
|
||||
|
||||
---
|
||||
@ -451,31 +451,5 @@ Unity 菜单:`Tools/Steam 上传流程测试器`
|
||||
- `type=ossdata` 普通 OSS 上传。
|
||||
- `type=bugreport` 玩家 Bug 汇报上传。
|
||||
- `type=multilingualreport` 玩家多语言问题汇报上传。
|
||||
- `type=questionnaire` 玩家问卷上传。
|
||||
- 顺序执行 1-5,测试完整新版上传链路。
|
||||
- 顺序执行 1-4,测试完整新版上传链路。
|
||||
|
||||
---
|
||||
|
||||
## 14. 玩家体验问卷
|
||||
|
||||
### 14.1 Unity 侧入口
|
||||
|
||||
主菜单「体验反馈」按钮打开 `UIOutsideQuestionnaire`。玩家填写后先保存到本地 `questionnaire_answers.json`,用于记录是否提交过以及支持重新填写;随后走 `type=questionnaire` 上传到 OSS。
|
||||
|
||||
JSON 内容:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema": "th1.questionnaire-answer.v1",
|
||||
"responseId": "guid",
|
||||
"questionnaireId": "player-feedback-2026-06",
|
||||
"steamId": "76561198xxx",
|
||||
"version": "1.2.3",
|
||||
"answerSheet": {
|
||||
"QuestionnaireId": "player-feedback-2026-06",
|
||||
"Answers": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Payload 包含上传时间、本地提交时间、版本、Unity 版本、平台、CrashSight 设备 ID、设备信息以及每题答案。上传成功后本地答卷记录 `LastUploadObjectKey` 和 `LastUploadedAtUtc`;上传失败时保留本地答卷并记录 `LastUploadError`,玩家重新提交时会再次上传。
|
||||
|
||||
553
MD/GameMDFramework/18-AI导演系统策划文档.md
Normal file
553
MD/GameMDFramework/18-AI导演系统策划文档.md
Normal file
@ -0,0 +1,553 @@
|
||||
# 18-AI导演系统策划文档
|
||||
|
||||
> 本文档定义 TH1 新 AI Director 的目标策划方案。
|
||||
> 它以 TH1 的回合制 4X 玩法、Action 权威行为层、英雄系统、城市发展、科技文化外交为基础,不依赖既有 AI 实现结构。
|
||||
|
||||
---
|
||||
|
||||
## 1. 方案定位
|
||||
|
||||
AI Director 是一套“局势缓存 + 分层规则 + 合法 Action 执行”的回合 AI。
|
||||
|
||||
它的职责是:
|
||||
|
||||
- 读懂当前 `MapData` 局势。
|
||||
- 判断当前回合最该解决的问题。
|
||||
- 从合法 Action 池中选出一个动作。
|
||||
- 通过 `CommonActionParams`、`CheckCan`、`CompleteExecute` 执行。
|
||||
- 执行后重新读局势,再决定下一步。
|
||||
|
||||
它不是:
|
||||
|
||||
- 不是 PPT 细节的逐条搬运。
|
||||
- 不是机器学习黑盒。
|
||||
- 不是所有动作统一套一个万能评分。
|
||||
- 不是绕过 Action 层直接改 `MapData` 的独立系统。
|
||||
|
||||
AI 的核心表现目标:
|
||||
|
||||
| 目标 | 表现 |
|
||||
|---|---|
|
||||
| 活下来 | 不轻易丢首都、空城和关键英雄 |
|
||||
| 会扩张 | 能抢村庄、遗迹、资源和可开发区域 |
|
||||
| 会打仗 | 会防守、会压线、会攻击高价值目标 |
|
||||
| 会发展 | 安全时能建设、升级、学科技、买文化卡 |
|
||||
| 会用英雄 | 英雄按自身机制行动,而不是当普通兵 |
|
||||
| 性能稳定 | 每次决策只做轻量缓存和候选筛选 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 一次 AI 动作的逻辑线
|
||||
|
||||
AI 不是一次性规划完整回合,而是重复执行“读局势,选一个 Action”。
|
||||
|
||||
```text
|
||||
构建局势缓存
|
||||
→ 构建合法动作池
|
||||
→ 按优先级车道选择一个动作
|
||||
→ 校验并执行 Action
|
||||
→ 动作改变 MapData
|
||||
→ 再次进入下一次决策
|
||||
```
|
||||
|
||||
一个普通兵为什么会动起来:
|
||||
|
||||
```text
|
||||
如果城市危险,它去救城。
|
||||
否则如果眼前能打有价值目标,它攻击。
|
||||
否则如果脚下或附近有占领、遗迹、资源、恢复、升级机会,它做机会动作。
|
||||
否则如果它空闲,它向防守、进攻或发展战线移动。
|
||||
否则它交给内政或兜底逻辑。
|
||||
```
|
||||
|
||||
一个城市为什么会生产:
|
||||
|
||||
```text
|
||||
如果城市会被威胁,优先守城和出防守兵。
|
||||
如果城市靠近前线,优先提供军力。
|
||||
如果城市安全,优先升级、建设、资源和奇观。
|
||||
```
|
||||
|
||||
一个英雄为什么会放技能:
|
||||
|
||||
```text
|
||||
先判断英雄当前上下文。
|
||||
再查英雄专属 Playbook。
|
||||
如果专属动作有合法 Action 且有局部价值,就执行。
|
||||
否则才退回普通攻击、移动或恢复。
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 决策骨架
|
||||
|
||||
Director 使用固定优先级车道。车道之间不做统一评分,车道内部只做同类轻量排序。
|
||||
|
||||
| 顺序 | 车道 | 策略目标 |
|
||||
|---|---|---|
|
||||
| 1 | Emergency | 阻止立即丢城、被偷家、关键单位暴毙 |
|
||||
| 2 | HeroManagement | 处理选英雄、英雄任务、出场和复活节奏 |
|
||||
| 3 | HeroPlaybook | 发挥英雄机制和阵营特色 |
|
||||
| 4 | Tactic | 处理当前可攻击或近距离交战 |
|
||||
| 5 | UnitOpportunity | 抢占领、遗迹、采集、恢复、升级等确定收益 |
|
||||
| 6 | Front | 把空闲单位送往正确战略方向 |
|
||||
| 7 | Growth | 推进城市、地块、科技、文化、外交 |
|
||||
| 8 | Fallback | 防止规则缺口导致 AI 停摆 |
|
||||
|
||||
车道顺序的核心含义:
|
||||
|
||||
```text
|
||||
活命 > 英雄体系 > 英雄特色 > 当前战斗 > 短期机会 > 战略移动 > 长期发展 > 兜底
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 局势缓存
|
||||
|
||||
局势缓存只服务本次决策,不维护跨回合大型状态机。
|
||||
|
||||
### 4.1 基础集合
|
||||
|
||||
| 数据 | 用途 |
|
||||
|---|---|
|
||||
| 己方单位 | 行动主体、守军、进攻力量、探索力量 |
|
||||
| 己方英雄 | 构建 HeroState,触发 HeroPlaybook |
|
||||
| 己方城市 | 构建 CityThreat、CityPlan、Front |
|
||||
| 己方领土 | 判断入侵、资源、可建设范围 |
|
||||
| 敌方单位 | 构建威胁、局部战斗、目标价值 |
|
||||
| 敌方英雄 | 判断高价值目标和控制优先级 |
|
||||
| 敌方城市 | 构建进攻战线和地缘距离 |
|
||||
| 可见资源/遗迹/村庄 | 构建发展目标和机会动作 |
|
||||
|
||||
### 4.2 局势指标
|
||||
|
||||
缓存中保留以下轻量指标:
|
||||
|
||||
| 指标 | 含义 |
|
||||
|---|---|
|
||||
| MilitaryValue | 单位战斗价值,普通兵按成本和血量,英雄单独加权 |
|
||||
| CounterValue | 兵种克制价值,来自 TH1 克制关系和特殊兵种映射 |
|
||||
| ThreatValue | 单位或格子可能受到的伤害压力 |
|
||||
| AttackValue | 某单位攻击某目标的局部收益 |
|
||||
| CityThreat | 城市周围敌我压力 |
|
||||
| DevelopmentValue | 村庄、遗迹、资源、边界探索的价值 |
|
||||
| FrontValue | 防守、进攻、发展的战线价值 |
|
||||
|
||||
这些指标不追求完美数学最优,只用于排序同类选择。
|
||||
|
||||
### 4.3 性能边界
|
||||
|
||||
缓存构建必须遵守:
|
||||
|
||||
- 不深拷贝 `MapData`。
|
||||
- 不在每个车道重复全量扫描地图。
|
||||
- 不保存跨多回合的大型黑板式复杂状态。
|
||||
- 不建立永久军团状态机。
|
||||
- 大范围搜索只保留 TopN 目标。
|
||||
|
||||
---
|
||||
|
||||
## 5. 战略态势
|
||||
|
||||
战略态势是本回合国家层面的粗判断。
|
||||
|
||||
```text
|
||||
Defense
|
||||
Expansion
|
||||
Attack
|
||||
Development
|
||||
```
|
||||
|
||||
### 5.1 Defense
|
||||
|
||||
进入 Defense 的典型条件:
|
||||
|
||||
- 有城市 `CityThreat` 达到危险。
|
||||
- 敌方单位进入己方领土。
|
||||
- 敌军压力显著高于附近守军。
|
||||
- 敌军下回合可能威胁城市中心。
|
||||
- 多个敌国同时压境。
|
||||
|
||||
Defense 的行为倾向:
|
||||
|
||||
- Emergency 车道优先回防。
|
||||
- 城市优先训练防守单位、建城墙、移动占城格单位。
|
||||
- 科技优先防御、基础兵种、移动和克制。
|
||||
- 英雄优先治疗、保护、控场、守城。
|
||||
|
||||
### 5.2 Expansion
|
||||
|
||||
进入 Expansion 的典型条件:
|
||||
|
||||
- 近距离有可达村庄。
|
||||
- 附近有高价值遗迹、资源、边界探索点。
|
||||
- 当前没有严重城市威胁。
|
||||
- 军事上暂时不适合主动进攻。
|
||||
|
||||
Expansion 的行为倾向:
|
||||
|
||||
- UnitOpportunity 优先占领和探索。
|
||||
- Development Front 指向村庄、遗迹、资源和边界。
|
||||
- 城市优先增长和基础建设。
|
||||
- 科技优先资源开发、移动能力和道路。
|
||||
|
||||
### 5.3 Attack
|
||||
|
||||
进入 Attack 的典型条件:
|
||||
|
||||
- 与某国战争或关系极差。
|
||||
- 敌城距离足够近。
|
||||
- 我方军事价值和克制价值不劣。
|
||||
- 附近没有更高优先级扩张目标。
|
||||
|
||||
Attack 的行为倾向:
|
||||
|
||||
- Front 指向进攻目标城市。
|
||||
- Tactic 优先击杀高价值目标和守城单位。
|
||||
- 城市优先训练克制兵种和攻城相关单位。
|
||||
- 科技优先军事克制、攻击、移动、海战和攻城。
|
||||
|
||||
### 5.4 Development
|
||||
|
||||
进入 Development 的典型条件:
|
||||
|
||||
- 无严重城市威胁。
|
||||
- 无明确进攻窗口。
|
||||
- 无近距离高价值扩张目标。
|
||||
- 经济、科技、文化或城市等级落后。
|
||||
|
||||
Development 的行为倾向:
|
||||
|
||||
- Growth 车道推进城市建设、资源开发、科技和文化。
|
||||
- Front 可生成低优先级发展战线。
|
||||
- 外交倾向稳定关系和建立使馆。
|
||||
|
||||
---
|
||||
|
||||
## 6. 城市计划
|
||||
|
||||
每座己方城市生成一个 CityPlan。
|
||||
|
||||
| 城市计划 | 触发局势 | 行为倾向 |
|
||||
|---|---|---|
|
||||
| EmergencyDefense | 城市会被立即威胁 | 守城、建城墙、训练防守兵 |
|
||||
| Mobilize | 前线压力存在但未立即丢城 | 训练克制兵、支援前线 |
|
||||
| Frontline | 离敌城或交战边境近 | 训练作战单位、复活英雄、补军力 |
|
||||
| BacklineGrowth | 后方安全 | 升级、建设、资源、科技文化 |
|
||||
| Wonder | 后方安全且奇观条件明显 | 启动或完成奇观 |
|
||||
|
||||
城市策略保持简单:
|
||||
|
||||
```text
|
||||
危险城市解决生存。
|
||||
前线城市提供军力。
|
||||
后方城市滚长期收益。
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 战线与单位意图
|
||||
|
||||
不建立大型跨回合军团状态机。第一版用轻量 Front 表达单位移动方向。
|
||||
|
||||
| Front 类型 | 来源 | 目标 |
|
||||
|---|---|---|
|
||||
| DefenseFront | 危险城市 | 城市中心、防守锚点 |
|
||||
| AttackFront | 目标敌城、敌军集结 | 敌城中心、敌军方向 |
|
||||
| DevelopmentFront | 村庄、遗迹、资源、边界 | 可扩张目标 |
|
||||
| HoldFront | 安全期驻扎 | 关键城市或交通点 |
|
||||
|
||||
单位每次决策只生成当前意图:
|
||||
|
||||
| UnitIntent | 含义 |
|
||||
|---|---|
|
||||
| DefendCity | 回防、攻击威胁城市的敌人 |
|
||||
| FightLocal | 当前附近有可打目标 |
|
||||
| TakeOpportunity | 占领、遗迹、采集、恢复、升级 |
|
||||
| MoveFront | 向 Front 移动 |
|
||||
| Hold | 不离开关键格,等待或恢复 |
|
||||
|
||||
这种设计保留 PPT 中“国家、城市、军团、自由人”的策划意图,但去掉跨回合军团编制和复杂重组。
|
||||
|
||||
---
|
||||
|
||||
## 8. 普通单位策略
|
||||
|
||||
普通单位的行为链:
|
||||
|
||||
```text
|
||||
城市危机可参与 → DefendCity
|
||||
附近有高价值战斗 → FightLocal
|
||||
脚下或附近有确定收益 → TakeOpportunity
|
||||
没有更高优先级 → MoveFront
|
||||
位置关键或低血 → Hold / Recover
|
||||
```
|
||||
|
||||
### 8.1 战斗目标
|
||||
|
||||
攻击目标按以下因素排序:
|
||||
|
||||
- 能否击杀。
|
||||
- 是否威胁城市。
|
||||
- 是否是英雄。
|
||||
- 目标军事价值。
|
||||
- 目标残血程度。
|
||||
- 我方反伤风险。
|
||||
- 外交关系和是否交战。
|
||||
|
||||
### 8.2 机会动作
|
||||
|
||||
机会动作优先级:
|
||||
|
||||
```text
|
||||
占敌城或村庄
|
||||
→ 开遗迹或宝物
|
||||
→ 采集高价值资源
|
||||
→ 英雄升级
|
||||
→ 普通升级或文化升级
|
||||
→ 低血恢复
|
||||
```
|
||||
|
||||
机会动作必须让位于 Emergency 和明显击杀。
|
||||
|
||||
### 8.3 移动
|
||||
|
||||
移动不追求完整路径规划,只选择当前可达格中的最优格。
|
||||
|
||||
移动格评估因素:
|
||||
|
||||
- 是否接近目标 Front。
|
||||
- 是否降低城市危机。
|
||||
- 是否进入敌方高威胁格。
|
||||
- 是否下回合能攻击或占领。
|
||||
- 是否堵住己方城市中心。
|
||||
- 是否保护英雄或关键单位。
|
||||
|
||||
---
|
||||
|
||||
## 9. 英雄策略
|
||||
|
||||
英雄不走普通单位公式。英雄由 HeroPlaybook 驱动。
|
||||
|
||||
HeroPlaybook 的判断顺序:
|
||||
|
||||
```text
|
||||
保命
|
||||
→ 救关键友军
|
||||
→ 使用高价值专属动作
|
||||
→ 攻击高价值目标
|
||||
→ 站位到正确 Front
|
||||
→ 普通恢复或等待
|
||||
```
|
||||
|
||||
英雄动作只通过现有 Action 入口落地:
|
||||
|
||||
| 英雄行为 | Action 入口 |
|
||||
|---|---|
|
||||
| 攻击敌人 | UnitAttack |
|
||||
| 治疗、保护、喂养、友军互动 | UnitAttackAlly |
|
||||
| 攻击地块、召唤、灵球、特殊落点 | UnitAttackGround |
|
||||
| 自身主动、切形态、爆破、坐镇、转换资源 | UnitAction |
|
||||
| 重新站位 | UnitMove |
|
||||
|
||||
### 9.1 红魔馆 / Egyptian
|
||||
|
||||
| 英雄 | AI 意图 |
|
||||
|---|---|
|
||||
| Flandre | 优先斩杀可杀高价值目标;特殊友军攻击只在能触发收益时使用 |
|
||||
| Remilia | 接战时铺血雾或增益;低血或可吸收时续航 |
|
||||
| Sakuya | 守护残血关键友军;有收割窗口时补刀 |
|
||||
| Meiling | 低血恢复,安全时承担前排和防守 |
|
||||
| Patchouli | 利用元素、地面和友军施法收益;危险时避免深入 |
|
||||
|
||||
### 9.2 永远亭 / French
|
||||
|
||||
| 英雄 | AI 意图 |
|
||||
|---|---|
|
||||
| Kaguya | 给前线友军提供协同和团队收益 |
|
||||
| Reisen | 能击杀或爆破时优先输出 |
|
||||
| Tewi | 接战阶段给相邻友军增益 |
|
||||
| Eirin | 优先治疗残血英雄,其次治疗高价值友军 |
|
||||
| Mokou | 残血且敌人密集时自爆换收益;蛋形态按危险密度移动 |
|
||||
|
||||
### 9.3 守矢 / Germany
|
||||
|
||||
| 英雄 | AI 意图 |
|
||||
|---|---|
|
||||
| Kanako | 安全时坐镇;需要移动或接敌时取消坐镇 |
|
||||
| Suwako | 用地面攻击和召唤制造前线压力 |
|
||||
| Sanae | 治疗友军,优先处理残血和英雄威胁 |
|
||||
| Aya | 用高机动追击、回防和踩路径收益 |
|
||||
| Momiji | 围绕猎物标记移动和攻击 |
|
||||
|
||||
### 9.4 地灵殿 / Indian
|
||||
|
||||
| 英雄 | AI 意图 |
|
||||
|---|---|
|
||||
| Satori | 压制敌方核心英雄,优先封禁或恐惧相关目标 |
|
||||
| Koishi | 靠近安全前线和骨堆目标,制造恐惧/治疗价值 |
|
||||
| Rin | 有尸火层数时转换输出或资源 |
|
||||
| Utsuho | 骨堆、敌城中心和高密度目标优先 |
|
||||
| Yuugi | 通过推进和强位移压线,寻找高收益移动格 |
|
||||
|
||||
### 9.5 博丽 / Norway
|
||||
|
||||
| 英雄 | AI 意图 |
|
||||
|---|---|
|
||||
| Reimu | 保护关键友军;退魔压力高时净化 |
|
||||
| Sumireko | 用灵球和地面攻击控制战线 |
|
||||
| Kasen | 高压战斗时切换形态,利用鬼形态压制 |
|
||||
| Aunn | 靠近防守线,保持双体和支援价值 |
|
||||
| Suika | 安全时生成小萃香,被围攻时解围 |
|
||||
|
||||
### 9.6 后续阵营通用规则
|
||||
|
||||
`BritishByakuren`、`PersianMiko`、`ByzantineZanmu` 等已在游戏数据中存在时,AI 不因为缺少专属 Playbook 而停摆。
|
||||
|
||||
默认规则:
|
||||
|
||||
- 按英雄等级和技能入口识别可用 Action。
|
||||
- 治疗和保护类动作优先给残血英雄和高价值友军。
|
||||
- 地面攻击类动作优先选敌军密集、敌城中心或可触发召唤的格子。
|
||||
- 自身主动类动作只在满足局部价值时使用。
|
||||
- 没有专属规则时退回通用英雄攻击、恢复和 Front 站位。
|
||||
|
||||
---
|
||||
|
||||
## 10. 科技、文化、外交
|
||||
|
||||
科技、文化、外交属于长期收益,不和 Emergency、Tactic、HeroPlaybook 抢优先级。
|
||||
|
||||
### 10.1 科技方向
|
||||
|
||||
| 局势 | 科技倾向 |
|
||||
|---|---|
|
||||
| Defense | 防御、基础兵种、城墙、克制、移动 |
|
||||
| Attack | 军事克制、攻击、移动、攻城、海战 |
|
||||
| Expansion | 资源开发、探索、道路、浅海/山地通行 |
|
||||
| Development | 经济、城市升级、文化、奇观条件 |
|
||||
|
||||
科技选择可以看当前科技,也可以看一层后继科技,但不做完整科技树搜索。
|
||||
|
||||
### 10.2 文化卡
|
||||
|
||||
文化卡按当前阶段选方向:
|
||||
|
||||
- 前线吃紧:单位强化、购买强单位、即时战力。
|
||||
- 英雄体系成型:英雄折扣、英雄强化、英雄相关收益。
|
||||
- 安全发展:长期经济、资源解锁、文化收益。
|
||||
|
||||
### 10.3 外交
|
||||
|
||||
外交保持简单:
|
||||
|
||||
- 高好感结盟请求优先接受。
|
||||
- 好感高且资源允许时建立使馆。
|
||||
- 好感极高且没有直接扩张冲突时尝试结盟。
|
||||
- 好感极低或已交战国家可进入 Attack 候选。
|
||||
- 队友和盟友不能被普通进攻逻辑选为目标。
|
||||
|
||||
---
|
||||
|
||||
## 11. 合法动作池
|
||||
|
||||
Director 不直接推演行为结果,而是从合法 Action 池中选择。
|
||||
|
||||
动作池需要覆盖:
|
||||
|
||||
| 分类 | 动作 |
|
||||
|---|---|
|
||||
| 单位战斗 | UnitAttack、UnitAttackAlly、UnitAttackGround |
|
||||
| 单位移动 | UnitMove |
|
||||
| 单位行为 | Capture、Examine、Gather、Recover、Upgrade、HeroUpgrade、CultureUnitUpgrade、英雄主动 |
|
||||
| 城市行为 | TrainUnit、CityLevelUpAction、CityAction、StartWonder、BuildWonder |
|
||||
| 地块行为 | Gain、Build、GridMisc |
|
||||
| 玩家行为 | LearnTech、BuyCultureCard、PlayerAction |
|
||||
| 英雄管理 | SelectHero、FinishHeroTask、出场、复活 |
|
||||
|
||||
危险动作默认不进入普通 AI 选择:
|
||||
|
||||
- 解散。
|
||||
- 强制解散。
|
||||
- 拆除。
|
||||
- 驱散。
|
||||
- 摧毁建筑。
|
||||
|
||||
除非后续单独设计明确的“牺牲/拆除/清场”规则。
|
||||
|
||||
---
|
||||
|
||||
## 12. 工程落地边界
|
||||
|
||||
AI 可以重新实现,但必须遵守 TH1 的游戏架构:
|
||||
|
||||
- 权威行为仍走 Action 层。
|
||||
- 参数仍用 `CommonActionParams`。
|
||||
- 合法性仍由 `CheckCan` 兜底。
|
||||
- 执行仍走 `CompleteExecute`。
|
||||
- 技能效果仍由 `SkillBase` 生命周期触发。
|
||||
- 城市、单位、玩家、格子的真实修改仍由现有逻辑模块承担。
|
||||
- 多人和回放不能依赖本地随机数、当前时间或不稳定遍历顺序。
|
||||
|
||||
这意味着 AI 文档定义“怎么选动作”,不定义“动作怎么改游戏数据”。
|
||||
|
||||
---
|
||||
|
||||
## 13. 性能目标
|
||||
|
||||
一次决策的成本应接近:
|
||||
|
||||
```text
|
||||
收集基础实体
|
||||
+ 局部距离和威胁缓存
|
||||
+ 合法动作枚举
|
||||
+ 各车道轻量排序
|
||||
```
|
||||
|
||||
性能原则:
|
||||
|
||||
- 城市威胁只看城市周围有限范围。
|
||||
- 局部战斗只看可接触范围。
|
||||
- Front 只保留少量高价值目标。
|
||||
- DevelopmentTarget 只保留 TopN。
|
||||
- 行动候选只生成一次。
|
||||
- 车道只查缓存和动作池。
|
||||
|
||||
如果卡顿,优先削减:
|
||||
|
||||
```text
|
||||
移动候选数量
|
||||
发展目标数量
|
||||
局部战斗搜索半径
|
||||
科技/建筑候选遍历次数
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 14. 测试标准
|
||||
|
||||
第一轮测试不只看胜负,先看行为线。
|
||||
|
||||
| 测试项 | 通过标准 |
|
||||
|---|---|
|
||||
| 回合稳定 | AI 能连续执行并正常结束回合 |
|
||||
| 城市防守 | 城市危险时优先攻击威胁或回防 |
|
||||
| 占领扩张 | 能占村、占城、开遗迹时不会长期无视 |
|
||||
| 局部战斗 | 能打高价值目标、残血目标和威胁城市目标 |
|
||||
| 英雄表现 | 英雄会治疗、保护、地面攻击、自爆、坐镇或控场 |
|
||||
| 战线移动 | 空闲单位能向防守、进攻、发展目标移动 |
|
||||
| 城市发展 | 安全城市能生产、升级、建设、科技文化 |
|
||||
| 外交行为 | 高好感不乱开战,必要时建立使馆或结盟 |
|
||||
| 性能 | 单个 AI 动作决策无明显卡顿 |
|
||||
|
||||
问题定位:
|
||||
|
||||
| 现象 | 优先检查 |
|
||||
|---|---|
|
||||
| 城市被偷 | CityThreat、Emergency、城市计划 |
|
||||
| 能占不占 | UnitOpportunity、DevelopmentTarget |
|
||||
| 英雄不放技能 | HeroState、HeroPlaybook、ActionPool |
|
||||
| 单位乱走 | Front、GridThreat、MoveTarget |
|
||||
| 城市不发展 | CityPlan、Growth、ActionPool |
|
||||
| 科技乱学 | StrategicPosture、TechScore |
|
||||
| 回合慢 | ActionPool、移动候选、局部搜索半径 |
|
||||
2186
MD/GameMDFramework/19-AI导演系统逻辑语言.md
Normal file
2186
MD/GameMDFramework/19-AI导演系统逻辑语言.md
Normal file
File diff suppressed because it is too large
Load Diff
691
MD/GameMDFramework/20-AI导演系统测试与诊断流程.md
Normal file
691
MD/GameMDFramework/20-AI导演系统测试与诊断流程.md
Normal file
@ -0,0 +1,691 @@
|
||||
# 20-AI导演系统测试与诊断流程
|
||||
|
||||
> 本文档定义新 AI Director 的本地测试流程和诊断日志阅读方式。
|
||||
> 目标不是只看胜负,而是能逐回合判断 AI 为什么这么做、缓存是否正确、决策是否聪明、执行是否成功。
|
||||
|
||||
---
|
||||
|
||||
## 1. 测试目标
|
||||
|
||||
AI 测试必须回答四个问题:
|
||||
|
||||
```text
|
||||
局势缓存是否正确读懂了地图
|
||||
战略和车道是否选对了问题
|
||||
候选 Action 是否覆盖了该做的事
|
||||
最终执行是否真的改变了 MapData
|
||||
```
|
||||
|
||||
如果 AI 行为不对,先不要直接改分数。按日志定位问题属于哪一层:
|
||||
|
||||
| 问题层 | 典型表现 | 优先处理 |
|
||||
|---|---|---|
|
||||
| 缓存错误 | 城市明明危险但 `cityThreats` 不危险 | 修缓存逻辑,必要时回写 18/19 |
|
||||
| 动作池缺失 | 日志里没有想要的 Action | 查 Action 生成、参数、`CheckCan` |
|
||||
| 车道错误 | 有城市危机却走 Growth | 修车道条件或优先级 |
|
||||
| 评分错误 | 车道对了但目标很蠢 | 修同类动作排序 |
|
||||
| 执行失败 | `executed=false` | 查 `CompleteExecute`、参数刷新、网络状态 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 诊断开关
|
||||
|
||||
诊断入口:
|
||||
|
||||
```csharp
|
||||
Logic.AI.Director.AIDirectorDiagnostics.Enable();
|
||||
Logic.AI.Director.AIDirectorDiagnostics.Disable();
|
||||
Logic.AI.Director.AIDirectorDiagnostics.BeginNewSession();
|
||||
var path = Logic.AI.Director.AIDirectorDiagnostics.CurrentLogPath;
|
||||
```
|
||||
|
||||
当前策略:
|
||||
|
||||
| 环境 | 行为 |
|
||||
|---|---|
|
||||
| Unity Editor | 默认启用,方便本地跑局直接产生日志 |
|
||||
| 非 Editor 普通包 | 默认 no-op,无日志成本 |
|
||||
| 非 Editor 诊断包 | 加 `TH1_AI_DIRECTOR_DIAGNOSTICS` 宏后可启用 |
|
||||
|
||||
如果 Editor 下临时不想产生日志,在测试入口或控制台调用:
|
||||
|
||||
```csharp
|
||||
AIDirectorDiagnostics.Disable();
|
||||
```
|
||||
|
||||
如果要从某一局重新开始单独记录:
|
||||
|
||||
```csharp
|
||||
AIDirectorDiagnostics.BeginNewSession();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 文件结构
|
||||
|
||||
日志采用 JSONL,一行一个事件。
|
||||
|
||||
Editor 路径:
|
||||
|
||||
```text
|
||||
Unity/Logs/AI_Director_Diagnostics/ai_director_yyyyMMdd_HHmmss.jsonl
|
||||
```
|
||||
|
||||
Player 路径:
|
||||
|
||||
```text
|
||||
Application.persistentDataPath/AI_Director_Diagnostics/ai_director_yyyyMMdd_HHmmss.jsonl
|
||||
```
|
||||
|
||||
文件组织选择:
|
||||
|
||||
```text
|
||||
一局一个文件
|
||||
同一局内所有 AI 玩家写入同一个文件
|
||||
每次 AI 决策和执行各写一行
|
||||
```
|
||||
|
||||
这样做的原因:
|
||||
|
||||
- 能按 `eventSequence` 看完整时间线。
|
||||
- 能看不同 AI 玩家之间的连续因果。
|
||||
- 不会每回合生成大量小文件。
|
||||
- JSONL 可追加,崩溃前的数据也能保留。
|
||||
|
||||
当运行中检测到 `MapData` 实例变化时,会自动开启新 session。
|
||||
|
||||
---
|
||||
|
||||
## 4. 事件类型
|
||||
|
||||
| eventType | 含义 |
|
||||
|---|---|
|
||||
| SessionStart | 新日志文件开始 |
|
||||
| TurnStart | 某个 AI 玩家开始本次 AI 逻辑 |
|
||||
| Decision | Director 完成一次思考并选出一个候选动作 |
|
||||
| Execution | `AILogic` 执行该动作后的结果 |
|
||||
| TurnEnd | 某个 AI 玩家结束本次 AI 逻辑 |
|
||||
|
||||
一次正常 AI 行动线大致是:
|
||||
|
||||
```text
|
||||
TurnStart
|
||||
Decision
|
||||
Execution
|
||||
Decision
|
||||
Execution
|
||||
...
|
||||
Decision(hasAction=false) 或 AI 无可用动作
|
||||
TurnEnd
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 关键字段
|
||||
|
||||
### 5.1 顶层字段
|
||||
|
||||
| 字段 | 用途 |
|
||||
|---|---|
|
||||
| eventSequence | 全文件递增序号,用来还原时间线 |
|
||||
| mapId | 当前地图 ID |
|
||||
| curPlayerId | `MapData.Net.CurPlayerId` |
|
||||
| netActionCount | 当前权威 Action 日志数量 |
|
||||
| playerId | 当前 AI 玩家 |
|
||||
| playerTurn | 当前 AI 玩家的回合数 |
|
||||
| playerCoin / playerCulture | 决策当时资源 |
|
||||
| selfCities / selfUnits | 当前己方城市和单位数量 |
|
||||
|
||||
### 5.2 decision
|
||||
|
||||
| 字段 | 用途 |
|
||||
|---|---|
|
||||
| hasAction | 本次是否选出动作 |
|
||||
| decideMs | 本次 Director 决策耗时 |
|
||||
| lane | 选中车道 |
|
||||
| priority | 车道内优先级 |
|
||||
| reason | 选中原因 |
|
||||
| isFallback | 是否兜底动作 |
|
||||
| action | 最终候选 Action 摘要 |
|
||||
|
||||
重点看:
|
||||
|
||||
```text
|
||||
lane 是否符合局势
|
||||
reason 是否能解释行为
|
||||
priority 是否明显异常
|
||||
action 是否是你预期的动作类型和目标
|
||||
decideMs 是否出现异常尖峰
|
||||
```
|
||||
|
||||
### 5.3 cache
|
||||
|
||||
| 字段 | 用途 |
|
||||
|---|---|
|
||||
| strategicPosture | 国家级态势 |
|
||||
| hasCriticalCityThreat | 是否存在危急城市 |
|
||||
| hasAnyEnemyContact | 是否接敌 |
|
||||
| selfMilitary / enemyMilitary | 粗略军力 |
|
||||
| cityThreats | 城市威胁 TopN |
|
||||
| cityPlans | 城市计划 TopN |
|
||||
| fronts | 战线 TopN |
|
||||
| developmentTargets | 扩张目标 TopN |
|
||||
| localBattles | 局部战斗 TopN |
|
||||
| heroStates | 英雄状态 |
|
||||
| unitOpportunities | 单位机会动作 |
|
||||
| diplomacy | 外交视图 |
|
||||
|
||||
重点看:
|
||||
|
||||
```text
|
||||
地图上的事实是否被 cache 正确表达
|
||||
最危险城市是否排在 cityThreats 前面
|
||||
英雄的 context 是否符合当前战况
|
||||
Front 指向是否符合防守、进攻、发展目标
|
||||
```
|
||||
|
||||
### 5.4 actionPool
|
||||
|
||||
| 字段 | 用途 |
|
||||
|---|---|
|
||||
| all | 合法候选总数 |
|
||||
| attacks | 攻击候选 |
|
||||
| moves | 移动候选 |
|
||||
| unitActions | 单位行为 |
|
||||
| cityActions | 城市行为 |
|
||||
| gridActions | 地块行为 |
|
||||
| playerActions | 玩家行为 |
|
||||
| heroManagementActions | 英雄管理行为 |
|
||||
|
||||
重点看:
|
||||
|
||||
```text
|
||||
如果 AI 没做某事,先看 actionPool 是否真的有这类动作
|
||||
如果 all 长期接近 MaxGeneratedActions,说明候选生成可能过多
|
||||
如果 all 为 0,说明 Action 生成或 CheckCan 有大问题
|
||||
```
|
||||
|
||||
### 5.5 lanes
|
||||
|
||||
`lanes` 是诊断自循环最关键的数据。它记录每个车道看到的 TopN 候选,而不是只记录最终选择。
|
||||
|
||||
| 字段 | 用途 |
|
||||
|---|---|
|
||||
| lane | 车道名 |
|
||||
| selected | 最终是否选中了该车道 |
|
||||
| note | 车道结果说明 |
|
||||
| candidates | 该车道候选 TopN |
|
||||
|
||||
`candidates` 字段:
|
||||
|
||||
| 字段 | 用途 |
|
||||
|---|---|
|
||||
| source | 候选来源,例如 LocalBattle、CityGrowth、HeroRule |
|
||||
| isValid | 候选是否可执行 |
|
||||
| rejectReason | 无效或未生成原因 |
|
||||
| priority | 当前候选分数 |
|
||||
| reason | 候选自然语言原因 |
|
||||
| action | 候选 Action 摘要 |
|
||||
| scoreTerms | 分数组成 |
|
||||
|
||||
`scoreTerms` 用来回答“为什么这个候选分高”:
|
||||
|
||||
```text
|
||||
base
|
||||
danger
|
||||
targetValue
|
||||
distance
|
||||
hero
|
||||
attackScore
|
||||
cityGrowth
|
||||
gridGrowth
|
||||
playerGrowth
|
||||
```
|
||||
|
||||
重点看:
|
||||
|
||||
```text
|
||||
最终 action 是否真的是所在 lane 的最高优先级候选
|
||||
未选动作是否进入过候选列表
|
||||
候选无效是 NoAction、InvalidCandidate,还是 CheckCan 过滤
|
||||
分数是被哪个 scoreTerms 拉高或拉低
|
||||
```
|
||||
|
||||
### 5.6 execution
|
||||
|
||||
| 字段 | 用途 |
|
||||
|---|---|
|
||||
| executed | `CompleteExecute` 返回值 |
|
||||
| action | 实际执行 Action 摘要 |
|
||||
| before | 执行前局势摘要 |
|
||||
| after | 执行后局势摘要 |
|
||||
| delta | 执行前后变化 |
|
||||
| isInSight | 是否在玩家视野内 |
|
||||
| duration | 本次 AI 行为等待时长 |
|
||||
|
||||
`before / after` 包含:
|
||||
|
||||
```text
|
||||
资源:coin / techPoint / culture / cultureCardCount
|
||||
规模:cityCount / unitCount / heroCount
|
||||
军力:selfMilitary / enemyMilitary
|
||||
威胁:criticalCityThreatCount / cityThreatCount / maxCityDangerScore
|
||||
关键对象:单位血量、位置、死亡,城市归属、等级
|
||||
```
|
||||
|
||||
`delta` 包含:
|
||||
|
||||
```text
|
||||
netActionDelta
|
||||
coinDelta / techPointDelta / cultureDelta
|
||||
cityDelta / unitDelta / heroDelta
|
||||
selfMilitaryDelta / enemyMilitaryDelta
|
||||
criticalCityThreatDelta / cityThreatDelta
|
||||
unitMoved / unitDied / targetUnitDied
|
||||
cityOwnerChanged / targetCityOwnerChanged
|
||||
```
|
||||
|
||||
重点看:
|
||||
|
||||
```text
|
||||
Decision 和 Execution 的 action 是否一致
|
||||
executed 是否为 true
|
||||
netActionDelta 是否推进
|
||||
delta 是否符合动作意图
|
||||
攻击是否造成 targetUnitHealthDelta 或 targetUnitDied
|
||||
回防是否降低 criticalCityThreatDelta 或 maxCityDangerScoreDelta
|
||||
占领是否造成 cityOwnerChanged 或 cityDelta
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 单回合阅读流程
|
||||
|
||||
看一个 AI 回合时按这个顺序读:
|
||||
|
||||
```text
|
||||
1. 找到该 playerId 的 TurnStart
|
||||
2. 顺着 eventSequence 看每个 Decision
|
||||
3. 先看 cache.strategicPosture 和 cityThreats
|
||||
4. 再看 decision.lane / reason / action
|
||||
5. 对照 actionPool 判断是不是没有可用动作
|
||||
6. 看 lanes,确认没选的候选为什么没赢
|
||||
7. 看紧随其后的 Execution 是否成功
|
||||
8. 看 execution.delta 判断动作结果是否赚
|
||||
9. 重复直到 TurnEnd
|
||||
```
|
||||
|
||||
判断一句话:
|
||||
|
||||
```text
|
||||
如果 cache 对,lane 对,lanes 候选解释合理,action 合理,executed=true,delta 符合意图,这一步就是正确的。
|
||||
```
|
||||
|
||||
如果行为看起来笨,但上面五项都对,说明 18/19 的 AI 思路本身需要调整,而不是代码 bug。
|
||||
|
||||
---
|
||||
|
||||
## 7. 标准测试用例
|
||||
|
||||
### 7.1 冒烟测试
|
||||
|
||||
设置:
|
||||
|
||||
```text
|
||||
小地图
|
||||
2-4 个 AI
|
||||
跑 20 个玩家回合
|
||||
```
|
||||
|
||||
通过标准:
|
||||
|
||||
```text
|
||||
没有异常中断
|
||||
TurnStart 和 TurnEnd 成对出现
|
||||
Decision/Execution 能连续推进
|
||||
Fallback 不应长期占主导
|
||||
executed=false 不应反复出现
|
||||
```
|
||||
|
||||
### 7.2 城市防守
|
||||
|
||||
设置:
|
||||
|
||||
```text
|
||||
AI 城市附近放敌军
|
||||
敌军能下回合威胁城市中心
|
||||
AI 有守军或城市可生产
|
||||
```
|
||||
|
||||
期望:
|
||||
|
||||
```text
|
||||
cache.hasCriticalCityThreat = true
|
||||
strategicPosture = Defense
|
||||
cityThreats 第一项是被威胁城市
|
||||
decision.lane 优先 Emergency
|
||||
动作是攻击威胁、回防、建墙或训练防守兵
|
||||
```
|
||||
|
||||
失败定位:
|
||||
|
||||
| 现象 | 检查 |
|
||||
|---|---|
|
||||
| 没进 Defense | `BuildCityThreats`、威胁距离、敌方单位归属 |
|
||||
| 进了 Defense 但不 Emergency | Emergency 车道条件 |
|
||||
| Emergency 没动作 | actionPool 中是否有攻击、移动、城市动作 |
|
||||
|
||||
### 7.3 占领扩张
|
||||
|
||||
设置:
|
||||
|
||||
```text
|
||||
AI 单位附近有村庄、敌空城、遗迹、宝物、资源
|
||||
附近没有城市危机和明显击杀机会
|
||||
```
|
||||
|
||||
期望:
|
||||
|
||||
```text
|
||||
strategicPosture = Expansion 或 Development
|
||||
developmentTargets 包含目标格
|
||||
unitOpportunities 包含 Capture / Examine / Gather
|
||||
decision.lane = UnitOpportunity 或 Front
|
||||
```
|
||||
|
||||
失败定位:
|
||||
|
||||
```text
|
||||
developmentTargets 没目标 -> 缓存识别问题
|
||||
unitOpportunities 没动作 -> ActionPool 或 UnitActionType 映射问题
|
||||
长期 Front 但不占领 -> UnitOpportunity 优先级或目标过滤问题
|
||||
```
|
||||
|
||||
### 7.4 局部战斗
|
||||
|
||||
设置:
|
||||
|
||||
```text
|
||||
AI 单位射程内有敌方残血、高价值单位、威胁城市单位、英雄
|
||||
```
|
||||
|
||||
期望:
|
||||
|
||||
```text
|
||||
localBattles 记录交战双方
|
||||
decision.lane = Tactic
|
||||
目标优先残血、高价值、英雄、威胁城市单位
|
||||
```
|
||||
|
||||
失败定位:
|
||||
|
||||
```text
|
||||
localBattles 为空 -> 距离或敌我判断问题
|
||||
Tactic 选低价值目标 -> ScoreAttackAction 问题
|
||||
不攻击而移动 -> AttackActions 缺失或 CheckCan 失败
|
||||
```
|
||||
|
||||
### 7.5 英雄 Playbook
|
||||
|
||||
设置:
|
||||
|
||||
```text
|
||||
每个阵营至少做一局专项测试
|
||||
给英雄放置对应技能窗口
|
||||
例如残血友军、敌方英雄、地面目标、敌军密集、自爆窗口、坐镇窗口
|
||||
```
|
||||
|
||||
期望:
|
||||
|
||||
```text
|
||||
heroStates 包含英雄
|
||||
context 与局势一致
|
||||
decision.lane = HeroPlaybook
|
||||
reason 能指向专属行为
|
||||
action 是 UnitAttack / UnitAttackAlly / UnitAttackGround / UnitAction / UnitMove 中的正确入口
|
||||
```
|
||||
|
||||
失败定位:
|
||||
|
||||
```text
|
||||
heroStates 没英雄 -> 英雄识别或 PlayerHeroData 问题
|
||||
HeroPlaybook 不触发 -> 专属规则条件过严
|
||||
触发但动作不对 -> 目标策略或 ActionPool 查询问题
|
||||
```
|
||||
|
||||
### 7.6 城市发展
|
||||
|
||||
设置:
|
||||
|
||||
```text
|
||||
无城市危险
|
||||
无明显战斗
|
||||
城市有升级、训练、建造、奇观、科技、文化卡可选
|
||||
```
|
||||
|
||||
期望:
|
||||
|
||||
```text
|
||||
strategicPosture = Development
|
||||
cityPlans 后方城市为 BacklineGrowth 或 Wonder
|
||||
decision.lane = Growth
|
||||
动作推进城市升级、建设、资源、科技或文化
|
||||
```
|
||||
|
||||
失败定位:
|
||||
|
||||
```text
|
||||
安全城市仍 Mobilize -> CityThreat 或 Front 判断过敏
|
||||
Growth 长期只训练兵 -> CityGrowth 分数偏军力
|
||||
科技文化不出现 -> PlayerActions 缺失或评分为 0
|
||||
```
|
||||
|
||||
### 7.7 外交
|
||||
|
||||
设置:
|
||||
|
||||
```text
|
||||
调整好感、队友、盟友、战争、邻近城市冲突
|
||||
```
|
||||
|
||||
期望:
|
||||
|
||||
```text
|
||||
diplomacy 正确记录 state / feeling / isTeammate
|
||||
高好感可建使馆或结盟
|
||||
队友不被 BreakAlly 或攻击目标选中
|
||||
低好感或战争目标可进入 Attack
|
||||
```
|
||||
|
||||
失败定位:
|
||||
|
||||
```text
|
||||
外交缓存错 -> GetCountryDiplomacyInfo 或 SameUnion 判断
|
||||
乱结盟 -> HasDirectExpansionConflict 过松
|
||||
乱开战 -> AttackTarget 条件过松
|
||||
```
|
||||
|
||||
### 7.8 性能
|
||||
|
||||
设置:
|
||||
|
||||
```text
|
||||
中大型地图
|
||||
多个 AI 连续跑 30-50 个玩家回合
|
||||
```
|
||||
|
||||
期望:
|
||||
|
||||
```text
|
||||
actionPool.all 不长期顶到 MaxGeneratedActions
|
||||
Decision 数量与实际动作数量接近
|
||||
无明显卡顿尖峰
|
||||
日志文件可接受
|
||||
```
|
||||
|
||||
性能问题优先削减:
|
||||
|
||||
```text
|
||||
移动候选
|
||||
DevelopmentTarget TopN
|
||||
Front TopN
|
||||
LocalBattle 搜索半径
|
||||
ActionPool 最大数量
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 异常信号
|
||||
|
||||
看到下面信号时要停下来查:
|
||||
|
||||
| 信号 | 含义 |
|
||||
|---|---|
|
||||
| 大量 `lane=Fallback` | 前面车道缺规则或条件过严 |
|
||||
| `hasAction=false` 但地图上明显有事可做 | ActionPool 或 CheckCan 问题 |
|
||||
| `executed=false` 连续出现 | Action 参数或同步执行问题 |
|
||||
| Defense 局面走 Growth | 车道或 CityThreat 错误 |
|
||||
| 英雄长期不进 HeroPlaybook | HeroState 或专属规则缺口 |
|
||||
| 同一个 stableKey 反复出现 | 可能动作未改变局势或循环 |
|
||||
| actionPool.all 长期接近上限 | 候选过多导致性能风险 |
|
||||
| cityThreats 全空但敌人在城边 | 敌我归属、距离或视野数据错误 |
|
||||
| lanes 里目标动作 isValid=false | Action 参数、CheckCan 或查询入口问题 |
|
||||
| scoreTerms 单项长期压倒全部 | 某个评分项过强,需要调权重 |
|
||||
| executed=true 但 netActionDelta=0 | 网络/权威执行路径可能没有落 Action 日志 |
|
||||
| targetUnitDied=false 且反复攻击残血目标 | 伤害估算或目标选择可能过乐观 |
|
||||
| criticalCityThreatDelta 长期不下降 | Emergency 没有真正缓解城市压力 |
|
||||
|
||||
---
|
||||
|
||||
## 9. 十局自循环分析
|
||||
|
||||
跑 10 局后,不直接看单局胜负,先聚合这些指标:
|
||||
|
||||
| 指标 | 来源字段 | 用途 |
|
||||
|---|---|---|
|
||||
| 胜负和存活 | 结算/外部汇总 | 总体强度 |
|
||||
| 回合推进 | TurnStart / TurnEnd | 是否卡死 |
|
||||
| 决策耗时 | decision.decideMs | 性能尖峰 |
|
||||
| Fallback率 | decision.lane | 规则缺口 |
|
||||
| NoAction率 | decision.hasAction | ActionPool或CheckCan问题 |
|
||||
| 执行失败率 | execution.executed | 参数或同步问题 |
|
||||
| 候选生成量 | actionPool.all | 性能风险 |
|
||||
| 城市威胁变化 | execution.delta.criticalCityThreatDelta | 防守效果 |
|
||||
| 军力交换 | selfMilitaryDelta / enemyMilitaryDelta | 战斗收益 |
|
||||
| 扩张收益 | cityDelta / cityOwnerChanged | 占领能力 |
|
||||
| 英雄存活 | heroDelta / unitDied | 英雄保命 |
|
||||
| 重复动作 | action.stableKey | 循环风险 |
|
||||
|
||||
自动归因规则:
|
||||
|
||||
```text
|
||||
Fallback率高
|
||||
=> 车道覆盖不足,优先改 18/19 或新增 lane 内规则
|
||||
|
||||
NoAction率高且 actionPool.all=0
|
||||
=> Action 生成或 CheckCan 过严
|
||||
|
||||
NoAction率低但想要的动作不在 lanes
|
||||
=> 车道没有把该动作纳入候选
|
||||
|
||||
想要的动作在 lanes 但 priority 低
|
||||
=> scoreTerms 权重问题
|
||||
|
||||
想要的动作 isValid=false
|
||||
=> 参数、目标选择或 CheckCan 问题
|
||||
|
||||
Emergency 后 criticalCityThreatDelta 不下降
|
||||
=> 防守动作没有真正解决问题,调整 Emergency 行动优先级
|
||||
|
||||
Tactic 后 enemyMilitaryDelta 不下降且 selfMilitaryDelta 下降
|
||||
=> 攻击评分低估反击或高估输出
|
||||
|
||||
UnitOpportunity 长期不触发 Capture/Examine/Gather
|
||||
=> UnitOpportunity 车道或 opportunity 分数过低
|
||||
|
||||
HeroPlaybook 候选少或长期无效
|
||||
=> 英雄规则条件或目标策略需要补
|
||||
|
||||
decideMs 尖峰且 actionPool.all 高
|
||||
=> 限制候选数量、Front/DevelopmentTarget TopN 或移动枚举
|
||||
```
|
||||
|
||||
自循环的固定流程:
|
||||
|
||||
```text
|
||||
1. 固定 10 个 seed、地图、阵营组合。
|
||||
2. 跑一批 JSONL。
|
||||
3. 聚合上面的指标。
|
||||
4. 找异常最高的 3 类问题。
|
||||
5. 对每类问题抽 3-5 条 eventSequence 做人工复核。
|
||||
6. 判断改 18、19、代码还是 Action 生成。
|
||||
7. 修改后用同一批 seed 回归。
|
||||
8. 对比指标是否改善。
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. 问题回写流程
|
||||
|
||||
每个问题按下面格式记录:
|
||||
|
||||
```text
|
||||
地图/存档:
|
||||
玩家:
|
||||
回合:
|
||||
eventSequence:
|
||||
现象:
|
||||
期望:
|
||||
日志证据:
|
||||
候选证据:
|
||||
delta证据:
|
||||
初判层级:
|
||||
处理方式:
|
||||
```
|
||||
|
||||
处理顺序:
|
||||
|
||||
```text
|
||||
1. 如果是策划思路不清,先改 18。
|
||||
2. 如果 18 清楚但伪代码不完整,改 19。
|
||||
3. 如果 19 正确但实现不符,改代码。
|
||||
4. 如果代码正确但 Action 层不给动作,查 Action 生成或 CheckCan。
|
||||
5. 修完重新跑同一存档,对比新旧 JSONL。
|
||||
```
|
||||
|
||||
不要只凭感觉改分数。每次改动都要能对应一条日志证据。
|
||||
|
||||
---
|
||||
|
||||
## 11. 建议测试节奏
|
||||
|
||||
第一轮:
|
||||
|
||||
```text
|
||||
只跑小地图
|
||||
每次看 1-2 个 AI 玩家
|
||||
优先修 executed=false、NoAction、Fallback 过多
|
||||
```
|
||||
|
||||
第二轮:
|
||||
|
||||
```text
|
||||
按城市防守、扩张、战斗、英雄、发展、外交逐项做专项场景
|
||||
每个问题都保留 eventSequence 和日志文件名
|
||||
```
|
||||
|
||||
第三轮:
|
||||
|
||||
```text
|
||||
跑中大型地图
|
||||
观察性能、重复动作、长期战略表现
|
||||
开始调整 18/19 的策略思路
|
||||
```
|
||||
|
||||
第四轮:
|
||||
|
||||
```text
|
||||
完整对局
|
||||
只看胜负、扩张速度、英雄发挥、是否有明显蠢动作
|
||||
把蠢动作回放到具体 Decision 日志再修
|
||||
```
|
||||
@ -73,7 +73,7 @@
|
||||
| `authTicket` | string | 条件必填 | Steam 客户端生成的 Auth Ticket(十六进制字符串)。首次预校验或身份缓存未命中时必须提供 |
|
||||
| `version` | string | 否 | 客户端版本号,不传则视为老版本 |
|
||||
| `action` | string | 否 | `steamauth` 表示只做 Steam 预校验;不传则进入上传凭证流程 |
|
||||
| `type` | string | 否 | 上传类型:`ossdata`(默认) / `collectdata` / `bugreport` / `multilingualreport` |
|
||||
| `type` | string | 否 | 上传类型:`ossdata`(默认) / `collectdata` / `bugreport` / `multilingualreport` / `questionnaire` |
|
||||
|
||||
**示例:**
|
||||
```json
|
||||
@ -142,6 +142,10 @@ bugreport:
|
||||
multilingualreport:
|
||||
有版本号:multilingualreport/{version}/{steamId}/{timestamp}-{random}.zip
|
||||
无版本号:multilingualreport/common/{steamId}/{timestamp}-{random}.zip
|
||||
|
||||
questionnaire:
|
||||
有版本号:questionnaire/{version}/{steamId}/{timestamp}-{random}.json
|
||||
无版本号:questionnaire/common/{steamId}/{timestamp}-{random}.json
|
||||
```
|
||||
|
||||
STS 权限策略仅允许写入该精确路径,最小化权限范围。
|
||||
@ -150,6 +154,8 @@ STS 权限策略仅允许写入该精确路径,最小化权限范围。
|
||||
|
||||
`multilingualreport` 用于玩家上报有问题的多语言文本,上传 zip 包,最大 1MB。内容包含 `manifest.json`、玩家选择的字符串和玩家自述,同样每次请求生成新 objectKey,不复用 Tablestore 缓存。
|
||||
|
||||
`questionnaire` 用于玩家问卷答卷,上传 JSON 文件,最大 512KB。该类型每次请求生成新 objectKey,不复用 Tablestore 缓存。
|
||||
|
||||
---
|
||||
|
||||
## 缓存机制
|
||||
@ -158,7 +164,7 @@ STS 权限策略仅允许写入该精确路径,最小化权限范围。
|
||||
|
||||
**Steam 身份缓存:**
|
||||
|
||||
客户端可以在 Steam 就绪后发送 `action=steamauth`,服务端验证通过后写入 `{steamId}#steamauth#{steamAppId}`,有效期 **10 分钟**。后续 `ossdata`、`collectdata`、`bugreport`、`multilingualreport` 上传在身份缓存有效期内可以跳过实时 Steam API 校验。
|
||||
客户端可以在 Steam 就绪后发送 `action=steamauth`,服务端验证通过后写入 `{steamId}#steamauth#{steamAppId}`,有效期 **10 分钟**。后续 `ossdata`、`collectdata`、`bugreport`、`multilingualreport`、`questionnaire` 上传在身份缓存有效期内可以跳过实时 Steam API 校验。
|
||||
|
||||
**STS 上传凭证缓存命中条件(同时满足):**
|
||||
1. 版本号与请求一致(`null` 视为老版本,需严格匹配)
|
||||
@ -204,7 +210,7 @@ STS 权限策略仅允许写入该精确路径,最小化权限范围。
|
||||
|------|----|------|
|
||||
| 服务端口 | `9000` | HTTP 监听端口 |
|
||||
| 最大请求体 | `1024` 字节 | 防止过大请求 |
|
||||
| 最大上传文件 | `3 MB` / `10 MB` / `1 MB` | 普通数据 / 玩家 Bug 汇报 / 玩家多语言汇报的 Post Policy 限制 |
|
||||
| 最大上传文件 | `3 MB` / `10 MB` / `1 MB` / `512 KB` | 普通数据 / 玩家 Bug 汇报 / 玩家多语言汇报 / 玩家问卷答卷的 Post Policy 限制 |
|
||||
| STS 有效期 | `900` 秒(15 分钟) | STS 临时凭证有效时长 |
|
||||
| 缓存有效期 | `5` 分钟 | Tablestore 缓存的最长复用时间 |
|
||||
| Steam 身份缓存 | `10` 分钟 | 预校验通过后的身份复用时间 |
|
||||
@ -217,5 +223,5 @@ STS 权限策略仅允许写入该精确路径,最小化权限范围。
|
||||
|
||||
- **Steam 身份验证**:服务端只接受 `3774440` / `3887950` 等允许的 AppID,并用客户端上传的 `steamAppId` 调用 Steam API;身份缓存命中时复用预校验结果,未命中时强制验证票据真实性并比对 SteamID 防止伪造。
|
||||
- **最小权限 STS**:每个令牌的 OSS 写入权限仅限于带时间戳的精确路径,无法覆盖其他玩家的文件。
|
||||
- **Post Policy 签名**:限制上传文件大小(普通数据≤3MB,玩家 Bug 汇报≤10MB,玩家多语言汇报≤1MB)和目标路径,防止客户端篡改上传目标。
|
||||
- **Post Policy 签名**:限制上传文件大小(普通数据≤3MB,玩家 Bug 汇报≤10MB,玩家多语言汇报≤1MB,玩家问卷答卷≤512KB)和目标路径,防止客户端篡改上传目标。
|
||||
- **版本隔离**:不同版本的客户端使用不同路径前缀,令牌不可跨版本复用。
|
||||
|
||||
31
Tools/OSS/game-upload-function/check-upload-contract.js
Normal file
31
Tools/OSS/game-upload-function/check-upload-contract.js
Normal file
@ -0,0 +1,31 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const source = fs.readFileSync(path.join(__dirname, 'index.js'), 'utf8');
|
||||
|
||||
const checks = [
|
||||
['ossdata type', /ossdata:\s*{[\s\S]*?extension:\s*'dat'[\s\S]*?maxUploadSize:\s*MAX_STANDARD_UPLOAD_SIZE/],
|
||||
['collectdata type', /collectdata:\s*{[\s\S]*?extension:\s*'dat'[\s\S]*?buildPathPrefix:\s*versionSegment\s*=>\s*`collect\/\$\{versionSegment\}`/],
|
||||
['bugreport type', /bugreport:\s*{[\s\S]*?extension:\s*'zip'[\s\S]*?uniqueObjectKey:\s*true[\s\S]*?buildPathPrefix:\s*versionSegment\s*=>\s*`bugreport\/\$\{versionSegment\}`/],
|
||||
['multilingualreport type', /multilingualreport:\s*{[\s\S]*?extension:\s*'zip'[\s\S]*?uniqueObjectKey:\s*true[\s\S]*?buildPathPrefix:\s*versionSegment\s*=>\s*`multilingualreport\/\$\{versionSegment\}`/],
|
||||
['questionnaire type', /questionnaire:\s*{[\s\S]*?extension:\s*'json'[\s\S]*?maxUploadSize:\s*MAX_QUESTIONNAIRE_UPLOAD_SIZE[\s\S]*?uniqueObjectKey:\s*true[\s\S]*?buildPathPrefix:\s*versionSegment\s*=>\s*`questionnaire\/\$\{versionSegment\}`/],
|
||||
['questionnaire size', /const MAX_QUESTIONNAIRE_UPLOAD_SIZE\s*=\s*512\s*\*\s*1024/],
|
||||
['exact key post policy', /\['eq', '\$key', objectKey\]/],
|
||||
['content length range post policy', /\['content-length-range', 1, maxUploadSize\]/],
|
||||
];
|
||||
|
||||
let failed = false;
|
||||
for (const [name, pattern] of checks) {
|
||||
if (!pattern.test(source)) {
|
||||
console.error(`contract check failed: ${name}`);
|
||||
failed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (failed) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('upload contract check passed');
|
||||
@ -11,6 +11,7 @@
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"check": "node -c index.js && node check-upload-contract.js",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"keywords": [],
|
||||
|
||||
12
Tools/PlayerQuestionnaireViewer/README.md
Normal file
12
Tools/PlayerQuestionnaireViewer/README.md
Normal file
@ -0,0 +1,12 @@
|
||||
# TH1 玩家问卷查看器
|
||||
|
||||
独立于玩家 Bug 查看器的本地工具,用于从 OSS 拉取玩家提交的 `questionnaire/` JSON 答卷,按版本、问卷 ID、SteamID 和题目 ID 筛选查看。
|
||||
|
||||
## 使用
|
||||
|
||||
1. 运行 `启动玩家问卷查看器.bat`。
|
||||
2. 工具会自动读取 Unity OSS 编辑器保存的 `AccessKey ID` / `AccessKey Secret`,确认 `Endpoint` 和 `Bucket`。
|
||||
3. 点击「更新内容」拉取 `questionnaire/` 下的 JSON。
|
||||
4. 选择条目查看问卷 ID、提交时间、版本、SteamID、CrashSight 设备 ID、设备信息和答案明细。
|
||||
|
||||
也可以用 `config.local.json` 或环境变量覆盖 OSS 配置。本地下载缓存放在 `Data/`,两者都只应保留在本机。
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"access_key_id": "LTAI5t7x5WJDtoFNpxDq4MWh",
|
||||
"access_key_secret": "fBDtyz7Z38B7rvRXHNzqVflw6LThXX",
|
||||
"access_key_id": "",
|
||||
"access_key_secret": "",
|
||||
"endpoint": "oss-cn-shanghai.aliyuncs.com",
|
||||
"bucket": "th1-oss",
|
||||
"skip_existing_downloads": true
|
||||
}
|
||||
}
|
||||
504
Tools/PlayerQuestionnaireViewer/player_questionnaire_viewer.py
Normal file
504
Tools/PlayerQuestionnaireViewer/player_questionnaire_viewer.py
Normal file
@ -0,0 +1,504 @@
|
||||
import base64
|
||||
import datetime as dt
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import xml.etree.ElementTree as ET
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from tkinter import BOTH, END, LEFT, RIGHT, X, Y, BooleanVar, StringVar, Tk, messagebox
|
||||
from tkinter import ttk
|
||||
|
||||
|
||||
APP_DIR = Path(__file__).resolve().parent
|
||||
TOOLS_DIR = APP_DIR.parent
|
||||
if str(TOOLS_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(TOOLS_DIR))
|
||||
|
||||
from oss_viewer_config import merge_default_oss_config, merge_local_oss_config
|
||||
|
||||
DATA_DIR = APP_DIR / "Data"
|
||||
CONFIG_PATH = APP_DIR / "config.local.json"
|
||||
OSS_PREFIX = "questionnaire/"
|
||||
|
||||
|
||||
def default_config() -> dict:
|
||||
return {
|
||||
"access_key_id": "",
|
||||
"access_key_secret": "",
|
||||
"endpoint": "oss-cn-shanghai.aliyuncs.com",
|
||||
"bucket": "th1-oss",
|
||||
"skip_existing_downloads": True,
|
||||
}
|
||||
|
||||
|
||||
def load_config() -> dict:
|
||||
cfg = merge_default_oss_config(default_config())
|
||||
if CONFIG_PATH.exists():
|
||||
try:
|
||||
cfg = merge_local_oss_config(cfg, json.loads(CONFIG_PATH.read_text(encoding="utf-8")))
|
||||
except Exception:
|
||||
pass
|
||||
return cfg
|
||||
|
||||
|
||||
def save_config(cfg: dict) -> None:
|
||||
CONFIG_PATH.write_text(json.dumps(cfg, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
|
||||
def human_bytes(value: int) -> str:
|
||||
if value >= 1024 * 1024:
|
||||
return f"{value / 1024 / 1024:.2f} MB"
|
||||
if value >= 1024:
|
||||
return f"{value / 1024:.1f} KB"
|
||||
return f"{value} B"
|
||||
|
||||
|
||||
def parse_oss_time(value: str) -> str:
|
||||
if not value:
|
||||
return ""
|
||||
try:
|
||||
return dt.datetime.fromisoformat(value.replace("Z", "+00:00")).astimezone().strftime("%Y-%m-%d %H:%M")
|
||||
except Exception:
|
||||
return value[:16]
|
||||
|
||||
|
||||
class OssClient:
|
||||
def __init__(self, access_key_id: str, access_key_secret: str, endpoint: str, bucket: str):
|
||||
self.access_key_id = access_key_id.strip()
|
||||
self.access_key_secret = access_key_secret.strip()
|
||||
self.endpoint = endpoint.strip()
|
||||
self.bucket = bucket.strip()
|
||||
|
||||
def _sign(self, verb: str, date: str, canonicalized_resource: str) -> str:
|
||||
string_to_sign = f"{verb}\n\n\n{date}\n{canonicalized_resource}"
|
||||
digest = hmac.new(
|
||||
self.access_key_secret.encode("utf-8"),
|
||||
string_to_sign.encode("utf-8"),
|
||||
hashlib.sha1,
|
||||
).digest()
|
||||
return base64.b64encode(digest).decode("ascii")
|
||||
|
||||
def _request(self, verb: str, url: str, canonicalized_resource: str) -> bytes:
|
||||
now = dt.datetime.now(dt.timezone.utc)
|
||||
date = now.strftime("%a, %d %b %Y %H:%M:%S GMT")
|
||||
signature = self._sign(verb, date, canonicalized_resource)
|
||||
request = urllib.request.Request(url, method=verb)
|
||||
request.add_header("Date", date)
|
||||
request.add_header("Authorization", f"OSS {self.access_key_id}:{signature}")
|
||||
with urllib.request.urlopen(request, timeout=60) as response:
|
||||
return response.read()
|
||||
|
||||
def list_objects(self, prefix: str) -> list[str]:
|
||||
keys: list[str] = []
|
||||
marker = ""
|
||||
|
||||
while True:
|
||||
query = f"prefix={urllib.parse.quote(prefix)}&max-keys=1000"
|
||||
canonicalized_resource = f"/{self.bucket}/"
|
||||
if marker:
|
||||
query += f"&marker={urllib.parse.quote(marker)}"
|
||||
|
||||
url = f"https://{self.bucket}.{self.endpoint}/?{query}"
|
||||
xml_bytes = self._request("GET", url, canonicalized_resource)
|
||||
root = ET.fromstring(xml_bytes)
|
||||
ns = ""
|
||||
if root.tag.startswith("{"):
|
||||
ns = root.tag.split("}", 1)[0] + "}"
|
||||
|
||||
for content in root.findall(f"{ns}Contents"):
|
||||
key = content.findtext(f"{ns}Key") or ""
|
||||
if key and not key.endswith("/"):
|
||||
keys.append(key)
|
||||
|
||||
is_truncated = (root.findtext(f"{ns}IsTruncated") or "").lower() == "true"
|
||||
marker = root.findtext(f"{ns}NextMarker") or (keys[-1] if keys else "")
|
||||
if not is_truncated or not marker:
|
||||
break
|
||||
|
||||
return keys
|
||||
|
||||
def download_object(self, object_key: str, local_path: Path) -> None:
|
||||
encoded_key = "/".join(urllib.parse.quote(part) for part in object_key.split("/"))
|
||||
url = f"https://{self.bucket}.{self.endpoint}/{encoded_key}"
|
||||
canonicalized_resource = f"/{self.bucket}/{object_key}"
|
||||
data = self._request("GET", url, canonicalized_resource)
|
||||
local_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
local_path.write_bytes(data)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ReportEntry:
|
||||
local_path: Path
|
||||
object_key: str
|
||||
response_id: str
|
||||
questionnaire_id: str
|
||||
version: str
|
||||
steam_id: str
|
||||
submitted_at_utc: str
|
||||
created_at_utc: str
|
||||
answer_count: int
|
||||
question_ids: str
|
||||
size: int
|
||||
payload: dict
|
||||
|
||||
|
||||
def local_path_for_key(object_key: str) -> Path:
|
||||
relative = object_key[len(OSS_PREFIX):] if object_key.startswith(OSS_PREFIX) else object_key
|
||||
return DATA_DIR / Path(*relative.split("/"))
|
||||
|
||||
|
||||
def read_report(local_path: Path) -> ReportEntry | None:
|
||||
try:
|
||||
payload = json.loads(local_path.read_text(encoding="utf-8"))
|
||||
sheet = payload.get("answerSheet") or {}
|
||||
answers = sheet.get("Answers") or sheet.get("answers") or []
|
||||
parts = local_path.relative_to(DATA_DIR).parts
|
||||
version = payload.get("version") or (parts[0] if len(parts) > 0 else "")
|
||||
steam_id = payload.get("steamId") or (parts[1] if len(parts) > 1 else "")
|
||||
object_key = OSS_PREFIX + "/".join(parts).replace("\\", "/")
|
||||
question_ids = ", ".join(
|
||||
str(answer.get("QuestionId") or answer.get("questionId") or "")
|
||||
for answer in answers
|
||||
if isinstance(answer, dict) and (answer.get("QuestionId") or answer.get("questionId"))
|
||||
)
|
||||
|
||||
return ReportEntry(
|
||||
local_path=local_path,
|
||||
object_key=object_key,
|
||||
response_id=payload.get("responseId", local_path.stem),
|
||||
questionnaire_id=payload.get("questionnaireId") or sheet.get("QuestionnaireId") or "",
|
||||
version=version,
|
||||
steam_id=steam_id,
|
||||
submitted_at_utc=payload.get("submittedAtUtc") or sheet.get("SubmittedAtUtc") or "",
|
||||
created_at_utc=payload.get("createdAtUtc", ""),
|
||||
answer_count=len(answers),
|
||||
question_ids=question_ids,
|
||||
size=local_path.stat().st_size,
|
||||
payload=payload,
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def load_reports() -> list[ReportEntry]:
|
||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
reports = [entry for entry in (read_report(path) for path in DATA_DIR.rglob("*.json")) if entry]
|
||||
reports.sort(
|
||||
key=lambda item: item.submitted_at_utc or item.created_at_utc or str(item.local_path.stat().st_mtime),
|
||||
reverse=True,
|
||||
)
|
||||
return reports
|
||||
|
||||
|
||||
def download_reports(cfg: dict) -> tuple[int, int, int]:
|
||||
client = OssClient(
|
||||
cfg["access_key_id"],
|
||||
cfg["access_key_secret"],
|
||||
cfg["endpoint"],
|
||||
cfg["bucket"],
|
||||
)
|
||||
keys = [key for key in client.list_objects(OSS_PREFIX) if key.endswith(".json")]
|
||||
downloaded = 0
|
||||
skipped = 0
|
||||
failed = 0
|
||||
|
||||
for key in keys:
|
||||
local_path = local_path_for_key(key)
|
||||
if cfg.get("skip_existing_downloads", True) and local_path.exists():
|
||||
skipped += 1
|
||||
continue
|
||||
try:
|
||||
client.download_object(key, local_path)
|
||||
downloaded += 1
|
||||
except Exception:
|
||||
failed += 1
|
||||
|
||||
return downloaded, skipped, failed
|
||||
|
||||
|
||||
def format_answer(answer: dict) -> str:
|
||||
question_id = answer.get("QuestionId") or answer.get("questionId") or ""
|
||||
question_type = answer.get("QuestionType") or answer.get("questionType") or ""
|
||||
selected = answer.get("SelectedOptionIds") or answer.get("selectedOptionIds") or []
|
||||
open_text = answer.get("OpenText") or answer.get("openText") or ""
|
||||
lines = [f"题目: {question_id}", f"类型: {question_type}"]
|
||||
if selected:
|
||||
lines.append("选项: " + ", ".join(str(item) for item in selected))
|
||||
if open_text:
|
||||
lines.append("文本:")
|
||||
lines.append(str(open_text))
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
class PlayerQuestionnaireViewer(Tk):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.title("TH1 玩家问卷查看器")
|
||||
self.geometry("1180x760")
|
||||
self.minsize(940, 580)
|
||||
|
||||
self.cfg = load_config()
|
||||
self.reports: list[ReportEntry] = []
|
||||
self.filtered_reports: list[ReportEntry] = []
|
||||
self.selected_report: ReportEntry | None = None
|
||||
|
||||
self.access_key_id = StringVar(value=self.cfg.get("access_key_id", ""))
|
||||
self.access_key_secret = StringVar(value=self.cfg.get("access_key_secret", ""))
|
||||
self.endpoint = StringVar(value=self.cfg.get("endpoint", "oss-cn-shanghai.aliyuncs.com"))
|
||||
self.bucket = StringVar(value=self.cfg.get("bucket", "th1-oss"))
|
||||
self.skip_existing = BooleanVar(value=bool(self.cfg.get("skip_existing_downloads", True)))
|
||||
self.version_filter = StringVar(value="全部版本")
|
||||
self.questionnaire_filter = StringVar(value="全部问卷")
|
||||
self.steam_filter = StringVar(value="")
|
||||
self.question_filter = StringVar(value="")
|
||||
self.status_text = StringVar(value="就绪")
|
||||
|
||||
self.answer_box = None
|
||||
self._build_ui()
|
||||
self.refresh_local_reports()
|
||||
|
||||
def _build_ui(self) -> None:
|
||||
config_frame = ttk.LabelFrame(self, text="OSS")
|
||||
config_frame.pack(fill=X, padx=10, pady=(10, 6))
|
||||
|
||||
ttk.Label(config_frame, text="AccessKey ID").grid(row=0, column=0, sticky="w", padx=8, pady=4)
|
||||
ttk.Entry(config_frame, textvariable=self.access_key_id, width=32).grid(row=0, column=1, sticky="ew", padx=4)
|
||||
ttk.Label(config_frame, text="AccessKey Secret").grid(row=0, column=2, sticky="w", padx=8)
|
||||
ttk.Entry(config_frame, textvariable=self.access_key_secret, show="*", width=32).grid(row=0, column=3, sticky="ew", padx=4)
|
||||
|
||||
ttk.Label(config_frame, text="Endpoint").grid(row=1, column=0, sticky="w", padx=8, pady=4)
|
||||
ttk.Entry(config_frame, textvariable=self.endpoint, width=32).grid(row=1, column=1, sticky="ew", padx=4)
|
||||
ttk.Label(config_frame, text="Bucket").grid(row=1, column=2, sticky="w", padx=8)
|
||||
ttk.Entry(config_frame, textvariable=self.bucket, width=32).grid(row=1, column=3, sticky="ew", padx=4)
|
||||
|
||||
ttk.Checkbutton(config_frame, text="跳过已下载 json", variable=self.skip_existing).grid(row=2, column=1, sticky="w", padx=4)
|
||||
ttk.Button(config_frame, text="保存配置", command=self.save_current_config).grid(row=2, column=2, sticky="e", padx=4, pady=4)
|
||||
ttk.Button(config_frame, text="更新内容", command=self.update_from_oss).grid(row=2, column=3, sticky="w", padx=4, pady=4)
|
||||
config_frame.columnconfigure(1, weight=1)
|
||||
config_frame.columnconfigure(3, weight=1)
|
||||
|
||||
filter_frame = ttk.Frame(self)
|
||||
filter_frame.pack(fill=X, padx=10, pady=(0, 6))
|
||||
ttk.Label(filter_frame, text="版本").pack(side=LEFT)
|
||||
self.version_combo = ttk.Combobox(filter_frame, textvariable=self.version_filter, state="readonly", width=15)
|
||||
self.version_combo.pack(side=LEFT, padx=(4, 10))
|
||||
self.version_combo.bind("<<ComboboxSelected>>", lambda _event: self.apply_filters())
|
||||
ttk.Label(filter_frame, text="问卷").pack(side=LEFT)
|
||||
self.questionnaire_combo = ttk.Combobox(filter_frame, textvariable=self.questionnaire_filter, state="readonly", width=22)
|
||||
self.questionnaire_combo.pack(side=LEFT, padx=(4, 10))
|
||||
self.questionnaire_combo.bind("<<ComboboxSelected>>", lambda _event: self.apply_filters())
|
||||
ttk.Label(filter_frame, text="题目ID").pack(side=LEFT)
|
||||
question_entry = ttk.Entry(filter_frame, textvariable=self.question_filter, width=14)
|
||||
question_entry.pack(side=LEFT, padx=4)
|
||||
question_entry.bind("<KeyRelease>", lambda _event: self.apply_filters())
|
||||
ttk.Label(filter_frame, text="SteamID").pack(side=LEFT)
|
||||
steam_entry = ttk.Entry(filter_frame, textvariable=self.steam_filter, width=22)
|
||||
steam_entry.pack(side=LEFT, padx=4)
|
||||
steam_entry.bind("<KeyRelease>", lambda _event: self.apply_filters())
|
||||
ttk.Button(filter_frame, text="刷新本地", command=self.refresh_local_reports).pack(side=LEFT, padx=8)
|
||||
ttk.Button(filter_frame, text="打开下载目录", command=self.open_data_dir).pack(side=LEFT)
|
||||
|
||||
pane = ttk.PanedWindow(self, orient="horizontal")
|
||||
pane.pack(fill=BOTH, expand=True, padx=10, pady=(0, 6))
|
||||
|
||||
list_frame = ttk.Frame(pane)
|
||||
self.tree = ttk.Treeview(
|
||||
list_frame,
|
||||
columns=("submitted", "version", "questionnaire", "steam", "answers", "size"),
|
||||
show="headings",
|
||||
selectmode="browse",
|
||||
)
|
||||
for col, text, width in (
|
||||
("submitted", "提交时间", 140),
|
||||
("version", "版本", 86),
|
||||
("questionnaire", "问卷ID", 170),
|
||||
("steam", "SteamID", 150),
|
||||
("answers", "答案", 58),
|
||||
("size", "大小", 78),
|
||||
):
|
||||
self.tree.heading(col, text=text)
|
||||
self.tree.column(col, width=width, anchor="w")
|
||||
self.tree.pack(side=LEFT, fill=BOTH, expand=True)
|
||||
scrollbar = ttk.Scrollbar(list_frame, orient="vertical", command=self.tree.yview)
|
||||
scrollbar.pack(side=RIGHT, fill=Y)
|
||||
self.tree.configure(yscrollcommand=scrollbar.set)
|
||||
self.tree.bind("<<TreeviewSelect>>", self.on_select_report)
|
||||
pane.add(list_frame, weight=3)
|
||||
|
||||
preview_frame = ttk.Frame(pane)
|
||||
self.preview = ttk.Treeview(preview_frame, columns=("field", "value"), show="headings", height=13)
|
||||
self.preview.heading("field", text="字段")
|
||||
self.preview.heading("value", text="内容")
|
||||
self.preview.column("field", width=120, anchor="w")
|
||||
self.preview.column("value", width=500, anchor="w")
|
||||
self.preview.pack(fill=X)
|
||||
self._build_answer_box(preview_frame)
|
||||
pane.add(preview_frame, weight=4)
|
||||
|
||||
status = ttk.Label(self, textvariable=self.status_text, anchor="w")
|
||||
status.pack(fill=X, padx=10, pady=(0, 8))
|
||||
|
||||
def _build_answer_box(self, parent) -> None:
|
||||
import tkinter as tk
|
||||
|
||||
answer_frame = ttk.LabelFrame(parent, text="答案明细")
|
||||
answer_frame.pack(fill=BOTH, expand=True, pady=(8, 0))
|
||||
self.answer_box = tk.Text(answer_frame, wrap="word", height=20)
|
||||
self.answer_box.pack(fill=BOTH, expand=True)
|
||||
self.answer_box.configure(state="disabled")
|
||||
|
||||
def current_config(self) -> dict:
|
||||
return {
|
||||
"access_key_id": self.access_key_id.get(),
|
||||
"access_key_secret": self.access_key_secret.get(),
|
||||
"endpoint": self.endpoint.get(),
|
||||
"bucket": self.bucket.get(),
|
||||
"skip_existing_downloads": self.skip_existing.get(),
|
||||
}
|
||||
|
||||
def save_current_config(self) -> None:
|
||||
self.cfg = self.current_config()
|
||||
save_config(self.cfg)
|
||||
self.status_text.set(f"配置已保存到 {CONFIG_PATH}")
|
||||
|
||||
def update_from_oss(self) -> None:
|
||||
cfg = self.current_config()
|
||||
if not cfg["access_key_id"] or not cfg["access_key_secret"]:
|
||||
messagebox.showerror("缺少配置", "请先填写 AccessKey ID 和 AccessKey Secret。")
|
||||
return
|
||||
|
||||
self.save_current_config()
|
||||
self.status_text.set("正在从 OSS 更新问卷答卷...")
|
||||
self._run_worker(lambda: download_reports(cfg), self._after_download)
|
||||
|
||||
def _after_download(self, result) -> None:
|
||||
if isinstance(result, Exception):
|
||||
messagebox.showerror("更新失败", str(result))
|
||||
self.status_text.set(f"更新失败: {result}")
|
||||
return
|
||||
downloaded, skipped, failed = result
|
||||
self.refresh_local_reports()
|
||||
self.status_text.set(f"更新完成: 下载 {downloaded}, 跳过 {skipped}, 失败 {failed};本地缓存 {len(self.reports)} 条")
|
||||
|
||||
def refresh_local_reports(self) -> None:
|
||||
self.reports = load_reports()
|
||||
versions = ["全部版本"] + sorted({entry.version for entry in self.reports if entry.version}, reverse=True)
|
||||
questionnaires = ["全部问卷"] + sorted({entry.questionnaire_id for entry in self.reports if entry.questionnaire_id})
|
||||
self.version_combo.configure(values=versions)
|
||||
self.questionnaire_combo.configure(values=questionnaires)
|
||||
if self.version_filter.get() not in versions:
|
||||
self.version_filter.set("全部版本")
|
||||
if self.questionnaire_filter.get() not in questionnaires:
|
||||
self.questionnaire_filter.set("全部问卷")
|
||||
self.apply_filters()
|
||||
|
||||
def apply_filters(self) -> None:
|
||||
version = self.version_filter.get()
|
||||
questionnaire_id = self.questionnaire_filter.get()
|
||||
steam = self.steam_filter.get().strip()
|
||||
question_id = self.question_filter.get().strip()
|
||||
self.filtered_reports = []
|
||||
for report in self.reports:
|
||||
if version and version != "全部版本" and report.version != version:
|
||||
continue
|
||||
if questionnaire_id and questionnaire_id != "全部问卷" and report.questionnaire_id != questionnaire_id:
|
||||
continue
|
||||
if steam and steam not in report.steam_id:
|
||||
continue
|
||||
if question_id and question_id not in report.question_ids:
|
||||
continue
|
||||
self.filtered_reports.append(report)
|
||||
|
||||
for item in self.tree.get_children():
|
||||
self.tree.delete(item)
|
||||
for index, report in enumerate(self.filtered_reports):
|
||||
self.tree.insert(
|
||||
"",
|
||||
END,
|
||||
iid=str(index),
|
||||
values=(
|
||||
parse_oss_time(report.submitted_at_utc or report.created_at_utc),
|
||||
report.version,
|
||||
report.questionnaire_id,
|
||||
report.steam_id,
|
||||
report.answer_count,
|
||||
human_bytes(report.size),
|
||||
),
|
||||
)
|
||||
self.status_text.set(f"本地缓存 {len(self.reports)} 条,当前筛选 {len(self.filtered_reports)} 条")
|
||||
|
||||
def on_select_report(self, _event=None) -> None:
|
||||
selection = self.tree.selection()
|
||||
if not selection:
|
||||
self.selected_report = None
|
||||
return
|
||||
index = int(selection[0])
|
||||
if index < 0 or index >= len(self.filtered_reports):
|
||||
return
|
||||
self.selected_report = self.filtered_reports[index]
|
||||
self.render_preview(self.selected_report)
|
||||
|
||||
def render_preview(self, report: ReportEntry) -> None:
|
||||
for item in self.preview.get_children():
|
||||
self.preview.delete(item)
|
||||
|
||||
rows = [
|
||||
("ResponseID", report.response_id),
|
||||
("问卷ID", report.questionnaire_id),
|
||||
("版本", report.version),
|
||||
("SteamID", report.steam_id),
|
||||
("提交时间", parse_oss_time(report.submitted_at_utc)),
|
||||
("创建时间", parse_oss_time(report.created_at_utc)),
|
||||
("本地时间", report.payload.get("createdAtLocal", "")),
|
||||
("时区", report.payload.get("timezone", "")),
|
||||
("CrashSight设备ID", report.payload.get("crashSightDeviceId", "")),
|
||||
("设备名", report.payload.get("deviceName", "")),
|
||||
("设备型号", report.payload.get("deviceModel", "")),
|
||||
("系统", report.payload.get("operatingSystem", "")),
|
||||
("CPU", report.payload.get("processorType", "")),
|
||||
("内存", f"{report.payload.get('systemMemorySizeMb', '')} MB"),
|
||||
("显卡", report.payload.get("graphicsDeviceName", "")),
|
||||
("显存", f"{report.payload.get('graphicsMemorySizeMb', '')} MB"),
|
||||
("OSS Key", report.object_key),
|
||||
("本地文件", str(report.local_path)),
|
||||
]
|
||||
for field, value in rows:
|
||||
self.preview.insert("", END, values=(field, value))
|
||||
|
||||
sheet = report.payload.get("answerSheet") or {}
|
||||
answers = sheet.get("Answers") or sheet.get("answers") or []
|
||||
details = []
|
||||
for index, answer in enumerate(answers, start=1):
|
||||
if isinstance(answer, dict):
|
||||
details.append(f"[{index}]\n{format_answer(answer)}")
|
||||
self._set_text("\n\n".join(details))
|
||||
|
||||
def _set_text(self, value: str) -> None:
|
||||
self.answer_box.configure(state="normal")
|
||||
self.answer_box.delete("1.0", END)
|
||||
self.answer_box.insert("1.0", value or "")
|
||||
self.answer_box.configure(state="disabled")
|
||||
|
||||
def open_data_dir(self) -> None:
|
||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
os.startfile(DATA_DIR)
|
||||
|
||||
def _run_worker(self, func, callback) -> None:
|
||||
def runner():
|
||||
try:
|
||||
result = func()
|
||||
except Exception as exc:
|
||||
result = exc
|
||||
self.after(0, lambda: callback(result))
|
||||
|
||||
threading.Thread(target=runner, daemon=True).start()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
PlayerQuestionnaireViewer().mainloop()
|
||||
5
Tools/PlayerQuestionnaireViewer/启动玩家问卷查看器.bat
Normal file
5
Tools/PlayerQuestionnaireViewer/启动玩家问卷查看器.bat
Normal file
@ -0,0 +1,5 @@
|
||||
@echo off
|
||||
title TH1 Player Questionnaire Viewer
|
||||
cd /d "%~dp0"
|
||||
python player_questionnaire_viewer.py
|
||||
pause
|
||||
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
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
using System.Collections.Generic;
|
||||
using Logic;
|
||||
using Logic.AI;
|
||||
using RuntimeData;
|
||||
using TH1_Anim;
|
||||
using TH1_Anim.Fragments;
|
||||
@ -102,6 +103,14 @@ namespace TH1_Core.Managers
|
||||
|
||||
public static void EnqueueTask(ISequencerTask task,bool viewNextFrame =false)
|
||||
{
|
||||
if (task == null)
|
||||
{
|
||||
Debug.LogError("试图添加一个空的任务!");
|
||||
return;
|
||||
}
|
||||
|
||||
if (AIDirectorBatchRuntime.SkipPresentationWait) return;
|
||||
|
||||
//处理Debug模式下屏蔽弹窗的情况
|
||||
if (DebugCenter.Instance.DebugHideCenterMessage)
|
||||
{
|
||||
@ -114,12 +123,6 @@ namespace TH1_Core.Managers
|
||||
}
|
||||
}
|
||||
|
||||
if (task == null)
|
||||
{
|
||||
Debug.LogError("试图添加一个空的任务!");
|
||||
return;
|
||||
}
|
||||
|
||||
//如果当前不是我的回合,并且进来了一个UISequencerTask,那么要先缓存,直到我的回合再显示出来
|
||||
if (IsDuplicateCityLevelUpChoiceTask(task) || IsDuplicateTreasureChoiceTask(task) || IsDuplicateDanegeldChoiceTask(task)) return;
|
||||
|
||||
|
||||
@ -311,16 +311,12 @@ namespace Logic.AI
|
||||
GeneratorActionIds(data, CommonActionType.UnitMove);
|
||||
GeneratorActionIds(data, CommonActionType.UnitAttack);
|
||||
// GeneratorActionIds(data, CommonActionType.AIParamControl);
|
||||
// GeneratorActionIds(data, CommonActionType.UnitAttackAlly);
|
||||
GeneratorActionIds(data, CommonActionType.UnitAttackAlly);
|
||||
GeneratorActionIds(data, CommonActionType.UnitAttackGround);
|
||||
}
|
||||
|
||||
for (int i = data.AIActions.Count - 1; i >= 0; i--)
|
||||
{
|
||||
if (data.AIActions[i].ActionLogic.ActionId.PlayerActionType == PlayerActionType.FinishHeroTask)
|
||||
{
|
||||
data.AIActions.RemoveAt(i);
|
||||
continue;
|
||||
}
|
||||
if (data.AIActions[i].ActionLogic.ActionId.UnitActionType == UnitActionType.Disband)
|
||||
{
|
||||
data.AIActions.RemoveAt(i);
|
||||
@ -393,6 +389,9 @@ namespace Logic.AI
|
||||
|
||||
public static void GeneratorActionIds(AICalculatorData data, CommonActionType type)
|
||||
{
|
||||
// AI暂不购买文化卡,避免隐藏/里程碑卡被当成普通候选行为反复执行。
|
||||
if (type == CommonActionType.BuyCultureCard) return;
|
||||
|
||||
var actions = ActionLogicFactory.GetActionLogicByType(type);
|
||||
if (actions == null || actions.Count == 0) return;
|
||||
|
||||
@ -484,6 +483,7 @@ namespace Logic.AI
|
||||
data.TargetParam.MainObjectType = ActionLogicFactory.GetMainObjectType(type);
|
||||
foreach (var action in actions)
|
||||
{
|
||||
if (action.ActionId.UnitActionType == UnitActionType.ToggleShenlan) continue;
|
||||
if (!action.CheckCan(data.TargetParam)) continue;
|
||||
var param = data.TargetParam.GetCopyParam();
|
||||
param.CityData = null;
|
||||
@ -631,8 +631,7 @@ namespace Logic.AI
|
||||
{
|
||||
foreach (var action in actions)
|
||||
{
|
||||
if (action.ActionId.PlayerActionType != PlayerActionType.SelectHero &&
|
||||
action.ActionId.PlayerActionType != PlayerActionType.FinishHeroTask) continue;
|
||||
if (action.ActionId.PlayerActionType != PlayerActionType.SelectHero) continue;
|
||||
if (!action.CheckCan(data.TargetParam)) continue;
|
||||
var param = data.TargetParam.GetCopyParam();
|
||||
param.UnitData = null;
|
||||
@ -674,6 +673,8 @@ namespace Logic.AI
|
||||
if (data.TargetParam.UnitData == null) return;
|
||||
if (!data.TargetParam.UnitData.IsAlive()) return;
|
||||
if (data.TargetParam.UnitData.GetActionPoint(ActionPointType.Attack) <= 0) return;
|
||||
if (!data.TargetParam.UnitData.IsCanAttackAlly() &&
|
||||
!data.TargetParam.UnitData.CanFeedBonePile(data.TargetParam.MapData)) return;
|
||||
|
||||
data.TargetParam.MainObjectType = ActionLogicFactory.GetMainObjectType(type);
|
||||
foreach (var unit in data.TargetParam.MapData.UnitMap.UnitList)
|
||||
|
||||
@ -2,21 +2,16 @@
|
||||
* @Author: 白哉
|
||||
* @Description: AI 逻辑总模块
|
||||
* @Date: 2025年04月01日 星期二 14:04:01
|
||||
* @Modify:
|
||||
* @Modify:
|
||||
*/
|
||||
|
||||
|
||||
using System.Collections.Generic;
|
||||
using Logic.Action;
|
||||
using Logic.AI.Director;
|
||||
using Logic.CrashSight;
|
||||
using NodeCanvas.BehaviourTrees;
|
||||
using NodeCanvas.Framework;
|
||||
using UnityEngine;
|
||||
using RuntimeData;
|
||||
using TH1_Core.Managers;
|
||||
using TH1_Logic.AITrain;
|
||||
using Unity.VisualScripting;
|
||||
|
||||
using UnityEngine;
|
||||
|
||||
namespace Logic.AI
|
||||
{
|
||||
@ -29,7 +24,6 @@ namespace Logic.AI
|
||||
Finished,
|
||||
}
|
||||
|
||||
|
||||
public enum AIActionType
|
||||
{
|
||||
Grid,
|
||||
@ -39,71 +33,53 @@ namespace Logic.AI
|
||||
Max,
|
||||
}
|
||||
|
||||
|
||||
public class AILogic
|
||||
{
|
||||
public AILogicState AILogicState;
|
||||
public PlayerData PlayerData => _playerData;
|
||||
|
||||
private float _targetTime;
|
||||
private bool _isWaitFrame;
|
||||
private AIActionScoreCalculator _scoreCalculator;
|
||||
private AIActionGenerator _generator;
|
||||
|
||||
private List<AIActionBase> RecordActions;
|
||||
private AIActionBase MaxScoreAction;
|
||||
|
||||
private IAIKernel _kernel;
|
||||
private AILogicContext _context;
|
||||
private MapData _mapData;
|
||||
private PlayerData _playerData;
|
||||
private AIConfigAsset _cfg;
|
||||
|
||||
private GameObject _logicObject;
|
||||
private BehaviourTreeOwner _btOwner;
|
||||
private AICalculatorData _data;
|
||||
private int _actionCount;
|
||||
private List<float> _actionBitCodec;
|
||||
private int _sameCount;
|
||||
private List<List<uint>> _nodeRecords;
|
||||
private int _kernelVersion;
|
||||
|
||||
public static uint CurrentAIPlayerId;
|
||||
public static Dictionary<uint, List<AIRecord>> AIRecordsDict;
|
||||
|
||||
|
||||
|
||||
public AILogic()
|
||||
{
|
||||
AIRecordsDict = new Dictionary<uint, List<AIRecord>>();
|
||||
AILogicState = AILogicState.Prepare;
|
||||
RecordActions = new List<AIActionBase>();
|
||||
_scoreCalculator = new AIActionScoreCalculator();
|
||||
_cfg = TH1Resource.ResourceLoader.Load<AIConfigAsset>("Export/AIConfig");
|
||||
_generator = new AIActionGenerator();
|
||||
|
||||
_logicObject = GameObject.Find("AIBT");
|
||||
_btOwner = _logicObject.GetComponent<BehaviourTreeOwner>();
|
||||
var data = _btOwner.blackboard.GetVariable<AICalculatorData>("Data");
|
||||
if (data.value == null)
|
||||
{
|
||||
_data = new AICalculatorData();
|
||||
data.value = _data;
|
||||
}
|
||||
else _data = data.value;
|
||||
RebuildKernel();
|
||||
}
|
||||
|
||||
public void RebuildKernel()
|
||||
{
|
||||
_context = new AILogicContext
|
||||
{
|
||||
Data = new AICalculatorData(),
|
||||
Generator = new AIActionGenerator(),
|
||||
ScoreCalculator = new AIActionScoreCalculator(),
|
||||
Config = TH1Resource.ResourceLoader.Load<AIConfigAsset>("Export/AIConfig")
|
||||
};
|
||||
|
||||
_kernel = AIKernelRegistry.Create();
|
||||
_kernel.Initialize(_context);
|
||||
_kernelVersion = AIKernelRegistry.Version;
|
||||
}
|
||||
|
||||
// 开始 AI 逻辑
|
||||
public void StartAILogic(MapData mapData, PlayerData playerData)
|
||||
{
|
||||
if (_kernel == null || _kernel.KernelType != AIKernelRegistry.CurrentKernelType || _kernelVersion != AIKernelRegistry.Version) RebuildKernel();
|
||||
|
||||
AILogicState = AILogicState.Playing;
|
||||
_actionCount = 0;
|
||||
_mapData = mapData;
|
||||
_playerData = playerData;
|
||||
var aiDiff = _cfg.GetAIDiffInfo(_mapData.MapConfig.AIDiff);
|
||||
_data.AiDiffInfo = aiDiff;
|
||||
_generator.Init(_mapData, _playerData);
|
||||
_data.Refresh(mapData, playerData);
|
||||
_btOwner.StopBehaviour();
|
||||
_btOwner.StartBehaviour();
|
||||
MainEditor.Instance.Data = _data;
|
||||
_kernel.StartTurn(_mapData, _playerData);
|
||||
|
||||
#if UNITY_EDITOR
|
||||
CurrentAIPlayerId = _playerData.Id;
|
||||
@ -112,14 +88,14 @@ namespace Logic.AI
|
||||
#endif
|
||||
}
|
||||
|
||||
// 结束 AI 逻辑
|
||||
public void FinishAILogic()
|
||||
{
|
||||
_kernel?.FinishTurn();
|
||||
_playerData = null;
|
||||
_mapData = null;
|
||||
AILogicState = AILogicState.Prepare;
|
||||
}
|
||||
|
||||
// 更新 AI 逻辑
|
||||
public void Update()
|
||||
{
|
||||
if (AILogicState == AILogicState.Finished || AILogicState == AILogicState.Prepare) return;
|
||||
@ -127,291 +103,120 @@ 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;
|
||||
if (_targetTime <= 0) AILogicState = AILogicState.Playing;
|
||||
// if (!_isWaitFrame) _isWaitFrame = true;
|
||||
// else
|
||||
// {
|
||||
// _targetTime -= Time.deltaTime;
|
||||
// if (_targetTime <= 0) AILogicState = AILogicState.Playing;
|
||||
// }
|
||||
}
|
||||
|
||||
if (AILogicState == AILogicState.Playing)
|
||||
if (AILogicState != AILogicState.Playing) return;
|
||||
|
||||
if (_actionCount > 200)
|
||||
{
|
||||
if (_actionCount > 200)
|
||||
{
|
||||
LogSystem.LogError($"AI 行为次数过多,可能进入死循环,强制结束 AI 逻辑 最终记录点为:{MainEditor.Instance.BTNodeId}");
|
||||
AILogicState = AILogicState.Finished;
|
||||
return;
|
||||
}
|
||||
|
||||
#if ENABLE_AIMODEL
|
||||
AIModelExecute();
|
||||
var btNodeId = _kernel?.KernelType == AIKernelType.BehaviourTree ? MainEditor.Instance.BTNodeId : 0;
|
||||
LogSystem.LogError($"AI 行为次数过多,可能进入死循环,强制结束 AI 逻辑 最终记录点为:{btNodeId}");
|
||||
AILogicState = AILogicState.Finished;
|
||||
return;
|
||||
#endif
|
||||
|
||||
var index = 0;
|
||||
_nodeRecords ??= new List<List<uint>>();
|
||||
for (int i = 0; i < _nodeRecords.Count; i++) _nodeRecords[i].Clear();
|
||||
var nodeRecordsUsed = 0;
|
||||
while (true)
|
||||
{
|
||||
if (MainEditor.Instance.IsEditor && !MainEditor.Instance.IsGo) return;
|
||||
index++;
|
||||
if (index > nodeRecordsUsed)
|
||||
{
|
||||
nodeRecordsUsed = index;
|
||||
if (index > _nodeRecords.Count) _nodeRecords.Add(new List<uint>());
|
||||
}
|
||||
_data.ClearCache();
|
||||
_nodeRecords[index - 1].Add(MainEditor.Instance.BTNodeId);
|
||||
_btOwner.UpdateBehaviour();
|
||||
MainEditor.Instance.IsGo = false;
|
||||
_nodeRecords[index - 1].Add(MainEditor.Instance.BTNodeId);
|
||||
if (_data.MaxAiAction != null || _data.IsFinish) break;
|
||||
|
||||
if (index > 150)
|
||||
{
|
||||
LogSystem.LogError($"死循环了,最终记录点为:{MainEditor.Instance.BTNodeId}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (_data.MaxAiAction == null || index > 100) AILogicState = AILogicState.Finished;
|
||||
else
|
||||
{
|
||||
|
||||
#if ENABLE_TRAIN
|
||||
bool isPack = TrainingState.Instance.GetActionBitCodec(_data.MaxAiAction.ActionLogic.ActionId, _data.MaxAiAction.Param, out var trainPacked);
|
||||
var curPlayer = _data.MaxAiAction.Param.MapData.CurPlayer;
|
||||
var beforeScore = TrainingState.Instance.GetMapScore(_data.MaxAiAction.Param.MapData, curPlayer);
|
||||
var state = TrainingState.Instance.GetMapState(_data.MaxAiAction.Param.MapData, curPlayer);
|
||||
var validActions = TrainingState.Instance.GetAllActionBitCodec(_data.MaxAiAction.Param.MapData, curPlayer, out var actions);
|
||||
if (isPack)
|
||||
{
|
||||
bool found = false;
|
||||
|
||||
foreach (var action in validActions)
|
||||
{
|
||||
if (action.Count != trainPacked.Count) continue;
|
||||
for (int i = 0; i < trainPacked.Count; i++)
|
||||
{
|
||||
if (action[i] - trainPacked[i] > 0.001f) break;
|
||||
if (i == trainPacked.Count - 1) found = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!found)
|
||||
{
|
||||
validActions.Add(trainPacked);
|
||||
LogSystem.LogError($"训练数据出错: {_data.MaxAiAction.ActionLogic.ActionId.GetStringLog()}");
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
TrainingState.Instance.GetActionBitCodecWithoutLimit(_data.MaxAiAction.ActionLogic.ActionId, _data.MaxAiAction.Param, out var packed);
|
||||
if (_actionBitCodec != null && _actionBitCodec.Count == packed.Count)
|
||||
{
|
||||
bool isSame = true;
|
||||
for (int i = 0; i < packed.Count; i++)
|
||||
{
|
||||
if (Mathf.Approximately(packed[i], _actionBitCodec[i])) continue;
|
||||
isSame = false;
|
||||
break;
|
||||
}
|
||||
|
||||
if (isSame)
|
||||
{
|
||||
_sameCount++;
|
||||
if(_sameCount > 5) LogSystem.LogError($"存在相似action ,记录点为:{MainEditor.Instance.BTNodeId} ," +
|
||||
$"Action为:{_data.MaxAiAction.ActionLogic.ActionId.GetStringLog()} " +
|
||||
$"重复次数 :{_sameCount}");
|
||||
}
|
||||
else
|
||||
{
|
||||
_sameCount = 0;
|
||||
}
|
||||
}
|
||||
|
||||
_data.MaxAiAction.ActionLogic.CompleteExecute(_data.MaxAiAction.Param);
|
||||
_actionBitCodec = packed;
|
||||
_actionCount++;
|
||||
|
||||
#if ENABLE_TRAIN
|
||||
if (isPack)
|
||||
{
|
||||
var afterScore = TrainingState.Instance.GetMapScore(_data.MaxAiAction.Param.MapData, curPlayer);
|
||||
var reward = afterScore - beforeScore;
|
||||
if (_data.MaxAiAction.ActionLogic.ActionId.UnitActionType == UnitActionType.Capture)
|
||||
{
|
||||
reward += 10;
|
||||
}
|
||||
if (_data.MaxAiAction.ActionLogic.ActionId.UnitActionType == UnitActionType.Upgrade)
|
||||
{
|
||||
reward += 10;
|
||||
}
|
||||
if (_data.MaxAiAction.ActionLogic.ActionId.UnitActionType == UnitActionType.CultureUnitUpgrade)
|
||||
{
|
||||
reward += 10;
|
||||
}
|
||||
if (_data.MaxAiAction.ActionLogic.ActionId.UnitActionType == UnitActionType.HeroUpgrade)
|
||||
{
|
||||
reward += 10;
|
||||
}
|
||||
if (_data.MaxAiAction.ActionLogic.ActionId.UnitActionType == UnitActionType.Gather)
|
||||
{
|
||||
reward += 10;
|
||||
}
|
||||
if (_data.MaxAiAction.ActionLogic.ActionId.UnitActionType == UnitActionType.Examine)
|
||||
{
|
||||
reward += 10;
|
||||
}
|
||||
var actionsArray = new float[validActions.Count][];
|
||||
for (int ai = 0; ai < validActions.Count; ai++) actionsArray[ai] = validActions[ai].ToArray();
|
||||
TrainingDataRecorder.Instance.RecordStep(curPlayer.Id, state, actionsArray, packed.ToArray(), reward);
|
||||
}
|
||||
#endif
|
||||
|
||||
_data.MaxAiAction.CheckIsActionDuration();
|
||||
_targetTime = Mathf.Max(_data.MaxAiAction.Duration, 0f);
|
||||
|
||||
#if UNITY_EDITOR
|
||||
var records = GetCurrentAIRecords();
|
||||
if (records != null && records.Count != 0)
|
||||
{
|
||||
var record = records[^1];
|
||||
record.Action = _data.MaxAiAction;
|
||||
}
|
||||
#endif
|
||||
|
||||
_isWaitFrame = false;
|
||||
MainEditor.Instance.OnActionExcuted();
|
||||
_data.MaxAiAction = null;
|
||||
AILogicState = AILogicState.Pausing;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void AIModelExecute()
|
||||
{
|
||||
#if ENABLE_AIMODEL
|
||||
var predictState = TrainingState.Instance.GetMapState(_data.Map, _data.Map.CurPlayer);
|
||||
var predictActionsId = TrainingState.Instance.GetAllActionBitCodecForUse(_data.Map, _data.Map.CurPlayer, out var predictActions);
|
||||
if (predictActionsId.Count == 0)
|
||||
var update = _kernel.Update();
|
||||
if (update.Result == AIKernelUpdateResult.None) return;
|
||||
if (update.Result == AIKernelUpdateResult.Finished || update.Action == null)
|
||||
{
|
||||
AILogicState = AILogicState.Finished;
|
||||
return;
|
||||
}
|
||||
else
|
||||
{
|
||||
var predictIndex = ModelInference.Instance.Predict(predictState, predictActionsId);
|
||||
// for (int i = 0; i < predictActions.Count; i++)
|
||||
// {
|
||||
// if (predictActions[i].ActionLogic.ActionId.UnitActionType == UnitActionType.Capture)
|
||||
// predictIndex = i;
|
||||
// }
|
||||
if (!TrainingState.Instance.GetActionFromBitCodec(predictActionsId[predictIndex], _data.Map, out var actionId, out var param))
|
||||
{
|
||||
LogSystem.LogError($"反序列化 Action 失败");
|
||||
}
|
||||
else
|
||||
{
|
||||
_data.MaxAiAction = new AIActionBase(param, ActionLogicFactory.GetActionLogic(actionId));
|
||||
|
||||
#if ENABLE_TRAIN
|
||||
bool isPack = TrainingState.Instance.GetActionBitCodec(_data.MaxAiAction.ActionLogic.ActionId, _data.MaxAiAction.Param, out var packed);
|
||||
var curPlayer = _data.MaxAiAction.Param.MapData.CurPlayer;
|
||||
var beforeScore = TrainingState.Instance.GetMapScore(_data.MaxAiAction.Param.MapData, curPlayer);
|
||||
var state = TrainingState.Instance.GetMapState(_data.MaxAiAction.Param.MapData, curPlayer);
|
||||
var validActions = TrainingState.Instance.GetAllActionBitCodec(_data.MaxAiAction.Param.MapData, curPlayer, out var actions);
|
||||
if (isPack)
|
||||
{
|
||||
bool found = false;
|
||||
|
||||
foreach (var action in validActions)
|
||||
{
|
||||
if (action.Count != packed.Count) continue;
|
||||
for (int i = 0; i < packed.Count; i++)
|
||||
{
|
||||
if (action[i] - packed[i] > 0.001f) break;
|
||||
if (i == packed.Count - 1) found = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!found)
|
||||
{
|
||||
validActions.Add(packed);
|
||||
LogSystem.LogError($"训练数据出错: {_data.MaxAiAction.ActionLogic.ActionId.GetStringLog()}");
|
||||
}
|
||||
}
|
||||
ExecuteAction(update.Action);
|
||||
}
|
||||
|
||||
private void ExecuteAction(AIActionBase action)
|
||||
{
|
||||
action.Param.MapData = _mapData;
|
||||
action.Param.RefreshParams();
|
||||
#if TH1_AI_DIRECTOR_DIAGNOSTICS || UNITY_EDITOR
|
||||
var actionIndex = _actionCount + 1;
|
||||
var before = AIDirectorDiagnostics.CaptureOutcomeProbe(_mapData, _playerData, action);
|
||||
var executed = action.ActionLogic.CompleteExecute(action.Param);
|
||||
var after = AIDirectorDiagnostics.CaptureOutcomeProbe(_mapData, _playerData, action);
|
||||
if (_kernel?.KernelType == AIKernelType.Director)
|
||||
{
|
||||
AIDirectorDiagnostics.RecordExecution(
|
||||
_mapData,
|
||||
_playerData,
|
||||
actionIndex,
|
||||
action,
|
||||
executed,
|
||||
before,
|
||||
after);
|
||||
}
|
||||
#else
|
||||
action.ActionLogic.CompleteExecute(action.Param);
|
||||
#endif
|
||||
|
||||
_data.MaxAiAction.ActionLogic.CompleteExecute(_data.MaxAiAction.Param);
|
||||
|
||||
#if ENABLE_TRAIN
|
||||
if (isPack)
|
||||
{
|
||||
var afterScore = TrainingState.Instance.GetMapScore(_data.MaxAiAction.Param.MapData, curPlayer);
|
||||
var reward = afterScore - beforeScore;
|
||||
if (_data.MaxAiAction.ActionLogic.ActionId.UnitActionType == UnitActionType.Capture)
|
||||
{
|
||||
LogSystem.LogError($"占领!!!");
|
||||
reward += 10;
|
||||
}
|
||||
var actionsArray2 = new float[validActions.Count][];
|
||||
for (int ai = 0; ai < validActions.Count; ai++) actionsArray2[ai] = validActions[ai].ToArray();
|
||||
TrainingDataRecorder.Instance.RecordStep(curPlayer.Id, state, actionsArray2, packed.ToArray(), reward);
|
||||
}
|
||||
#endif
|
||||
|
||||
LogSystem.LogWarning($"{_data.MaxAiAction.ActionLogic.ActionId.GetStringLog()}");
|
||||
_data.MaxAiAction.CheckIsActionInPlayerSight();
|
||||
_data.MaxAiAction.CheckIsActionDuration();
|
||||
_targetTime = Mathf.Max(_data.MaxAiAction.Duration, 0f);
|
||||
_isWaitFrame = false;
|
||||
_data.MaxAiAction = null;
|
||||
AILogicState = AILogicState.Pausing;
|
||||
}
|
||||
|
||||
action.CheckIsActionDuration();
|
||||
_targetTime = Mathf.Max(action.Duration, 0f);
|
||||
|
||||
#if UNITY_EDITOR
|
||||
var records = GetCurrentAIRecords();
|
||||
if (records != null && records.Count != 0)
|
||||
{
|
||||
var record = records[^1];
|
||||
record.Action = action;
|
||||
}
|
||||
#endif
|
||||
|
||||
_actionCount++;
|
||||
if (_kernel?.KernelType == AIKernelType.BehaviourTree) MainEditor.Instance.OnActionExcuted();
|
||||
AILogicState = AILogicState.Pausing;
|
||||
}
|
||||
|
||||
public static void RegisterKernel(AIKernelType kernelType)
|
||||
{
|
||||
AIKernelRegistry.Register(kernelType);
|
||||
}
|
||||
|
||||
public static void UseBehaviourTreeKernel()
|
||||
{
|
||||
AIKernelRegistry.Register(AIKernelType.BehaviourTree);
|
||||
}
|
||||
|
||||
public static void UseDirectorKernel()
|
||||
{
|
||||
AIKernelRegistry.Register(AIKernelType.Director);
|
||||
}
|
||||
|
||||
public static List<AIRecord> GetCurrentAIRecords()
|
||||
{
|
||||
return AIRecordsDict.GetValueOrDefault(CurrentAIPlayerId);
|
||||
}
|
||||
|
||||
|
||||
public static List<AIRecord> GetAIRecords(uint playerId)
|
||||
{
|
||||
return AIRecordsDict.GetValueOrDefault(playerId);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public class AIStateRecord
|
||||
{
|
||||
public uint ID;
|
||||
public string Desc;
|
||||
public bool Result;
|
||||
}
|
||||
|
||||
|
||||
|
||||
public class AIRecord
|
||||
{
|
||||
public List<AIStateRecord> StateRecords;
|
||||
public AIActionBase Action;
|
||||
public bool IsFoldout;
|
||||
|
||||
|
||||
public AIRecord()
|
||||
{
|
||||
IsFoldout = false;
|
||||
StateRecords = new List<AIStateRecord>();
|
||||
}
|
||||
|
||||
|
||||
public string GetDesc()
|
||||
{
|
||||
if (Action == null) return "暂无动作";
|
||||
|
||||
8
Unity/Assets/Scripts/TH1_Logic/AI/BehaviourTree.meta
Normal file
8
Unity/Assets/Scripts/TH1_Logic/AI/BehaviourTree.meta
Normal file
@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b10c1828bae40bd4f907f57b7f1faf76
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
8
Unity/Assets/Scripts/TH1_Logic/AI/Director.meta
Normal file
8
Unity/Assets/Scripts/TH1_Logic/AI/Director.meta
Normal file
@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4b6d5f786f414f8391d3f4bc12f6c4e1
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@ -0,0 +1,450 @@
|
||||
using System.Collections.Generic;
|
||||
using Logic.Action;
|
||||
using RuntimeData;
|
||||
using TH1_Logic.Action;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Logic.AI.Director
|
||||
{
|
||||
public sealed class AIDirectorActionIndex
|
||||
{
|
||||
public readonly List<AIActionBase> AllActions = new();
|
||||
public readonly List<AIActionBase> AttackActions = new();
|
||||
public readonly List<AIActionBase> AttackAllyActions = new();
|
||||
public readonly List<AIActionBase> AttackGroundActions = new();
|
||||
public readonly List<AIActionBase> MoveActions = new();
|
||||
public readonly List<AIActionBase> UnitActions = new();
|
||||
public readonly List<AIActionBase> CityActions = new();
|
||||
public readonly List<AIActionBase> GridActions = new();
|
||||
public readonly List<AIActionBase> PlayerActions = new();
|
||||
public readonly List<AIActionBase> HeroManagementActions = new();
|
||||
|
||||
private readonly Dictionary<uint, List<AIActionBase>> _unitActionsByUnit = new();
|
||||
private readonly Dictionary<uint, List<AIActionBase>> _attacksByUnit = new();
|
||||
private readonly Dictionary<uint, List<AIActionBase>> _movesByUnit = new();
|
||||
private readonly Dictionary<uint, List<AIActionBase>> _attackAlliesByUnit = new();
|
||||
private readonly Dictionary<uint, List<AIActionBase>> _attackGroundByUnit = new();
|
||||
private readonly Dictionary<uint, List<AIActionBase>> _cityActionsByCity = new();
|
||||
private readonly Dictionary<uint, List<AIActionBase>> _gridActionsByGrid = new();
|
||||
|
||||
public static AIDirectorActionIndex Build(AIDirectorContext ctx)
|
||||
{
|
||||
var index = new AIDirectorActionIndex();
|
||||
if (ctx?.Map == null || ctx.Player == null) return index;
|
||||
|
||||
var generated = AIActionGenerator.GeneratorAllActionIdsForUse(ctx.Map, ctx.Player);
|
||||
if (generated == null) return index;
|
||||
|
||||
var limit = ctx.Config.MaxGeneratedActions <= 0 ? int.MaxValue : ctx.Config.MaxGeneratedActions;
|
||||
foreach (var action in generated)
|
||||
{
|
||||
if (action?.ActionLogic?.ActionId == null || action.Param == null) continue;
|
||||
if (index.AllActions.Count >= limit) break;
|
||||
var copied = CopyAction(action);
|
||||
if (copied == null) continue;
|
||||
if (!copied.ActionLogic.CheckCan(copied.Param)) continue;
|
||||
if (IsDangerousAction(copied)) continue;
|
||||
index.Add(copied);
|
||||
}
|
||||
|
||||
index.SortAll();
|
||||
|
||||
return index;
|
||||
}
|
||||
|
||||
public AIDirectorActionCandidate Candidate(AIActionBase action, AIDirectorLane lane, string reason, float priority, bool fallback = false)
|
||||
{
|
||||
if (action == null) return AIDirectorActionCandidate.Invalid(lane, reason, priority, fallback);
|
||||
var copied = CopyAction(action);
|
||||
if (copied == null || !copied.ActionLogic.CheckCan(copied.Param)) return AIDirectorActionCandidate.Invalid(lane, reason, priority, fallback);
|
||||
return new AIDirectorActionCandidate(copied, lane, reason, priority, fallback);
|
||||
}
|
||||
|
||||
public AIActionBase FindUnitAction(UnitData unit, UnitActionType type)
|
||||
{
|
||||
if (unit == null) return null;
|
||||
if (!_unitActionsByUnit.TryGetValue(unit.Id, out var actions)) return null;
|
||||
foreach (var action in actions)
|
||||
{
|
||||
if (action.ActionLogic.ActionId.UnitActionType == type) return action;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public AIActionBase FindBestAttack(UnitData unit, UnitData target = null)
|
||||
{
|
||||
if (unit == null) return null;
|
||||
if (!_attacksByUnit.TryGetValue(unit.Id, out var actions)) return null;
|
||||
AIActionBase best = null;
|
||||
var bestScore = float.MinValue;
|
||||
foreach (var action in actions)
|
||||
{
|
||||
var targetUnit = action.Param.TargetUnitData;
|
||||
if (target != null && targetUnit?.Id != target.Id) continue;
|
||||
var score = ScoreAttackTarget(targetUnit);
|
||||
if (score <= bestScore) continue;
|
||||
bestScore = score;
|
||||
best = action;
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
public AIActionBase FindBestAttackAlly(UnitData unit, UnitData target = null)
|
||||
{
|
||||
if (unit == null) return null;
|
||||
if (!_attackAlliesByUnit.TryGetValue(unit.Id, out var actions)) return null;
|
||||
AIActionBase best = null;
|
||||
var bestScore = float.MinValue;
|
||||
foreach (var action in actions)
|
||||
{
|
||||
var targetUnit = action.Param.TargetUnitData;
|
||||
if (target != null && targetUnit?.Id != target.Id) continue;
|
||||
var score = targetUnit == null ? 0f : (1f - AIDirectorMath.HealthRatio(targetUnit)) * 100f + AIDirectorMath.UnitPower(targetUnit);
|
||||
if (score <= bestScore) continue;
|
||||
bestScore = score;
|
||||
best = action;
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
public AIActionBase FindBestAttackGround(UnitData unit, GridData targetGrid = null)
|
||||
{
|
||||
if (unit == null) return null;
|
||||
if (!_attackGroundByUnit.TryGetValue(unit.Id, out var actions)) return null;
|
||||
foreach (var action in actions)
|
||||
{
|
||||
if (targetGrid == null) return action;
|
||||
var grid = action.Param.TargetGridData ?? action.Param.GridData;
|
||||
if (grid?.Id == targetGrid.Id) return action;
|
||||
if (action.Param.TargetGridId == targetGrid.Id || action.Param.GridId == targetGrid.Id) return action;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public AIActionBase FindHeroManagementAction(PlayerActionType type)
|
||||
{
|
||||
foreach (var action in HeroManagementActions)
|
||||
{
|
||||
if (action.ActionLogic.ActionId.PlayerActionType == type) return action;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public IEnumerable<AIActionBase> GetCityActions(CityData city)
|
||||
{
|
||||
if (city == null) yield break;
|
||||
if (!_cityActionsByCity.TryGetValue(city.Id, out var actions)) yield break;
|
||||
foreach (var action in actions) yield return action;
|
||||
}
|
||||
|
||||
public IEnumerable<AIActionBase> GetGridActions(GridData grid)
|
||||
{
|
||||
if (grid == null) yield break;
|
||||
if (!_gridActionsByGrid.TryGetValue(grid.Id, out var actions)) yield break;
|
||||
foreach (var action in actions) yield return action;
|
||||
}
|
||||
|
||||
public AIActionBase FindBestHeroTaskFinish(PlayerData player)
|
||||
{
|
||||
if (player?.PlayerHeroData == null) return null;
|
||||
AIActionBase best = null;
|
||||
var bestLevel = uint.MaxValue;
|
||||
foreach (var action in HeroManagementActions)
|
||||
{
|
||||
var id = action.ActionLogic.ActionId;
|
||||
if (id.PlayerActionType != PlayerActionType.FinishHeroTask) continue;
|
||||
var level = player.PlayerHeroData.GetHeroLevel(id.GiantType);
|
||||
if (!CanForceHeroTaskNow(player, id.GiantType, level)) continue;
|
||||
if (level >= bestLevel) continue;
|
||||
bestLevel = level;
|
||||
best = action;
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
public AIActionBase FindBestMove(UnitData unit, GridData targetGrid = null)
|
||||
{
|
||||
if (unit == null) return null;
|
||||
if (!_movesByUnit.TryGetValue(unit.Id, out var actions)) return null;
|
||||
if (targetGrid == null) return actions.Count > 0 ? actions[0] : null;
|
||||
|
||||
AIActionBase best = null;
|
||||
var bestDistance = int.MaxValue;
|
||||
foreach (var action in actions)
|
||||
{
|
||||
var grid = action.Param.TargetGridData ?? action.Param.GridData;
|
||||
var distance = AIDirectorMath.Distance(action.Param.MapData, grid, targetGrid);
|
||||
if (distance > bestDistance) continue;
|
||||
if (distance == bestDistance && StableActionKey(action).CompareTo(StableActionKey(best)) >= 0) continue;
|
||||
bestDistance = distance;
|
||||
best = action;
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
public AIActionBase FindAnyByType(CommonActionType actionType)
|
||||
{
|
||||
foreach (var action in AllActions)
|
||||
{
|
||||
if (action.ActionLogic.ActionId.ActionType == actionType) return action;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public AIActionBase FindBestFallback()
|
||||
{
|
||||
return FindFirstFallbackAction(AttackActions)
|
||||
?? FindFirstFallbackAction(MoveActions)
|
||||
?? FindFirstFallbackAction(CityActions)
|
||||
?? FindFirstFallbackAction(GridActions)
|
||||
?? FindFirstFallbackAction(PlayerActions)
|
||||
?? FindFirstFallbackAction(UnitActions)
|
||||
?? FindFirstFallbackAction(AllActions);
|
||||
}
|
||||
|
||||
private void Add(AIActionBase action)
|
||||
{
|
||||
AllActions.Add(action);
|
||||
var id = action.ActionLogic.ActionId;
|
||||
switch (id.ActionType)
|
||||
{
|
||||
case CommonActionType.UnitAttack:
|
||||
AttackActions.Add(action);
|
||||
AddByUnit(_attacksByUnit, action);
|
||||
break;
|
||||
case CommonActionType.UnitAttackAlly:
|
||||
AttackAllyActions.Add(action);
|
||||
AddByUnit(_attackAlliesByUnit, action);
|
||||
break;
|
||||
case CommonActionType.UnitAttackGround:
|
||||
AttackGroundActions.Add(action);
|
||||
AddByUnit(_attackGroundByUnit, action);
|
||||
break;
|
||||
case CommonActionType.UnitMove:
|
||||
MoveActions.Add(action);
|
||||
AddByUnit(_movesByUnit, action);
|
||||
break;
|
||||
case CommonActionType.UnitAction:
|
||||
case CommonActionType.UnitSkill:
|
||||
UnitActions.Add(action);
|
||||
AddByUnit(_unitActionsByUnit, action);
|
||||
break;
|
||||
case CommonActionType.CityAction:
|
||||
case CommonActionType.CityLevelUpAction:
|
||||
case CommonActionType.TrainUnit:
|
||||
case CommonActionType.BuildWonder:
|
||||
case CommonActionType.StartWonder:
|
||||
CityActions.Add(action);
|
||||
AddByCity(_cityActionsByCity, action);
|
||||
break;
|
||||
case CommonActionType.Build:
|
||||
case CommonActionType.Gain:
|
||||
case CommonActionType.GridMisc:
|
||||
case CommonActionType.HakureiEinherjarCityDevelopment:
|
||||
GridActions.Add(action);
|
||||
AddByGrid(_gridActionsByGrid, action);
|
||||
break;
|
||||
case CommonActionType.PlayerAction:
|
||||
if (id.PlayerActionType == PlayerActionType.SelectHero ||
|
||||
id.PlayerActionType == PlayerActionType.FinishHeroTask)
|
||||
{
|
||||
HeroManagementActions.Add(action);
|
||||
break;
|
||||
}
|
||||
|
||||
PlayerActions.Add(action);
|
||||
break;
|
||||
case CommonActionType.LearnTech:
|
||||
case CommonActionType.BuyCultureCard:
|
||||
PlayerActions.Add(action);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddByUnit(Dictionary<uint, List<AIActionBase>> dict, AIActionBase action)
|
||||
{
|
||||
var unit = action.Param.UnitData;
|
||||
if (unit == null) return;
|
||||
if (!dict.TryGetValue(unit.Id, out var list))
|
||||
{
|
||||
list = new List<AIActionBase>();
|
||||
dict[unit.Id] = list;
|
||||
}
|
||||
|
||||
list.Add(action);
|
||||
}
|
||||
|
||||
private static void AddByCity(Dictionary<uint, List<AIActionBase>> dict, AIActionBase action)
|
||||
{
|
||||
var city = action.Param.CityData;
|
||||
if (city == null) return;
|
||||
if (!dict.TryGetValue(city.Id, out var list))
|
||||
{
|
||||
list = new List<AIActionBase>();
|
||||
dict[city.Id] = list;
|
||||
}
|
||||
|
||||
list.Add(action);
|
||||
}
|
||||
|
||||
private static void AddByGrid(Dictionary<uint, List<AIActionBase>> dict, AIActionBase action)
|
||||
{
|
||||
var grid = action.Param.GridData ?? action.Param.TargetGridData;
|
||||
if (grid == null) return;
|
||||
if (!dict.TryGetValue(grid.Id, out var list))
|
||||
{
|
||||
list = new List<AIActionBase>();
|
||||
dict[grid.Id] = list;
|
||||
}
|
||||
|
||||
list.Add(action);
|
||||
}
|
||||
|
||||
private static AIActionBase CopyAction(AIActionBase action)
|
||||
{
|
||||
if (action?.Param == null || action.ActionLogic == null) return null;
|
||||
var param = action.Param.GetCopyParam();
|
||||
param.MapData = action.Param.MapData;
|
||||
param.RefreshParams();
|
||||
return new AIActionBase(param, action.ActionLogic);
|
||||
}
|
||||
|
||||
private static bool CanForceHeroTaskNow(PlayerData player, GiantType giantType, uint currentLevel)
|
||||
{
|
||||
if (player?.PlayerHeroData == null) return false;
|
||||
if (player.Turn < 8 || player.Turn % 4 != 0) return false;
|
||||
if (!player.PlayerHeroData.GetHeroTask(giantType, out var task) || task == null) return false;
|
||||
if (task.IsForceFinished) return false;
|
||||
if (currentLevel == 1) return player.Turn >= 15;
|
||||
if (currentLevel == 2) return player.Turn >= 25;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static float ScoreAttackTarget(UnitData target)
|
||||
{
|
||||
if (target == null) return 0f;
|
||||
var score = AIDirectorMath.UnitPower(target);
|
||||
score += (1f - AIDirectorMath.HealthRatio(target)) * 100f;
|
||||
if (target.Health <= 1) score += 200f;
|
||||
return score;
|
||||
}
|
||||
|
||||
private void SortAll()
|
||||
{
|
||||
Sort(AllActions);
|
||||
Sort(AttackActions);
|
||||
Sort(AttackAllyActions);
|
||||
Sort(AttackGroundActions);
|
||||
Sort(MoveActions);
|
||||
Sort(UnitActions);
|
||||
Sort(CityActions);
|
||||
Sort(GridActions);
|
||||
Sort(PlayerActions);
|
||||
Sort(HeroManagementActions);
|
||||
foreach (var pair in _unitActionsByUnit) Sort(pair.Value);
|
||||
foreach (var pair in _attacksByUnit) Sort(pair.Value);
|
||||
foreach (var pair in _movesByUnit) Sort(pair.Value);
|
||||
foreach (var pair in _attackAlliesByUnit) Sort(pair.Value);
|
||||
foreach (var pair in _attackGroundByUnit) Sort(pair.Value);
|
||||
foreach (var pair in _cityActionsByCity) Sort(pair.Value);
|
||||
foreach (var pair in _gridActionsByGrid) Sort(pair.Value);
|
||||
}
|
||||
|
||||
private static void Sort(List<AIActionBase> actions)
|
||||
{
|
||||
actions.Sort((a, b) => StableActionKey(a).CompareTo(StableActionKey(b)));
|
||||
}
|
||||
|
||||
private static bool IsDangerousAction(AIActionBase action)
|
||||
{
|
||||
var id = action?.ActionLogic?.ActionId;
|
||||
if (id == null) return true;
|
||||
if (id.UnitActionType is UnitActionType.Disband or UnitActionType.ForceDisband or UnitActionType.Demolish or UnitActionType.Disperse or UnitActionType.ToggleShenlan) return true;
|
||||
if (id.PlayerActionType == PlayerActionType.FinishHeroTask) return true;
|
||||
if (IsDirectorExcludedPlayerAction(id.PlayerActionType)) return true;
|
||||
if (id.GridMiscActionType == GridMiscActionType.Destroy) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
public static bool IsDirectorExcludedPlayerAction(PlayerActionType type)
|
||||
{
|
||||
return type is PlayerActionType.OfferAlly
|
||||
or PlayerActionType.AcceptAlly
|
||||
or PlayerActionType.RefuseAlly
|
||||
or PlayerActionType.BreakAlly
|
||||
or PlayerActionType.Embassy
|
||||
or PlayerActionType.BreakEmbassy
|
||||
or PlayerActionType.DanegeldDemand
|
||||
or PlayerActionType.DanegeldPay
|
||||
or PlayerActionType.DanegeldReject;
|
||||
}
|
||||
|
||||
private static AIActionBase FindFirstFallbackAction(List<AIActionBase> actions)
|
||||
{
|
||||
foreach (var action in actions)
|
||||
{
|
||||
if (IsFallbackAction(action)) return action;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool IsFallbackAction(AIActionBase action)
|
||||
{
|
||||
var id = action?.ActionLogic?.ActionId;
|
||||
if (id == null) return false;
|
||||
|
||||
if (id.ActionType == CommonActionType.UnitAction
|
||||
&& id.UnitActionType is UnitActionType.Examine
|
||||
or UnitActionType.KANAKOSIT
|
||||
or UnitActionType.KANAKOUNSIT)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (id.ActionType == CommonActionType.PlayerAction
|
||||
&& id.PlayerActionType is PlayerActionType.SelectHero
|
||||
or PlayerActionType.FinishHeroTask)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return !IsDangerousAction(action);
|
||||
}
|
||||
|
||||
public static string StableActionKey(AIActionBase action)
|
||||
{
|
||||
if (action == null) return string.Empty;
|
||||
var id = action.ActionLogic?.ActionId;
|
||||
var param = action.Param;
|
||||
return string.Join(":",
|
||||
id?.ActionType.ToString() ?? string.Empty,
|
||||
((int)(id?.UnitActionType ?? UnitActionType.None)).ToString("D4"),
|
||||
((int)(id?.PlayerActionType ?? PlayerActionType.None)).ToString("D4"),
|
||||
((int)(id?.CityActionType ?? CityActionType.None)).ToString("D4"),
|
||||
((int)(id?.CityLevelUpActionType ?? CityLevelUpActionType.None)).ToString("D4"),
|
||||
((int)(id?.GridMiscActionType ?? GridMiscActionType.None)).ToString("D4"),
|
||||
((int)(id?.TechType ?? TechType.None)).ToString("D4"),
|
||||
((int)(id?.CultureCardType ?? CultureCardType.None)).ToString("D4"),
|
||||
((int)(id?.ResourceType ?? ResourceType.None)).ToString("D4"),
|
||||
((int)(id?.UnitType ?? UnitType.None)).ToString("D4"),
|
||||
((int)(id?.GiantType ?? GiantType.None)).ToString("D4"),
|
||||
(id?.UnitLevel ?? 0).ToString("D4"),
|
||||
(param?.PlayerId ?? 0).ToString("D8"),
|
||||
(param?.CityId ?? 0).ToString("D8"),
|
||||
(param?.UnitId ?? 0).ToString("D8"),
|
||||
(param?.GridId ?? 0).ToString("D8"),
|
||||
(param?.TargetUnitId ?? 0).ToString("D8"),
|
||||
(param?.TargetGridId ?? 0).ToString("D8"),
|
||||
(param?.TargetPlayerId ?? 0).ToString("D8"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 12611efd81e4e39409aab374a47d8b98
|
||||
guid: ed15bd0d18204bc7ad370bcd6ece3407
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
1134
Unity/Assets/Scripts/TH1_Logic/AI/Director/AIDirectorDiagnostics.cs
Normal file
1134
Unity/Assets/Scripts/TH1_Logic/AI/Director/AIDirectorDiagnostics.cs
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9f7a1d2c3b4e5f608192a3b4c5d6e7f8
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@ -0,0 +1,333 @@
|
||||
using Logic.Action;
|
||||
using RuntimeData;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Logic.AI.Director
|
||||
{
|
||||
public sealed class AIDirectorHeroRuleEvaluator
|
||||
{
|
||||
public AIDirectorActionCandidate Evaluate(AIDirectorContext ctx, AIDirectorHeroState state)
|
||||
{
|
||||
if (ctx?.ActionIndex == null || state?.Hero == null || ctx.Config.HeroRules == null) return AIDirectorActionCandidate.None;
|
||||
|
||||
AIDirectorActionCandidate best = AIDirectorActionCandidate.None;
|
||||
foreach (var rule in ctx.Config.HeroRules)
|
||||
{
|
||||
if (!RuleMatches(ctx, state, rule)) continue;
|
||||
var candidate = BuildCandidate(ctx, state, rule);
|
||||
if (!candidate.IsValid) continue;
|
||||
if (!best.IsValid || candidate.Priority > best.Priority) best = candidate;
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
private bool RuleMatches(AIDirectorContext ctx, AIDirectorHeroState state, AIDirectorHeroRule rule)
|
||||
{
|
||||
if (rule == null) return false;
|
||||
if (rule.GiantType != GiantType.None && rule.GiantType != state.GiantType) return false;
|
||||
if (rule.UnitType != UnitType.None && state.Hero.UnitType != rule.UnitType) return false;
|
||||
if (rule.MinLevel > 0 && state.Level < rule.MinLevel) return false;
|
||||
if (rule.RequiredSkill != null && !AIDirectorMath.HasSkill(state.Hero, rule.RequiredSkill)) return false;
|
||||
|
||||
if (rule.Context != AIDirectorHeroContext.None && rule.Context != state.Context)
|
||||
{
|
||||
if (!AllowContextFallback(ctx, state, rule)) return false;
|
||||
}
|
||||
|
||||
return RuleHasEnoughLocalValue(ctx, state, rule);
|
||||
}
|
||||
|
||||
private bool AllowContextFallback(AIDirectorContext ctx, AIDirectorHeroState state, AIDirectorHeroRule rule)
|
||||
{
|
||||
if (rule.Context == AIDirectorHeroContext.FieldBattle && state.IsOnFront) return true;
|
||||
if (rule.Context == AIDirectorHeroContext.Defense && ctx.Cache.HasCriticalCityThreat) return true;
|
||||
if (rule.Context == AIDirectorHeroContext.Recovery && state.HealthRatio <= ctx.Config.HeroLowHealthRatio) return true;
|
||||
if (rule.Context == AIDirectorHeroContext.Control && ctx.Cache.EnemyHeroes.Count > 0) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
private bool RuleHasEnoughLocalValue(AIDirectorContext ctx, AIDirectorHeroState state, AIDirectorHeroRule rule)
|
||||
{
|
||||
switch (rule.RuleId)
|
||||
{
|
||||
case "Mokou_Boom":
|
||||
return state.HealthRatio <= 0.55f && CountEnemiesAround(ctx, state.Hero, 1) >= 2;
|
||||
case "Remilia_Absorb":
|
||||
return state.HealthRatio <= ctx.Config.HeroLowHealthRatio || AIDirectorMath.HasSkill(state.Hero, "RedMist");
|
||||
case "Kanako_Sit":
|
||||
return !ctx.Cache.HasAnyEnemyContact && !AIDirectorMath.HasSkill(state.Hero, "KANAKOSITTING");
|
||||
case "Kanako_Unsit":
|
||||
return ctx.Cache.HasAnyEnemyContact && AIDirectorMath.HasSkill(state.Hero, "KANAKOSITTING");
|
||||
case "Suika_CreateMini":
|
||||
return state.HealthRatio >= 0.7f && CountEnemiesAround(ctx, state.Hero, 2) <= 1;
|
||||
case "Suika_ShakeOff":
|
||||
return CountEnemiesAround(ctx, state.Hero, 1) >= 2 || state.HealthRatio <= ctx.Config.HeroLowHealthRatio;
|
||||
case "Rin_Corpse":
|
||||
return AIDirectorMath.HasSkill(state.Hero, "CorpseBuff") || AIDirectorMath.HasSkill(state.Hero, "MahaCorpseBuff");
|
||||
case "Utsuho_BoneBoom":
|
||||
return HasBonePileOpportunity(ctx, state.Hero);
|
||||
case "Reimu_ClearExtermination":
|
||||
return HasMarkedAlly(ctx, "ReimuExtermination");
|
||||
case "Reisen_Boom":
|
||||
return CountEnemiesAround(ctx, state.Hero, 2) >= 2;
|
||||
case "Eirin_HealHero":
|
||||
case "Sakuya_GuardStrike":
|
||||
case "Reimu_Protect":
|
||||
return FindInjuredAlly(ctx, state.Hero) != null;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private AIDirectorActionCandidate BuildCandidate(AIDirectorContext ctx, AIDirectorHeroState state, AIDirectorHeroRule rule)
|
||||
{
|
||||
var targetUnit = ResolveTargetUnit(ctx, state, rule.TargetPolicy);
|
||||
var targetGrid = ResolveTargetGrid(ctx, state, rule.TargetPolicy, targetUnit);
|
||||
AIActionBase action = null;
|
||||
|
||||
switch (rule.ActionKind)
|
||||
{
|
||||
case AIDirectorHeroActionKind.UnitAction:
|
||||
action = ctx.ActionIndex.FindUnitAction(state.Hero, rule.UnitActionType);
|
||||
break;
|
||||
case AIDirectorHeroActionKind.AttackEnemy:
|
||||
action = ctx.ActionIndex.FindBestAttack(state.Hero, targetUnit) ?? ctx.ActionIndex.FindBestAttack(state.Hero);
|
||||
break;
|
||||
case AIDirectorHeroActionKind.AttackAlly:
|
||||
action = ctx.ActionIndex.FindBestAttackAlly(state.Hero, targetUnit) ?? ctx.ActionIndex.FindBestAttackAlly(state.Hero);
|
||||
break;
|
||||
case AIDirectorHeroActionKind.AttackGround:
|
||||
action = ctx.ActionIndex.FindBestAttackGround(state.Hero, targetGrid) ?? ctx.ActionIndex.FindBestAttackGround(state.Hero);
|
||||
break;
|
||||
case AIDirectorHeroActionKind.MoveTowardFront:
|
||||
action = ctx.ActionIndex.FindBestMove(state.Hero, targetGrid ?? state.Front?.TargetGrid ?? state.Front?.AnchorGrid);
|
||||
break;
|
||||
case AIDirectorHeroActionKind.MoveAwayFromThreat:
|
||||
action = ctx.ActionIndex.FindBestMove(state.Hero, targetGrid);
|
||||
break;
|
||||
case AIDirectorHeroActionKind.Hold:
|
||||
return AIDirectorActionCandidate.None;
|
||||
}
|
||||
|
||||
var priority = rule.Priority + GetContextPriorityBonus(ctx, state, rule, targetUnit);
|
||||
return ctx.ActionIndex.Candidate(action, AIDirectorLane.HeroPlaybook, $"{state.GiantType}:{rule.Reason}", priority);
|
||||
}
|
||||
|
||||
private float GetContextPriorityBonus(AIDirectorContext ctx, AIDirectorHeroState state, AIDirectorHeroRule rule, UnitData target)
|
||||
{
|
||||
var bonus = 0f;
|
||||
if (state.IsThreatened && rule.Context == AIDirectorHeroContext.Recovery) bonus += 80f;
|
||||
if (state.Front?.FrontType == AIDirectorFrontType.Defense && rule.Context == AIDirectorHeroContext.Defense) bonus += 60f;
|
||||
if (target != null)
|
||||
{
|
||||
bonus += AIDirectorMath.UnitPower(target) * 0.5f;
|
||||
bonus += (1f - AIDirectorMath.HealthRatio(target)) * 60f;
|
||||
if (target.TreatedAsHero(ctx.Map, target)) bonus += 80f;
|
||||
if (!string.IsNullOrEmpty(rule.AvoidTargetSkill) && AIDirectorMath.HasSkill(target, rule.AvoidTargetSkill)) bonus -= 200f;
|
||||
}
|
||||
|
||||
return bonus;
|
||||
}
|
||||
|
||||
private UnitData ResolveTargetUnit(AIDirectorContext ctx, AIDirectorHeroState state, AIDirectorHeroTargetPolicy policy)
|
||||
{
|
||||
switch (policy)
|
||||
{
|
||||
case AIDirectorHeroTargetPolicy.Self:
|
||||
return state.Hero;
|
||||
case AIDirectorHeroTargetPolicy.KillableEnemy:
|
||||
return FindKillableEnemy(ctx, state.Hero) ?? FindBestEnemy(ctx, state.Hero, false);
|
||||
case AIDirectorHeroTargetPolicy.BestEnemyHero:
|
||||
return FindBestEnemy(ctx, state.Hero, true) ?? FindBestEnemy(ctx, state.Hero, false);
|
||||
case AIDirectorHeroTargetPolicy.BestEnemy:
|
||||
case AIDirectorHeroTargetPolicy.ThreateningEnemy:
|
||||
return FindBestEnemy(ctx, state.Hero, false);
|
||||
case AIDirectorHeroTargetPolicy.InjuredAlly:
|
||||
return FindInjuredAlly(ctx, state.Hero);
|
||||
case AIDirectorHeroTargetPolicy.AdjacentAlly:
|
||||
case AIDirectorHeroTargetPolicy.BestAlly:
|
||||
return FindBestAlly(ctx, state.Hero);
|
||||
case AIDirectorHeroTargetPolicy.BonePile:
|
||||
return FindBonePile(ctx, state.Hero);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private GridData ResolveTargetGrid(AIDirectorContext ctx, AIDirectorHeroState state, AIDirectorHeroTargetPolicy policy, UnitData targetUnit)
|
||||
{
|
||||
if (targetUnit != null && ctx.Map.GetGridDataByUnitId(targetUnit.Id, out var unitGrid)) return unitGrid;
|
||||
switch (policy)
|
||||
{
|
||||
case AIDirectorHeroTargetPolicy.FrontEnemyCity:
|
||||
case AIDirectorHeroTargetPolicy.CityCenterEnemy:
|
||||
return state.Front?.TargetGrid ?? ctx.Cache.PrimaryFront?.TargetGrid;
|
||||
case AIDirectorHeroTargetPolicy.BestGroundTarget:
|
||||
return FindBestGroundTarget(ctx, state.Hero);
|
||||
case AIDirectorHeroTargetPolicy.SafeForwardGrid:
|
||||
return state.Front?.TargetGrid ?? ctx.Cache.PrimaryFront?.TargetGrid;
|
||||
case AIDirectorHeroTargetPolicy.RetreatGrid:
|
||||
return FindRetreatAnchor(ctx, state.Hero);
|
||||
default:
|
||||
return state.Front?.TargetGrid;
|
||||
}
|
||||
}
|
||||
|
||||
private UnitData FindBestEnemy(AIDirectorContext ctx, UnitData hero, bool heroOnly)
|
||||
{
|
||||
if (!ctx.Map.GetGridDataByUnitId(hero.Id, out var heroGrid)) return null;
|
||||
UnitData best = null;
|
||||
var bestScore = float.MinValue;
|
||||
foreach (var enemy in ctx.Cache.EnemyUnits)
|
||||
{
|
||||
if (heroOnly && !enemy.TreatedAsHero(ctx.Map, enemy)) continue;
|
||||
if (!ctx.Map.GetGridDataByUnitId(enemy.Id, out var enemyGrid)) continue;
|
||||
var distance = AIDirectorMath.Distance(ctx.Map, heroGrid, enemyGrid);
|
||||
var score = AIDirectorMath.UnitPower(enemy) + (1f - AIDirectorMath.HealthRatio(enemy)) * 80f - distance * 8f;
|
||||
if (AIDirectorMath.HasSkill(enemy, "MomijiPrey")) score += 80f;
|
||||
if (AIDirectorMath.HasSkill(enemy, "KomeijiFear")) score += 40f;
|
||||
if (score <= bestScore) continue;
|
||||
bestScore = score;
|
||||
best = enemy;
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
private UnitData FindKillableEnemy(AIDirectorContext ctx, UnitData hero)
|
||||
{
|
||||
UnitData best = null;
|
||||
var bestScore = float.MinValue;
|
||||
foreach (var enemy in ctx.Cache.EnemyUnits)
|
||||
{
|
||||
var damage = Table.Instance.CalcDamage(ctx.Map, hero, enemy);
|
||||
if (damage < enemy.Health) continue;
|
||||
if (ctx.ActionIndex.FindBestAttack(hero, enemy) == null) continue;
|
||||
var score = AIDirectorMath.UnitPower(enemy) + (enemy.TreatedAsHero(ctx.Map, enemy) ? 200f : 0f);
|
||||
if (score <= bestScore) continue;
|
||||
bestScore = score;
|
||||
best = enemy;
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
private UnitData FindInjuredAlly(AIDirectorContext ctx, UnitData hero)
|
||||
{
|
||||
UnitData best = null;
|
||||
var bestScore = float.MinValue;
|
||||
foreach (var ally in ctx.Cache.SelfUnits)
|
||||
{
|
||||
if (ally.Id == hero.Id) continue;
|
||||
var missing = 1f - AIDirectorMath.HealthRatio(ally);
|
||||
if (missing < 0.2f) continue;
|
||||
var score = missing * 100f + AIDirectorMath.UnitPower(ally);
|
||||
if (ally.TreatedAsHero(ctx.Map, ally)) score += 120f;
|
||||
if (score <= bestScore) continue;
|
||||
bestScore = score;
|
||||
best = ally;
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
private UnitData FindBestAlly(AIDirectorContext ctx, UnitData hero)
|
||||
{
|
||||
if (!ctx.Map.GetGridDataByUnitId(hero.Id, out var heroGrid)) return null;
|
||||
UnitData best = null;
|
||||
var bestScore = float.MinValue;
|
||||
foreach (var ally in ctx.Cache.SelfUnits)
|
||||
{
|
||||
if (ally.Id == hero.Id || !ctx.Map.GetGridDataByUnitId(ally.Id, out var allyGrid)) continue;
|
||||
var distance = AIDirectorMath.Distance(ctx.Map, heroGrid, allyGrid);
|
||||
var score = AIDirectorMath.UnitPower(ally) - distance * 10f;
|
||||
if (ally.TreatedAsHero(ctx.Map, ally)) score += 80f;
|
||||
if (score <= bestScore) continue;
|
||||
bestScore = score;
|
||||
best = ally;
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
private UnitData FindBonePile(AIDirectorContext ctx, UnitData hero)
|
||||
{
|
||||
if (!ctx.Map.GetGridDataByUnitId(hero.Id, out var heroGrid)) return null;
|
||||
UnitData best = null;
|
||||
var bestScore = float.MinValue;
|
||||
foreach (var unit in ctx.Cache.SelfUnits)
|
||||
{
|
||||
if (unit.UnitType != UnitType.BonePile) continue;
|
||||
if (!ctx.Map.GetGridDataByUnitId(unit.Id, out var grid)) continue;
|
||||
var enemies = CountEnemiesAroundGrid(ctx, grid, 1);
|
||||
var score = enemies * 100f - AIDirectorMath.Distance(ctx.Map, heroGrid, grid) * 5f;
|
||||
if (score <= bestScore) continue;
|
||||
bestScore = score;
|
||||
best = unit;
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
private GridData FindBestGroundTarget(AIDirectorContext ctx, UnitData hero)
|
||||
{
|
||||
UnitData bestEnemy = FindBestEnemy(ctx, hero, false);
|
||||
if (bestEnemy != null && ctx.Map.GetGridDataByUnitId(bestEnemy.Id, out var grid)) return grid;
|
||||
return ctx.Cache.PrimaryFront?.TargetGrid ?? ctx.Cache.PrimaryFront?.AnchorGrid;
|
||||
}
|
||||
|
||||
private GridData FindRetreatAnchor(AIDirectorContext ctx, UnitData hero)
|
||||
{
|
||||
if (!ctx.Map.GetGridDataByUnitId(hero.Id, out var heroGrid)) return null;
|
||||
GridData best = null;
|
||||
var bestScore = float.MinValue;
|
||||
foreach (var city in ctx.Cache.SelfCities)
|
||||
{
|
||||
if (!ctx.Map.GetGridDataByCityId(city.Id, out var cityGrid)) continue;
|
||||
var distance = AIDirectorMath.Distance(ctx.Map, heroGrid, cityGrid);
|
||||
var pressure = CountEnemiesAroundGrid(ctx, cityGrid, ctx.Config.EmergencyEnemySearchRange);
|
||||
var score = -distance * 10f - pressure * 50f;
|
||||
if (score <= bestScore) continue;
|
||||
bestScore = score;
|
||||
best = cityGrid;
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
private bool HasBonePileOpportunity(AIDirectorContext ctx, UnitData hero)
|
||||
{
|
||||
var bone = FindBonePile(ctx, hero);
|
||||
return bone != null && ctx.Map.GetGridDataByUnitId(bone.Id, out var grid) && CountEnemiesAroundGrid(ctx, grid, 1) >= 2;
|
||||
}
|
||||
|
||||
private bool HasMarkedAlly(AIDirectorContext ctx, string skillName)
|
||||
{
|
||||
foreach (var unit in ctx.Cache.SelfUnits)
|
||||
{
|
||||
if (AIDirectorMath.HasSkill(unit, skillName)) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private int CountEnemiesAround(AIDirectorContext ctx, UnitData unit, int range)
|
||||
{
|
||||
if (!ctx.Map.GetGridDataByUnitId(unit.Id, out var grid)) return 0;
|
||||
return CountEnemiesAroundGrid(ctx, grid, range);
|
||||
}
|
||||
|
||||
private int CountEnemiesAroundGrid(AIDirectorContext ctx, GridData grid, int range)
|
||||
{
|
||||
var count = 0;
|
||||
foreach (var enemy in ctx.Cache.EnemyUnits)
|
||||
{
|
||||
if (!ctx.Map.GetGridDataByUnitId(enemy.Id, out var enemyGrid)) continue;
|
||||
if (AIDirectorMath.Distance(ctx.Map, grid, enemyGrid) <= range) count++;
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6be9bf430e0644149adf7986bb5a03aa
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
906
Unity/Assets/Scripts/TH1_Logic/AI/Director/AIDirectorLogic.cs
Normal file
906
Unity/Assets/Scripts/TH1_Logic/AI/Director/AIDirectorLogic.cs
Normal file
@ -0,0 +1,906 @@
|
||||
using Logic.Action;
|
||||
using RuntimeData;
|
||||
using System.Diagnostics;
|
||||
using TH1_Logic.Action;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Logic.AI.Director
|
||||
{
|
||||
public sealed class AIDirectorLogic
|
||||
{
|
||||
private readonly AIDirectorWorldCacheBuilder _cacheBuilder = new();
|
||||
private readonly AIDirectorHeroRuleEvaluator _heroEvaluator = new();
|
||||
|
||||
public AIDirectorDecision Decide(MapData map, PlayerData player, AIDirectorConfig config = null)
|
||||
{
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
var decision = new AIDirectorDecision();
|
||||
if (map == null || player == null)
|
||||
{
|
||||
decision.AddTrace("AI Director skipped: map or player is null.", config?.MaxCandidateTraceCount ?? 16);
|
||||
stopwatch.Stop();
|
||||
decision.DecideMs = stopwatch.Elapsed.TotalMilliseconds;
|
||||
return decision;
|
||||
}
|
||||
|
||||
var ctx = new AIDirectorContext(map, player, config ?? AIDirectorConfig.CreateDefault());
|
||||
ctx.Cache = _cacheBuilder.Build(ctx);
|
||||
ctx.ActionIndex = AIDirectorActionIndex.Build(ctx);
|
||||
_cacheBuilder.BuildUnitOpportunities(ctx);
|
||||
decision.Cache = ctx.Cache;
|
||||
decision.ActionIndex = ctx.ActionIndex;
|
||||
|
||||
decision.AddTrace(
|
||||
$"Cache posture={ctx.Cache.StrategicPosture}, selfUnits={ctx.Cache.SelfUnits.Count}, enemyUnits={ctx.Cache.EnemyUnits.Count}, fronts={ctx.Cache.Fronts.Count}, opportunities={ctx.Cache.UnitOpportunities.Count}, actions={ctx.ActionIndex.AllActions.Count}.",
|
||||
ctx.Config.MaxCandidateTraceCount);
|
||||
|
||||
if (ctx.ActionIndex.AllActions.Count == 0)
|
||||
{
|
||||
decision.AddTrace("No legal AI action generated.", ctx.Config.MaxCandidateTraceCount);
|
||||
stopwatch.Stop();
|
||||
decision.DecideMs = stopwatch.Elapsed.TotalMilliseconds;
|
||||
return decision;
|
||||
}
|
||||
|
||||
if (TryEmergencyLane(ctx, decision, out var candidate)
|
||||
|| TryHeroManagementLane(ctx, decision, out candidate)
|
||||
|| TryHeroPlaybookLane(ctx, decision, out candidate)
|
||||
|| TryTacticLane(ctx, decision, out candidate)
|
||||
|| TryUnitOpportunityLane(ctx, decision, out candidate)
|
||||
|| TryFrontLane(ctx, decision, out candidate)
|
||||
|| TryGrowthLane(ctx, decision, out candidate)
|
||||
|| TryFallback(ctx, decision, out candidate))
|
||||
{
|
||||
decision.Candidate = candidate;
|
||||
decision.RecordLaneResult(candidate.Lane, true, "Selected");
|
||||
decision.AddTrace($"Picked {candidate.ActionId?.ActionType} lane={candidate.Lane}, priority={candidate.Priority}, reason={candidate.Reason}.", ctx.Config.MaxCandidateTraceCount);
|
||||
}
|
||||
else
|
||||
{
|
||||
decision.AddTrace("No legal AI Director action found.", ctx.Config.MaxCandidateTraceCount);
|
||||
}
|
||||
|
||||
stopwatch.Stop();
|
||||
decision.DecideMs = stopwatch.Elapsed.TotalMilliseconds;
|
||||
return decision;
|
||||
}
|
||||
|
||||
public bool TryDecide(MapData map, PlayerData player, out AIDirectorActionCandidate candidate, AIDirectorConfig config = null)
|
||||
{
|
||||
var decision = Decide(map, player, config);
|
||||
candidate = decision.Candidate;
|
||||
return decision.HasAction;
|
||||
}
|
||||
|
||||
public bool TryExecuteDecision(AIDirectorDecision decision)
|
||||
{
|
||||
var candidate = decision?.Candidate;
|
||||
if (candidate == null || !candidate.IsValid || !candidate.CheckCan()) return false;
|
||||
return candidate.ActionLogic.CompleteExecute(candidate.Param);
|
||||
}
|
||||
|
||||
private bool TryEmergencyLane(AIDirectorContext ctx, AIDirectorDecision decision, out AIDirectorActionCandidate candidate)
|
||||
{
|
||||
candidate = AIDirectorActionCandidate.None;
|
||||
|
||||
foreach (var threat in ctx.Cache.CityThreats)
|
||||
{
|
||||
if (threat == null) continue;
|
||||
if (!threat.IsCritical && threat.EnemyUnits.Count < ctx.Config.CityDangerEnemyCount && threat.DangerScore <= 0f) continue;
|
||||
|
||||
var attack = TryEmergencyAttack(ctx, decision, threat);
|
||||
if (attack.IsValid)
|
||||
{
|
||||
candidate = attack;
|
||||
decision.AddTrace($"Emergency: attack city threat for city={threat.City?.Id}.", ctx.Config.MaxCandidateTraceCount);
|
||||
return true;
|
||||
}
|
||||
|
||||
var move = TryEmergencyMove(ctx, decision, threat);
|
||||
if (move.IsValid)
|
||||
{
|
||||
candidate = move;
|
||||
decision.AddTrace($"Emergency: move defender to city={threat.City?.Id}.", ctx.Config.MaxCandidateTraceCount);
|
||||
return true;
|
||||
}
|
||||
|
||||
var cityAction = TryEmergencyCityAction(ctx, decision, threat);
|
||||
if (cityAction.IsValid)
|
||||
{
|
||||
candidate = cityAction;
|
||||
decision.AddTrace($"Emergency: city action for city={threat.City?.Id}.", ctx.Config.MaxCandidateTraceCount);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private AIDirectorActionCandidate TryEmergencyAttack(AIDirectorContext ctx, AIDirectorDecision decision, AIDirectorCityThreat threat)
|
||||
{
|
||||
AIDirectorActionCandidate best = AIDirectorActionCandidate.None;
|
||||
foreach (var defender in threat.Defenders)
|
||||
{
|
||||
var target = ChooseBestThreatTarget(ctx, threat);
|
||||
var action = ctx.ActionIndex.FindBestAttack(defender, target) ?? ctx.ActionIndex.FindBestAttack(defender);
|
||||
var targetValue = UnitTargetValue(ctx, target);
|
||||
var score = 960f + threat.DangerScore * 20f + targetValue;
|
||||
var candidate = ctx.ActionIndex.Candidate(action, AIDirectorLane.Emergency, "守军攻击城市威胁", score);
|
||||
AddTerms(candidate, ("base", 960f), ("danger", threat.DangerScore * 20f), ("targetValue", targetValue));
|
||||
candidate.Unit = candidate.Unit ?? defender;
|
||||
candidate.TargetUnit = candidate.TargetUnit ?? target;
|
||||
candidate.City = candidate.City ?? threat.City;
|
||||
candidate.TargetGrid = candidate.TargetGrid ?? threat.CityGrid;
|
||||
RecordCandidate(ctx, decision, "EmergencyAttack", candidate, action == null ? "NoAttackAction" : (candidate.IsValid ? null : "CheckCanFailed"));
|
||||
best = MaxCandidate(best, candidate);
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
private AIDirectorActionCandidate TryEmergencyMove(AIDirectorContext ctx, AIDirectorDecision decision, AIDirectorCityThreat threat)
|
||||
{
|
||||
AIDirectorActionCandidate best = AIDirectorActionCandidate.None;
|
||||
foreach (var unit in ctx.Cache.SelfUnits)
|
||||
{
|
||||
if (unit == null || unit.GetActionPoint(ActionPointType.Move) <= 0) continue;
|
||||
if (threat.Defenders.Contains(unit) && threat.DefenderPower >= threat.EnemyPower) continue;
|
||||
var action = ctx.ActionIndex.FindBestMove(unit, threat.CityGrid);
|
||||
var unitGrid = unit.Grid(ctx.Map);
|
||||
var startDistance = AIDirectorMath.Distance(ctx.Map, unitGrid, threat.CityGrid);
|
||||
var unitPower = AIDirectorMath.UnitPower(unit);
|
||||
var distancePenalty = -startDistance * 12f;
|
||||
var heroBonus = unit.TreatedAsHero(ctx.Map, unit) ? 60f : 0f;
|
||||
var score = 900f + threat.DangerScore * 20f + unitPower + distancePenalty + heroBonus;
|
||||
var candidate = ctx.ActionIndex.Candidate(action, AIDirectorLane.Emergency, "回防城市", score);
|
||||
AddTerms(candidate, ("base", 900f), ("danger", threat.DangerScore * 20f), ("unitPower", unitPower), ("distance", distancePenalty), ("hero", heroBonus));
|
||||
candidate.Unit = candidate.Unit ?? unit;
|
||||
candidate.City = candidate.City ?? threat.City;
|
||||
candidate.TargetGrid = candidate.TargetGrid ?? threat.CityGrid;
|
||||
RecordCandidate(ctx, decision, "EmergencyMove", candidate, action == null ? "NoMoveAction" : (candidate.IsValid ? null : "CheckCanFailed"));
|
||||
best = MaxCandidate(best, candidate);
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
private AIDirectorActionCandidate TryEmergencyCityAction(AIDirectorContext ctx, AIDirectorDecision decision, AIDirectorCityThreat threat)
|
||||
{
|
||||
var plan = FindCityPlan(ctx, threat.City);
|
||||
if (plan == null) return AIDirectorActionCandidate.None;
|
||||
AIDirectorActionCandidate best = AIDirectorActionCandidate.None;
|
||||
foreach (var action in ctx.ActionIndex.GetCityActions(threat.City))
|
||||
{
|
||||
var score = ScoreCityGrowth(ctx, action, plan);
|
||||
if (score <= 0f) continue;
|
||||
var candidate = ctx.ActionIndex.Candidate(action, AIDirectorLane.Emergency, "危险城市生产或防御", score + 250f);
|
||||
AddTerms(candidate, ("cityGrowth", score), ("emergencyBonus", 250f));
|
||||
candidate.City = candidate.City ?? threat.City;
|
||||
candidate.TargetGrid = candidate.TargetGrid ?? threat.CityGrid;
|
||||
RecordCandidate(ctx, decision, "EmergencyCityAction", candidate, candidate.IsValid ? null : "CheckCanFailed");
|
||||
best = MaxCandidate(best, candidate);
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
private bool TryHeroManagementLane(AIDirectorContext ctx, AIDirectorDecision decision, out AIDirectorActionCandidate candidate)
|
||||
{
|
||||
candidate = AIDirectorActionCandidate.None;
|
||||
|
||||
var selectHero = ctx.ActionIndex.FindHeroManagementAction(PlayerActionType.SelectHero);
|
||||
candidate = ctx.ActionIndex.Candidate(selectHero, AIDirectorLane.HeroManagement, "选择英雄", 890f);
|
||||
AddTerms(candidate, ("base", 890f));
|
||||
RecordCandidate(ctx, decision, "SelectHero", candidate, selectHero == null ? "NoAction" : null);
|
||||
if (candidate.IsValid)
|
||||
{
|
||||
decision.AddTrace("HeroManagement: select hero.", ctx.Config.MaxCandidateTraceCount);
|
||||
return true;
|
||||
}
|
||||
|
||||
var finishTask = ctx.ActionIndex.FindBestHeroTaskFinish(ctx.Player);
|
||||
candidate = ctx.ActionIndex.Candidate(finishTask, AIDirectorLane.HeroManagement, "推进最低等级英雄任务", 870f);
|
||||
AddTerms(candidate, ("base", 870f));
|
||||
RecordCandidate(ctx, decision, "FinishHeroTask", candidate, finishTask == null ? "NoAction" : null);
|
||||
if (candidate.IsValid)
|
||||
{
|
||||
decision.AddTrace("HeroManagement: finish hero task.", ctx.Config.MaxCandidateTraceCount);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private bool TryHeroPlaybookLane(AIDirectorContext ctx, AIDirectorDecision decision, out AIDirectorActionCandidate candidate)
|
||||
{
|
||||
candidate = AIDirectorActionCandidate.None;
|
||||
if (!ctx.Config.PreferHeroRules || ctx.Cache.HeroStates.Count == 0) return false;
|
||||
|
||||
AIDirectorActionCandidate best = AIDirectorActionCandidate.None;
|
||||
foreach (var state in ctx.Cache.HeroStates)
|
||||
{
|
||||
var playbook = _heroEvaluator.Evaluate(ctx, state);
|
||||
RecordCandidate(ctx, decision, "HeroRule", playbook, playbook.IsValid ? null : "NoHeroRuleAction");
|
||||
best = MaxCandidate(best, playbook);
|
||||
|
||||
var generic = TryGenericHeroRule(ctx, state);
|
||||
RecordCandidate(ctx, decision, "GenericHeroRule", generic, generic.IsValid ? null : "NoGenericHeroAction");
|
||||
best = MaxCandidate(best, generic);
|
||||
}
|
||||
|
||||
if (!best.IsValid) return false;
|
||||
candidate = best;
|
||||
decision.AddTrace($"HeroPlaybook: hero={candidate.Unit?.Id}.", ctx.Config.MaxCandidateTraceCount);
|
||||
return true;
|
||||
}
|
||||
|
||||
private AIDirectorActionCandidate TryGenericHeroRule(AIDirectorContext ctx, AIDirectorHeroState state)
|
||||
{
|
||||
if (state?.Hero == null) return AIDirectorActionCandidate.None;
|
||||
|
||||
if (state.HealthRatio <= ctx.Config.HeroLowHealthRatio)
|
||||
{
|
||||
var recover = ctx.ActionIndex.FindUnitAction(state.Hero, UnitActionType.Recover);
|
||||
var recoverCandidate = ctx.ActionIndex.Candidate(recover, AIDirectorLane.HeroPlaybook, "英雄低血恢复", 840f);
|
||||
AddTerms(recoverCandidate, ("base", 840f));
|
||||
if (recoverCandidate.IsValid) return recoverCandidate;
|
||||
|
||||
var retreatTarget = FindSafestSelfCityGrid(ctx, state.Hero);
|
||||
var move = ctx.ActionIndex.FindBestMove(state.Hero, retreatTarget);
|
||||
var moveCandidate = ctx.ActionIndex.Candidate(move, AIDirectorLane.HeroPlaybook, "英雄低血撤退", 820f);
|
||||
AddTerms(moveCandidate, ("base", 820f));
|
||||
if (moveCandidate.IsValid) return moveCandidate;
|
||||
}
|
||||
|
||||
var attack = ctx.ActionIndex.FindBestAttack(state.Hero);
|
||||
var targetValue = UnitTargetValue(ctx, attack?.Param?.TargetUnitData);
|
||||
var attackCandidate = ctx.ActionIndex.Candidate(attack, AIDirectorLane.HeroPlaybook, "英雄通用高价值攻击", 760f + targetValue);
|
||||
AddTerms(attackCandidate, ("base", 760f), ("targetValue", targetValue));
|
||||
if (attackCandidate.IsValid) return attackCandidate;
|
||||
|
||||
var target = state.Front?.TargetGrid ?? state.Front?.AnchorGrid;
|
||||
var frontMove = ctx.ActionIndex.FindBestMove(state.Hero, target);
|
||||
var frontCandidate = ctx.ActionIndex.Candidate(frontMove, AIDirectorLane.HeroPlaybook, "英雄站位到战线", 650f);
|
||||
AddTerms(frontCandidate, ("base", 650f));
|
||||
return frontCandidate;
|
||||
}
|
||||
|
||||
private bool TryTacticLane(AIDirectorContext ctx, AIDirectorDecision decision, out AIDirectorActionCandidate candidate)
|
||||
{
|
||||
candidate = AIDirectorActionCandidate.None;
|
||||
AIDirectorActionCandidate best = AIDirectorActionCandidate.None;
|
||||
|
||||
foreach (var battle in ctx.Cache.LocalBattles)
|
||||
{
|
||||
if (battle.SelfUnit == null || battle.EnemyUnit == null) continue;
|
||||
var action = ctx.ActionIndex.FindBestAttack(battle.SelfUnit, battle.EnemyUnit);
|
||||
var attackScore = ScoreAttackAction(ctx, action);
|
||||
var score = 700f + battle.Value + attackScore * 0.1f;
|
||||
var current = ctx.ActionIndex.Candidate(action, AIDirectorLane.Tactic, "局部战斗攻击", score);
|
||||
AddTerms(current, ("base", 700f), ("localBattle", battle.Value), ("attackScore", attackScore * 0.1f));
|
||||
RecordCandidate(ctx, decision, "LocalBattle", current, action == null ? "NoAttackAction" : null);
|
||||
best = MaxCandidate(best, current);
|
||||
}
|
||||
|
||||
foreach (var action in ctx.ActionIndex.AttackActions)
|
||||
{
|
||||
var score = ScoreAttackAction(ctx, action);
|
||||
var current = ctx.ActionIndex.Candidate(action, AIDirectorLane.Tactic, "普通攻击收益", score);
|
||||
AddTerms(current, ("attackScore", score));
|
||||
RecordCandidate(ctx, decision, "AttackAction", current);
|
||||
best = MaxCandidate(best, current);
|
||||
}
|
||||
|
||||
if (!best.IsValid) return false;
|
||||
candidate = best;
|
||||
decision.AddTrace($"Tactic: attacker={candidate.Unit?.Id}, target={candidate.TargetUnit?.Id}.", ctx.Config.MaxCandidateTraceCount);
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool TryUnitOpportunityLane(AIDirectorContext ctx, AIDirectorDecision decision, out AIDirectorActionCandidate candidate)
|
||||
{
|
||||
candidate = AIDirectorActionCandidate.None;
|
||||
foreach (var opportunity in ctx.Cache.UnitOpportunities)
|
||||
{
|
||||
var current = ctx.ActionIndex.Candidate(opportunity.Action, AIDirectorLane.UnitOpportunity, opportunity.OpportunityType.ToString(), opportunity.Value);
|
||||
AddTerms(current, ("opportunityValue", opportunity.Value));
|
||||
RecordCandidate(ctx, decision, opportunity.OpportunityType.ToString(), current);
|
||||
if (!current.IsValid) continue;
|
||||
candidate = current;
|
||||
decision.AddTrace($"UnitOpportunity: unit={candidate.Unit?.Id}, type={opportunity.OpportunityType}.", ctx.Config.MaxCandidateTraceCount);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private bool TryFrontLane(AIDirectorContext ctx, AIDirectorDecision decision, out AIDirectorActionCandidate candidate)
|
||||
{
|
||||
candidate = AIDirectorActionCandidate.None;
|
||||
AIDirectorActionCandidate best = AIDirectorActionCandidate.None;
|
||||
|
||||
foreach (var front in ctx.Cache.Fronts)
|
||||
{
|
||||
var target = ResolveFrontTarget(front);
|
||||
if (target == null) continue;
|
||||
foreach (var unit in ctx.Cache.SelfUnits)
|
||||
{
|
||||
if (ShouldSkipFrontMove(ctx, unit, front)) continue;
|
||||
var action = ctx.ActionIndex.FindBestMove(unit, target);
|
||||
var score = ScoreFrontMove(ctx, unit, action, front);
|
||||
var current = ctx.ActionIndex.Candidate(action, AIDirectorLane.Front, $"{front.FrontType} 战线移动", score);
|
||||
AddTerms(current, ("frontMove", score));
|
||||
RecordCandidate(ctx, decision, front.FrontType.ToString(), current, action == null ? "NoMoveAction" : null);
|
||||
best = MaxCandidate(best, current);
|
||||
}
|
||||
}
|
||||
|
||||
if (!best.IsValid) return false;
|
||||
candidate = best;
|
||||
decision.AddTrace($"Front: unit={candidate.Unit?.Id}, grid={candidate.Grid?.Id}.", ctx.Config.MaxCandidateTraceCount);
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool TryGrowthLane(AIDirectorContext ctx, AIDirectorDecision decision, out AIDirectorActionCandidate candidate)
|
||||
{
|
||||
candidate = AIDirectorActionCandidate.None;
|
||||
var best = AIDirectorActionCandidate.None;
|
||||
|
||||
foreach (var action in ctx.ActionIndex.CityActions)
|
||||
{
|
||||
var plan = FindCityPlanByAction(ctx, action);
|
||||
var score = ScoreCityGrowth(ctx, action, plan);
|
||||
var current = ctx.ActionIndex.Candidate(action, AIDirectorLane.Growth, "城市发展", score);
|
||||
AddTerms(current, ("cityGrowth", score));
|
||||
RecordCandidate(ctx, decision, "CityGrowth", current);
|
||||
best = MaxCandidate(best, current);
|
||||
}
|
||||
|
||||
foreach (var action in ctx.ActionIndex.GridActions)
|
||||
{
|
||||
var score = ScoreGridGrowth(ctx, action);
|
||||
var current = ctx.ActionIndex.Candidate(action, AIDirectorLane.Growth, "地块发展", score);
|
||||
AddTerms(current, ("gridGrowth", score));
|
||||
RecordCandidate(ctx, decision, "GridGrowth", current);
|
||||
best = MaxCandidate(best, current);
|
||||
}
|
||||
|
||||
foreach (var action in ctx.ActionIndex.PlayerActions)
|
||||
{
|
||||
var score = ScorePlayerGrowth(ctx, action);
|
||||
var current = ctx.ActionIndex.Candidate(action, AIDirectorLane.Growth, "玩家发展", score);
|
||||
AddTerms(current, ("playerGrowth", score));
|
||||
RecordCandidate(ctx, decision, "PlayerGrowth", current);
|
||||
best = MaxCandidate(best, current);
|
||||
}
|
||||
|
||||
if (!best.IsValid || best.Priority <= 0f) return false;
|
||||
candidate = best;
|
||||
decision.AddTrace($"Growth: action={candidate.ActionId?.ActionType}.", ctx.Config.MaxCandidateTraceCount);
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool TryFallback(AIDirectorContext ctx, AIDirectorDecision decision, out AIDirectorActionCandidate candidate)
|
||||
{
|
||||
var action = ctx.ActionIndex.FindBestFallback();
|
||||
candidate = ctx.ActionIndex.Candidate(action, AIDirectorLane.Fallback, "兜底合法动作", 1f, true);
|
||||
AddTerms(candidate, ("base", 1f));
|
||||
RecordCandidate(ctx, decision, "Fallback", candidate, action == null ? "NoFallbackAction" : null);
|
||||
if (!candidate.IsValid) return false;
|
||||
decision.AddTrace("Fallback: picked first legal action from generated pool.", ctx.Config.MaxCandidateTraceCount);
|
||||
return true;
|
||||
}
|
||||
|
||||
private float ScoreAttackAction(AIDirectorContext ctx, AIActionBase action)
|
||||
{
|
||||
if (action?.Param?.UnitData == null || action.Param.TargetUnitData == null) return 0f;
|
||||
var attacker = action.Param.UnitData;
|
||||
var target = action.Param.TargetUnitData;
|
||||
var damage = Table.Instance.CalcDamage(ctx.Map, attacker, target);
|
||||
var score = 620f;
|
||||
score += damage / Mathf.Max(1f, target.GetMaxHealth()) * UnitTargetValue(ctx, target) * 2f;
|
||||
score += CounterBonus(attacker, target);
|
||||
score -= CounterThreat(ctx, attacker, target);
|
||||
if (damage >= target.Health) score += 160f;
|
||||
if (target.TreatedAsHero(ctx.Map, target)) score += 120f;
|
||||
if (IsThreateningAnyCity(ctx, target)) score += 120f;
|
||||
if (TargetOwnerFeelingHighAndNotWar(ctx, target)) score -= 80f;
|
||||
return score;
|
||||
}
|
||||
|
||||
private float ScoreFrontMove(AIDirectorContext ctx, UnitData unit, AIActionBase action, AIDirectorFront front)
|
||||
{
|
||||
if (unit == null || action?.Param == null || front == null) return 0f;
|
||||
var endGrid = action.Param.GridData ?? action.Param.TargetGridData;
|
||||
var target = ResolveFrontTarget(front);
|
||||
if (endGrid == null || target == null) return 0f;
|
||||
|
||||
var score = front.FrontType switch
|
||||
{
|
||||
AIDirectorFrontType.Defense => 620f + front.Pressure * 20f,
|
||||
AIDirectorFrontType.Attack => 560f + front.Opportunity * 30f,
|
||||
AIDirectorFrontType.Development => 500f + front.Opportunity * 15f,
|
||||
AIDirectorFrontType.Hold => 420f,
|
||||
_ => 0f
|
||||
};
|
||||
|
||||
score -= AIDirectorMath.Distance(ctx.Map, endGrid, target) * 16f;
|
||||
score -= GridThreat(ctx, endGrid) * 0.4f;
|
||||
if (CanNextTurnPressureTarget(ctx, unit, endGrid, target)) score += 80f;
|
||||
if (unit.GetActionPoint(ActionPointType.Move) >= 2 && front.FrontType != AIDirectorFrontType.Hold) score += 40f;
|
||||
return score;
|
||||
}
|
||||
|
||||
private float ScoreCityGrowth(AIDirectorContext ctx, AIActionBase action, AIDirectorCityPlan plan)
|
||||
{
|
||||
if (action?.ActionLogic?.ActionId == null) return 0f;
|
||||
var id = action.ActionLogic.ActionId;
|
||||
var city = action.Param.CityData;
|
||||
var score = 360f + (plan?.Priority ?? 0f) * 0.2f;
|
||||
|
||||
var kind = plan?.Kind ?? AIDirectorCityPlanKind.BacklineGrowth;
|
||||
if (kind == AIDirectorCityPlanKind.EmergencyDefense)
|
||||
{
|
||||
if (id.ActionType == CommonActionType.TrainUnit) score += TrainUnitDefenseValue(ctx, action, plan.Threat);
|
||||
if (id.ActionType == CommonActionType.CityAction && id.CityActionType == CityActionType.BuildCityWall) score += 220f;
|
||||
if (id.ActionType is CommonActionType.StartWonder or CommonActionType.BuildWonder) score -= 300f;
|
||||
}
|
||||
else if (kind is AIDirectorCityPlanKind.Mobilize or AIDirectorCityPlanKind.Frontline)
|
||||
{
|
||||
if (id.ActionType == CommonActionType.TrainUnit) score += TrainUnitWarValue(ctx, action);
|
||||
if (id.ActionType == CommonActionType.CityAction && id.CityActionType == CityActionType.BuildCityWall) score += 100f;
|
||||
if (id.ActionType == CommonActionType.CityLevelUpAction) score += 50f;
|
||||
}
|
||||
else if (kind == AIDirectorCityPlanKind.BacklineGrowth)
|
||||
{
|
||||
if (id.ActionType == CommonActionType.CityLevelUpAction) score += 140f;
|
||||
if (id.ActionType == CommonActionType.TrainUnit) score += NeedStandingArmy(ctx, city) ? 80f : 20f;
|
||||
if (id.ActionType == CommonActionType.CityAction && id.CityActionType == CityActionType.BuildCityWall) score += 20f;
|
||||
}
|
||||
else if (kind == AIDirectorCityPlanKind.Wonder)
|
||||
{
|
||||
if (id.ActionType is CommonActionType.StartWonder or CommonActionType.BuildWonder) score += 160f + WonderValue(action);
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
private float ScoreGridGrowth(AIDirectorContext ctx, AIActionBase action)
|
||||
{
|
||||
if (action?.ActionLogic?.ActionId == null) return 0f;
|
||||
var grid = action.Param.GridData ?? action.Param.TargetGridData;
|
||||
if (grid == null) return 0f;
|
||||
var id = action.ActionLogic.ActionId;
|
||||
var score = 330f;
|
||||
|
||||
if (GridIsNearDangerCity(ctx, grid) && id.ActionType is not CommonActionType.Build and not CommonActionType.Gain) score -= 100f;
|
||||
if (id.ActionType == CommonActionType.Build) score += BuildResourceValue(grid);
|
||||
if (id.ActionType == CommonActionType.Gain) score += ResourceValue(grid);
|
||||
if (id.ActionType == CommonActionType.GridMisc) score += GridMiscValue(id);
|
||||
if (FindDevelopmentTarget(ctx, grid) != null) score += 60f;
|
||||
return score;
|
||||
}
|
||||
|
||||
private float ScorePlayerGrowth(AIDirectorContext ctx, AIActionBase action)
|
||||
{
|
||||
if (action?.ActionLogic?.ActionId == null) return 0f;
|
||||
var id = action.ActionLogic.ActionId;
|
||||
if (id.ActionType == CommonActionType.LearnTech) return ScoreTech(ctx, id.TechType);
|
||||
if (id.ActionType == CommonActionType.BuyCultureCard) return ScoreCultureCard(ctx, id.CultureCardType);
|
||||
if (id.ActionType == CommonActionType.PlayerAction) return ScorePlayerAction(ctx, action);
|
||||
return 0f;
|
||||
}
|
||||
|
||||
private float ScoreTech(AIDirectorContext ctx, TechType tech)
|
||||
{
|
||||
var score = 300f;
|
||||
switch (ctx.Cache.StrategicPosture)
|
||||
{
|
||||
case AIDirectorStrategicPosture.Defense:
|
||||
if (TechLooksDefensive(tech)) score += 120f;
|
||||
if (TechLooksMilitary(tech)) score += 70f;
|
||||
break;
|
||||
case AIDirectorStrategicPosture.Attack:
|
||||
if (TechLooksMilitary(tech)) score += 130f;
|
||||
if (TechLooksMobility(tech)) score += 80f;
|
||||
break;
|
||||
case AIDirectorStrategicPosture.Expansion:
|
||||
if (TechLooksEconomic(tech)) score += 90f;
|
||||
if (TechLooksMobility(tech)) score += 100f;
|
||||
break;
|
||||
default:
|
||||
if (TechLooksEconomic(tech)) score += 120f;
|
||||
if (tech == TechType.Philosophy || tech == TechType.Diplomacy) score += 70f;
|
||||
break;
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
private float ScoreCultureCard(AIDirectorContext ctx, CultureCardType card)
|
||||
{
|
||||
var score = 300f;
|
||||
if (ctx.Cache.StrategicPosture == AIDirectorStrategicPosture.Defense && card == CultureCardType.AdvancedMilitaryEnhance) score += 120f;
|
||||
if (ctx.Cache.SelfHeroes.Count >= 1 && card is CultureCardType.SecondHero or CultureCardType.ThirdHero or CultureCardType.AdvancedHeroEnhance) score += 100f;
|
||||
if (ctx.Cache.StrategicPosture == AIDirectorStrategicPosture.Development && card == CultureCardType.AdvancedEconomyEnhance) score += 80f;
|
||||
return score;
|
||||
}
|
||||
|
||||
private float ScorePlayerAction(AIDirectorContext ctx, AIActionBase action)
|
||||
{
|
||||
var id = action.ActionLogic.ActionId.PlayerActionType;
|
||||
if (id is PlayerActionType.SelectTreasureOptionA or PlayerActionType.SelectTreasureOptionB or PlayerActionType.TreasureGainCoin or PlayerActionType.TreasureGainUnit or PlayerActionType.TreasureGainTech or PlayerActionType.TreasureGainCulture or PlayerActionType.TreasureGainCityExp)
|
||||
return 520f;
|
||||
if (AIDirectorActionIndex.IsDirectorExcludedPlayerAction(id))
|
||||
return 0f;
|
||||
|
||||
var target = action.Param.TargetPlayerData;
|
||||
var feeling = target != null && ctx.Player.GetCountryDiplomacyInfo(target.Id, out var info) && info != null ? info.FeelingValue : 50f;
|
||||
if (id == PlayerActionType.Embassy) return feeling >= 70f ? 360f : 0f;
|
||||
if (id == PlayerActionType.OfferAlly) return feeling >= 90f && !HasDirectExpansionConflict(ctx, target) ? 340f : 0f;
|
||||
if (id == PlayerActionType.BreakAlly) return feeling <= 35f && target != null && !IsTeammate(ctx, target) ? 220f : 0f;
|
||||
return 180f;
|
||||
}
|
||||
|
||||
private AIDirectorActionCandidate MaxCandidate(AIDirectorActionCandidate a, AIDirectorActionCandidate b)
|
||||
{
|
||||
if (a == null || !a.IsValid) return b ?? AIDirectorActionCandidate.None;
|
||||
if (b == null || !b.IsValid) return a;
|
||||
if (b.Priority > a.Priority) return b;
|
||||
if (Mathf.Approximately(b.Priority, a.Priority)
|
||||
&& string.Compare(AIDirectorActionIndex.StableActionKey(b.AIAction), AIDirectorActionIndex.StableActionKey(a.AIAction), System.StringComparison.Ordinal) < 0)
|
||||
return b;
|
||||
return a;
|
||||
}
|
||||
|
||||
private static void RecordCandidate(
|
||||
AIDirectorContext ctx,
|
||||
AIDirectorDecision decision,
|
||||
string source,
|
||||
AIDirectorActionCandidate candidate,
|
||||
string rejectReason = null)
|
||||
{
|
||||
if (decision == null || candidate == null) return;
|
||||
var max = ctx?.Config?.MaxDiagnosticCandidatePerLane ?? 8;
|
||||
decision.RecordCandidate(candidate.Lane, source, candidate, max, rejectReason);
|
||||
}
|
||||
|
||||
private static void AddTerms(AIDirectorActionCandidate candidate, params (string name, float value)[] terms)
|
||||
{
|
||||
if (candidate == null || terms == null) return;
|
||||
foreach (var term in terms)
|
||||
{
|
||||
if (string.IsNullOrEmpty(term.name)) continue;
|
||||
candidate.ScoreTerms.Add(new AIDirectorScoreTerm(term.name, term.value));
|
||||
}
|
||||
}
|
||||
|
||||
private UnitData ChooseBestThreatTarget(AIDirectorContext ctx, AIDirectorCityThreat threat)
|
||||
{
|
||||
UnitData best = null;
|
||||
var bestScore = float.MinValue;
|
||||
foreach (var enemy in threat.EnemyUnits)
|
||||
{
|
||||
var score = UnitTargetValue(ctx, enemy);
|
||||
if (score <= bestScore) continue;
|
||||
bestScore = score;
|
||||
best = enemy;
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
private float UnitTargetValue(AIDirectorContext ctx, UnitData target)
|
||||
{
|
||||
if (target == null) return 0f;
|
||||
var score = AIDirectorMath.UnitPower(target) + (1f - AIDirectorMath.HealthRatio(target)) * 80f;
|
||||
if (target.TreatedAsHero(ctx.Map, target)) score += 160f;
|
||||
return score;
|
||||
}
|
||||
|
||||
private AIDirectorCityPlan FindCityPlan(AIDirectorContext ctx, CityData city)
|
||||
{
|
||||
if (city == null) return null;
|
||||
foreach (var plan in ctx.Cache.CityPlans)
|
||||
{
|
||||
if (plan.City?.Id == city.Id) return plan;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private AIDirectorCityPlan FindCityPlanByAction(AIDirectorContext ctx, AIActionBase action)
|
||||
{
|
||||
return FindCityPlan(ctx, action?.Param?.CityData);
|
||||
}
|
||||
|
||||
private GridData ResolveFrontTarget(AIDirectorFront front)
|
||||
{
|
||||
if (front == null) return null;
|
||||
return front.FrontType == AIDirectorFrontType.Defense ? front.AnchorGrid : front.TargetGrid ?? front.AnchorGrid;
|
||||
}
|
||||
|
||||
private bool ShouldSkipFrontMove(AIDirectorContext ctx, UnitData unit, AIDirectorFront front)
|
||||
{
|
||||
if (unit == null || unit.GetActionPoint(ActionPointType.Move) <= 0) return true;
|
||||
if (unit.TreatedAsHero(ctx.Map, unit) && front.FrontType != AIDirectorFrontType.Defense) return true;
|
||||
if (AIDirectorMath.HealthRatio(unit) <= ctx.Config.LowHealthRatio && front.FrontType == AIDirectorFrontType.Attack) return true;
|
||||
if (IsStandingOnCriticalCityCenter(ctx, unit)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
private bool IsStandingOnCriticalCityCenter(AIDirectorContext ctx, UnitData unit)
|
||||
{
|
||||
if (unit == null || !ctx.Map.GetGridDataByUnitId(unit.Id, out var grid)) return false;
|
||||
foreach (var threat in ctx.Cache.CityThreats)
|
||||
{
|
||||
if (!threat.IsCritical) continue;
|
||||
if (threat.CityGrid?.Id == grid.Id) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private bool IsThreateningAnyCity(AIDirectorContext ctx, UnitData target)
|
||||
{
|
||||
if (target == null) return false;
|
||||
foreach (var threat in ctx.Cache.CityThreats)
|
||||
{
|
||||
foreach (var enemy in threat.EnemyUnits)
|
||||
{
|
||||
if (enemy.Id == target.Id) return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private bool TargetOwnerFeelingHighAndNotWar(AIDirectorContext ctx, UnitData target)
|
||||
{
|
||||
if (target == null || !ctx.Map.GetPlayerDataByUnitId(target.Id, out var owner) || owner == null) return false;
|
||||
if (!ctx.Player.GetCountryDiplomacyInfo(owner.Id, out var info) || info == null) return false;
|
||||
return info.FeelingValue >= 80f && info.DiplomacyState != DiplomacyState.War;
|
||||
}
|
||||
|
||||
private float CounterBonus(UnitData attacker, UnitData target)
|
||||
{
|
||||
if (attacker == null || target == null) return 0f;
|
||||
return attacker.GetMilitary() - target.GetMilitary() > 0 ? 40f : 0f;
|
||||
}
|
||||
|
||||
private float CounterThreat(AIDirectorContext ctx, UnitData attacker, UnitData target)
|
||||
{
|
||||
if (attacker == null || target == null) return 0f;
|
||||
if (!ctx.Map.GetGridDataByUnitId(attacker.Id, out var attackerGrid)) return 0f;
|
||||
if (!ctx.Map.GetGridDataByUnitId(target.Id, out var targetGrid)) return 0f;
|
||||
var distance = AIDirectorMath.Distance(ctx.Map, attackerGrid, targetGrid);
|
||||
if (distance > target.GetAttackRange(ctx.Map)) return 0f;
|
||||
return AIDirectorMath.UnitPower(target) * 0.25f;
|
||||
}
|
||||
|
||||
private GridData FindSafestSelfCityGrid(AIDirectorContext ctx, UnitData unit)
|
||||
{
|
||||
GridData best = null;
|
||||
var bestScore = float.MinValue;
|
||||
var unitGrid = unit?.Grid(ctx.Map);
|
||||
foreach (var city in ctx.Cache.SelfCities)
|
||||
{
|
||||
if (!ctx.Map.GetGridDataByCityId(city.Id, out var cityGrid)) continue;
|
||||
var score = -AIDirectorMath.Distance(ctx.Map, unitGrid, cityGrid) * 10f - GridThreat(ctx, cityGrid);
|
||||
if (city.IsCapital) score += 20f;
|
||||
if (score <= bestScore) continue;
|
||||
bestScore = score;
|
||||
best = cityGrid;
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
private bool CanNextTurnPressureTarget(AIDirectorContext ctx, UnitData unit, GridData endGrid, GridData target)
|
||||
{
|
||||
if (unit == null || endGrid == null || target == null) return false;
|
||||
return AIDirectorMath.Distance(ctx.Map, endGrid, target) <= unit.GetAttackRange(ctx.Map) + unit.GetActionPoint(ActionPointType.Move);
|
||||
}
|
||||
|
||||
private float GridThreat(AIDirectorContext ctx, GridData grid)
|
||||
{
|
||||
if (grid == null) return 0f;
|
||||
var threat = 0f;
|
||||
foreach (var enemy in ctx.Cache.EnemyUnits)
|
||||
{
|
||||
if (!ctx.Map.GetGridDataByUnitId(enemy.Id, out var enemyGrid)) continue;
|
||||
if (AIDirectorMath.Distance(ctx.Map, enemyGrid, grid) <= enemy.GetAttackRange(ctx.Map) + enemy.GetActionPoint(ActionPointType.Move))
|
||||
threat += AIDirectorMath.UnitPower(enemy);
|
||||
}
|
||||
|
||||
return threat;
|
||||
}
|
||||
|
||||
private float TrainUnitDefenseValue(AIDirectorContext ctx, AIActionBase action, AIDirectorCityThreat threat)
|
||||
{
|
||||
var id = action.ActionLogic.ActionId;
|
||||
var score = UnitBaseMilitaryValue(id.UnitType);
|
||||
if (threat != null)
|
||||
{
|
||||
foreach (var enemy in threat.EnemyUnits)
|
||||
{
|
||||
score += id.UnitType == UnitType.Defender ? 45f : 20f;
|
||||
if (enemy.GetAttackRange(ctx.Map) > 1 && id.UnitType == UnitType.Rider) score += 30f;
|
||||
}
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
private float TrainUnitWarValue(AIDirectorContext ctx, AIActionBase action)
|
||||
{
|
||||
var id = action.ActionLogic.ActionId;
|
||||
var score = UnitBaseMilitaryValue(id.UnitType);
|
||||
if (ctx.Cache.StrategicPosture == AIDirectorStrategicPosture.Attack) score += 60f;
|
||||
if (id.UnitType is UnitType.Rider or UnitType.Knights or UnitType.Catapult) score += 40f;
|
||||
return score;
|
||||
}
|
||||
|
||||
private float UnitBaseMilitaryValue(UnitType unitType)
|
||||
{
|
||||
return unitType switch
|
||||
{
|
||||
UnitType.Defender => 90f,
|
||||
UnitType.Warrior => 70f,
|
||||
UnitType.Archer => 75f,
|
||||
UnitType.Rider or UnitType.Knights => 85f,
|
||||
UnitType.Catapult => 100f,
|
||||
UnitType.Giant or UnitType.GiantJuggernaut => 140f,
|
||||
_ => 50f
|
||||
};
|
||||
}
|
||||
|
||||
private bool NeedStandingArmy(AIDirectorContext ctx, CityData city)
|
||||
{
|
||||
if (city == null || !ctx.Map.GetGridDataByCityId(city.Id, out var cityGrid)) return true;
|
||||
var defenders = 0;
|
||||
foreach (var unit in ctx.Cache.SelfUnits)
|
||||
{
|
||||
if (!ctx.Map.GetGridDataByUnitId(unit.Id, out var unitGrid)) continue;
|
||||
if (AIDirectorMath.Distance(ctx.Map, cityGrid, unitGrid) <= ctx.Config.EmergencyEnemySearchRange) defenders++;
|
||||
}
|
||||
|
||||
return defenders < 2;
|
||||
}
|
||||
|
||||
private float WonderValue(AIActionBase action)
|
||||
{
|
||||
return action?.ActionLogic?.ActionId?.WonderType == WonderTypeEnum.None ? 0f : 120f;
|
||||
}
|
||||
|
||||
private bool GridIsNearDangerCity(AIDirectorContext ctx, GridData grid)
|
||||
{
|
||||
foreach (var threat in ctx.Cache.CityThreats)
|
||||
{
|
||||
if (!threat.IsCritical || threat.CityGrid == null) continue;
|
||||
if (AIDirectorMath.Distance(ctx.Map, grid, threat.CityGrid) <= ctx.Config.EmergencyEnemySearchRange) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private float BuildResourceValue(GridData grid)
|
||||
{
|
||||
return ResourceValue(grid) + (grid?.HasBuilding() == true ? 0f : 50f);
|
||||
}
|
||||
|
||||
private float ResourceValue(GridData grid)
|
||||
{
|
||||
if (grid == null || grid.Resource == ResourceType.None) return 0f;
|
||||
return grid.Resource switch
|
||||
{
|
||||
ResourceType.Metal => 130f,
|
||||
ResourceType.Fruit => 100f,
|
||||
ResourceType.Crop => 90f,
|
||||
ResourceType.Animal => 80f,
|
||||
ResourceType.Fish => 80f,
|
||||
ResourceType.Starfish => 120f,
|
||||
ResourceType.Treasure => 180f,
|
||||
_ => 60f
|
||||
};
|
||||
}
|
||||
|
||||
private float GridMiscValue(CommonActionId id)
|
||||
{
|
||||
return id.GridMiscActionType switch
|
||||
{
|
||||
GridMiscActionType.GrowForest => 70f,
|
||||
GridMiscActionType.UpgradeTemple => 110f,
|
||||
GridMiscActionType.CreateMountain => 80f,
|
||||
GridMiscActionType.SellMetal => 60f,
|
||||
_ => 20f
|
||||
};
|
||||
}
|
||||
|
||||
private AIDirectorDevelopmentTarget FindDevelopmentTarget(AIDirectorContext ctx, GridData grid)
|
||||
{
|
||||
if (grid == null) return null;
|
||||
foreach (var target in ctx.Cache.DevelopmentTargets)
|
||||
{
|
||||
if (target.Grid?.Id == grid.Id) return target;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private bool HasDirectExpansionConflict(AIDirectorContext ctx, PlayerData target)
|
||||
{
|
||||
if (target == null) return false;
|
||||
foreach (var city in ctx.Cache.EnemyCities)
|
||||
{
|
||||
if (!ctx.Map.GetPlayerDataByCityId(city.Id, out var owner) || owner.Id != target.Id) continue;
|
||||
if (!ctx.Map.GetGridDataByCityId(city.Id, out var grid)) continue;
|
||||
foreach (var selfCity in ctx.Cache.SelfCities)
|
||||
{
|
||||
if (!ctx.Map.GetGridDataByCityId(selfCity.Id, out var selfGrid)) continue;
|
||||
if (AIDirectorMath.Distance(ctx.Map, grid, selfGrid) <= ctx.Config.FrontSearchRange) return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private bool IsTeammate(AIDirectorContext ctx, PlayerData target)
|
||||
{
|
||||
if (target == null) return false;
|
||||
if (ctx.Player.GetCountryDiplomacyInfo(target.Id, out var selfToTarget) && selfToTarget != null && selfToTarget.IsTeammate) return true;
|
||||
return target.GetCountryDiplomacyInfo(ctx.Player.Id, out var targetToSelf) && targetToSelf != null && targetToSelf.IsTeammate;
|
||||
}
|
||||
|
||||
private bool TechLooksDefensive(TechType tech)
|
||||
{
|
||||
return tech is TechType.Smithery or TechType.Strategy or TechType.Construction or TechType.Meditation or TechType.KanakoSmithery or TechType.HakureiStrategy;
|
||||
}
|
||||
|
||||
private bool TechLooksMilitary(TechType tech)
|
||||
{
|
||||
return tech is TechType.Archery or TechType.Riding or TechType.Chivalry or TechType.Ramming or TechType.Hunting
|
||||
or TechType.KaguyaArcher or TechType.KaguyaHunting or TechType.KanakoRiding or TechType.KanakoChivalry
|
||||
or TechType.KomeijiIndianArchery or TechType.KomeijiIndianRiding or TechType.KomeijiIndianChivalry
|
||||
or TechType.HakureiRamming or TechType.HakureiSmithery;
|
||||
}
|
||||
|
||||
private bool TechLooksMobility(TechType tech)
|
||||
{
|
||||
return tech is TechType.Climbing or TechType.Roads or TechType.Sailing or TechType.Navigation or TechType.FreeSpirit
|
||||
or TechType.KaguyaRoad or TechType.KanakoClimbing or TechType.KanakoRoads or TechType.KanakoNavigation
|
||||
or TechType.KomeijiIndianSailing or TechType.KomeijiIndianNavigation or TechType.HakureiFishing;
|
||||
}
|
||||
|
||||
private bool TechLooksEconomic(TechType tech)
|
||||
{
|
||||
return tech is TechType.Mining or TechType.Farming or TechType.Forestry or TechType.Trade or TechType.Fishing or TechType.Philosophy
|
||||
or TechType.KaguyaForestry or TechType.KaguyaTrade or TechType.KaguyaConstruction
|
||||
or TechType.RemiliaFarming or TechType.KanakoMining or TechType.KanakoTrade or TechType.HakureiTrade;
|
||||
}
|
||||
}
|
||||
|
||||
public static class AIDirectorIntegrationPort
|
||||
{
|
||||
private static readonly AIDirectorLogic Logic = new();
|
||||
|
||||
public static AIDirectorDecision PreviewNextAction(MapData map, PlayerData player, AIDirectorConfig config = null)
|
||||
{
|
||||
return Logic.Decide(map, player, config);
|
||||
}
|
||||
|
||||
public static bool TryGetNextAction(MapData map, PlayerData player, out AIActionBase action, AIDirectorConfig config = null)
|
||||
{
|
||||
action = null;
|
||||
var decision = Logic.Decide(map, player, config);
|
||||
if (!decision.HasAction) return false;
|
||||
action = decision.Candidate.AIAction;
|
||||
return action != null;
|
||||
}
|
||||
|
||||
public static bool TryGetNextCandidate(MapData map, PlayerData player, out AIDirectorActionCandidate candidate, AIDirectorConfig config = null)
|
||||
{
|
||||
return Logic.TryDecide(map, player, out candidate, config);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8d5fa376f9ee4aa588d02f959ba8a99e
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
670
Unity/Assets/Scripts/TH1_Logic/AI/Director/AIDirectorTypes.cs
Normal file
670
Unity/Assets/Scripts/TH1_Logic/AI/Director/AIDirectorTypes.cs
Normal file
@ -0,0 +1,670 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Logic.Action;
|
||||
using RuntimeData;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Logic.AI.Director
|
||||
{
|
||||
public enum AIDirectorLane
|
||||
{
|
||||
None,
|
||||
Emergency,
|
||||
HeroManagement,
|
||||
HeroPlaybook,
|
||||
Tactic,
|
||||
UnitOpportunity,
|
||||
Front,
|
||||
Growth,
|
||||
Fallback
|
||||
}
|
||||
|
||||
public enum AIDirectorFrontType
|
||||
{
|
||||
None,
|
||||
Defense,
|
||||
Attack,
|
||||
Development,
|
||||
Hold
|
||||
}
|
||||
|
||||
public enum AIDirectorStrategicPosture
|
||||
{
|
||||
Development,
|
||||
Defense,
|
||||
Expansion,
|
||||
Attack
|
||||
}
|
||||
|
||||
public enum AIDirectorCityPlanKind
|
||||
{
|
||||
BacklineGrowth,
|
||||
EmergencyDefense,
|
||||
Mobilize,
|
||||
Frontline,
|
||||
Wonder
|
||||
}
|
||||
|
||||
public enum AIDirectorDevelopmentTargetType
|
||||
{
|
||||
None,
|
||||
Village,
|
||||
EnemyEmptyCity,
|
||||
Treasure,
|
||||
Resource,
|
||||
FogBoundary
|
||||
}
|
||||
|
||||
public enum AIDirectorUnitOpportunityType
|
||||
{
|
||||
None,
|
||||
Capture,
|
||||
Examine,
|
||||
Gather,
|
||||
HeroUpgrade,
|
||||
Upgrade,
|
||||
CultureUnitUpgrade,
|
||||
Recover
|
||||
}
|
||||
|
||||
public enum AIDirectorHeroRole
|
||||
{
|
||||
None,
|
||||
Vanguard,
|
||||
Defender,
|
||||
Support,
|
||||
Caster,
|
||||
Mobility,
|
||||
Control,
|
||||
Economy,
|
||||
Summoner,
|
||||
Assassin
|
||||
}
|
||||
|
||||
public enum AIDirectorHeroContext
|
||||
{
|
||||
None,
|
||||
Siege,
|
||||
Defense,
|
||||
FieldBattle,
|
||||
Recovery,
|
||||
Economy,
|
||||
Mobility,
|
||||
Control
|
||||
}
|
||||
|
||||
public enum AIDirectorHeroActionKind
|
||||
{
|
||||
None,
|
||||
UnitAction,
|
||||
AttackEnemy,
|
||||
AttackAlly,
|
||||
AttackGround,
|
||||
MoveTowardFront,
|
||||
MoveAwayFromThreat,
|
||||
Hold
|
||||
}
|
||||
|
||||
public enum AIDirectorHeroTargetPolicy
|
||||
{
|
||||
None,
|
||||
Self,
|
||||
BestEnemy,
|
||||
KillableEnemy,
|
||||
BestEnemyHero,
|
||||
BestAlly,
|
||||
InjuredAlly,
|
||||
AdjacentAlly,
|
||||
FrontEnemyCity,
|
||||
ThreateningEnemy,
|
||||
BestGroundTarget,
|
||||
BonePile,
|
||||
CityCenterEnemy,
|
||||
SafeForwardGrid,
|
||||
RetreatGrid
|
||||
}
|
||||
|
||||
public sealed class AIDirectorConfig
|
||||
{
|
||||
public int EmergencyEnemySearchRange = 3;
|
||||
public int FrontSearchRange = 6;
|
||||
public int LocalBattleRange = 2;
|
||||
public int DevelopmentSearchRange = 6;
|
||||
public int MaxGeneratedActions = 4096;
|
||||
public int MaxFrontCount = 12;
|
||||
public int MaxDevelopmentTargetCount = 20;
|
||||
public float LowHealthRatio = 0.45f;
|
||||
public float CriticalHealthRatio = 0.25f;
|
||||
public float HeroLowHealthRatio = 0.6f;
|
||||
public int CityDangerEnemyCount = 2;
|
||||
public int CityCriticalDangerEnemyCount = 4;
|
||||
public float CityThreatPowerRatio = 1.25f;
|
||||
public int HeroTaskStartTurn = 8;
|
||||
public int HeroTaskInterval = 4;
|
||||
public int HeroLevel2MinTurn = 15;
|
||||
public int HeroLevel3MinTurn = 25;
|
||||
public int MaxCandidateTraceCount = 16;
|
||||
public int MaxDiagnosticCandidatePerLane = 8;
|
||||
public bool PreferHeroRules = true;
|
||||
public bool AllowExecuteFromFacade = false;
|
||||
public readonly List<AIDirectorHeroRule> HeroRules = new();
|
||||
|
||||
public static AIDirectorConfig CreateDefault()
|
||||
{
|
||||
var config = new AIDirectorConfig();
|
||||
AIDirectorHeroRuleCatalog.FillDefault(config.HeroRules);
|
||||
return config;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class AIDirectorContext
|
||||
{
|
||||
public readonly MapData Map;
|
||||
public readonly PlayerData Player;
|
||||
public readonly AIDirectorConfig Config;
|
||||
public AIDirectorWorldCache Cache;
|
||||
public AIDirectorActionIndex ActionIndex;
|
||||
|
||||
public AIDirectorContext(MapData map, PlayerData player, AIDirectorConfig config)
|
||||
{
|
||||
Map = map;
|
||||
Player = player;
|
||||
Config = config ?? AIDirectorConfig.CreateDefault();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class AIDirectorWorldCache
|
||||
{
|
||||
public readonly List<UnitData> SelfUnits = new();
|
||||
public readonly List<UnitData> SelfHeroes = new();
|
||||
public readonly List<UnitData> EnemyUnits = new();
|
||||
public readonly List<UnitData> EnemyHeroes = new();
|
||||
public readonly List<CityData> SelfCities = new();
|
||||
public readonly List<CityData> EnemyCities = new();
|
||||
public readonly List<PlayerData> EnemyPlayers = new();
|
||||
public readonly List<PlayerData> AlliedPlayers = new();
|
||||
public readonly List<PlayerData> WarTargetPlayers = new();
|
||||
public readonly List<PlayerData> HighTrustPlayers = new();
|
||||
public readonly List<PlayerData> LowTrustPlayers = new();
|
||||
public readonly List<AIDirectorCityThreat> CityThreats = new();
|
||||
public readonly List<AIDirectorCityPlan> CityPlans = new();
|
||||
public readonly List<AIDirectorDevelopmentTarget> DevelopmentTargets = new();
|
||||
public readonly List<AIDirectorFront> Fronts = new();
|
||||
public readonly List<AIDirectorHeroState> HeroStates = new();
|
||||
public readonly List<AIDirectorLocalBattle> LocalBattles = new();
|
||||
public readonly List<AIDirectorUnitOpportunity> UnitOpportunities = new();
|
||||
public readonly HashSet<uint> SelfTerritoryGridIds = new();
|
||||
public AIDirectorFront PrimaryFront;
|
||||
public AIDirectorStrategicPosture StrategicPosture;
|
||||
public bool HasCriticalCityThreat;
|
||||
public bool HasAnyEnemyContact;
|
||||
public float SelfMilitary;
|
||||
public float EnemyMilitary;
|
||||
}
|
||||
|
||||
public sealed class AIDirectorCityThreat
|
||||
{
|
||||
public CityData City;
|
||||
public GridData CityGrid;
|
||||
public readonly List<UnitData> EnemyUnits = new();
|
||||
public readonly List<UnitData> Defenders = new();
|
||||
public float EnemyPower;
|
||||
public float DefenderPower;
|
||||
public float RescuePower;
|
||||
public int NearestEnemyDistance = int.MaxValue;
|
||||
public bool IsCritical;
|
||||
public bool IsCapital;
|
||||
public bool HasWall;
|
||||
public bool HasEnemyOnTerritory;
|
||||
public bool CanBeThreatenedNextTurn;
|
||||
public float DangerScore;
|
||||
}
|
||||
|
||||
public sealed class AIDirectorCityPlan
|
||||
{
|
||||
public CityData City;
|
||||
public GridData CityGrid;
|
||||
public AIDirectorCityThreat Threat;
|
||||
public AIDirectorCityPlanKind Kind;
|
||||
public bool NeedWall;
|
||||
public bool NeedMilitary;
|
||||
public bool NeedGrowth;
|
||||
public bool NeedWonder;
|
||||
public float Priority;
|
||||
}
|
||||
|
||||
public sealed class AIDirectorDevelopmentTarget
|
||||
{
|
||||
public GridData Grid;
|
||||
public AIDirectorDevelopmentTargetType TargetType;
|
||||
public CityData NearestSelfCity;
|
||||
public int Distance = int.MaxValue;
|
||||
public float Value;
|
||||
}
|
||||
|
||||
public sealed class AIDirectorFront
|
||||
{
|
||||
public AIDirectorFrontType FrontType;
|
||||
public CityData SelfCity;
|
||||
public CityData TargetCity;
|
||||
public GridData AnchorGrid;
|
||||
public GridData TargetGrid;
|
||||
public float Pressure;
|
||||
public float Opportunity;
|
||||
public int Distance = int.MaxValue;
|
||||
public readonly List<UnitData> SelfUnits = new();
|
||||
public readonly List<UnitData> EnemyUnits = new();
|
||||
}
|
||||
|
||||
public sealed class AIDirectorHeroState
|
||||
{
|
||||
public UnitData Hero;
|
||||
public GiantType GiantType;
|
||||
public uint Level;
|
||||
public AIDirectorHeroRole Role;
|
||||
public AIDirectorHeroContext Context;
|
||||
public AIDirectorFront Front;
|
||||
public float HealthRatio;
|
||||
public bool IsThreatened;
|
||||
public bool IsOnFront;
|
||||
}
|
||||
|
||||
public sealed class AIDirectorLocalBattle
|
||||
{
|
||||
public UnitData SelfUnit;
|
||||
public UnitData EnemyUnit;
|
||||
public GridData SelfGrid;
|
||||
public GridData EnemyGrid;
|
||||
public int Distance;
|
||||
public float Value;
|
||||
}
|
||||
|
||||
public sealed class AIDirectorUnitOpportunity
|
||||
{
|
||||
public UnitData Unit;
|
||||
public AIActionBase Action;
|
||||
public AIDirectorUnitOpportunityType OpportunityType;
|
||||
public float Value;
|
||||
}
|
||||
|
||||
public sealed class AIDirectorHeroRule
|
||||
{
|
||||
public string RuleId;
|
||||
public GiantType GiantType;
|
||||
public UnitType UnitType;
|
||||
public uint MinLevel;
|
||||
public AIDirectorHeroRole Role;
|
||||
public AIDirectorHeroContext Context;
|
||||
public AIDirectorHeroActionKind ActionKind;
|
||||
public UnitActionType UnitActionType;
|
||||
public AIDirectorHeroTargetPolicy TargetPolicy;
|
||||
public int Priority;
|
||||
public string RequiredSkill;
|
||||
public string AvoidTargetSkill;
|
||||
public string Reason;
|
||||
}
|
||||
|
||||
public sealed class AIDirectorActionCandidate
|
||||
{
|
||||
public static readonly AIDirectorActionCandidate None = new();
|
||||
|
||||
public AIActionBase AIAction;
|
||||
public CommonActionParams Param;
|
||||
public ActionLogicBase ActionLogic;
|
||||
public CommonActionId ActionId;
|
||||
public AIDirectorLane Lane;
|
||||
public UnitData Unit;
|
||||
public UnitData TargetUnit;
|
||||
public CityData City;
|
||||
public GridData Grid;
|
||||
public GridData TargetGrid;
|
||||
public string Reason;
|
||||
public float Priority;
|
||||
public bool IsFallback;
|
||||
public readonly List<AIDirectorScoreTerm> ScoreTerms = new();
|
||||
|
||||
public bool IsValid => Param != null && ActionLogic != null && ActionId != null;
|
||||
|
||||
public bool CheckCan()
|
||||
{
|
||||
if (!IsValid) return false;
|
||||
Param.RefreshParams();
|
||||
return ActionLogic.CheckCan(Param);
|
||||
}
|
||||
|
||||
private AIDirectorActionCandidate()
|
||||
{
|
||||
}
|
||||
|
||||
public static AIDirectorActionCandidate Invalid(
|
||||
AIDirectorLane lane,
|
||||
string reason,
|
||||
float priority,
|
||||
bool fallback = false)
|
||||
{
|
||||
return new AIDirectorActionCandidate
|
||||
{
|
||||
Lane = lane,
|
||||
Reason = reason,
|
||||
Priority = priority,
|
||||
IsFallback = fallback
|
||||
};
|
||||
}
|
||||
|
||||
public AIDirectorActionCandidate(
|
||||
AIActionBase action,
|
||||
AIDirectorLane lane,
|
||||
string reason,
|
||||
float priority,
|
||||
bool fallback = false)
|
||||
{
|
||||
AIAction = action;
|
||||
Param = action?.Param;
|
||||
ActionLogic = action?.ActionLogic;
|
||||
ActionId = ActionLogic?.ActionId;
|
||||
Lane = lane;
|
||||
Reason = reason;
|
||||
Priority = priority;
|
||||
IsFallback = fallback;
|
||||
Unit = Param?.UnitData;
|
||||
TargetUnit = Param?.TargetUnitData;
|
||||
City = Param?.CityData;
|
||||
Grid = Param?.GridData;
|
||||
TargetGrid = Param?.TargetGridData;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class AIDirectorDecision
|
||||
{
|
||||
public bool HasAction => Candidate != null && Candidate.IsValid;
|
||||
public AIDirectorActionCandidate Candidate;
|
||||
public AIDirectorWorldCache Cache;
|
||||
public AIDirectorActionIndex ActionIndex;
|
||||
public double DecideMs;
|
||||
public readonly List<string> Trace = new();
|
||||
public readonly List<AIDirectorLaneDiagnostic> LaneDiagnostics = new();
|
||||
|
||||
public void AddTrace(string message, int maxCount)
|
||||
{
|
||||
if (string.IsNullOrEmpty(message)) return;
|
||||
if (maxCount > 0 && Trace.Count >= maxCount) return;
|
||||
Trace.Add(message);
|
||||
}
|
||||
|
||||
public void RecordCandidate(
|
||||
AIDirectorLane lane,
|
||||
string source,
|
||||
AIDirectorActionCandidate candidate,
|
||||
int maxPerLane,
|
||||
string rejectReason = null)
|
||||
{
|
||||
var diagnostic = GetLaneDiagnostic(lane);
|
||||
diagnostic.AddCandidate(source, candidate, maxPerLane, rejectReason);
|
||||
}
|
||||
|
||||
public void RecordLaneResult(AIDirectorLane lane, bool selected, string note)
|
||||
{
|
||||
var diagnostic = GetLaneDiagnostic(lane);
|
||||
diagnostic.selected = selected;
|
||||
diagnostic.note = note ?? string.Empty;
|
||||
}
|
||||
|
||||
private AIDirectorLaneDiagnostic GetLaneDiagnostic(AIDirectorLane lane)
|
||||
{
|
||||
foreach (var diagnostic in LaneDiagnostics)
|
||||
{
|
||||
if (diagnostic.lane == lane) return diagnostic;
|
||||
}
|
||||
|
||||
var created = new AIDirectorLaneDiagnostic { lane = lane };
|
||||
LaneDiagnostics.Add(created);
|
||||
return created;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class AIDirectorLaneDiagnostic
|
||||
{
|
||||
public AIDirectorLane lane;
|
||||
public bool selected;
|
||||
public string note;
|
||||
public readonly List<AIDirectorCandidateDiagnostic> candidates = new();
|
||||
|
||||
public void AddCandidate(
|
||||
string source,
|
||||
AIDirectorActionCandidate candidate,
|
||||
int maxPerLane,
|
||||
string rejectReason = null)
|
||||
{
|
||||
if (candidate == null) return;
|
||||
var entry = new AIDirectorCandidateDiagnostic
|
||||
{
|
||||
source = source ?? string.Empty,
|
||||
candidate = candidate,
|
||||
priority = candidate.Priority,
|
||||
isValid = candidate.IsValid,
|
||||
rejectReason = rejectReason ?? (candidate.IsValid ? string.Empty : "InvalidCandidate")
|
||||
};
|
||||
|
||||
if (maxPerLane <= 0)
|
||||
{
|
||||
candidates.Add(entry);
|
||||
return;
|
||||
}
|
||||
|
||||
var insertIndex = candidates.Count;
|
||||
for (var i = 0; i < candidates.Count; i++)
|
||||
{
|
||||
if (Compare(entry, candidates[i]) < 0)
|
||||
{
|
||||
insertIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (insertIndex < candidates.Count) candidates.Insert(insertIndex, entry);
|
||||
else candidates.Add(entry);
|
||||
|
||||
while (candidates.Count > maxPerLane) candidates.RemoveAt(candidates.Count - 1);
|
||||
}
|
||||
|
||||
private static int Compare(AIDirectorCandidateDiagnostic a, AIDirectorCandidateDiagnostic b)
|
||||
{
|
||||
if (a.isValid != b.isValid) return a.isValid ? -1 : 1;
|
||||
var scoreCompare = b.priority.CompareTo(a.priority);
|
||||
if (scoreCompare != 0) return scoreCompare;
|
||||
return string.Compare(
|
||||
AIDirectorActionIndex.StableActionKey(a.candidate?.AIAction),
|
||||
AIDirectorActionIndex.StableActionKey(b.candidate?.AIAction),
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class AIDirectorCandidateDiagnostic
|
||||
{
|
||||
public string source;
|
||||
public AIDirectorActionCandidate candidate;
|
||||
public float priority;
|
||||
public bool isValid;
|
||||
public string rejectReason;
|
||||
}
|
||||
|
||||
public sealed class AIDirectorScoreTerm
|
||||
{
|
||||
public string Name;
|
||||
public float Value;
|
||||
|
||||
public AIDirectorScoreTerm(string name, float value)
|
||||
{
|
||||
Name = name;
|
||||
Value = value;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class AIDirectorOutcomeProbe
|
||||
{
|
||||
public int NetActionCount;
|
||||
public uint PlayerId;
|
||||
public uint PlayerTurn;
|
||||
public int PlayerCoin;
|
||||
public int PlayerTechPoint;
|
||||
public int PlayerCulture;
|
||||
public int CultureCardCount;
|
||||
public int PlayerScore;
|
||||
public int CityCount;
|
||||
public int UnitCount;
|
||||
public int HeroCount;
|
||||
public float SelfMilitary;
|
||||
public float EnemyMilitary;
|
||||
public int CriticalCityThreatCount;
|
||||
public int CityThreatCount;
|
||||
public float MaxCityDangerScore;
|
||||
public string StrategicPosture;
|
||||
public uint UnitId;
|
||||
public bool UnitAlive;
|
||||
public int UnitHealth;
|
||||
public uint UnitGridId;
|
||||
public uint TargetUnitId;
|
||||
public bool TargetUnitAlive;
|
||||
public int TargetUnitHealth;
|
||||
public uint TargetUnitGridId;
|
||||
public uint CityId;
|
||||
public uint CityOwnerId;
|
||||
public int CityLevel;
|
||||
public uint TargetCityId;
|
||||
public uint TargetCityOwnerId;
|
||||
public int TargetCityLevel;
|
||||
}
|
||||
|
||||
internal static class AIDirectorMath
|
||||
{
|
||||
public static float UnitPower(UnitData unit)
|
||||
{
|
||||
if (unit == null || !unit.IsAlive()) return 0f;
|
||||
return Mathf.Max(1f, unit.GetMilitary());
|
||||
}
|
||||
|
||||
public static float HealthRatio(UnitData unit)
|
||||
{
|
||||
if (unit == null || unit.GetMaxHealth() <= 0) return 0f;
|
||||
return Mathf.Clamp01(unit.GetHealthRatio());
|
||||
}
|
||||
|
||||
public static int Distance(MapData map, GridData a, GridData b)
|
||||
{
|
||||
if (map?.GridMap == null || a == null || b == null) return int.MaxValue;
|
||||
return map.GridMap.CalcDistance(a, b);
|
||||
}
|
||||
|
||||
public static bool SameSide(MapData map, UnitData a, UnitData b)
|
||||
{
|
||||
if (map == null || a == null || b == null) return false;
|
||||
return map.SameUnionByUnitId(a.Id, b.Id);
|
||||
}
|
||||
|
||||
public static bool IsEnemy(MapData map, PlayerData self, UnitData unit)
|
||||
{
|
||||
if (map == null || self == null || unit == null) return false;
|
||||
return map.GetPlayerDataByUnitId(unit.Id, out var owner) && !map.SameUnion(self.Id, owner.Id);
|
||||
}
|
||||
|
||||
public static bool TryGetSkillLevel(UnitData unit, string skillName, out int level)
|
||||
{
|
||||
level = 0;
|
||||
if (unit == null || string.IsNullOrEmpty(skillName)) return false;
|
||||
if (!Enum.TryParse(skillName, out SkillType skillType)) return false;
|
||||
if (!unit.GetSkill(skillType, out var skill) || skill == null) return false;
|
||||
level = skill.Level;
|
||||
return level > 0;
|
||||
}
|
||||
|
||||
public static bool HasSkill(UnitData unit, string skillName)
|
||||
{
|
||||
return TryGetSkillLevel(unit, skillName, out _);
|
||||
}
|
||||
|
||||
public static int StableUnitId(UnitData unit)
|
||||
{
|
||||
return unit == null ? int.MaxValue : unchecked((int)unit.Id);
|
||||
}
|
||||
|
||||
public static int StableCityId(CityData city)
|
||||
{
|
||||
return city == null ? int.MaxValue : unchecked((int)city.Id);
|
||||
}
|
||||
|
||||
public static int StableGridId(GridData grid)
|
||||
{
|
||||
return grid == null ? int.MaxValue : unchecked((int)grid.Id);
|
||||
}
|
||||
}
|
||||
|
||||
public static class AIDirectorHeroRuleCatalog
|
||||
{
|
||||
public static void FillDefault(List<AIDirectorHeroRule> rules)
|
||||
{
|
||||
if (rules == null) return;
|
||||
rules.Clear();
|
||||
|
||||
Add(rules, "Flandre_Kill", GiantType.EgyptianFlandre, AIDirectorHeroRole.Assassin, AIDirectorHeroContext.FieldBattle, AIDirectorHeroActionKind.AttackEnemy, UnitActionType.None, AIDirectorHeroTargetPolicy.KillableEnemy, 900, "低血量斩杀高价值目标");
|
||||
Add(rules, "Remilia_Buff", GiantType.EgyptianRemilia, AIDirectorHeroRole.Support, AIDirectorHeroContext.FieldBattle, AIDirectorHeroActionKind.UnitAction, UnitActionType.REMILIABUFF, AIDirectorHeroTargetPolicy.Self, 820, "敌我接触时铺血雾增益");
|
||||
Add(rules, "Remilia_Absorb", GiantType.EgyptianRemilia, AIDirectorHeroRole.Vanguard, AIDirectorHeroContext.Recovery, AIDirectorHeroActionKind.UnitAction, UnitActionType.REMILIAABSORB, AIDirectorHeroTargetPolicy.Self, 860, "可吸收血雾时先保证续航");
|
||||
Add(rules, "Sakuya_GuardStrike", GiantType.EgyptianSakuya, AIDirectorHeroRole.Defender, AIDirectorHeroContext.Defense, AIDirectorHeroActionKind.AttackAlly, UnitActionType.None, AIDirectorHeroTargetPolicy.InjuredAlly, 850, "保护前线残血友军");
|
||||
Add(rules, "Meiling_Recover", GiantType.EgyptianMeiling, AIDirectorHeroRole.Defender, AIDirectorHeroContext.Recovery, AIDirectorHeroActionKind.UnitAction, UnitActionType.Recover, AIDirectorHeroTargetPolicy.Self, 830, "低血量且不急于攻击时恢复");
|
||||
Add(rules, "Patchouli_MoveSeal", GiantType.EgyptianPatchouli, AIDirectorHeroRole.Caster, AIDirectorHeroContext.Control, AIDirectorHeroActionKind.MoveAwayFromThreat, UnitActionType.None, AIDirectorHeroTargetPolicy.RetreatGrid, 810, "移动叠层后等待法术收益");
|
||||
Add(rules, "Patchouli_Earth", GiantType.EgyptianPatchouli, AIDirectorHeroRole.Caster, AIDirectorHeroContext.Recovery, AIDirectorHeroActionKind.AttackGround, UnitActionType.None, AIDirectorHeroTargetPolicy.BestGroundTarget, 820, "用地块攻击触发土系回复");
|
||||
|
||||
Add(rules, "Kaguya_Synergy", GiantType.FrenchKaguya, AIDirectorHeroRole.Control, AIDirectorHeroContext.FieldBattle, AIDirectorHeroActionKind.AttackEnemy, UnitActionType.None, AIDirectorHeroTargetPolicy.BestEnemy, 820, "优先攻击可叠协同标记的敌人");
|
||||
Add(rules, "Reisen_Boom", GiantType.FrenchReisen, AIDirectorHeroRole.Caster, AIDirectorHeroContext.FieldBattle, AIDirectorHeroActionKind.UnitAction, UnitActionType.ReisenFrenchBoom, AIDirectorHeroTargetPolicy.BestEnemy, 870, "敌方密集时触发幻象爆破");
|
||||
Add(rules, "Tewi_Buff", GiantType.FrenchTewi, AIDirectorHeroRole.Support, AIDirectorHeroContext.FieldBattle, AIDirectorHeroActionKind.UnitAction, UnitActionType.TEWIFRENCHBUFF, AIDirectorHeroTargetPolicy.AdjacentAlly, 780, "给相邻作战友军增益");
|
||||
Add(rules, "Eirin_HealHero", GiantType.FrenchEirin, AIDirectorHeroRole.Support, AIDirectorHeroContext.Recovery, AIDirectorHeroActionKind.AttackAlly, UnitActionType.None, AIDirectorHeroTargetPolicy.InjuredAlly, 860, "优先治疗低血英雄");
|
||||
Add(rules, "Mokou_Boom", GiantType.FrenchMokou, AIDirectorHeroRole.Vanguard, AIDirectorHeroContext.FieldBattle, AIDirectorHeroActionKind.UnitAction, UnitActionType.MOKOUFRENCHBOOM, AIDirectorHeroTargetPolicy.Self, 900, "残血且敌人密集时自爆换收益");
|
||||
|
||||
Add(rules, "Sanae_WindAttack", GiantType.GermanySanae, AIDirectorHeroRole.Caster, AIDirectorHeroContext.FieldBattle, AIDirectorHeroActionKind.AttackGround, UnitActionType.None, AIDirectorHeroTargetPolicy.BestGroundTarget, 820, "用攻击地块扩大风系收益");
|
||||
Add(rules, "Kanako_Unsit", GiantType.GermanyKanako, AIDirectorHeroRole.Defender, AIDirectorHeroContext.Defense, AIDirectorHeroActionKind.UnitAction, UnitActionType.KANAKOUNSIT, AIDirectorHeroTargetPolicy.Self, 840, "战线移动时取消坐镇");
|
||||
Add(rules, "Kanako_Sit", GiantType.GermanyKanako, AIDirectorHeroRole.Economy, AIDirectorHeroContext.Economy, AIDirectorHeroActionKind.UnitAction, UnitActionType.KANAKOSIT, AIDirectorHeroTargetPolicy.Self, 720, "安全内政阶段坐镇城市");
|
||||
Add(rules, "Suwako_Attack", GiantType.GermanySuwako, AIDirectorHeroRole.Vanguard, AIDirectorHeroContext.FieldBattle, AIDirectorHeroActionKind.AttackEnemy, UnitActionType.None, AIDirectorHeroTargetPolicy.BestEnemy, 800, "贴近前线稳定输出");
|
||||
Add(rules, "Aya_MoveAgain", GiantType.GermanyAya, AIDirectorHeroRole.Mobility, AIDirectorHeroContext.Mobility, AIDirectorHeroActionKind.UnitAction, UnitActionType.AYAMOVEAGAIN, AIDirectorHeroTargetPolicy.Self, 860, "二动用于追击或回防");
|
||||
Add(rules, "Momiji_Prey", GiantType.GermanyMomiji, AIDirectorHeroRole.Assassin, AIDirectorHeroContext.FieldBattle, AIDirectorHeroActionKind.AttackEnemy, UnitActionType.None, AIDirectorHeroTargetPolicy.BestEnemy, 810, "优先打猎物标记目标");
|
||||
|
||||
Add(rules, "Satori_Ban", GiantType.IndianSatori, AIDirectorHeroRole.Control, AIDirectorHeroContext.Control, AIDirectorHeroActionKind.AttackEnemy, UnitActionType.None, AIDirectorHeroTargetPolicy.BestEnemyHero, 870, "封禁敌方核心英雄");
|
||||
Add(rules, "Koishi_Fear", GiantType.IndianKoishi, AIDirectorHeroRole.Control, AIDirectorHeroContext.Control, AIDirectorHeroActionKind.MoveTowardFront, UnitActionType.None, AIDirectorHeroTargetPolicy.SafeForwardGrid, 760, "走位制造恐惧光环");
|
||||
Add(rules, "Rin_Corpse", GiantType.IndianRin, AIDirectorHeroRole.Caster, AIDirectorHeroContext.FieldBattle, AIDirectorHeroActionKind.UnitAction, UnitActionType.KomeijiRinFire, AIDirectorHeroTargetPolicy.Self, 830, "有尸火层数时转换输出");
|
||||
Add(rules, "Utsuho_BoneBoom", GiantType.IndianUtsuho, AIDirectorHeroRole.Caster, AIDirectorHeroContext.FieldBattle, AIDirectorHeroActionKind.UnitAction, UnitActionType.KomeijiBonePileBoom, AIDirectorHeroTargetPolicy.BonePile, 880, "骨堆爆破命中多人时使用");
|
||||
Add(rules, "Yuugi_Push", GiantType.IndianYuugi, AIDirectorHeroRole.Vanguard, AIDirectorHeroContext.FieldBattle, AIDirectorHeroActionKind.AttackEnemy, UnitActionType.None, AIDirectorHeroTargetPolicy.BestEnemy, 830, "利用近战推进压制");
|
||||
|
||||
Add(rules, "Reimu_Protect", GiantType.NorwayReimu, AIDirectorHeroRole.Support, AIDirectorHeroContext.Defense, AIDirectorHeroActionKind.AttackAlly, UnitActionType.None, AIDirectorHeroTargetPolicy.InjuredAlly, 880, "为关键友军挂保护");
|
||||
Add(rules, "Reimu_ClearExtermination", GiantType.NorwayReimu, AIDirectorHeroRole.Support, AIDirectorHeroContext.Recovery, AIDirectorHeroActionKind.UnitAction, UnitActionType.ReimuPayClearExtermination, AIDirectorHeroTargetPolicy.Self, 780, "标记压力过高时清除退魔");
|
||||
Add(rules, "Sumireko_Orb", GiantType.NorwaySumireko, AIDirectorHeroRole.Caster, AIDirectorHeroContext.Control, AIDirectorHeroActionKind.AttackGround, UnitActionType.None, AIDirectorHeroTargetPolicy.BestGroundTarget, 800, "用灵球控制战线");
|
||||
Add(rules, "Kasen_Oni", GiantType.NorwayKasen, AIDirectorHeroRole.Vanguard, AIDirectorHeroContext.FieldBattle, AIDirectorHeroActionKind.UnitAction, UnitActionType.KasenToggleOniForm, AIDirectorHeroTargetPolicy.Self, 840, "进入鬼形态处理高压战斗");
|
||||
Add(rules, "Aunn_Twin", GiantType.NorwayAunn, AIDirectorHeroRole.Defender, AIDirectorHeroContext.Defense, AIDirectorHeroActionKind.MoveTowardFront, UnitActionType.None, AIDirectorHeroTargetPolicy.SafeForwardGrid, 760, "双狛犬保持相互支援距离");
|
||||
Add(rules, "Suika_CreateMini", GiantType.NorwaySuika, AIDirectorHeroRole.Summoner, AIDirectorHeroContext.FieldBattle, AIDirectorHeroActionKind.UnitAction, UnitActionType.SuikaCreateMiniByHp, AIDirectorHeroTargetPolicy.Self, 840, "安全时用血量换小萃香");
|
||||
Add(rules, "Suika_ShakeOff", GiantType.NorwaySuika, AIDirectorHeroRole.Summoner, AIDirectorHeroContext.Defense, AIDirectorHeroActionKind.UnitAction, UnitActionType.SuikaShakeOffMinis, AIDirectorHeroTargetPolicy.Self, 860, "被围攻时甩出小萃香解围");
|
||||
}
|
||||
|
||||
private static void Add(
|
||||
List<AIDirectorHeroRule> rules,
|
||||
string id,
|
||||
GiantType giantType,
|
||||
AIDirectorHeroRole role,
|
||||
AIDirectorHeroContext context,
|
||||
AIDirectorHeroActionKind actionKind,
|
||||
UnitActionType unitActionType,
|
||||
AIDirectorHeroTargetPolicy targetPolicy,
|
||||
int priority,
|
||||
string reason)
|
||||
{
|
||||
rules.Add(new AIDirectorHeroRule
|
||||
{
|
||||
RuleId = id,
|
||||
GiantType = giantType,
|
||||
Role = role,
|
||||
Context = context,
|
||||
ActionKind = actionKind,
|
||||
UnitActionType = unitActionType,
|
||||
TargetPolicy = targetPolicy,
|
||||
Priority = priority,
|
||||
Reason = reason
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7b1d6241b76d4fd99e6b5a9c9a84c4a5
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@ -0,0 +1,807 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Logic.Action;
|
||||
using RuntimeData;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Logic.AI.Director
|
||||
{
|
||||
public sealed class AIDirectorWorldCacheBuilder
|
||||
{
|
||||
private readonly List<GridData> _around = new();
|
||||
|
||||
public AIDirectorWorldCache Build(AIDirectorContext ctx)
|
||||
{
|
||||
var cache = new AIDirectorWorldCache();
|
||||
if (ctx?.Map == null || ctx.Player == null) return cache;
|
||||
|
||||
CollectWorldObjects(ctx, cache);
|
||||
BuildDiplomacyView(ctx, cache);
|
||||
BuildMilitarySummary(ctx, cache);
|
||||
BuildCityThreats(ctx, cache);
|
||||
BuildDevelopmentTargets(ctx, cache);
|
||||
BuildStrategicPosture(ctx, cache);
|
||||
BuildCityPlans(ctx, cache);
|
||||
BuildFronts(ctx, cache);
|
||||
BuildLocalBattles(ctx, cache);
|
||||
BuildHeroStates(ctx, cache);
|
||||
cache.PrimaryFront = ChoosePrimaryFront(cache);
|
||||
return cache;
|
||||
}
|
||||
|
||||
public void BuildUnitOpportunities(AIDirectorContext ctx)
|
||||
{
|
||||
var cache = ctx?.Cache;
|
||||
var actions = ctx?.ActionIndex;
|
||||
if (cache == null || actions == null) return;
|
||||
cache.UnitOpportunities.Clear();
|
||||
|
||||
foreach (var action in actions.UnitActions)
|
||||
{
|
||||
if (action?.Param?.UnitData == null || action.ActionLogic?.ActionId == null) continue;
|
||||
var type = GetOpportunityType(action.ActionLogic.ActionId.UnitActionType);
|
||||
if (type == AIDirectorUnitOpportunityType.None) continue;
|
||||
var value = ScoreUnitOpportunity(ctx, action, type);
|
||||
if (value <= 0f) continue;
|
||||
cache.UnitOpportunities.Add(new AIDirectorUnitOpportunity
|
||||
{
|
||||
Unit = action.Param.UnitData,
|
||||
Action = action,
|
||||
OpportunityType = type,
|
||||
Value = value
|
||||
});
|
||||
}
|
||||
|
||||
cache.UnitOpportunities.Sort((a, b) =>
|
||||
{
|
||||
var c = b.Value.CompareTo(a.Value);
|
||||
if (c != 0) return c;
|
||||
return AIDirectorMath.StableUnitId(a.Unit).CompareTo(AIDirectorMath.StableUnitId(b.Unit));
|
||||
});
|
||||
}
|
||||
|
||||
private void CollectWorldObjects(AIDirectorContext ctx, AIDirectorWorldCache cache)
|
||||
{
|
||||
ctx.Map.GetUnitDataListByPlayerId(ctx.Player.Id, cache.SelfUnits);
|
||||
ctx.Map.GetCityDataListByPlayerId(ctx.Player.Id, cache.SelfCities);
|
||||
ctx.Map.GetPlayerTerritoryGridIdSet(ctx.Player.Id, cache.SelfTerritoryGridIds);
|
||||
|
||||
for (var i = cache.SelfUnits.Count - 1; i >= 0; i--)
|
||||
{
|
||||
var unit = cache.SelfUnits[i];
|
||||
if (unit == null || !unit.IsAlive())
|
||||
{
|
||||
cache.SelfUnits.RemoveAt(i);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (unit.TreatedAsHero(ctx.Map, unit)) cache.SelfHeroes.Add(unit);
|
||||
}
|
||||
|
||||
if (ctx.Map.PlayerMap?.PlayerDataList != null)
|
||||
{
|
||||
foreach (var player in ctx.Map.PlayerMap.PlayerDataList)
|
||||
{
|
||||
if (player == null || player.Id == ctx.Player.Id) continue;
|
||||
if (ctx.Map.SameUnion(ctx.Player.Id, player.Id)) cache.AlliedPlayers.Add(player);
|
||||
else cache.EnemyPlayers.Add(player);
|
||||
}
|
||||
}
|
||||
|
||||
if (ctx.Map.UnitMap?.UnitList != null)
|
||||
{
|
||||
foreach (var unit in ctx.Map.UnitMap.UnitList)
|
||||
{
|
||||
if (unit == null || !unit.IsAlive()) continue;
|
||||
if (!AIDirectorMath.IsEnemy(ctx.Map, ctx.Player, unit)) continue;
|
||||
cache.EnemyUnits.Add(unit);
|
||||
if (unit.TreatedAsHero(ctx.Map, unit)) cache.EnemyHeroes.Add(unit);
|
||||
}
|
||||
}
|
||||
|
||||
if (ctx.Map.CityMap?.CityList != null)
|
||||
{
|
||||
foreach (var city in ctx.Map.CityMap.CityList)
|
||||
{
|
||||
if (city == null) continue;
|
||||
if (!ctx.Map.GetPlayerDataByCityId(city.Id, out var owner) || owner == null) continue;
|
||||
if (!ctx.Map.SameUnion(ctx.Player.Id, owner.Id)) cache.EnemyCities.Add(city);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void BuildDiplomacyView(AIDirectorContext ctx, AIDirectorWorldCache cache)
|
||||
{
|
||||
foreach (var enemy in cache.EnemyPlayers)
|
||||
{
|
||||
if (!ctx.Player.GetCountryDiplomacyInfo(enemy.Id, out var info) || info == null) continue;
|
||||
if (info.DiplomacyState == DiplomacyState.War) cache.WarTargetPlayers.Add(enemy);
|
||||
if (info.FeelingValue >= 80f) cache.HighTrustPlayers.Add(enemy);
|
||||
if (info.FeelingValue <= 30f) cache.LowTrustPlayers.Add(enemy);
|
||||
}
|
||||
}
|
||||
|
||||
private void BuildMilitarySummary(AIDirectorContext ctx, AIDirectorWorldCache cache)
|
||||
{
|
||||
foreach (var unit in cache.SelfUnits) cache.SelfMilitary += AIDirectorMath.UnitPower(unit);
|
||||
foreach (var unit in cache.EnemyUnits) cache.EnemyMilitary += AIDirectorMath.UnitPower(unit);
|
||||
}
|
||||
|
||||
private void BuildCityThreats(AIDirectorContext ctx, AIDirectorWorldCache cache)
|
||||
{
|
||||
foreach (var city in cache.SelfCities)
|
||||
{
|
||||
if (city == null || !ctx.Map.GetGridDataByCityId(city.Id, out var cityGrid)) continue;
|
||||
var threat = BuildSingleCityThreat(ctx, cache, city, cityGrid);
|
||||
cache.CityThreats.Add(threat);
|
||||
cache.HasCriticalCityThreat |= threat.IsCritical;
|
||||
cache.HasAnyEnemyContact |= threat.EnemyUnits.Count > 0;
|
||||
}
|
||||
|
||||
cache.CityThreats.Sort((a, b) =>
|
||||
{
|
||||
var c = b.DangerScore.CompareTo(a.DangerScore);
|
||||
if (c != 0) return c;
|
||||
return AIDirectorMath.StableCityId(a.City).CompareTo(AIDirectorMath.StableCityId(b.City));
|
||||
});
|
||||
}
|
||||
|
||||
private AIDirectorCityThreat BuildSingleCityThreat(AIDirectorContext ctx, AIDirectorWorldCache cache, CityData city, GridData cityGrid)
|
||||
{
|
||||
var threat = new AIDirectorCityThreat
|
||||
{
|
||||
City = city,
|
||||
CityGrid = cityGrid,
|
||||
IsCapital = city.IsCapital,
|
||||
HasWall = city.CityWall
|
||||
};
|
||||
|
||||
foreach (var enemy in cache.EnemyUnits)
|
||||
{
|
||||
if (!ctx.Map.GetGridDataByUnitId(enemy.Id, out var enemyGrid)) continue;
|
||||
var distance = AIDirectorMath.Distance(ctx.Map, cityGrid, enemyGrid);
|
||||
var reach = ctx.Config.EmergencyEnemySearchRange + enemy.GetAttackRange(ctx.Map);
|
||||
if (distance <= reach)
|
||||
{
|
||||
threat.EnemyUnits.Add(enemy);
|
||||
threat.EnemyPower += AIDirectorMath.UnitPower(enemy) / Mathf.Max(1, distance);
|
||||
threat.NearestEnemyDistance = Mathf.Min(threat.NearestEnemyDistance, distance);
|
||||
}
|
||||
|
||||
if (cache.SelfTerritoryGridIds.Contains(enemyGrid.Id)) threat.HasEnemyOnTerritory = true;
|
||||
if (CanThreatenCityNextTurn(ctx, enemy, cityGrid)) threat.CanBeThreatenedNextTurn = true;
|
||||
}
|
||||
|
||||
foreach (var unit in cache.SelfUnits)
|
||||
{
|
||||
if (!ctx.Map.GetGridDataByUnitId(unit.Id, out var unitGrid)) continue;
|
||||
var distance = AIDirectorMath.Distance(ctx.Map, cityGrid, unitGrid);
|
||||
if (distance <= ctx.Config.EmergencyEnemySearchRange)
|
||||
{
|
||||
threat.Defenders.Add(unit);
|
||||
threat.DefenderPower += AIDirectorMath.UnitPower(unit) / Mathf.Max(1, distance);
|
||||
}
|
||||
|
||||
if (distance <= ctx.Config.FrontSearchRange)
|
||||
{
|
||||
threat.RescuePower += AIDirectorMath.UnitPower(unit) / Mathf.Max(1, distance);
|
||||
}
|
||||
}
|
||||
|
||||
threat.DangerScore = threat.EnemyPower
|
||||
- threat.DefenderPower
|
||||
+ (threat.HasEnemyOnTerritory ? 4f : 0f)
|
||||
+ (threat.CanBeThreatenedNextTurn ? 6f : 0f)
|
||||
+ (threat.IsCapital ? 3f : 0f)
|
||||
- (threat.HasWall ? 2f : 0f);
|
||||
threat.IsCritical = threat.CanBeThreatenedNextTurn
|
||||
|| threat.EnemyUnits.Count >= ctx.Config.CityCriticalDangerEnemyCount
|
||||
|| threat.HasEnemyOnTerritory
|
||||
|| threat.EnemyPower > threat.DefenderPower * ctx.Config.CityThreatPowerRatio;
|
||||
return threat;
|
||||
}
|
||||
|
||||
private bool CanThreatenCityNextTurn(AIDirectorContext ctx, UnitData enemy, GridData cityGrid)
|
||||
{
|
||||
if (enemy == null || cityGrid == null) return false;
|
||||
if (!ctx.Map.GetGridDataByUnitId(enemy.Id, out var enemyGrid)) return false;
|
||||
var reach = enemy.GetActionPoint(ActionPointType.Move) + enemy.GetAttackRange(ctx.Map);
|
||||
if (enemy.GetActionPoint(ActionPointType.Capture) > 0) reach += 1;
|
||||
return AIDirectorMath.Distance(ctx.Map, enemyGrid, cityGrid) <= Mathf.Max(1, reach);
|
||||
}
|
||||
|
||||
private void BuildStrategicPosture(AIDirectorContext ctx, AIDirectorWorldCache cache)
|
||||
{
|
||||
if (cache.HasCriticalCityThreat)
|
||||
{
|
||||
cache.StrategicPosture = AIDirectorStrategicPosture.Defense;
|
||||
return;
|
||||
}
|
||||
|
||||
if (ExistsHighValueDevelopmentTargetNearCity(ctx, cache))
|
||||
{
|
||||
cache.StrategicPosture = AIDirectorStrategicPosture.Expansion;
|
||||
return;
|
||||
}
|
||||
|
||||
BuildAttackTargetPlayers(ctx, cache);
|
||||
if (cache.WarTargetPlayers.Count > 0)
|
||||
{
|
||||
cache.StrategicPosture = AIDirectorStrategicPosture.Attack;
|
||||
return;
|
||||
}
|
||||
|
||||
cache.StrategicPosture = AIDirectorStrategicPosture.Development;
|
||||
}
|
||||
|
||||
private void BuildAttackTargetPlayers(AIDirectorContext ctx, AIDirectorWorldCache cache)
|
||||
{
|
||||
foreach (var enemy in cache.EnemyPlayers)
|
||||
{
|
||||
if (enemy == null || cache.WarTargetPlayers.Contains(enemy)) continue;
|
||||
var nearest = NearestCityDistance(ctx, ctx.Player, enemy);
|
||||
var militaryGap = cache.SelfMilitary - EstimatePlayerMilitary(ctx, enemy);
|
||||
var feeling = GetFeeling(ctx.Player, enemy);
|
||||
|
||||
if (feeling <= 30f)
|
||||
{
|
||||
cache.WarTargetPlayers.Add(enemy);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (nearest <= 5 && militaryGap >= 0f)
|
||||
{
|
||||
cache.WarTargetPlayers.Add(enemy);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (nearest <= 7 && militaryGap >= -3f && PlayerThreatNearSelfCities(ctx, cache, enemy) >= 10f)
|
||||
{
|
||||
cache.WarTargetPlayers.Add(enemy);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void BuildCityPlans(AIDirectorContext ctx, AIDirectorWorldCache cache)
|
||||
{
|
||||
foreach (var city in cache.SelfCities)
|
||||
{
|
||||
if (city == null || !ctx.Map.GetGridDataByCityId(city.Id, out var cityGrid)) continue;
|
||||
var threat = cache.CityThreats.FirstOrDefault(t => t.City?.Id == city.Id);
|
||||
var plan = new AIDirectorCityPlan
|
||||
{
|
||||
City = city,
|
||||
CityGrid = cityGrid,
|
||||
Threat = threat,
|
||||
Kind = AIDirectorCityPlanKind.BacklineGrowth
|
||||
};
|
||||
|
||||
if (threat != null && threat.IsCritical) plan.Kind = AIDirectorCityPlanKind.EmergencyDefense;
|
||||
else if (threat != null && threat.DangerScore > 0f) plan.Kind = AIDirectorCityPlanKind.Mobilize;
|
||||
else if (IsNearAttackFront(ctx, cache, cityGrid)) plan.Kind = AIDirectorCityPlanKind.Frontline;
|
||||
else if (CanReasonablyPursueWonder(ctx, cache, city)) plan.Kind = AIDirectorCityPlanKind.Wonder;
|
||||
|
||||
plan.NeedWall = (plan.Kind == AIDirectorCityPlanKind.EmergencyDefense ||
|
||||
plan.Kind == AIDirectorCityPlanKind.Mobilize ||
|
||||
plan.Kind == AIDirectorCityPlanKind.Frontline) && !city.CityWall;
|
||||
plan.NeedMilitary = plan.Kind == AIDirectorCityPlanKind.EmergencyDefense ||
|
||||
plan.Kind == AIDirectorCityPlanKind.Mobilize ||
|
||||
plan.Kind == AIDirectorCityPlanKind.Frontline;
|
||||
plan.NeedGrowth = plan.Kind == AIDirectorCityPlanKind.BacklineGrowth ||
|
||||
plan.Kind == AIDirectorCityPlanKind.Wonder;
|
||||
plan.NeedWonder = plan.Kind == AIDirectorCityPlanKind.Wonder;
|
||||
plan.Priority = ScoreCityPlan(plan);
|
||||
cache.CityPlans.Add(plan);
|
||||
}
|
||||
|
||||
cache.CityPlans.Sort((a, b) =>
|
||||
{
|
||||
var c = b.Priority.CompareTo(a.Priority);
|
||||
if (c != 0) return c;
|
||||
return AIDirectorMath.StableCityId(a.City).CompareTo(AIDirectorMath.StableCityId(b.City));
|
||||
});
|
||||
}
|
||||
|
||||
private void BuildDevelopmentTargets(AIDirectorContext ctx, AIDirectorWorldCache cache)
|
||||
{
|
||||
var targets = new List<AIDirectorDevelopmentTarget>();
|
||||
foreach (var grid in ctx.Map.GridMap.GridList)
|
||||
{
|
||||
if (grid == null) continue;
|
||||
var target = TryBuildDevelopmentTarget(ctx, cache, grid);
|
||||
if (target == null) continue;
|
||||
if (target.Distance > ctx.Config.DevelopmentSearchRange && target.TargetType != AIDirectorDevelopmentTargetType.EnemyEmptyCity) continue;
|
||||
if (GridThreat(ctx, cache, grid) > 160f && target.TargetType != AIDirectorDevelopmentTargetType.EnemyEmptyCity) continue;
|
||||
targets.Add(target);
|
||||
}
|
||||
|
||||
targets.Sort((a, b) =>
|
||||
{
|
||||
var c = b.Value.CompareTo(a.Value);
|
||||
if (c != 0) return c;
|
||||
return AIDirectorMath.StableGridId(a.Grid).CompareTo(AIDirectorMath.StableGridId(b.Grid));
|
||||
});
|
||||
|
||||
var limit = Mathf.Max(1, ctx.Config.MaxDevelopmentTargetCount);
|
||||
for (var i = 0; i < targets.Count && i < limit; i++) cache.DevelopmentTargets.Add(targets[i]);
|
||||
}
|
||||
|
||||
private void BuildFronts(AIDirectorContext ctx, AIDirectorWorldCache cache)
|
||||
{
|
||||
foreach (var threat in cache.CityThreats)
|
||||
{
|
||||
if (threat.DangerScore <= 0f && threat.EnemyUnits.Count == 0) continue;
|
||||
var front = new AIDirectorFront
|
||||
{
|
||||
FrontType = AIDirectorFrontType.Defense,
|
||||
SelfCity = threat.City,
|
||||
AnchorGrid = threat.CityGrid,
|
||||
Pressure = threat.DangerScore,
|
||||
Opportunity = threat.RescuePower,
|
||||
Distance = threat.NearestEnemyDistance,
|
||||
};
|
||||
front.SelfUnits.AddRange(threat.Defenders);
|
||||
front.EnemyUnits.AddRange(threat.EnemyUnits);
|
||||
cache.Fronts.Add(front);
|
||||
}
|
||||
|
||||
foreach (var enemyCity in cache.EnemyCities)
|
||||
{
|
||||
if (enemyCity == null || !ctx.Map.GetGridDataByCityId(enemyCity.Id, out var enemyGrid)) continue;
|
||||
if (!ctx.Map.GetPlayerDataByCityId(enemyCity.Id, out var owner) || owner == null) continue;
|
||||
if (cache.StrategicPosture != AIDirectorStrategicPosture.Attack && !cache.WarTargetPlayers.Contains(owner)) continue;
|
||||
var selfCity = FindNearestSelfCity(ctx, cache, enemyGrid, out var distance);
|
||||
if (selfCity == null || !ctx.Map.GetGridDataByCityId(selfCity.Id, out var selfGrid)) continue;
|
||||
cache.Fronts.Add(new AIDirectorFront
|
||||
{
|
||||
FrontType = AIDirectorFrontType.Attack,
|
||||
SelfCity = selfCity,
|
||||
TargetCity = enemyCity,
|
||||
AnchorGrid = selfGrid,
|
||||
TargetGrid = enemyGrid,
|
||||
Distance = distance,
|
||||
Opportunity = Mathf.Max(1f, ctx.Config.FrontSearchRange + 2 - distance) + (enemyCity.IsCapital ? 3f : 0f),
|
||||
Pressure = 0f
|
||||
});
|
||||
}
|
||||
|
||||
foreach (var target in cache.DevelopmentTargets)
|
||||
{
|
||||
if (target.Grid == null) continue;
|
||||
cache.Fronts.Add(new AIDirectorFront
|
||||
{
|
||||
FrontType = AIDirectorFrontType.Development,
|
||||
SelfCity = target.NearestSelfCity,
|
||||
AnchorGrid = target.NearestSelfCity?.Grid(ctx.Map),
|
||||
TargetGrid = target.Grid,
|
||||
Distance = target.Distance,
|
||||
Opportunity = target.Value * 0.01f
|
||||
});
|
||||
}
|
||||
|
||||
foreach (var city in cache.SelfCities)
|
||||
{
|
||||
if (city == null || !ctx.Map.GetGridDataByCityId(city.Id, out var grid)) continue;
|
||||
cache.Fronts.Add(new AIDirectorFront
|
||||
{
|
||||
FrontType = AIDirectorFrontType.Hold,
|
||||
SelfCity = city,
|
||||
AnchorGrid = grid,
|
||||
TargetGrid = grid,
|
||||
Distance = 0,
|
||||
Opportunity = city.IsCapital ? 2f : 1f
|
||||
});
|
||||
}
|
||||
|
||||
cache.Fronts.Sort((a, b) =>
|
||||
{
|
||||
var c = ScoreFront(b).CompareTo(ScoreFront(a));
|
||||
if (c != 0) return c;
|
||||
c = a.Distance.CompareTo(b.Distance);
|
||||
if (c != 0) return c;
|
||||
return AIDirectorMath.StableGridId(a.TargetGrid ?? a.AnchorGrid)
|
||||
.CompareTo(AIDirectorMath.StableGridId(b.TargetGrid ?? b.AnchorGrid));
|
||||
});
|
||||
|
||||
while (cache.Fronts.Count > ctx.Config.MaxFrontCount) cache.Fronts.RemoveAt(cache.Fronts.Count - 1);
|
||||
}
|
||||
|
||||
private void BuildLocalBattles(AIDirectorContext ctx, AIDirectorWorldCache cache)
|
||||
{
|
||||
foreach (var self in cache.SelfUnits)
|
||||
{
|
||||
if (self == null || !ctx.Map.GetGridDataByUnitId(self.Id, out var selfGrid)) continue;
|
||||
foreach (var enemy in cache.EnemyUnits)
|
||||
{
|
||||
if (enemy == null || !ctx.Map.GetGridDataByUnitId(enemy.Id, out var enemyGrid)) continue;
|
||||
var distance = AIDirectorMath.Distance(ctx.Map, selfGrid, enemyGrid);
|
||||
if (distance > ctx.Config.LocalBattleRange + self.GetAttackRange(ctx.Map)) continue;
|
||||
cache.LocalBattles.Add(new AIDirectorLocalBattle
|
||||
{
|
||||
SelfUnit = self,
|
||||
EnemyUnit = enemy,
|
||||
SelfGrid = selfGrid,
|
||||
EnemyGrid = enemyGrid,
|
||||
Distance = distance,
|
||||
Value = AIDirectorMath.UnitPower(enemy) + (1f - AIDirectorMath.HealthRatio(enemy)) * 80f - distance * 8f
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
cache.LocalBattles.Sort((a, b) =>
|
||||
{
|
||||
var c = b.Value.CompareTo(a.Value);
|
||||
if (c != 0) return c;
|
||||
return AIDirectorMath.StableUnitId(a.SelfUnit).CompareTo(AIDirectorMath.StableUnitId(b.SelfUnit));
|
||||
});
|
||||
}
|
||||
|
||||
private void BuildHeroStates(AIDirectorContext ctx, AIDirectorWorldCache cache)
|
||||
{
|
||||
foreach (var hero in cache.SelfHeroes)
|
||||
{
|
||||
if (hero == null || !hero.TreatedAsHero(ctx.Map, hero, out var giantType)) continue;
|
||||
var state = new AIDirectorHeroState
|
||||
{
|
||||
Hero = hero,
|
||||
GiantType = giantType,
|
||||
Level = ctx.Player.PlayerHeroData?.GetHeroLevel(giantType) ?? 0,
|
||||
Role = GuessHeroRole(giantType),
|
||||
HealthRatio = AIDirectorMath.HealthRatio(hero)
|
||||
};
|
||||
state.Front = FindNearestFront(ctx, cache, hero, out var distance);
|
||||
state.IsOnFront = state.Front != null && distance <= ctx.Config.FrontSearchRange;
|
||||
state.IsThreatened = state.HealthRatio <= ctx.Config.HeroLowHealthRatio || HasEnemyNear(ctx, cache, hero, ctx.Config.LocalBattleRange);
|
||||
state.Context = GuessHeroContext(ctx, state);
|
||||
cache.HeroStates.Add(state);
|
||||
}
|
||||
}
|
||||
|
||||
private AIDirectorDevelopmentTarget TryBuildDevelopmentTarget(AIDirectorContext ctx, AIDirectorWorldCache cache, GridData grid)
|
||||
{
|
||||
var nearestCity = FindNearestSelfCity(ctx, cache, grid, out var distance);
|
||||
if (nearestCity == null) return null;
|
||||
var type = AIDirectorDevelopmentTargetType.None;
|
||||
var value = 0f;
|
||||
|
||||
if (grid.CityOnGrid(ctx.Map, out var city) && city != null)
|
||||
{
|
||||
if (!ctx.Map.GetPlayerDataByCityId(city.Id, out var owner) || owner == null || !ctx.Map.SameUnion(ctx.Player.Id, owner.Id))
|
||||
{
|
||||
type = AIDirectorDevelopmentTargetType.EnemyEmptyCity;
|
||||
value = 950f + (city.IsCapital ? 120f : 0f);
|
||||
}
|
||||
}
|
||||
else if (grid.Resource != ResourceType.None && !grid.HasBuilding())
|
||||
{
|
||||
type = AIDirectorDevelopmentTargetType.Resource;
|
||||
value = 650f + ResourceValue(grid);
|
||||
}
|
||||
else if (IsFogBoundary(ctx, cache, grid))
|
||||
{
|
||||
type = AIDirectorDevelopmentTargetType.FogBoundary;
|
||||
value = 420f;
|
||||
}
|
||||
|
||||
if (type == AIDirectorDevelopmentTargetType.None) return null;
|
||||
return new AIDirectorDevelopmentTarget
|
||||
{
|
||||
Grid = grid,
|
||||
TargetType = type,
|
||||
NearestSelfCity = nearestCity,
|
||||
Distance = distance,
|
||||
Value = value - distance * 12f
|
||||
};
|
||||
}
|
||||
|
||||
private AIDirectorUnitOpportunityType GetOpportunityType(UnitActionType type)
|
||||
{
|
||||
return type switch
|
||||
{
|
||||
UnitActionType.Capture => AIDirectorUnitOpportunityType.Capture,
|
||||
UnitActionType.Examine => AIDirectorUnitOpportunityType.Examine,
|
||||
UnitActionType.Gather => AIDirectorUnitOpportunityType.Gather,
|
||||
UnitActionType.HeroUpgrade => AIDirectorUnitOpportunityType.HeroUpgrade,
|
||||
UnitActionType.Upgrade => AIDirectorUnitOpportunityType.Upgrade,
|
||||
UnitActionType.CultureUnitUpgrade => AIDirectorUnitOpportunityType.CultureUnitUpgrade,
|
||||
UnitActionType.Recover => AIDirectorUnitOpportunityType.Recover,
|
||||
_ => AIDirectorUnitOpportunityType.None
|
||||
};
|
||||
}
|
||||
|
||||
private float ScoreUnitOpportunity(AIDirectorContext ctx, AIActionBase action, AIDirectorUnitOpportunityType type)
|
||||
{
|
||||
var unit = action.Param.UnitData;
|
||||
var grid = action.Param.GridData ?? unit?.Grid(ctx.Map);
|
||||
var score = type switch
|
||||
{
|
||||
AIDirectorUnitOpportunityType.Capture => 820f,
|
||||
AIDirectorUnitOpportunityType.Examine => 760f,
|
||||
AIDirectorUnitOpportunityType.Gather => 680f + ResourceValue(grid),
|
||||
AIDirectorUnitOpportunityType.HeroUpgrade => 760f,
|
||||
AIDirectorUnitOpportunityType.Upgrade => 610f,
|
||||
AIDirectorUnitOpportunityType.CultureUnitUpgrade => 600f,
|
||||
AIDirectorUnitOpportunityType.Recover => ScoreRecover(ctx, unit),
|
||||
_ => 0f
|
||||
};
|
||||
|
||||
if (grid != null)
|
||||
{
|
||||
score += DevelopmentTargetValue(ctx.Cache, grid) * 0.2f;
|
||||
score -= GridThreat(ctx, ctx.Cache, grid) * 0.25f;
|
||||
}
|
||||
|
||||
if (unit != null && UnitIsCriticalCityDefender(ctx.Cache, unit)) score -= 180f;
|
||||
return score;
|
||||
}
|
||||
|
||||
private float ScoreRecover(AIDirectorContext ctx, UnitData unit)
|
||||
{
|
||||
if (unit == null) return 0f;
|
||||
var hp = AIDirectorMath.HealthRatio(unit);
|
||||
if (hp > ctx.Config.LowHealthRatio) return 0f;
|
||||
var score = 560f + (1f - hp) * 240f;
|
||||
if (unit.TreatedAsHero(ctx.Map, unit)) score += 120f;
|
||||
return score;
|
||||
}
|
||||
|
||||
private bool ExistsHighValueDevelopmentTargetNearCity(AIDirectorContext ctx, AIDirectorWorldCache cache)
|
||||
{
|
||||
foreach (var target in cache.DevelopmentTargets)
|
||||
{
|
||||
if (target is { Value: >= 700f }) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private AIDirectorFront ChoosePrimaryFront(AIDirectorWorldCache cache)
|
||||
{
|
||||
return cache.Fronts.Count > 0 ? cache.Fronts[0] : null;
|
||||
}
|
||||
|
||||
private AIDirectorFront FindNearestFront(AIDirectorContext ctx, AIDirectorWorldCache cache, UnitData unit, out int distance)
|
||||
{
|
||||
distance = int.MaxValue;
|
||||
if (unit == null || !ctx.Map.GetGridDataByUnitId(unit.Id, out var unitGrid)) return null;
|
||||
AIDirectorFront best = null;
|
||||
foreach (var front in cache.Fronts)
|
||||
{
|
||||
var target = front.TargetGrid ?? front.AnchorGrid;
|
||||
if (target == null) continue;
|
||||
var d = AIDirectorMath.Distance(ctx.Map, unitGrid, target);
|
||||
if (d >= distance) continue;
|
||||
distance = d;
|
||||
best = front;
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
private bool HasEnemyNear(AIDirectorContext ctx, AIDirectorWorldCache cache, UnitData unit, int range)
|
||||
{
|
||||
if (unit == null || !ctx.Map.GetGridDataByUnitId(unit.Id, out var grid)) return false;
|
||||
foreach (var enemy in cache.EnemyUnits)
|
||||
{
|
||||
if (!ctx.Map.GetGridDataByUnitId(enemy.Id, out var enemyGrid)) continue;
|
||||
if (AIDirectorMath.Distance(ctx.Map, grid, enemyGrid) <= range + enemy.GetAttackRange(ctx.Map)) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private AIDirectorHeroContext GuessHeroContext(AIDirectorContext ctx, AIDirectorHeroState state)
|
||||
{
|
||||
if (state.HealthRatio <= ctx.Config.HeroLowHealthRatio) return AIDirectorHeroContext.Recovery;
|
||||
if (state.Front?.FrontType == AIDirectorFrontType.Defense) return AIDirectorHeroContext.Defense;
|
||||
if (state.IsOnFront) return AIDirectorHeroContext.FieldBattle;
|
||||
return state.Role switch
|
||||
{
|
||||
AIDirectorHeroRole.Economy => AIDirectorHeroContext.Economy,
|
||||
AIDirectorHeroRole.Control => AIDirectorHeroContext.Control,
|
||||
AIDirectorHeroRole.Mobility => AIDirectorHeroContext.Mobility,
|
||||
_ => AIDirectorHeroContext.FieldBattle
|
||||
};
|
||||
}
|
||||
|
||||
private AIDirectorHeroRole GuessHeroRole(GiantType giantType)
|
||||
{
|
||||
return giantType switch
|
||||
{
|
||||
GiantType.EgyptianFlandre or GiantType.GermanyMomiji => AIDirectorHeroRole.Assassin,
|
||||
GiantType.EgyptianRemilia or GiantType.FrenchTewi or GiantType.FrenchEirin or GiantType.NorwayReimu => AIDirectorHeroRole.Support,
|
||||
GiantType.EgyptianPatchouli or GiantType.FrenchReisen or GiantType.GermanySanae or GiantType.IndianUtsuho or GiantType.NorwaySumireko => AIDirectorHeroRole.Caster,
|
||||
GiantType.GermanyAya => AIDirectorHeroRole.Mobility,
|
||||
GiantType.IndianSatori or GiantType.IndianKoishi or GiantType.FrenchKaguya => AIDirectorHeroRole.Control,
|
||||
GiantType.NorwaySuika => AIDirectorHeroRole.Summoner,
|
||||
GiantType.GermanyKanako => AIDirectorHeroRole.Economy,
|
||||
_ => AIDirectorHeroRole.Vanguard
|
||||
};
|
||||
}
|
||||
|
||||
private bool IsNearAttackFront(AIDirectorContext ctx, AIDirectorWorldCache cache, GridData cityGrid)
|
||||
{
|
||||
foreach (var enemyCity in cache.EnemyCities)
|
||||
{
|
||||
if (!ctx.Map.GetGridDataByCityId(enemyCity.Id, out var enemyGrid)) continue;
|
||||
if (AIDirectorMath.Distance(ctx.Map, cityGrid, enemyGrid) <= ctx.Config.FrontSearchRange) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private bool CanReasonablyPursueWonder(AIDirectorContext ctx, AIDirectorWorldCache cache, CityData city)
|
||||
{
|
||||
return city != null && ctx?.Player != null && cache != null && ctx.Player.PlayerCoin >= 20 && !cache.HasAnyEnemyContact;
|
||||
}
|
||||
|
||||
private float ScoreCityPlan(AIDirectorCityPlan plan)
|
||||
{
|
||||
var score = plan.Kind switch
|
||||
{
|
||||
AIDirectorCityPlanKind.EmergencyDefense => 1000f,
|
||||
AIDirectorCityPlanKind.Mobilize => 760f,
|
||||
AIDirectorCityPlanKind.Frontline => 620f,
|
||||
AIDirectorCityPlanKind.Wonder => 520f,
|
||||
_ => 460f
|
||||
};
|
||||
|
||||
if (plan.Threat != null) score += plan.Threat.DangerScore * 20f;
|
||||
if (plan.City != null && plan.City.IsCapital) score += 50f;
|
||||
return score;
|
||||
}
|
||||
|
||||
private float ScoreFront(AIDirectorFront front)
|
||||
{
|
||||
if (front == null) return 0f;
|
||||
return front.FrontType switch
|
||||
{
|
||||
AIDirectorFrontType.Defense => 1000f + front.Pressure * 20f,
|
||||
AIDirectorFrontType.Attack => 700f + front.Opportunity * 30f - front.Distance * 10f,
|
||||
AIDirectorFrontType.Development => 520f + front.Opportunity * 30f - front.Distance * 6f,
|
||||
AIDirectorFrontType.Hold => 300f + front.Opportunity,
|
||||
_ => 0f
|
||||
};
|
||||
}
|
||||
|
||||
private CityData FindNearestSelfCity(AIDirectorContext ctx, AIDirectorWorldCache cache, GridData target, out int distance)
|
||||
{
|
||||
distance = int.MaxValue;
|
||||
CityData best = null;
|
||||
if (target == null) return null;
|
||||
foreach (var city in cache.SelfCities)
|
||||
{
|
||||
if (city == null || !ctx.Map.GetGridDataByCityId(city.Id, out var cityGrid)) continue;
|
||||
var d = AIDirectorMath.Distance(ctx.Map, cityGrid, target);
|
||||
if (d >= distance) continue;
|
||||
distance = d;
|
||||
best = city;
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
private int NearestCityDistance(AIDirectorContext ctx, PlayerData a, PlayerData b)
|
||||
{
|
||||
var aCities = new List<CityData>();
|
||||
var bCities = new List<CityData>();
|
||||
ctx.Map.GetCityDataListByPlayerId(a.Id, aCities);
|
||||
ctx.Map.GetCityDataListByPlayerId(b.Id, bCities);
|
||||
var best = int.MaxValue;
|
||||
foreach (var ca in aCities)
|
||||
{
|
||||
if (!ctx.Map.GetGridDataByCityId(ca.Id, out var ga)) continue;
|
||||
foreach (var cb in bCities)
|
||||
{
|
||||
if (!ctx.Map.GetGridDataByCityId(cb.Id, out var gb)) continue;
|
||||
best = Mathf.Min(best, AIDirectorMath.Distance(ctx.Map, ga, gb));
|
||||
}
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
private float EstimatePlayerMilitary(AIDirectorContext ctx, PlayerData player)
|
||||
{
|
||||
var units = new List<UnitData>();
|
||||
ctx.Map.GetUnitDataListByPlayerId(player.Id, units);
|
||||
var score = 0f;
|
||||
foreach (var unit in units) score += AIDirectorMath.UnitPower(unit);
|
||||
return score;
|
||||
}
|
||||
|
||||
private float PlayerThreatNearSelfCities(AIDirectorContext ctx, AIDirectorWorldCache cache, PlayerData player)
|
||||
{
|
||||
var score = 0f;
|
||||
foreach (var unit in cache.EnemyUnits)
|
||||
{
|
||||
if (!ctx.Map.GetPlayerDataByUnitId(unit.Id, out var owner) || owner.Id != player.Id) continue;
|
||||
if (!ctx.Map.GetGridDataByUnitId(unit.Id, out var unitGrid)) continue;
|
||||
foreach (var city in cache.SelfCities)
|
||||
{
|
||||
if (!ctx.Map.GetGridDataByCityId(city.Id, out var cityGrid)) continue;
|
||||
var distance = AIDirectorMath.Distance(ctx.Map, unitGrid, cityGrid);
|
||||
if (distance <= ctx.Config.FrontSearchRange) score += AIDirectorMath.UnitPower(unit) / Mathf.Max(1, distance);
|
||||
}
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
private float GetFeeling(PlayerData self, PlayerData target)
|
||||
{
|
||||
return self.GetCountryDiplomacyInfo(target.Id, out var info) && info != null ? info.FeelingValue : 50f;
|
||||
}
|
||||
|
||||
private float GridThreat(AIDirectorContext ctx, AIDirectorWorldCache cache, GridData grid)
|
||||
{
|
||||
if (grid == null) return 0f;
|
||||
var threat = 0f;
|
||||
foreach (var enemy in cache.EnemyUnits)
|
||||
{
|
||||
if (!ctx.Map.GetGridDataByUnitId(enemy.Id, out var enemyGrid)) continue;
|
||||
if (AIDirectorMath.Distance(ctx.Map, enemyGrid, grid) <= enemy.GetActionPoint(ActionPointType.Move) + enemy.GetAttackRange(ctx.Map))
|
||||
{
|
||||
threat += AIDirectorMath.UnitPower(enemy);
|
||||
}
|
||||
}
|
||||
|
||||
return threat;
|
||||
}
|
||||
|
||||
private float DevelopmentTargetValue(AIDirectorWorldCache cache, GridData grid)
|
||||
{
|
||||
if (grid == null) return 0f;
|
||||
foreach (var target in cache.DevelopmentTargets)
|
||||
{
|
||||
if (target.Grid?.Id == grid.Id) return target.Value;
|
||||
}
|
||||
|
||||
return 0f;
|
||||
}
|
||||
|
||||
private bool UnitIsCriticalCityDefender(AIDirectorWorldCache cache, UnitData unit)
|
||||
{
|
||||
if (unit == null) return false;
|
||||
foreach (var threat in cache.CityThreats)
|
||||
{
|
||||
if (!threat.IsCritical) continue;
|
||||
foreach (var defender in threat.Defenders)
|
||||
{
|
||||
if (defender?.Id == unit.Id) return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private float ResourceValue(GridData grid)
|
||||
{
|
||||
if (grid == null || grid.Resource == ResourceType.None) return 0f;
|
||||
return grid.Resource switch
|
||||
{
|
||||
ResourceType.Metal => 130f,
|
||||
ResourceType.Fruit => 100f,
|
||||
ResourceType.Crop => 90f,
|
||||
ResourceType.Animal => 80f,
|
||||
ResourceType.Fish => 80f,
|
||||
ResourceType.Starfish => 120f,
|
||||
_ => 60f
|
||||
};
|
||||
}
|
||||
|
||||
private bool IsFogBoundary(AIDirectorContext ctx, AIDirectorWorldCache cache, GridData grid)
|
||||
{
|
||||
if (grid == null || ctx.Player.Sight == null) return false;
|
||||
if (ctx.Player.Sight.CheckIsInSight(grid.Id)) return false;
|
||||
_around.Clear();
|
||||
ctx.Map.GridMap.GetAroundGridData(1, 1, grid, _around);
|
||||
foreach (var around in _around)
|
||||
{
|
||||
if (around != null && ctx.Player.Sight.CheckIsInSight(around.Id)) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 06a6c26852ce48a19c34a312f684d5e5
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
8
Unity/Assets/Scripts/TH1_Logic/AI/Kernel.meta
Normal file
8
Unity/Assets/Scripts/TH1_Logic/AI/Kernel.meta
Normal file
@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7d15e9fe8d704a21bdcbdb2229323e8a
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
67
Unity/Assets/Scripts/TH1_Logic/AI/Kernel/AIKernel.cs
Normal file
67
Unity/Assets/Scripts/TH1_Logic/AI/Kernel/AIKernel.cs
Normal file
@ -0,0 +1,67 @@
|
||||
using RuntimeData;
|
||||
|
||||
namespace Logic.AI
|
||||
{
|
||||
public enum AIKernelType
|
||||
{
|
||||
BehaviourTree,
|
||||
Director
|
||||
}
|
||||
|
||||
public enum AIKernelUpdateResult
|
||||
{
|
||||
None,
|
||||
ActionReady,
|
||||
Finished
|
||||
}
|
||||
|
||||
public static class AIDirectorBatchRuntime
|
||||
{
|
||||
#if UNITY_EDITOR
|
||||
public static bool ForceAllPlayersAi;
|
||||
public static bool SkipPresentationWait;
|
||||
public static bool CompactDiagnostics;
|
||||
#else
|
||||
public const bool ForceAllPlayersAi = false;
|
||||
public const bool SkipPresentationWait = false;
|
||||
public const bool CompactDiagnostics = false;
|
||||
#endif
|
||||
}
|
||||
|
||||
public readonly struct AIKernelUpdate
|
||||
{
|
||||
public readonly AIKernelUpdateResult Result;
|
||||
public readonly AIActionBase Action;
|
||||
|
||||
private AIKernelUpdate(AIKernelUpdateResult result, AIActionBase action)
|
||||
{
|
||||
Result = result;
|
||||
Action = action;
|
||||
}
|
||||
|
||||
public static AIKernelUpdate None => new(AIKernelUpdateResult.None, null);
|
||||
public static AIKernelUpdate Finished => new(AIKernelUpdateResult.Finished, null);
|
||||
|
||||
public static AIKernelUpdate ActionReady(AIActionBase action)
|
||||
{
|
||||
return action == null ? None : new AIKernelUpdate(AIKernelUpdateResult.ActionReady, action);
|
||||
}
|
||||
}
|
||||
|
||||
public interface IAIKernel
|
||||
{
|
||||
AIKernelType KernelType { get; }
|
||||
void Initialize(AILogicContext context);
|
||||
void StartTurn(MapData mapData, PlayerData playerData);
|
||||
AIKernelUpdate Update();
|
||||
void FinishTurn();
|
||||
}
|
||||
|
||||
public sealed class AILogicContext
|
||||
{
|
||||
public AICalculatorData Data;
|
||||
public AIActionGenerator Generator;
|
||||
public AIActionScoreCalculator ScoreCalculator;
|
||||
public AIConfigAsset Config;
|
||||
}
|
||||
}
|
||||
11
Unity/Assets/Scripts/TH1_Logic/AI/Kernel/AIKernel.cs.meta
Normal file
11
Unity/Assets/Scripts/TH1_Logic/AI/Kernel/AIKernel.cs.meta
Normal file
@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0db393964b7c492c9056effc73908b8b
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
44
Unity/Assets/Scripts/TH1_Logic/AI/Kernel/AIKernelRegistry.cs
Normal file
44
Unity/Assets/Scripts/TH1_Logic/AI/Kernel/AIKernelRegistry.cs
Normal file
@ -0,0 +1,44 @@
|
||||
using System;
|
||||
|
||||
namespace Logic.AI
|
||||
{
|
||||
public static class AIKernelRegistry
|
||||
{
|
||||
private static Func<IAIKernel> _kernelFactory = () => new BehaviourTreeAIKernel();
|
||||
|
||||
public static AIKernelType CurrentKernelType { get; private set; } = AIKernelType.BehaviourTree;
|
||||
public static int Version { get; private set; }
|
||||
|
||||
public static void Register(AIKernelType kernelType)
|
||||
{
|
||||
CurrentKernelType = kernelType;
|
||||
_kernelFactory = kernelType switch
|
||||
{
|
||||
AIKernelType.Director => () => new DirectorAIKernel(),
|
||||
_ => () => new BehaviourTreeAIKernel()
|
||||
};
|
||||
Version++;
|
||||
}
|
||||
|
||||
public static void Register<TKernel>() where TKernel : IAIKernel, new()
|
||||
{
|
||||
CurrentKernelType = new TKernel().KernelType;
|
||||
_kernelFactory = () => new TKernel();
|
||||
Version++;
|
||||
}
|
||||
|
||||
public static void Register(AIKernelType kernelType, Func<IAIKernel> factory)
|
||||
{
|
||||
CurrentKernelType = kernelType;
|
||||
_kernelFactory = factory ?? throw new ArgumentNullException(nameof(factory));
|
||||
Version++;
|
||||
}
|
||||
|
||||
public static IAIKernel Create()
|
||||
{
|
||||
var kernel = _kernelFactory();
|
||||
if (kernel == null) throw new InvalidOperationException("AI kernel factory returned null.");
|
||||
return kernel;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 00a9e2adf9d64061bb9b489d14ed0d2b
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@ -0,0 +1,114 @@
|
||||
using System.Collections.Generic;
|
||||
using Logic.CrashSight;
|
||||
using NodeCanvas.BehaviourTrees;
|
||||
using NodeCanvas.Framework;
|
||||
using RuntimeData;
|
||||
using TH1_Logic.Core;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Logic.AI
|
||||
{
|
||||
public sealed class BehaviourTreeAIKernel : IAIKernel
|
||||
{
|
||||
private AILogicContext _context;
|
||||
private BehaviourTreeOwner _btOwner;
|
||||
private readonly List<List<uint>> _nodeRecords = new();
|
||||
|
||||
public AIKernelType KernelType => AIKernelType.BehaviourTree;
|
||||
|
||||
public void Initialize(AILogicContext context)
|
||||
{
|
||||
_context = context;
|
||||
|
||||
var logicObject = GameObject.Find("AIBT");
|
||||
if (logicObject == null)
|
||||
{
|
||||
LogSystem.LogError("AIBT object missing, BehaviourTreeAIKernel can not run.");
|
||||
return;
|
||||
}
|
||||
|
||||
_btOwner = logicObject.GetComponent<BehaviourTreeOwner>();
|
||||
if (_btOwner == null)
|
||||
{
|
||||
LogSystem.LogError("AIBT BehaviourTreeOwner missing, BehaviourTreeAIKernel can not run.");
|
||||
return;
|
||||
}
|
||||
|
||||
var dataVariable = _btOwner.blackboard.GetVariable<AICalculatorData>("Data");
|
||||
if (dataVariable == null)
|
||||
{
|
||||
LogSystem.LogError("AIBT blackboard variable Data missing, BehaviourTreeAIKernel can not run.");
|
||||
_btOwner = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (dataVariable.value == null)
|
||||
{
|
||||
dataVariable.value = _context.Data;
|
||||
}
|
||||
else
|
||||
{
|
||||
_context.Data = dataVariable.value;
|
||||
}
|
||||
}
|
||||
|
||||
public void StartTurn(MapData mapData, PlayerData playerData)
|
||||
{
|
||||
if (_context == null || _btOwner == null) return;
|
||||
|
||||
_context.Generator.Init(mapData, playerData);
|
||||
_context.Data.AiDiffInfo = _context.Config.GetAIDiffInfo(mapData.MapConfig.AIDiff);
|
||||
_context.Data.Refresh(mapData, playerData);
|
||||
_btOwner.StopBehaviour();
|
||||
_btOwner.StartBehaviour();
|
||||
MainEditor.Instance.Data = _context.Data;
|
||||
}
|
||||
|
||||
public AIKernelUpdate Update()
|
||||
{
|
||||
if (_context == null || _btOwner == null) return AIKernelUpdate.Finished;
|
||||
|
||||
var index = 0;
|
||||
for (var i = 0; i < _nodeRecords.Count; i++) _nodeRecords[i].Clear();
|
||||
var nodeRecordsUsed = 0;
|
||||
|
||||
while (true)
|
||||
{
|
||||
if (MainEditor.Instance.IsEditor && !MainEditor.Instance.IsGo) return AIKernelUpdate.None;
|
||||
|
||||
index++;
|
||||
if (index > nodeRecordsUsed)
|
||||
{
|
||||
nodeRecordsUsed = index;
|
||||
if (index > _nodeRecords.Count) _nodeRecords.Add(new List<uint>());
|
||||
}
|
||||
|
||||
_context.Data.ClearCache();
|
||||
_nodeRecords[index - 1].Add(MainEditor.Instance.BTNodeId);
|
||||
_btOwner.UpdateBehaviour();
|
||||
MainEditor.Instance.IsGo = false;
|
||||
_nodeRecords[index - 1].Add(MainEditor.Instance.BTNodeId);
|
||||
|
||||
if (_context.Data.MaxAiAction != null || _context.Data.IsFinish) break;
|
||||
|
||||
if (index > 150)
|
||||
{
|
||||
LogSystem.LogError($"AI 行为树疑似死循环,最终记录点为:{MainEditor.Instance.BTNodeId}");
|
||||
return AIKernelUpdate.Finished;
|
||||
}
|
||||
}
|
||||
|
||||
if (_context.Data.MaxAiAction == null || index > 100) return AIKernelUpdate.Finished;
|
||||
|
||||
var action = _context.Data.MaxAiAction;
|
||||
_context.Data.MaxAiAction = null;
|
||||
return AIKernelUpdate.ActionReady(action);
|
||||
}
|
||||
|
||||
public void FinishTurn()
|
||||
{
|
||||
if (_btOwner != null) _btOwner.StopBehaviour();
|
||||
if (_context?.Data != null) _context.Data.MaxAiAction = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3a249f4d5a1140d99d7592229e8503b7
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
58
Unity/Assets/Scripts/TH1_Logic/AI/Kernel/DirectorAIKernel.cs
Normal file
58
Unity/Assets/Scripts/TH1_Logic/AI/Kernel/DirectorAIKernel.cs
Normal file
@ -0,0 +1,58 @@
|
||||
using Logic.AI.Director;
|
||||
using Logic.CrashSight;
|
||||
using RuntimeData;
|
||||
using TH1_Logic.Action;
|
||||
|
||||
namespace Logic.AI
|
||||
{
|
||||
public sealed class DirectorAIKernel : IAIKernel
|
||||
{
|
||||
private readonly AIDirectorLogic _director = new();
|
||||
private MapData _mapData;
|
||||
private PlayerData _playerData;
|
||||
|
||||
public AIKernelType KernelType => AIKernelType.Director;
|
||||
|
||||
public void Initialize(AILogicContext context)
|
||||
{
|
||||
}
|
||||
|
||||
public void StartTurn(MapData mapData, PlayerData playerData)
|
||||
{
|
||||
_mapData = mapData;
|
||||
_playerData = playerData;
|
||||
#if TH1_AI_DIRECTOR_DIAGNOSTICS || UNITY_EDITOR
|
||||
AIDirectorDiagnostics.RecordTurnStart(_mapData, _playerData);
|
||||
#endif
|
||||
}
|
||||
|
||||
public AIKernelUpdate Update()
|
||||
{
|
||||
if (_mapData == null || _playerData == null) return AIKernelUpdate.Finished;
|
||||
var decision = _director.Decide(_mapData, _playerData);
|
||||
#if TH1_AI_DIRECTOR_DIAGNOSTICS || UNITY_EDITOR
|
||||
AIDirectorDiagnostics.RecordDecision(_mapData, _playerData, decision);
|
||||
#endif
|
||||
if (!decision.HasAction) return AIKernelUpdate.Finished;
|
||||
var candidate = decision.Candidate;
|
||||
var action = candidate.AIAction;
|
||||
if (action?.Param == null || action.ActionLogic == null) return AIKernelUpdate.Finished;
|
||||
action.Param.MapData = _mapData;
|
||||
action.Param.RefreshParams();
|
||||
action.CheckIsActionInPlayerSight();
|
||||
if (action.IsInSight) action.ActionLogic.CameraControl(action.Param);
|
||||
if (action.ActionLogic.ActionId.PlayerActionType == PlayerActionType.OfferAlly)
|
||||
LogSystem.LogInfo("AI 发起结盟");
|
||||
return AIKernelUpdate.ActionReady(action);
|
||||
}
|
||||
|
||||
public void FinishTurn()
|
||||
{
|
||||
#if TH1_AI_DIRECTOR_DIAGNOSTICS || UNITY_EDITOR
|
||||
AIDirectorDiagnostics.RecordTurnEnd(_mapData, _playerData);
|
||||
#endif
|
||||
_mapData = null;
|
||||
_playerData = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3b4547bbdfd743ac978e45ddc16fef08
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@ -1,3 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8aadc7c4125b49b7af9d56af927fd52d
|
||||
timeCreated: 1764056674
|
||||
@ -1,144 +0,0 @@
|
||||
using Microsoft.ML.OnnxRuntime;
|
||||
using Microsoft.ML.OnnxRuntime.Tensors;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using UnityEngine;
|
||||
|
||||
namespace TH1_Logic.AITrain
|
||||
{
|
||||
/// <summary>
|
||||
/// ONNX模型推理类
|
||||
/// 基于train.py的配置: STATE_DIM=802, ACTION_DIM=8
|
||||
///
|
||||
/// 依赖: Microsoft.ML.OnnxRuntime
|
||||
/// 安装命令: 在Unity中通过NuGet安装 Microsoft.ML.OnnxRuntime 包
|
||||
/// </summary>
|
||||
public class ModelInference
|
||||
{
|
||||
public static ModelInference Instance = new ModelInference();
|
||||
|
||||
private const int STATE_DIM = 802;
|
||||
private const int ACTION_DIM = 8;
|
||||
|
||||
private InferenceSession session;
|
||||
private string inputName;
|
||||
private string outputName;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 加载ONNX模型
|
||||
/// </summary>
|
||||
/// <param name="modelPath">ONNX模型文件路径</param>
|
||||
/// <param name="useGPU">是否使用GPU (默认false,需要CUDA支持)</param>
|
||||
public void LoadModel(string modelPath="AIModel/cql_model", bool useGPU = false)
|
||||
{
|
||||
UnloadModel();
|
||||
|
||||
// 配置会话选项
|
||||
var options = new SessionOptions();
|
||||
if (useGPU)
|
||||
{
|
||||
// 需要安装 Microsoft.ML.OnnxRuntime.Gpu 包
|
||||
options.AppendExecutionProvider_CUDA(0);
|
||||
}
|
||||
|
||||
// 从Resources加载模型文件(不包含扩展名和Resources/前缀)
|
||||
TextAsset modelAsset = TH1Resource.ResourceLoader.Load<TextAsset>(modelPath);
|
||||
if (modelAsset == null)
|
||||
{
|
||||
throw new System.Exception($"无法从Resources加载模型: {modelPath}");
|
||||
}
|
||||
|
||||
// 使用字节数组创建会话
|
||||
session = new InferenceSession(modelAsset.bytes, options);
|
||||
|
||||
// 获取输入输出节点名称
|
||||
inputName = session.InputMetadata.Keys.First();
|
||||
outputName = session.OutputMetadata.Keys.First();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 卸载模型
|
||||
/// </summary>
|
||||
public void UnloadModel()
|
||||
{
|
||||
session?.Dispose();
|
||||
session = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 推理 - 从合法actions中选择最佳action
|
||||
/// </summary>
|
||||
/// <param name="state">状态向量 (802维)</param>
|
||||
/// <param name="legalActions">合法动作列表,每个动作是8维float数组</param>
|
||||
/// <returns>选择的最佳动作在列表中的索引,失败返回-1</returns>
|
||||
public int Predict(float[] state, List<List<float>> legalActions)
|
||||
{
|
||||
if (session == null || state == null || state.Length != STATE_DIM)
|
||||
return -1;
|
||||
|
||||
if (legalActions == null || legalActions.Count == 0)
|
||||
return -1;
|
||||
|
||||
// 如果只有一个合法动作,直接返回索引0
|
||||
if (legalActions.Count == 1)
|
||||
return 0;
|
||||
|
||||
// 获取模型预测的动作
|
||||
// 创建输入Tensor (1, 802)
|
||||
var inputTensor = new DenseTensor<float>(new[] { 1, STATE_DIM });
|
||||
for (int i = 0; i < STATE_DIM; i++)
|
||||
{
|
||||
inputTensor[0, i] = state[i];
|
||||
}
|
||||
|
||||
// 创建输入容器
|
||||
var inputs = new List<NamedOnnxValue>
|
||||
{
|
||||
NamedOnnxValue.CreateFromTensor(inputName, inputTensor)
|
||||
};
|
||||
|
||||
// 执行推理
|
||||
float[] predictedAction;
|
||||
using (var results = session.Run(inputs))
|
||||
{
|
||||
// 获取输出
|
||||
var outputTensor = results.First().AsTensor<float>();
|
||||
|
||||
// 提取预测的动作向量
|
||||
predictedAction = new float[ACTION_DIM];
|
||||
for (int i = 0; i < ACTION_DIM; i++)
|
||||
{
|
||||
predictedAction[i] = outputTensor[0, i];
|
||||
}
|
||||
}
|
||||
|
||||
// 从合法actions中选择与预测最接近的action
|
||||
float minDistance = float.MaxValue;
|
||||
int bestIndex = 0;
|
||||
|
||||
for (int i = 0; i < legalActions.Count; i++)
|
||||
{
|
||||
if (legalActions[i] == null || legalActions[i].Count != ACTION_DIM)
|
||||
continue;
|
||||
|
||||
// 计算欧几里得距离
|
||||
float distance = 0f;
|
||||
for (int j = 0; j < ACTION_DIM; j++)
|
||||
{
|
||||
float diff = predictedAction[j] - legalActions[i][j];
|
||||
distance += diff * diff;
|
||||
}
|
||||
|
||||
if (distance < minDistance)
|
||||
{
|
||||
minDistance = distance;
|
||||
bestIndex = i;
|
||||
}
|
||||
}
|
||||
|
||||
return bestIndex;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,3 +0,0 @@
|
||||
{
|
||||
"reference": "TH1.Hotfix"
|
||||
}
|
||||
@ -1,7 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d2ae4cde6abf41d5afd19d21acf5b44f
|
||||
AssemblyDefinitionReferenceImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@ -1,105 +0,0 @@
|
||||
/*
|
||||
* @Author: 白哉
|
||||
* @Description:
|
||||
* @Date: 2025年12月01日 星期一 16:12:06
|
||||
* @Modify:
|
||||
*/
|
||||
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using UnityEngine;
|
||||
using System.Text;
|
||||
|
||||
|
||||
namespace TH1_Logic.AITrain
|
||||
{
|
||||
[System.Serializable]
|
||||
public class AllActions
|
||||
{
|
||||
public float[] data;
|
||||
}
|
||||
|
||||
|
||||
|
||||
[System.Serializable]
|
||||
public class TrainingData
|
||||
{
|
||||
public float[] State;
|
||||
public AllActions[] Actions;
|
||||
public float[] SelectedAction;
|
||||
public float Reward;
|
||||
public bool Done;
|
||||
}
|
||||
|
||||
|
||||
public class TrainingDataRecorder
|
||||
{
|
||||
public static TrainingDataRecorder Instance = new ();
|
||||
private Dictionary<uint, List<TrainingData>> _episodeData = new ();
|
||||
private string _outputDir;
|
||||
private TrainingState _trainState;
|
||||
|
||||
|
||||
public TrainingDataRecorder(string outputDir = "TrainingData")
|
||||
{
|
||||
_trainState = new TrainingState();
|
||||
_outputDir = Path.Combine(@"F:\TrainData", outputDir);
|
||||
Directory.CreateDirectory(_outputDir);
|
||||
}
|
||||
|
||||
// 记录单步数据到内存
|
||||
public void RecordStep(uint playerID, float[] state, float[][] validActions, float[] selectedAction, float reward)
|
||||
{
|
||||
if (!_episodeData.ContainsKey(playerID))
|
||||
{
|
||||
_episodeData[playerID] = new List<TrainingData>();
|
||||
}
|
||||
|
||||
var data = new TrainingData
|
||||
{
|
||||
State = state,
|
||||
Actions = validActions.Select(x => new AllActions { data = x }).ToArray(),
|
||||
SelectedAction = selectedAction,
|
||||
Reward = reward,
|
||||
Done = false
|
||||
};
|
||||
_episodeData[playerID].Add(data);
|
||||
}
|
||||
|
||||
// 游戏结束时一次性写入文件
|
||||
public void SaveEpisode()
|
||||
{
|
||||
if (_episodeData.Count == 0) return;
|
||||
string timestamp = System.DateTime.Now.ToString("yyyyMMdd_HHmmss");
|
||||
string uniqueId = System.Guid.NewGuid().ToString("N").Substring(0, 8);
|
||||
foreach (var kv in _episodeData)
|
||||
{
|
||||
kv.Value[^1].Done = true;
|
||||
var fileName = $"episode_{timestamp}_{uniqueId}_{kv.Key}.jsonl";
|
||||
string filePath = Path.Combine(_outputDir, fileName);
|
||||
StringBuilder sb = new StringBuilder();
|
||||
foreach (var data in kv.Value)
|
||||
{
|
||||
string json = JsonUtility.ToJson(data);
|
||||
sb.AppendLine(json);
|
||||
}
|
||||
|
||||
// 使用UTF8NoBOM避免BOM字节序标记
|
||||
var utf8NoBom = new System.Text.UTF8Encoding(false);
|
||||
File.WriteAllText(filePath, sb.ToString(), utf8NoBom);
|
||||
Debug.Log($"训练数据已保存: {filePath}, 共 {kv.Value.Count} 步");
|
||||
}
|
||||
|
||||
_episodeData.Clear();
|
||||
}
|
||||
|
||||
// 清空当前回合数据(不保存)
|
||||
public void ClearEpisode()
|
||||
{
|
||||
_episodeData.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,3 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7caddcfed4744645ae284218e3b4d1f5
|
||||
timeCreated: 1764579480
|
||||
@ -1,543 +0,0 @@
|
||||
/*
|
||||
* @Author: 白哉
|
||||
* @Description:
|
||||
* @Date: 2025年11月25日 星期二 15:11:13
|
||||
* @Modify:
|
||||
*/
|
||||
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Logic.Action;
|
||||
using Logic.AI;
|
||||
using Logic.CrashSight;
|
||||
using Logic.Pool;
|
||||
using MemoryPack;
|
||||
using RuntimeData;
|
||||
using TH1_Logic.Core;
|
||||
using TH1_Logic.Tools;
|
||||
using UnityEngine;
|
||||
|
||||
|
||||
namespace TH1_Logic.AITrain
|
||||
{
|
||||
public class TrainingState
|
||||
{
|
||||
public static TrainingState Instance = new TrainingState();
|
||||
private ActionLogicIdData _actionLogicIdData;
|
||||
|
||||
// 初始化环境
|
||||
public void Initialize()
|
||||
{
|
||||
Main.Instance.StartMatch();
|
||||
}
|
||||
|
||||
// State 获取
|
||||
// Map State 维度只能向后拓展
|
||||
// State 的数据选取非常重要,与 Action 的数据一起决定了所有 AI 的行为,对应导向的行为必须和 State 数据相关联
|
||||
// 2 + 40 + 400 + 160 + 200 = 802 维度
|
||||
public float[] GetMapState(MapData map, PlayerData selfPlayer)
|
||||
{
|
||||
using var pooledState = THCollectionPool.GetListHandle<float>(out var state);
|
||||
|
||||
// 1. 己方信息
|
||||
|
||||
using var pooledSelfUnits = THCollectionPool.GetHashSetHandle<UnitData>(out var selfUnits);
|
||||
map.GetUnitDataListByPlayerId(selfPlayer.Id, selfUnits);
|
||||
using var pooledSelfCities = THCollectionPool.GetHashSetHandle<CityData>(out var selfCities);
|
||||
map.GetCityDataListByPlayerId(selfPlayer.Id, selfCities);
|
||||
var maxScore = map.PlayerMap.GetMaxPlayerScore();
|
||||
|
||||
// 回合数进度
|
||||
state.Add(Mathf.Min(selfPlayer.Turn / 100f, 1));
|
||||
// 我的 Player Index
|
||||
state.Add(GetPlayerIndex(map, selfPlayer.Id) / 10f);
|
||||
|
||||
using var pooledUnits = THCollectionPool.GetListHandle<UnitData>(out var units);
|
||||
using var pooledCities = THCollectionPool.GetListHandle<CityData>(out var cities);
|
||||
foreach (var id in selfPlayer.Sight.SightGidSet)
|
||||
{
|
||||
//TODO 白哉确认
|
||||
if (map.GridMap.GetGridDataByGid(id,out var grid) && grid.RealUnit(map, out var unit)) units.Add(unit);
|
||||
if (map.GetCityDataByGid(id, out var city)) cities.Add(city);
|
||||
}
|
||||
|
||||
// Player state,容量 10 * 4,共 40 维度
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
if (i >= map.PlayerMap.PlayerDataList.Count)
|
||||
{
|
||||
state.Add(0);
|
||||
state.Add(0);
|
||||
state.Add(0);
|
||||
state.Add(0);
|
||||
}
|
||||
else
|
||||
{
|
||||
var player = map.PlayerMap.PlayerDataList[i];
|
||||
var territory = map.GetPlayerTerritoryGridIdSet(player.Id);
|
||||
// 领土总数比例
|
||||
state.Add((float)territory.Count / map.MapConfig.Height / map.MapConfig.Width);
|
||||
// 视野总数比例
|
||||
state.Add((float)player.Sight.SightGidSet.Count / map.MapConfig.Height / map.MapConfig.Width);
|
||||
// 钱
|
||||
state.Add(Mathf.Min(player.PlayerCoin / 50f, 1));
|
||||
// 积分
|
||||
state.Add(maxScore > 0 ? player.PlayerScore / (float)maxScore : 0f);
|
||||
}
|
||||
}
|
||||
|
||||
// 小兵 state, 容量 80 * 5, 共 400 维度
|
||||
for (int i = 0; i < 80; i++)
|
||||
{
|
||||
if (i >= units.Count)
|
||||
{
|
||||
state.Add(0);
|
||||
state.Add(0);
|
||||
state.Add(0);
|
||||
state.Add(0);
|
||||
state.Add(0);
|
||||
}
|
||||
else
|
||||
{
|
||||
var unit = units[i];
|
||||
var grid = unit.Grid(map);
|
||||
state.Add(GetUnitIndex(map, unit.Id) / 100f);
|
||||
state.Add(selfUnits.Contains(unit) ? 1 : 0);
|
||||
state.Add(GetGridIndex(map, grid.Id) / 500f);
|
||||
state.Add(Table.Instance.UnitTypeDataAssets.GetUnitTypeInfoIndex(unit) / 100f);
|
||||
state.Add(unit.GetHealthRatio());
|
||||
}
|
||||
}
|
||||
|
||||
// 城市 state, 容量 40 * 4, 共 160 维度
|
||||
for (int i = 0; i < 40; i++)
|
||||
{
|
||||
if (i >= cities.Count)
|
||||
{
|
||||
state.Add(0);
|
||||
state.Add(0);
|
||||
state.Add(0);
|
||||
state.Add(0);
|
||||
}
|
||||
else
|
||||
{
|
||||
var city = cities[i];
|
||||
var grid = city.Grid(map);
|
||||
state.Add(GetCityIndex(map, city.Id) / 100f);
|
||||
state.Add(selfCities.Contains(city) ? 1 : 0);
|
||||
state.Add(GetGridIndex(map, grid.Id) / 500f);
|
||||
state.Add(Mathf.Min((city.Level + city.LevelExp / (float)city.Level / 2f) / 10f, 1f));
|
||||
}
|
||||
}
|
||||
|
||||
// 格子 state, 容量 100 * 2, 共 200 维度 (放最后方便拓展)
|
||||
var gridStateCount = 0;
|
||||
foreach (var id in selfPlayer.Sight.SightGidSet)
|
||||
{
|
||||
if (map.GridMap.GetGridDataByGid(id, out var grid)) continue;
|
||||
if (grid.Resource != ResourceType.None)
|
||||
{
|
||||
state.Add(GetGridIndex(map, grid.Id) / 500f);
|
||||
state.Add((int)grid.Resource / 100f);
|
||||
gridStateCount++;
|
||||
if (gridStateCount >= 100) break;
|
||||
}
|
||||
|
||||
if (grid.SpTypeList.Count != 0)
|
||||
{
|
||||
foreach (var spType in grid.SpTypeList)
|
||||
{
|
||||
state.Add(GetGridIndex(map, grid.Id) / 500f);
|
||||
state.Add((int)spType / 30f);
|
||||
gridStateCount++;
|
||||
if (gridStateCount >= 100) break;
|
||||
}
|
||||
}
|
||||
if (gridStateCount >= 100) break;
|
||||
}
|
||||
for (int i = gridStateCount; i < 100; i++)
|
||||
{
|
||||
state.Add(0);
|
||||
state.Add(0);
|
||||
}
|
||||
|
||||
return state.ToArray();
|
||||
}
|
||||
|
||||
// Action 获取
|
||||
// Action 维度为固定 64 位整数 (可变长编码)
|
||||
public bool GetActionBitCodec(CommonActionId actionId, CommonActionParams param, out List<float> packed)
|
||||
{
|
||||
LoadActionLogicIdData();
|
||||
var bitCodec64Var = new AIActionPacker();
|
||||
bitCodec64Var.ActionId = _actionLogicIdData.GetActionIdIndex(actionId);
|
||||
if (param.PlayerId != 0) bitCodec64Var.PlayerIndex = GetPlayerIndex(param.MapData, param.PlayerId);
|
||||
if (param.UnitId != 0) bitCodec64Var.UnitIndex = GetUnitIndex(param.MapData, param.UnitId);
|
||||
if (param.CityId != 0) bitCodec64Var.CityIndex = GetCityIndex(param.MapData, param.CityId);
|
||||
if (param.GridId != 0) bitCodec64Var.GridIndex = GetGridIndex(param.MapData, param.GridId);
|
||||
if (param.TargetUnitId != 0) bitCodec64Var.TargetUnitIndex = GetUnitIndex(param.MapData, param.TargetUnitId);
|
||||
if (param.TargetGridId != 0) bitCodec64Var.TargetGridIndex = GetGridIndex(param.MapData, param.TargetGridId);
|
||||
if (param.TargetPlayerId != 0) bitCodec64Var.TargetPlayerIndex = GetPlayerIndex(param.MapData, param.TargetPlayerId);
|
||||
return bitCodec64Var.TryPack(out packed);
|
||||
}
|
||||
|
||||
public bool GetActionBitCodecWithoutLimit(CommonActionId actionId, CommonActionParams param, out List<float> packed)
|
||||
{
|
||||
LoadActionLogicIdData();
|
||||
var bitCodec64Var = new AIActionPacker();
|
||||
bitCodec64Var.ActionId = _actionLogicIdData.GetActionIdIndex(actionId);
|
||||
if (param.PlayerId != 0) bitCodec64Var.PlayerIndex = GetPlayerIndex(param.MapData, param.PlayerId);
|
||||
if (param.UnitId != 0) bitCodec64Var.UnitIndex = GetUnitIndex(param.MapData, param.UnitId);
|
||||
if (param.CityId != 0) bitCodec64Var.CityIndex = GetCityIndex(param.MapData, param.CityId);
|
||||
if (param.GridId != 0) bitCodec64Var.GridIndex = GetGridIndex(param.MapData, param.GridId);
|
||||
if (param.TargetUnitId != 0) bitCodec64Var.TargetUnitIndex = GetUnitIndex(param.MapData, param.TargetUnitId);
|
||||
if (param.TargetGridId != 0) bitCodec64Var.TargetGridIndex = GetGridIndex(param.MapData, param.TargetGridId);
|
||||
if (param.TargetPlayerId != 0) bitCodec64Var.TargetPlayerIndex = GetPlayerIndex(param.MapData, param.TargetPlayerId);
|
||||
return bitCodec64Var.TryPackWithOutLimit(out packed);
|
||||
}
|
||||
|
||||
// Score 获取, Score 差值即为 Reward
|
||||
public float GetMapScore(MapData mapData, PlayerData player)
|
||||
{
|
||||
var score = GetUnitsScore(mapData, player) + GetCityScore(mapData, player);
|
||||
return score / 5f;
|
||||
}
|
||||
|
||||
// 获取当前所有可以被执行的 Action 的 BitCodec 列表
|
||||
public List<List<float>> GetAllActionBitCodecForUse(MapData mapData, PlayerData selfPlayer, out List<AIActionBase> actions)
|
||||
{
|
||||
var packedList = new List<List<float>>();
|
||||
var actionList = AIActionGenerator.GeneratorAllActionIdsForUse(mapData, selfPlayer);
|
||||
foreach (var action in actionList)
|
||||
{
|
||||
if (!GetActionBitCodec(action.ActionLogic.ActionId, action.Param, out var packed)) continue;
|
||||
packedList.Add(packed);
|
||||
}
|
||||
|
||||
actions = actionList;
|
||||
return packedList;
|
||||
}
|
||||
|
||||
// 获取当前所有可以被执行的 Action 的 BitCodec 列表
|
||||
public List<List<float>> GetAllActionBitCodec(MapData mapData, PlayerData selfPlayer, out List<AIActionBase> actions)
|
||||
{
|
||||
var packedList = new List<List<float>>();
|
||||
var actionList = AIActionGenerator.GeneratorAllActionIds(mapData, selfPlayer);
|
||||
foreach (var action in actionList)
|
||||
{
|
||||
if (!GetActionBitCodec(action.ActionLogic.ActionId, action.Param, out var packed)) continue;
|
||||
packedList.Add(packed);
|
||||
}
|
||||
|
||||
actions = actionList;
|
||||
return packedList;
|
||||
}
|
||||
|
||||
// Action 反编码
|
||||
public bool GetActionFromBitCodec(List<float> packed, MapData mapData, out CommonActionId actionId,
|
||||
out CommonActionParams param)
|
||||
{
|
||||
actionId = default;
|
||||
param = null;
|
||||
|
||||
LoadActionLogicIdData();
|
||||
|
||||
var packer = new AIActionPacker();
|
||||
if (!packer.TryUnpack(packed)) return false;
|
||||
|
||||
// 将 ActionId 索引映射回 CommonActionId
|
||||
if (packer.ActionId < 0 || packer.ActionId >= _actionLogicIdData.ActionIdList.Count) return false;
|
||||
actionId = _actionLogicIdData.ActionIdList[packer.ActionId];
|
||||
|
||||
var p = new CommonActionParams
|
||||
{
|
||||
MapData = mapData
|
||||
};
|
||||
|
||||
// 按索引回填对象(存在即取,不存在即返回 false)
|
||||
if (packer.PlayerIndex != -1)
|
||||
{
|
||||
if (packer.PlayerIndex < 0 || packer.PlayerIndex >= mapData.PlayerMap.PlayerDataList.Count) return false;
|
||||
p.PlayerData = mapData.PlayerMap.PlayerDataList[packer.PlayerIndex];
|
||||
}
|
||||
|
||||
if (packer.UnitIndex != -1)
|
||||
{
|
||||
if (packer.UnitIndex < 0 || packer.UnitIndex >= mapData.UnitMap.UnitList.Count) return false;
|
||||
p.UnitData = mapData.UnitMap.UnitList[packer.UnitIndex];
|
||||
}
|
||||
|
||||
if (packer.CityIndex != -1)
|
||||
{
|
||||
if (packer.CityIndex < 0 || packer.CityIndex >= mapData.CityMap.CityList.Count) return false;
|
||||
p.CityData = mapData.CityMap.CityList[packer.CityIndex];
|
||||
}
|
||||
|
||||
if (packer.GridIndex != -1)
|
||||
{
|
||||
if (packer.GridIndex < 0 || packer.GridIndex >= mapData.GridMap.GridList.Count) return false;
|
||||
p.GridData = mapData.GridMap.GridList[packer.GridIndex];
|
||||
}
|
||||
|
||||
if (packer.TargetUnitIndex != -1)
|
||||
{
|
||||
if (packer.TargetUnitIndex < 0 || packer.TargetUnitIndex >= mapData.UnitMap.UnitList.Count) return false;
|
||||
p.TargetUnitData = mapData.UnitMap.UnitList[packer.TargetUnitIndex];
|
||||
}
|
||||
|
||||
if (packer.TargetGridIndex != -1)
|
||||
{
|
||||
if (packer.GridIndex < 0 || packer.TargetGridIndex >= mapData.GridMap.GridList.Count) return false;
|
||||
p.TargetGridData = mapData.GridMap.GridList[packer.TargetGridIndex];
|
||||
}
|
||||
|
||||
if (packer.TargetPlayerIndex != -1)
|
||||
{
|
||||
if (packer.TargetPlayerIndex < 0 || packer.TargetPlayerIndex >= mapData.PlayerMap.PlayerDataList.Count)
|
||||
return false;
|
||||
p.TargetPlayerData = mapData.PlayerMap.PlayerDataList[packer.TargetPlayerIndex];
|
||||
}
|
||||
|
||||
param = p;
|
||||
param.MainObjectType = ActionLogicFactory.GetMainObjectType(actionId.ActionType);
|
||||
param.OnParamChanged();
|
||||
return true;
|
||||
}
|
||||
|
||||
// 获取 Player 的 Index
|
||||
private int GetPlayerIndex(MapData map, uint id)
|
||||
{
|
||||
int index = 0;
|
||||
foreach (var p in map.PlayerMap.PlayerDataList)
|
||||
{
|
||||
if (p.Id == id) return index;
|
||||
index++;
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
// 获取 Unit 的 Index
|
||||
private int GetUnitIndex(MapData map, uint id)
|
||||
{
|
||||
int index = 0;
|
||||
foreach (var u in map.UnitMap.UnitList)
|
||||
{
|
||||
if (u.Id == id) return index;
|
||||
index++;
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
// 获取 City 的 Index
|
||||
private int GetCityIndex(MapData map, uint id)
|
||||
{
|
||||
int index = 0;
|
||||
foreach (var c in map.CityMap.CityList)
|
||||
{
|
||||
if (c.Id == id) return index;
|
||||
index++;
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
// 获取 Grid 的 Index
|
||||
private int GetGridIndex(MapData map, uint id)
|
||||
{
|
||||
return map.GridMap.GetGridIndexByGid(id);
|
||||
}
|
||||
|
||||
// 按照 Player 的 PlayerScore 排名得分
|
||||
public float GetPlayerScore(MapData mapData, PlayerData playerData)
|
||||
{
|
||||
var playerList = mapData.PlayerMap.PlayerDataList;
|
||||
if (playerList.Count == 0) return 0f;
|
||||
if (playerList.Count == 1) return 10f;
|
||||
|
||||
// 按照 PlayerScore 降序排序获取排名
|
||||
var sortedPlayers = new List<PlayerData>(playerList);
|
||||
sortedPlayers.Sort((a, b) => b.PlayerScore.CompareTo(a.PlayerScore));
|
||||
|
||||
int rank = 0;
|
||||
for (int i = 0; i < sortedPlayers.Count; i++)
|
||||
{
|
||||
if (sortedPlayers[i].Id == playerData.Id)
|
||||
{
|
||||
rank = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 将排名转换为 0-10 分数,第一名 10 分,最后一名 0 分
|
||||
// 使用线性插值
|
||||
float score = 100f * (1f - (float)rank / (playerList.Count - 1));
|
||||
score += playerData.Sight.SightGidSet.Count / 10f;
|
||||
return score;
|
||||
}
|
||||
|
||||
private float GetUnitsScore(MapData mapData, PlayerData playerData)
|
||||
{
|
||||
float score = 0f;
|
||||
using var pooledUnits = THCollectionPool.GetHashSetHandle<UnitData>(out var units);
|
||||
mapData.GetUnitDataListByPlayerId(playerData.Id, units);
|
||||
foreach (var unit in mapData.UnitMap.UnitList)
|
||||
{
|
||||
if (!unit.IsAlive()) continue;
|
||||
var unitScore = unit.GetAttackRange(mapData) + unit.GetMoveRange(mapData) +
|
||||
unit.GetAllAttackValue(mapData) + unit.GetAllDefenseValue(mapData);
|
||||
if (units.Contains(unit)) score += unitScore;
|
||||
else score -= unitScore;
|
||||
}
|
||||
return score;
|
||||
}
|
||||
|
||||
private float GetCityScore(MapData mapData, PlayerData playerData)
|
||||
{
|
||||
float score = 0f;
|
||||
using var pooledCities = THCollectionPool.GetHashSetHandle<CityData>(out var cities);
|
||||
mapData.GetCityDataListByPlayerId(playerData.Id, cities);
|
||||
foreach (var city in mapData.CityMap.CityList)
|
||||
{
|
||||
var cityScore = city.Level * 10f + city.Territory.TerritoryArea.Count * 2;
|
||||
if (cities.Contains(city)) score += cityScore;
|
||||
else score -= cityScore;
|
||||
}
|
||||
return score;
|
||||
}
|
||||
|
||||
private void LoadActionLogicIdData()
|
||||
{
|
||||
if (_actionLogicIdData != null) return;
|
||||
TextAsset asset = TH1Resource.ResourceLoader.Load<TextAsset>($"CommonIdData/CommonIdData");
|
||||
var data = asset?.bytes ?? Array.Empty<byte>();
|
||||
_actionLogicIdData = TH1Serialization.Deserialize<ActionLogicIdData>(data) ?? new ActionLogicIdData();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
[MemoryPackable]
|
||||
public partial class ActionLogicIdData
|
||||
{
|
||||
public List<CommonActionId> ActionIdList = new();
|
||||
private Dictionary<CommonActionId, int> _idIndex = new();
|
||||
|
||||
public void Initialize()
|
||||
{
|
||||
if (_idIndex.Count == ActionIdList.Count) return;
|
||||
_idIndex.Clear();
|
||||
for (int i = 0; i < ActionIdList.Count; i++)
|
||||
{
|
||||
_idIndex[ActionIdList[i]] = i;
|
||||
}
|
||||
}
|
||||
|
||||
public int GetActionIdIndex(CommonActionId actionId)
|
||||
{
|
||||
Initialize();
|
||||
return _idIndex.GetValueOrDefault(actionId, -1);
|
||||
}
|
||||
|
||||
public void AddActionId(CommonActionId actionId)
|
||||
{
|
||||
Initialize();
|
||||
if (_idIndex.ContainsKey(actionId)) return;
|
||||
ActionIdList.Add(actionId);
|
||||
_idIndex[actionId] = ActionIdList.Count - 1;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public class AIActionPacker
|
||||
{
|
||||
public int ActionId;
|
||||
public int PlayerIndex;
|
||||
public int UnitIndex;
|
||||
public int CityIndex;
|
||||
public int GridIndex;
|
||||
public int TargetUnitIndex;
|
||||
public int TargetGridIndex;
|
||||
public int TargetPlayerIndex;
|
||||
|
||||
public int MaxActionID = 500;
|
||||
public int MaxPlayerIndex = 10;
|
||||
public int MaxUnitIndex = 80;
|
||||
public int MaxCityIndex = 40;
|
||||
public int MaxGridIndex = 500;
|
||||
public int MaxTargetUnitIndex = 80;
|
||||
public int MaxTargetGridIndex = 500;
|
||||
public int MaxTargetPlayerIndex = 10;
|
||||
|
||||
|
||||
public AIActionPacker()
|
||||
{
|
||||
PlayerIndex = -1;
|
||||
UnitIndex = -1;
|
||||
CityIndex = -1;
|
||||
GridIndex = -1;
|
||||
TargetUnitIndex = -1;
|
||||
TargetGridIndex = -1;
|
||||
TargetPlayerIndex = -1;
|
||||
}
|
||||
|
||||
// 紧凑序列化(前置存在位 + 变长位段)
|
||||
public bool TryPack(out List<float> packed)
|
||||
{
|
||||
packed = new List<float>();
|
||||
packed.Add((float)(ActionId + 1) / MaxActionID);
|
||||
packed.Add((float)(PlayerIndex + 1) / MaxPlayerIndex);
|
||||
packed.Add((float)(UnitIndex + 1) / MaxUnitIndex);
|
||||
packed.Add((float)(CityIndex + 1) / MaxCityIndex);
|
||||
packed.Add((float)(GridIndex + 1) / MaxGridIndex);
|
||||
packed.Add((float)(TargetUnitIndex + 1) / MaxTargetUnitIndex);
|
||||
packed.Add((float)(TargetGridIndex + 1) / MaxTargetGridIndex);
|
||||
packed.Add((float)(TargetPlayerIndex + 1) / MaxTargetPlayerIndex);
|
||||
foreach (var value in packed)
|
||||
{
|
||||
if (value > 1 || value < 0)
|
||||
{
|
||||
LogSystem.LogError($"数据越界!!! {ActionId} {PlayerIndex} {UnitIndex} {CityIndex} {GridIndex} {TargetUnitIndex} {TargetGridIndex} {TargetPlayerIndex}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool TryPackWithOutLimit(out List<float> packed)
|
||||
{
|
||||
packed = new List<float>();
|
||||
packed.Add((float)(ActionId + 1) / MaxActionID);
|
||||
packed.Add((float)(PlayerIndex + 1) / MaxPlayerIndex);
|
||||
packed.Add((float)(UnitIndex + 1) / MaxUnitIndex);
|
||||
packed.Add((float)(CityIndex + 1) / MaxCityIndex);
|
||||
packed.Add((float)(GridIndex + 1) / MaxGridIndex);
|
||||
packed.Add((float)(TargetUnitIndex + 1) / MaxTargetUnitIndex);
|
||||
packed.Add((float)(TargetGridIndex + 1) / MaxTargetGridIndex);
|
||||
packed.Add((float)(TargetPlayerIndex + 1) / MaxTargetPlayerIndex);
|
||||
return true;
|
||||
}
|
||||
|
||||
// 反序列化(读取存在位,再按位宽依序提取)
|
||||
// 对应解包,顺序必须与 TryPack 完全一致
|
||||
public bool TryUnpack(List<float> packed)
|
||||
{
|
||||
ActionId = (int)Math.Round(packed[0] * MaxActionID) - 1;
|
||||
PlayerIndex = (int)Math.Round(packed[1] * MaxPlayerIndex) - 1;
|
||||
UnitIndex = (int)Math.Round(packed[2] * MaxUnitIndex) - 1;
|
||||
CityIndex = (int)Math.Round(packed[3] * MaxCityIndex) - 1;
|
||||
GridIndex = (int)Math.Round(packed[4] * MaxGridIndex) - 1;
|
||||
TargetUnitIndex = (int)Math.Round(packed[5] * MaxTargetUnitIndex) - 1;
|
||||
TargetGridIndex = (int)Math.Round(packed[6] * MaxTargetGridIndex) - 1;
|
||||
TargetPlayerIndex = (int)Math.Round(packed[7] * MaxTargetPlayerIndex) - 1;
|
||||
|
||||
foreach (var value in packed)
|
||||
{
|
||||
if (value > 1 || value < 0)
|
||||
{
|
||||
LogSystem.LogError($"数据越界!!! {ActionId} {PlayerIndex} {UnitIndex} {CityIndex} {GridIndex} {TargetUnitIndex} {TargetGridIndex} {TargetPlayerIndex}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,3 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 199f5199fd66439f87cec581d0b58eba
|
||||
timeCreated: 1764056709
|
||||
@ -1474,6 +1474,10 @@ namespace Logic.Action
|
||||
|
||||
private void ReportBeforeActionDiagnostics(CommonActionParams actionParams)
|
||||
{
|
||||
#if UNITY_EDITOR
|
||||
if (Logic.AI.AIDirectorBatchRuntime.SkipPresentationWait) return;
|
||||
#endif
|
||||
|
||||
try
|
||||
{
|
||||
ReportBoatUnitOnLandBeforeAction(actionParams);
|
||||
@ -2069,6 +2073,10 @@ namespace Logic.Action
|
||||
var player = actionParam.PlayerData;
|
||||
var grid = actionParam.GridData;
|
||||
var map = actionParam.MapData;
|
||||
if (!map.GetCityDataByTerritoryGid(grid.Id, out _))
|
||||
return false;
|
||||
if (!Table.Instance.GridAndResourceDataAssets.GetWonderInfoByType(_actionId.WonderType, player, out _))
|
||||
return false;
|
||||
//找到grid所属于的player,如果是无主领土就return
|
||||
if (!map.GetPlayerDataByTerritoryGridId(grid.Id, out var gridPlayer))
|
||||
return false;
|
||||
|
||||
@ -839,7 +839,12 @@ namespace TH1_Logic.Action
|
||||
if (info.GiantEmpire != actionParams.PlayerData.Empire) return false;
|
||||
if (!ContentGate.CanUseHeroForPlayer(player, _actionId.GiantType))
|
||||
{
|
||||
#if UNITY_EDITOR
|
||||
if (!Logic.AI.AIDirectorBatchRuntime.SkipPresentationWait)
|
||||
LogSystem.LogWarning($"Blocked unavailable hero select: player={player.Id}, giant={_actionId.GiantType}");
|
||||
#else
|
||||
LogSystem.LogWarning($"Blocked unavailable hero select: player={player.Id}, giant={_actionId.GiantType}");
|
||||
#endif
|
||||
return false;
|
||||
}
|
||||
//Step #3 检查当前玩家的HeroData,是否已经出了英雄,且拥有空的槽位
|
||||
|
||||
@ -15,7 +15,6 @@ using Logic.CrashSight;
|
||||
using RuntimeData;
|
||||
using TH1_Core.Events;
|
||||
using TH1_Core.Managers;
|
||||
using TH1_Logic.AITrain;
|
||||
using TH1_Logic.Collect;
|
||||
using TH1_Logic.Core;
|
||||
using TH1_Logic.GameArchive;
|
||||
@ -156,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)
|
||||
@ -368,7 +367,7 @@ namespace Logic
|
||||
public override void Enter()
|
||||
{
|
||||
UIManager.Instance.AIPlayingHint.SetActive(true);
|
||||
MainEditor.Instance.OnAIStarted();
|
||||
if (AIKernelRegistry.CurrentKernelType == AIKernelType.BehaviourTree) MainEditor.Instance.OnAIStarted();
|
||||
}
|
||||
|
||||
public override void End()
|
||||
@ -661,12 +660,6 @@ namespace Logic
|
||||
var id = LobbyManager.Instance.Lobby.GetSelfMemberId();
|
||||
if (id != 0) OssManager.Instance.UploadCollectData(id.ToString(), Main.MapData);
|
||||
|
||||
#if ENABLE_TRAIN
|
||||
|
||||
TrainingDataRecorder.Instance.SaveEpisode();
|
||||
|
||||
#endif
|
||||
|
||||
// 添加延迟退出,确保数据保存完成
|
||||
_gameLogic.Main.StartCoroutine(QuitGameAfterDelay(10f));
|
||||
}
|
||||
@ -686,7 +679,7 @@ namespace Logic
|
||||
yield return new WaitForSeconds(delay);
|
||||
|
||||
// 退出游戏
|
||||
#if !UNITY_EDITOR && ENABLE_TRAIN && GAME_AUTO_DEBUG
|
||||
#if !UNITY_EDITOR && GAME_AUTO_DEBUG
|
||||
Application.Quit();
|
||||
#endif
|
||||
}
|
||||
|
||||
@ -18,7 +18,6 @@ using OPS.Obfuscator.Attribute;
|
||||
using RuntimeData;
|
||||
using TH1_Core.Events;
|
||||
using TH1_Core.Managers;
|
||||
using TH1_Logic.AITrain;
|
||||
using TH1_Logic.Collect;
|
||||
using TH1_Logic.Comic;
|
||||
using TH1_Logic.Config;
|
||||
@ -189,9 +188,6 @@ namespace TH1_Logic.Core
|
||||
|
||||
ConfirmMap = new PlayerConfirmMap();
|
||||
|
||||
// 模型暂时不需要
|
||||
//ModelInference.Instance.LoadModel();
|
||||
|
||||
//step #7 唤醒所有小弟的OnGameStart生命周期
|
||||
OnGameStart();
|
||||
|
||||
@ -857,7 +853,7 @@ namespace TH1_Logic.Core
|
||||
//目前fragmentmanager没用,全是走presentation的
|
||||
//FragmentManager.Instance.Update();
|
||||
InputConfigManager.Instance.Update();
|
||||
MainEditor.Instance.Update();
|
||||
if (AIKernelRegistry.CurrentKernelType == AIKernelType.BehaviourTree) MainEditor.Instance.Update();
|
||||
GameLogic.Update();
|
||||
|
||||
UIManager.Instance.Update();
|
||||
|
||||
815
Unity/Assets/Scripts/TH1_Logic/Editor/AIDirectorBatchRunner.cs
Normal file
815
Unity/Assets/Scripts/TH1_Logic/Editor/AIDirectorBatchRunner.cs
Normal file
@ -0,0 +1,815 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using Logic;
|
||||
using Logic.AI;
|
||||
using Logic.CrashSight;
|
||||
using RuntimeData;
|
||||
using TH1_Core.Managers;
|
||||
using TH1_Logic.Core;
|
||||
using TH1_Logic.MatchConfig;
|
||||
using TH1_Logic.Net;
|
||||
using TH1Renderer;
|
||||
using UnityEditor;
|
||||
using UnityEditor.SceneManagement;
|
||||
using UnityEngine;
|
||||
|
||||
namespace TH1_Logic.Editor
|
||||
{
|
||||
[InitializeOnLoad]
|
||||
public static class AIDirectorBatchRunner
|
||||
{
|
||||
private const string PendingKey = "TH1.AIDirectorBatch.Pending";
|
||||
private const string OptionsKey = "TH1.AIDirectorBatch.Options";
|
||||
private const string GameIndexKey = "TH1.AIDirectorBatch.GameIndex";
|
||||
private const string ResultsKey = "TH1.AIDirectorBatch.Results";
|
||||
|
||||
static AIDirectorBatchRunner()
|
||||
{
|
||||
if (SessionState.GetBool(PendingKey, false))
|
||||
{
|
||||
EditorApplication.playModeStateChanged -= OnPlayModeStateChanged;
|
||||
EditorApplication.playModeStateChanged += OnPlayModeStateChanged;
|
||||
}
|
||||
}
|
||||
|
||||
[MenuItem("Tools/TH1/AI Director/Run Batch")]
|
||||
public static void RunFromMenu()
|
||||
{
|
||||
StartBatch(ParseOptionsFromCommandLine());
|
||||
}
|
||||
|
||||
public static void Run()
|
||||
{
|
||||
StartBatch(ParseOptionsFromCommandLine());
|
||||
}
|
||||
|
||||
private static void StartBatch(BatchOptions options)
|
||||
{
|
||||
try
|
||||
{
|
||||
options.Normalize();
|
||||
Directory.CreateDirectory(options.OutputDirectory);
|
||||
SessionState.SetString(OptionsKey, JsonUtility.ToJson(options));
|
||||
SessionState.SetInt(GameIndexKey, 0);
|
||||
SessionState.SetString(ResultsKey, JsonUtility.ToJson(new BatchResultList()));
|
||||
SessionState.SetBool(PendingKey, true);
|
||||
|
||||
EditorApplication.playModeStateChanged -= OnPlayModeStateChanged;
|
||||
EditorApplication.playModeStateChanged += OnPlayModeStateChanged;
|
||||
|
||||
if (!EditorApplication.isPlaying)
|
||||
{
|
||||
OpenStartupScene(options.ScenePath);
|
||||
EditorApplication.EnterPlaymode();
|
||||
return;
|
||||
}
|
||||
|
||||
StartPlayModeRunner();
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Debug.LogError($"[AI.Batch] Start failed:\n{e}");
|
||||
ExitEditor(1);
|
||||
}
|
||||
}
|
||||
|
||||
private static void OnPlayModeStateChanged(PlayModeStateChange state)
|
||||
{
|
||||
if (!SessionState.GetBool(PendingKey, false)) return;
|
||||
if (state != PlayModeStateChange.EnteredPlayMode) return;
|
||||
|
||||
EditorApplication.playModeStateChanged -= OnPlayModeStateChanged;
|
||||
StartPlayModeRunner();
|
||||
}
|
||||
|
||||
private static void StartPlayModeRunner()
|
||||
{
|
||||
EditorApplication.update -= WaitForMainAndStartCoroutine;
|
||||
EditorApplication.update += WaitForMainAndStartCoroutine;
|
||||
}
|
||||
|
||||
private static void WaitForMainAndStartCoroutine()
|
||||
{
|
||||
if (!EditorApplication.isPlaying) return;
|
||||
if (Main.Instance == null) return;
|
||||
|
||||
EditorApplication.update -= WaitForMainAndStartCoroutine;
|
||||
Main.Instance.StartCoroutine(RunChecked(RunBatchCoroutine(), HandleFatalBatchException));
|
||||
}
|
||||
|
||||
private static IEnumerator RunBatchCoroutine()
|
||||
{
|
||||
var options = ReadOptions();
|
||||
var gameIndex = SessionState.GetInt(GameIndexKey, 0);
|
||||
var results = ReadResults();
|
||||
|
||||
while (gameIndex < options.Games)
|
||||
{
|
||||
var result = new BatchGameResult
|
||||
{
|
||||
gameIndex = gameIndex,
|
||||
startedAt = DateTime.UtcNow.ToString("o", CultureInfo.InvariantCulture)
|
||||
};
|
||||
|
||||
var gameDirectory = Path.Combine(options.OutputDirectory, $"game_{gameIndex + 1:000}");
|
||||
Directory.CreateDirectory(gameDirectory);
|
||||
|
||||
Exception gameException = null;
|
||||
yield return RunChecked(RunGameCoroutine(options, result, gameIndex), e => gameException = e);
|
||||
|
||||
if (gameException != null)
|
||||
{
|
||||
result.success = false;
|
||||
result.reason = $"Exception: {gameException.GetType().Name}: {gameException.Message}";
|
||||
result.stackTrace = gameException.ToString();
|
||||
Debug.LogError($"[AI.Batch] Game {gameIndex + 1} failed:\n{gameException}");
|
||||
}
|
||||
|
||||
#if UNITY_EDITOR
|
||||
AIDirectorBatchRuntime.ForceAllPlayersAi = false;
|
||||
AIDirectorBatchRuntime.SkipPresentationWait = false;
|
||||
AIDirectorBatchRuntime.CompactDiagnostics = false;
|
||||
#endif
|
||||
CompleteResult(result, gameDirectory);
|
||||
results.Add(result);
|
||||
WriteGameSummary(gameDirectory, result);
|
||||
WriteBatchSummary(options, results);
|
||||
SessionState.SetString(ResultsKey, JsonUtility.ToJson(new BatchResultList { results = results }));
|
||||
gameIndex++;
|
||||
SessionState.SetInt(GameIndexKey, gameIndex);
|
||||
|
||||
if (!result.success && options.FailFast) break;
|
||||
if (gameIndex < options.Games)
|
||||
{
|
||||
yield return ResetForNextGame();
|
||||
}
|
||||
}
|
||||
|
||||
var hasFailure = results.Any(item => !item.success);
|
||||
CleanupSessionState();
|
||||
Debug.Log($"[AI.Batch] Finished. games={results.Count}, failed={results.Count(item => !item.success)}, output={options.OutputDirectory}");
|
||||
ExitEditor(hasFailure ? 1 : 0);
|
||||
}
|
||||
|
||||
private static IEnumerator RunGameCoroutine(BatchOptions options, BatchGameResult result, int gameIndex)
|
||||
{
|
||||
yield return WaitForRuntimeReady(options);
|
||||
ConfigureDebugRuntime(options);
|
||||
AILogic.UseDirectorKernel();
|
||||
AIDirectorBatchRuntime.ForceAllPlayersAi = true;
|
||||
AIDirectorBatchRuntime.SkipPresentationWait = true;
|
||||
AIDirectorBatchRuntime.CompactDiagnostics = true;
|
||||
|
||||
var main = Main.Instance;
|
||||
main.MapConfig = BuildMapConfig(options, gameIndex);
|
||||
result.playerCount = (int)main.MapConfig.PlayerCount;
|
||||
result.width = (int)main.MapConfig.Width;
|
||||
result.height = (int)main.MapConfig.Height;
|
||||
result.aiKernel = AIKernelRegistry.CurrentKernelType.ToString();
|
||||
|
||||
Debug.Log($"[AI.Batch] Start game {gameIndex + 1}/{options.Games}: players={result.playerCount}, size={result.width}x{result.height}");
|
||||
main.StartMatch();
|
||||
|
||||
yield return null;
|
||||
yield return RunOneGame(options, result);
|
||||
}
|
||||
|
||||
private static IEnumerator RunChecked(IEnumerator root, Action<Exception> onException)
|
||||
{
|
||||
var stack = new Stack<IEnumerator>();
|
||||
stack.Push(root);
|
||||
|
||||
while (stack.Count > 0)
|
||||
{
|
||||
object current;
|
||||
try
|
||||
{
|
||||
var top = stack.Peek();
|
||||
if (!top.MoveNext())
|
||||
{
|
||||
stack.Pop();
|
||||
continue;
|
||||
}
|
||||
current = top.Current;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
onException?.Invoke(e);
|
||||
yield break;
|
||||
}
|
||||
|
||||
if (current is IEnumerator nested)
|
||||
{
|
||||
stack.Push(nested);
|
||||
continue;
|
||||
}
|
||||
|
||||
yield return current;
|
||||
}
|
||||
}
|
||||
|
||||
private static void HandleFatalBatchException(Exception exception)
|
||||
{
|
||||
Debug.LogError($"[AI.Batch] Fatal failure:\n{exception}");
|
||||
#if UNITY_EDITOR
|
||||
AIDirectorBatchRuntime.ForceAllPlayersAi = false;
|
||||
AIDirectorBatchRuntime.SkipPresentationWait = false;
|
||||
AIDirectorBatchRuntime.CompactDiagnostics = false;
|
||||
#endif
|
||||
try
|
||||
{
|
||||
var options = ReadOptionsSafe();
|
||||
var results = ReadResultsSafe();
|
||||
var gameIndex = SessionState.GetInt(GameIndexKey, 0);
|
||||
var gameDirectory = Path.Combine(options.OutputDirectory, $"game_{gameIndex + 1:000}");
|
||||
Directory.CreateDirectory(gameDirectory);
|
||||
|
||||
var result = new BatchGameResult
|
||||
{
|
||||
gameIndex = gameIndex,
|
||||
success = false,
|
||||
reason = $"FatalException:{exception.GetType().Name}:{exception.Message}",
|
||||
stackTrace = exception.ToString(),
|
||||
startedAt = DateTime.UtcNow.ToString("o", CultureInfo.InvariantCulture)
|
||||
};
|
||||
|
||||
CompleteResult(result, gameDirectory);
|
||||
results.Add(result);
|
||||
WriteGameSummary(gameDirectory, result);
|
||||
WriteBatchSummary(options, results);
|
||||
}
|
||||
catch (Exception writeException)
|
||||
{
|
||||
Debug.LogError($"[AI.Batch] Failed to write fatal summary:\n{writeException}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
CleanupSessionState();
|
||||
ExitEditor(1);
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerator WaitForRuntimeReady(BatchOptions options)
|
||||
{
|
||||
var deadline = EditorApplication.timeSinceStartup + options.StartupTimeoutSeconds;
|
||||
while (EditorApplication.timeSinceStartup <= deadline)
|
||||
{
|
||||
var main = Main.Instance;
|
||||
if (main != null
|
||||
&& main.GameLogic != null
|
||||
&& Main.PlayerLogic != null
|
||||
&& Main.CityLogic != null
|
||||
&& Main.UnitLogic != null
|
||||
&& UIManager.Instance != null)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
yield return null;
|
||||
}
|
||||
|
||||
throw new TimeoutException($"Runtime startup timeout after {options.StartupTimeoutSeconds} seconds.");
|
||||
}
|
||||
|
||||
private static IEnumerator ResetForNextGame()
|
||||
{
|
||||
var main = Main.Instance;
|
||||
if (main != null)
|
||||
{
|
||||
main.Clear();
|
||||
yield return null;
|
||||
while (PresentationManager.Busy) yield return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerator RunOneGame(BatchOptions options, BatchGameResult result)
|
||||
{
|
||||
var deadline = EditorApplication.timeSinceStartup + options.TimeoutSeconds;
|
||||
var lastNetActionCount = GetNetActionCount();
|
||||
var lastTurnKey = string.Empty;
|
||||
var actionsThisPlayerTurn = 0;
|
||||
var stagnantFrames = 0;
|
||||
|
||||
while (EditorApplication.timeSinceStartup <= deadline)
|
||||
{
|
||||
yield return null;
|
||||
result.frames++;
|
||||
|
||||
var map = Main.MapData;
|
||||
if (map == null)
|
||||
{
|
||||
result.success = false;
|
||||
result.reason = "Main.MapData is null.";
|
||||
yield break;
|
||||
}
|
||||
|
||||
result.netActions = GetNetActionCount();
|
||||
result.maxPlayerTurn = GetMaxPlayerTurn(map);
|
||||
result.curPlayerId = map.CurPlayer?.Id ?? 0;
|
||||
result.curPlayerTurn = map.CurPlayer?.Turn ?? 0;
|
||||
result.survivingPlayers = CountSurvivingPlayers(map);
|
||||
result.gameState = Main.Instance.GameLogic?.GetCurState().ToString() ?? string.Empty;
|
||||
|
||||
if (map.CheckIfGameEnd(out var isWin)
|
||||
|| Main.Instance.GameLogic?.GetCurState() == GameState.Finished)
|
||||
{
|
||||
result.success = true;
|
||||
result.reason = isWin ? "GameEnd:SelfOrTeamWin" : "GameEnd";
|
||||
yield break;
|
||||
}
|
||||
|
||||
if (options.MaxTurns > 0 && result.maxPlayerTurn >= options.MaxTurns)
|
||||
{
|
||||
result.success = true;
|
||||
result.reason = $"ReachedMaxTurns:{options.MaxTurns}";
|
||||
yield break;
|
||||
}
|
||||
|
||||
if (options.MaxActions > 0 && result.netActions >= options.MaxActions)
|
||||
{
|
||||
result.success = false;
|
||||
result.reason = $"ReachedMaxActions:{options.MaxActions}";
|
||||
yield break;
|
||||
}
|
||||
|
||||
var turnKey = $"{result.curPlayerId}:{result.curPlayerTurn}";
|
||||
if (turnKey != lastTurnKey)
|
||||
{
|
||||
lastTurnKey = turnKey;
|
||||
actionsThisPlayerTurn = 0;
|
||||
}
|
||||
|
||||
if (result.netActions > lastNetActionCount)
|
||||
{
|
||||
actionsThisPlayerTurn += result.netActions - lastNetActionCount;
|
||||
lastNetActionCount = result.netActions;
|
||||
stagnantFrames = 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
stagnantFrames++;
|
||||
}
|
||||
|
||||
if (options.MaxActionsPerPlayerTurn > 0 && actionsThisPlayerTurn > options.MaxActionsPerPlayerTurn)
|
||||
{
|
||||
result.success = false;
|
||||
result.reason = $"ActionsPerPlayerTurnGuard:{actionsThisPlayerTurn}>{options.MaxActionsPerPlayerTurn}";
|
||||
yield break;
|
||||
}
|
||||
|
||||
if (options.StagnantFrameLimit > 0 && stagnantFrames > options.StagnantFrameLimit)
|
||||
{
|
||||
result.success = false;
|
||||
result.reason = $"StagnantFrameGuard:{stagnantFrames}";
|
||||
yield break;
|
||||
}
|
||||
}
|
||||
|
||||
result.success = false;
|
||||
result.reason = $"Timeout:{options.TimeoutSeconds}s";
|
||||
}
|
||||
|
||||
private static MapConfig BuildMapConfig(BatchOptions options, int gameIndex)
|
||||
{
|
||||
var config = new MapConfig((uint)options.Width, (uint)options.Height, (uint)options.Players, 0, 0, options.Difficulty)
|
||||
{
|
||||
GameMode = GameMode.DOMINATION,
|
||||
MatchSettlement = MatchSettlementType.Normal,
|
||||
IsLimitTime = false,
|
||||
DisableNearbySpawnPoints = options.DisableNearbySpawnPoints,
|
||||
WaterType = options.WaterType
|
||||
};
|
||||
|
||||
config.SetPlayerCount((uint)options.Players, NetMode.Single);
|
||||
var used = new HashSet<uint>();
|
||||
for (var i = 0; i < options.Players; i++)
|
||||
{
|
||||
var civId = PickCivId(i + gameIndex, used);
|
||||
used.Add(civId);
|
||||
if (!config.SetSinglePlayerSlotCiv(i, civId, civId))
|
||||
{
|
||||
config.SetPlayerSlotRandomCiv(i, NetMode.Single);
|
||||
}
|
||||
}
|
||||
|
||||
config.EnsurePlayerSlots(NetMode.Single);
|
||||
return config;
|
||||
}
|
||||
|
||||
private static uint PickCivId(int preferredIndex, HashSet<uint> used)
|
||||
{
|
||||
const int defaultCivCount = 17;
|
||||
uint fallback = 0;
|
||||
var hasFallback = false;
|
||||
for (var offset = 0; offset < defaultCivCount; offset++)
|
||||
{
|
||||
var civId = (uint)((preferredIndex + offset) % defaultCivCount);
|
||||
if (!ContentGate.CanUseEmpire(civId, civId)) continue;
|
||||
if (!hasFallback)
|
||||
{
|
||||
fallback = civId;
|
||||
hasFallback = true;
|
||||
}
|
||||
if (!used.Contains(civId)) return civId;
|
||||
}
|
||||
|
||||
return hasFallback ? fallback : 0;
|
||||
}
|
||||
|
||||
private static void ConfigureDebugRuntime(BatchOptions options)
|
||||
{
|
||||
var main = Main.Instance;
|
||||
if (main != null)
|
||||
{
|
||||
main.NoAI = false;
|
||||
main.FullSight = true;
|
||||
main.AIActionTime = 0f;
|
||||
main.AnimationSpeed = Mathf.Max(1f, options.AnimationSpeed);
|
||||
main.DebugMode = true;
|
||||
main.DebugHideCenterMessage = true;
|
||||
}
|
||||
|
||||
if (DebugCenter.Instance == null) return;
|
||||
DebugCenter.Instance.DebugNoAI = false;
|
||||
DebugCenter.Instance.DebugSelfPlayerAllSight = true;
|
||||
DebugCenter.Instance.DebugAIActionTime = 0f;
|
||||
DebugCenter.Instance.AnimationSpeed = Mathf.Max(1f, options.AnimationSpeed);
|
||||
DebugCenter.Instance.DebugMode = true;
|
||||
DebugCenter.Instance.DebugHideCenterMessage = true;
|
||||
}
|
||||
|
||||
private static BatchOptions ParseOptionsFromCommandLine()
|
||||
{
|
||||
var options = new BatchOptions();
|
||||
options.Games = GetIntArg("-aiBatchGames", options.Games);
|
||||
options.Players = GetIntArg("-aiBatchPlayers", options.Players);
|
||||
options.Width = GetIntArg("-aiBatchWidth", options.Width);
|
||||
options.Height = GetIntArg("-aiBatchHeight", options.Height);
|
||||
options.MaxTurns = GetIntArg("-aiBatchTurns", options.MaxTurns);
|
||||
options.TimeoutSeconds = GetIntArg("-aiBatchTimeoutSeconds", options.TimeoutSeconds);
|
||||
options.StartupTimeoutSeconds = GetIntArg("-aiBatchStartupTimeoutSeconds", options.StartupTimeoutSeconds);
|
||||
options.MaxActions = GetIntArg("-aiBatchMaxActions", options.MaxActions);
|
||||
options.MaxActionsPerPlayerTurn = GetIntArg("-aiBatchMaxActionsPerPlayerTurn", options.MaxActionsPerPlayerTurn);
|
||||
options.StagnantFrameLimit = GetIntArg("-aiBatchStagnantFrames", options.StagnantFrameLimit);
|
||||
options.AnimationSpeed = GetFloatArg("-aiBatchAnimationSpeed", options.AnimationSpeed);
|
||||
options.FailFast = GetBoolArg("-aiBatchFailFast", options.FailFast);
|
||||
options.DisableNearbySpawnPoints = GetBoolArg("-aiBatchDisableNearbySpawnPoints", options.DisableNearbySpawnPoints);
|
||||
|
||||
var outputDirectory = GetStringArg("-aiBatchOut");
|
||||
if (!string.IsNullOrWhiteSpace(outputDirectory)) options.OutputDirectory = outputDirectory;
|
||||
|
||||
var scenePath = GetStringArg("-aiBatchScene");
|
||||
if (!string.IsNullOrWhiteSpace(scenePath)) options.ScenePath = scenePath;
|
||||
|
||||
var difficulty = GetStringArg("-aiBatchDifficulty");
|
||||
if (!string.IsNullOrWhiteSpace(difficulty) && Enum.TryParse(difficulty, true, out AIDifficult parsedDifficulty))
|
||||
options.Difficulty = parsedDifficulty;
|
||||
|
||||
var waterType = GetStringArg("-aiBatchWaterType");
|
||||
if (!string.IsNullOrWhiteSpace(waterType) && Enum.TryParse(waterType, true, out MapWaterType parsedWaterType))
|
||||
options.WaterType = parsedWaterType;
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
private static BatchOptions ReadOptions()
|
||||
{
|
||||
var json = SessionState.GetString(OptionsKey, string.Empty);
|
||||
var options = string.IsNullOrWhiteSpace(json)
|
||||
? new BatchOptions()
|
||||
: JsonUtility.FromJson<BatchOptions>(json);
|
||||
options.Normalize();
|
||||
return options;
|
||||
}
|
||||
|
||||
private static List<BatchGameResult> ReadResults()
|
||||
{
|
||||
var json = SessionState.GetString(ResultsKey, string.Empty);
|
||||
if (string.IsNullOrWhiteSpace(json)) return new List<BatchGameResult>();
|
||||
if (json.TrimStart().StartsWith("[", StringComparison.Ordinal)) return new List<BatchGameResult>();
|
||||
var list = JsonUtility.FromJson<BatchResultList>(json);
|
||||
return list?.results ?? new List<BatchGameResult>();
|
||||
}
|
||||
|
||||
private static BatchOptions ReadOptionsSafe()
|
||||
{
|
||||
try
|
||||
{
|
||||
return ReadOptions();
|
||||
}
|
||||
catch
|
||||
{
|
||||
var options = ParseOptionsFromCommandLine();
|
||||
options.Normalize();
|
||||
return options;
|
||||
}
|
||||
}
|
||||
|
||||
private static List<BatchGameResult> ReadResultsSafe()
|
||||
{
|
||||
try
|
||||
{
|
||||
return ReadResults();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new List<BatchGameResult>();
|
||||
}
|
||||
}
|
||||
|
||||
private static void CompleteResult(BatchGameResult result, string gameDirectory)
|
||||
{
|
||||
result.endedAt = DateTime.UtcNow.ToString("o", CultureInfo.InvariantCulture);
|
||||
result.summaryPath = Path.Combine(gameDirectory, "batch_game_summary.json").Replace('\\', '/');
|
||||
var map = Main.MapData;
|
||||
if (map == null) return;
|
||||
|
||||
result.mapId = map.MapID;
|
||||
result.netActions = GetNetActionCount();
|
||||
result.maxPlayerTurn = GetMaxPlayerTurn(map);
|
||||
result.curPlayerId = map.CurPlayer?.Id ?? 0;
|
||||
result.curPlayerTurn = map.CurPlayer?.Turn ?? 0;
|
||||
result.survivingPlayers = CountSurvivingPlayers(map);
|
||||
result.players = BuildPlayerResults(map);
|
||||
}
|
||||
|
||||
private static List<BatchPlayerResult> BuildPlayerResults(MapData map)
|
||||
{
|
||||
var results = new List<BatchPlayerResult>();
|
||||
var players = map.PlayerMap?.PlayerDataList;
|
||||
if (players == null) return results;
|
||||
|
||||
foreach (var player in players)
|
||||
{
|
||||
if (player == null) continue;
|
||||
results.Add(new BatchPlayerResult
|
||||
{
|
||||
id = player.Id,
|
||||
civId = player.PlayerCivId,
|
||||
forceId = player.PlayerForceId,
|
||||
turn = player.Turn,
|
||||
alive = player.Alive,
|
||||
score = player.PlayerScore,
|
||||
coin = player.PlayerCoin,
|
||||
techPoint = player.PlayerTechPoint,
|
||||
cityCount = CountPlayerCities(map, player.Id),
|
||||
unitCount = CountPlayerUnits(map, player.Id),
|
||||
isWin = map.MatchSettlement?.IsWin(player.Id) ?? false
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private static int CountPlayerCities(MapData map, uint playerId)
|
||||
{
|
||||
var cities = map.CityMap?.CityList;
|
||||
if (cities == null) return 0;
|
||||
var count = 0;
|
||||
foreach (var city in cities)
|
||||
{
|
||||
if (city != null && map.CheckCityIdBelongPlayerId(city.Id, playerId)) count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private static int CountPlayerUnits(MapData map, uint playerId)
|
||||
{
|
||||
var units = map.UnitMap?.UnitList;
|
||||
if (units == null) return 0;
|
||||
var count = 0;
|
||||
foreach (var unit in units)
|
||||
{
|
||||
if (unit != null && map.GetPlayerIdByUnitId(unit.Id, out var ownerId) && ownerId == playerId && unit.IsAlive()) count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private static int CountSurvivingPlayers(MapData map)
|
||||
{
|
||||
var players = map.PlayerMap?.PlayerDataList;
|
||||
if (players == null) return 0;
|
||||
var count = 0;
|
||||
foreach (var player in players)
|
||||
{
|
||||
if (player != null && player.IsSurvival) count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private static uint GetMaxPlayerTurn(MapData map)
|
||||
{
|
||||
var players = map.PlayerMap?.PlayerDataList;
|
||||
if (players == null || players.Count == 0) return 0;
|
||||
var maxTurn = 0u;
|
||||
foreach (var player in players)
|
||||
{
|
||||
if (player != null && player.Turn > maxTurn) maxTurn = player.Turn;
|
||||
}
|
||||
return maxTurn;
|
||||
}
|
||||
|
||||
private static int GetNetActionCount()
|
||||
{
|
||||
return Main.MapData?.Net?.Actions?.Count ?? 0;
|
||||
}
|
||||
|
||||
private static void WriteGameSummary(string gameDirectory, BatchGameResult result)
|
||||
{
|
||||
var path = Path.Combine(gameDirectory, "batch_game_summary.json");
|
||||
File.WriteAllText(path, JsonUtility.ToJson(result, true), Encoding.UTF8);
|
||||
}
|
||||
|
||||
private static void WriteBatchSummary(BatchOptions options, List<BatchGameResult> results)
|
||||
{
|
||||
var summary = new BatchSummary
|
||||
{
|
||||
options = options,
|
||||
results = results,
|
||||
generatedAt = DateTime.UtcNow.ToString("o", CultureInfo.InvariantCulture),
|
||||
totalGames = results.Count,
|
||||
failedGames = results.Count(item => !item.success)
|
||||
};
|
||||
var path = Path.Combine(options.OutputDirectory, "batch_summary.json");
|
||||
File.WriteAllText(path, JsonUtility.ToJson(summary, true), Encoding.UTF8);
|
||||
}
|
||||
|
||||
private static void OpenStartupScene(string scenePath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(scenePath))
|
||||
{
|
||||
var scene = EditorBuildSettings.scenes.FirstOrDefault(item => item.enabled);
|
||||
scenePath = scene?.path;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(scenePath)) return;
|
||||
if (!File.Exists(Path.Combine(ProjectRoot, scenePath)))
|
||||
{
|
||||
throw new FileNotFoundException($"Startup scene not found: {scenePath}");
|
||||
}
|
||||
|
||||
EditorSceneManager.OpenScene(scenePath);
|
||||
}
|
||||
|
||||
private static string GetStringArg(string name)
|
||||
{
|
||||
var args = Environment.GetCommandLineArgs();
|
||||
for (var i = 0; i < args.Length - 1; i++)
|
||||
{
|
||||
if (!string.Equals(args[i], name, StringComparison.OrdinalIgnoreCase)) continue;
|
||||
return args[i + 1];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static int GetIntArg(string name, int fallback)
|
||||
{
|
||||
var value = GetStringArg(name);
|
||||
return int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
private static float GetFloatArg(string name, float fallback)
|
||||
{
|
||||
var value = GetStringArg(name);
|
||||
return float.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
private static bool GetBoolArg(string name, bool fallback)
|
||||
{
|
||||
var value = GetStringArg(name);
|
||||
if (string.IsNullOrWhiteSpace(value)) return fallback;
|
||||
if (bool.TryParse(value, out var parsed)) return parsed;
|
||||
return value == "1" || value.Equals("yes", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static void CleanupSessionState()
|
||||
{
|
||||
SessionState.EraseBool(PendingKey);
|
||||
SessionState.EraseString(OptionsKey);
|
||||
SessionState.EraseInt(GameIndexKey);
|
||||
SessionState.EraseString(ResultsKey);
|
||||
}
|
||||
|
||||
private static void ExitEditor(int code)
|
||||
{
|
||||
if (Application.isBatchMode)
|
||||
{
|
||||
EditorApplication.Exit(code);
|
||||
return;
|
||||
}
|
||||
|
||||
if (EditorApplication.isPlaying)
|
||||
{
|
||||
EditorApplication.ExitPlaymode();
|
||||
}
|
||||
}
|
||||
|
||||
private static string ProjectRoot => Directory.GetParent(Application.dataPath).FullName;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class BatchOptions
|
||||
{
|
||||
public int Games = 1;
|
||||
public int Players = 17;
|
||||
public int Width = 30;
|
||||
public int Height = 30;
|
||||
public int MaxTurns = 100;
|
||||
public int TimeoutSeconds = 1800;
|
||||
public int StartupTimeoutSeconds = 180;
|
||||
public int MaxActions = 20000;
|
||||
public int MaxActionsPerPlayerTurn = 260;
|
||||
public int StagnantFrameLimit = 3600;
|
||||
public float AnimationSpeed = 100f;
|
||||
public bool FailFast = true;
|
||||
public bool DisableNearbySpawnPoints = false;
|
||||
public string OutputDirectory;
|
||||
public string ScenePath;
|
||||
public AIDifficult Difficulty = AIDifficult.LUNATIC;
|
||||
public MapWaterType WaterType = MapWaterType.Pangea;
|
||||
|
||||
public void Normalize()
|
||||
{
|
||||
Games = Mathf.Clamp(Games, 1, 1000);
|
||||
Players = Mathf.Clamp(Players, 2, 17);
|
||||
Width = Mathf.Clamp(Width, 8, 80);
|
||||
Height = Mathf.Clamp(Height, 8, 80);
|
||||
MaxTurns = Mathf.Max(0, MaxTurns);
|
||||
TimeoutSeconds = Mathf.Max(30, TimeoutSeconds);
|
||||
StartupTimeoutSeconds = Mathf.Max(30, StartupTimeoutSeconds);
|
||||
MaxActions = Mathf.Max(0, MaxActions);
|
||||
MaxActionsPerPlayerTurn = Mathf.Max(0, MaxActionsPerPlayerTurn);
|
||||
StagnantFrameLimit = Mathf.Max(0, StagnantFrameLimit);
|
||||
AnimationSpeed = Mathf.Max(1f, AnimationSpeed);
|
||||
if (string.IsNullOrWhiteSpace(OutputDirectory))
|
||||
{
|
||||
var root = Directory.GetParent(Application.dataPath).FullName;
|
||||
OutputDirectory = Path.Combine(root, "Logs", "AI_Batch", DateTime.Now.ToString("yyyyMMdd_HHmmss", CultureInfo.InvariantCulture));
|
||||
}
|
||||
OutputDirectory = Path.GetFullPath(OutputDirectory);
|
||||
}
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class BatchSummary
|
||||
{
|
||||
public string generatedAt;
|
||||
public int totalGames;
|
||||
public int failedGames;
|
||||
public BatchOptions options;
|
||||
public List<BatchGameResult> results;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class BatchResultList
|
||||
{
|
||||
public List<BatchGameResult> results = new();
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class BatchGameResult
|
||||
{
|
||||
public int gameIndex;
|
||||
public bool success;
|
||||
public string reason;
|
||||
public string startedAt;
|
||||
public string endedAt;
|
||||
public string summaryPath;
|
||||
public string stackTrace;
|
||||
public string aiKernel;
|
||||
public string gameState;
|
||||
public uint mapId;
|
||||
public int playerCount;
|
||||
public int width;
|
||||
public int height;
|
||||
public int frames;
|
||||
public int netActions;
|
||||
public uint maxPlayerTurn;
|
||||
public uint curPlayerId;
|
||||
public uint curPlayerTurn;
|
||||
public int survivingPlayers;
|
||||
public List<BatchPlayerResult> players = new();
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class BatchPlayerResult
|
||||
{
|
||||
public uint id;
|
||||
public uint civId;
|
||||
public uint forceId;
|
||||
public uint turn;
|
||||
public bool alive;
|
||||
public bool isWin;
|
||||
public int score;
|
||||
public int coin;
|
||||
public int techPoint;
|
||||
public int cityCount;
|
||||
public int unitCount;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4ea05ed6538d4a439ba76df86c185fe4
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@ -1,99 +0,0 @@
|
||||
/*
|
||||
* @Author: 白哉
|
||||
* @Description:
|
||||
* @Date: 2025年07月25日 星期五 14:07:55
|
||||
* @Modify:
|
||||
*/
|
||||
|
||||
|
||||
using System;
|
||||
using System.Linq;
|
||||
using Logic.Action;
|
||||
using Logic.Config;
|
||||
using TH1_Logic.AITrain;
|
||||
using Unity.VisualScripting;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
|
||||
namespace Logic.Editor
|
||||
{
|
||||
public class AITrainEditorWindow : EditorWindow
|
||||
{
|
||||
// 背景
|
||||
private GUIStyle _redBoxStyle;
|
||||
private GUIStyle _whiteBoxStyle;
|
||||
|
||||
|
||||
[MenuItem("Tools/AI训练窗口")]
|
||||
private static void ShowWindow()
|
||||
{
|
||||
var window = GetWindow<AITrainEditorWindow>();
|
||||
window.titleContent = new GUIContent("AI训练窗口");
|
||||
window.Show();
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
private void OnGUI()
|
||||
{
|
||||
if (_redBoxStyle == null)
|
||||
{
|
||||
_redBoxStyle = InspectorUtils.GetHelpBoxStyle();
|
||||
InspectorUtils.AddBorder(_redBoxStyle, new Color(0.5f, 0.4f, 0.4f, 0.6f));
|
||||
}
|
||||
if (_whiteBoxStyle == null)
|
||||
{
|
||||
_whiteBoxStyle = InspectorUtils.GetHelpBoxStyle();
|
||||
InspectorUtils.AddBorder(_whiteBoxStyle, new Color(1f, 1f, 1f, 0.2f));
|
||||
}
|
||||
|
||||
if (InspectorUtils.InspectorButtonWithTextWidth($"构建 AI 行为集"))
|
||||
{
|
||||
ActionLogicIdData data = null;
|
||||
var bytes = LoadActionLogicIdData();
|
||||
if (bytes.Length > 0) data = MemoryPack.MemoryPackSerializer.Deserialize<ActionLogicIdData>(bytes);
|
||||
if (data == null) data = new ActionLogicIdData();
|
||||
|
||||
var dict = ActionLogicFactory.GetActionLogicDict();
|
||||
foreach (var actionId in dict.Keys) data.AddActionId(actionId);
|
||||
SaveActionLogicIdData(MemoryPack.MemoryPackSerializer.Serialize(data));
|
||||
Debug.LogError($"Action数量: {data.ActionIdList.Count}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private byte[] LoadActionLogicIdData()
|
||||
{
|
||||
TextAsset asset = TH1Resource.ResourceLoader.Load<TextAsset>($"CommonIdData/CommonIdData");
|
||||
return asset?.bytes ?? Array.Empty<byte>();
|
||||
}
|
||||
|
||||
private void SaveActionLogicIdData(byte[] data)
|
||||
{
|
||||
// 构建完整路径(Resources 子目录)
|
||||
string directory = "Assets/BundleResources/CommonIdData";
|
||||
string filePath = $"{directory}/CommonIdData.bytes";
|
||||
|
||||
// 检查目录,不存在则创建
|
||||
if (!System.IO.Directory.Exists(directory))
|
||||
{
|
||||
System.IO.Directory.CreateDirectory(directory);
|
||||
}
|
||||
// 写入文件(存在则覆盖,不存在则创建)
|
||||
try
|
||||
{
|
||||
System.IO.File.WriteAllBytes(filePath, data);
|
||||
// 刷新 Unity 资源数据库
|
||||
AssetDatabase.Refresh();
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Debug.LogError($"写入文件失败: {e.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,3 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5b0319a8d0354ad2a6899d5f6ddaf928
|
||||
timeCreated: 1764405047
|
||||
@ -18,8 +18,6 @@ namespace Logic.Editor
|
||||
{
|
||||
// 定义宏名称
|
||||
public const string ENABLE_SPEEDUP = "ENABLE_SPEEDUP";
|
||||
public const string ENABLE_TRAIN = "ENABLE_TRAIN";
|
||||
public const string ENABLE_AIMODEL = "ENABLE_AIMODEL";
|
||||
public const string GAME_AUTO_DEBUG = "GAME_AUTO_DEBUG";
|
||||
public const string CHECK_ACTIONDEFFERENCE = "CHECK_ACTIONDEFFERENCE";
|
||||
public const string STEAM_TEST = "STEAM_TEST";
|
||||
@ -132,28 +130,6 @@ namespace Logic.Editor
|
||||
EditorGUILayout.EndVertical();
|
||||
|
||||
|
||||
EditorGUILayout.BeginHorizontal();
|
||||
#if ENABLE_AIMODEL
|
||||
InspectorUtils.InspectorTextWidthRich($"<b><color=red>AI模型已开启:</color></b>");
|
||||
if (InspectorUtils.InspectorButtonWithTextWidth($"关闭AI模型")) RemoveDefine(ENABLE_AIMODEL);
|
||||
#else
|
||||
InspectorUtils.InspectorTextWidthRich($"<b>AI模型已关闭:</b>");
|
||||
if (InspectorUtils.InspectorButtonWithTextWidth($"开启AI模型")) AddDefine(ENABLE_AIMODEL);
|
||||
#endif
|
||||
EditorGUILayout.EndHorizontal();
|
||||
|
||||
|
||||
EditorGUILayout.BeginHorizontal();
|
||||
#if ENABLE_TRAIN
|
||||
InspectorUtils.InspectorTextWidthRich($"<b><color=red>训练模式已开启:</color></b>");
|
||||
if (InspectorUtils.InspectorButtonWithTextWidth($"关闭训练模式")) RemoveDefine(ENABLE_TRAIN);
|
||||
#else
|
||||
InspectorUtils.InspectorTextWidthRich($"<b>训练模式已关闭:</b>");
|
||||
if (InspectorUtils.InspectorButtonWithTextWidth($"开启训练模式")) AddDefine(ENABLE_TRAIN);
|
||||
#endif
|
||||
EditorGUILayout.EndHorizontal();
|
||||
|
||||
|
||||
EditorGUILayout.BeginHorizontal();
|
||||
#if ENABLE_SPEEDUP
|
||||
InspectorUtils.InspectorTextWidthRich($"<b><color=red>加速模式已开启:</color></b>");
|
||||
@ -224,8 +200,6 @@ namespace Logic.Editor
|
||||
{
|
||||
AddDefine(STEAM_CHANNEL);
|
||||
RemoveDefine(ENABLE_SPEEDUP);
|
||||
RemoveDefine(ENABLE_TRAIN);
|
||||
RemoveDefine(ENABLE_AIMODEL);
|
||||
RemoveDefine(GAME_AUTO_DEBUG);
|
||||
RemoveDefine(CHECK_ACTIONDEFFERENCE);
|
||||
RemoveDefine(STEAM_TEST);
|
||||
@ -373,4 +347,4 @@ namespace Logic.Editor
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
|
||||
using System;
|
||||
using System.Text;
|
||||
using Logic.CrashSight;
|
||||
using Logic.Multilingual;
|
||||
using Steamworks;
|
||||
using TH1_Logic.Core;
|
||||
@ -208,6 +209,9 @@ namespace Logic.Editor
|
||||
private string _multilingualSelectedText = "测试多语言选中文本";
|
||||
private string _multilingualDescription = "测试多语言问题自述";
|
||||
private MultilingualType _multilingualLanguage = MultilingualType.EN;
|
||||
private string _questionnaireId = "upload-flow-test";
|
||||
private string _questionnaireSingleChoiceId = "test-choice-a";
|
||||
private string _questionnaireOpenText = "测试问卷开放题回答";
|
||||
private string _status = "就绪";
|
||||
private string _lastObjectKey = "";
|
||||
|
||||
@ -304,6 +308,10 @@ namespace Logic.Editor
|
||||
_multilingualSelectedText = EditorGUILayout.TextArea(_multilingualSelectedText, GUILayout.MinHeight(55));
|
||||
EditorGUILayout.LabelField("多语言玩家自述");
|
||||
_multilingualDescription = EditorGUILayout.TextArea(_multilingualDescription, GUILayout.MinHeight(55));
|
||||
_questionnaireId = EditorGUILayout.TextField("问卷 ID", _questionnaireId);
|
||||
_questionnaireSingleChoiceId = EditorGUILayout.TextField("问卷单选选项 ID", _questionnaireSingleChoiceId);
|
||||
EditorGUILayout.LabelField("问卷开放题回答");
|
||||
_questionnaireOpenText = EditorGUILayout.TextArea(_questionnaireOpenText, GUILayout.MinHeight(45));
|
||||
|
||||
EditorGUILayout.EndVertical();
|
||||
}
|
||||
@ -323,7 +331,9 @@ namespace Logic.Editor
|
||||
TestBugReportUpload();
|
||||
if (GUILayout.Button("4. 多语言汇报上传 type=multilingualreport", GUILayout.Height(28)))
|
||||
TestMultilingualReportUpload();
|
||||
if (GUILayout.Button("顺序测试 1-4", GUILayout.Height(32)))
|
||||
if (GUILayout.Button("5. 问卷上传 type=questionnaire", GUILayout.Height(28)))
|
||||
TestQuestionnaireUpload();
|
||||
if (GUILayout.Button("顺序测试 1-5", GUILayout.Height(32)))
|
||||
TestAll();
|
||||
}
|
||||
|
||||
@ -412,9 +422,29 @@ namespace Logic.Editor
|
||||
});
|
||||
}
|
||||
|
||||
private async void TestQuestionnaireUpload()
|
||||
{
|
||||
await RunTest("问卷上传", async () =>
|
||||
{
|
||||
var steamId = GetSteamId();
|
||||
var authTicket = GetAuthTicket();
|
||||
var questionnaireBytes = BuildQuestionnairePayloadBytes(steamId);
|
||||
var credentials = await _stsService.RequestStsTokenAsync(steamId, authTicket, "questionnaire",
|
||||
_version);
|
||||
if (!IsQuestionnaireObjectKey(credentials?.objectKey))
|
||||
throw new Exception($"服务端返回了非问卷 objectKey: {credentials?.objectKey}");
|
||||
var success = await _uploadService.UploadFileAsync(credentials, questionnaireBytes,
|
||||
"application/json", MaxQuestionnaireUploadBytes);
|
||||
_lastObjectKey = credentials.objectKey ?? "";
|
||||
return success
|
||||
? $"问卷上传成功: {credentials.objectKey}"
|
||||
: $"问卷上传失败: {credentials.objectKey}";
|
||||
});
|
||||
}
|
||||
|
||||
private async void TestAll()
|
||||
{
|
||||
await RunTest("顺序测试 1-4", async () =>
|
||||
await RunTest("顺序测试 1-5", async () =>
|
||||
{
|
||||
var steamId = GetSteamId();
|
||||
var authTicket = GetAuthTicket();
|
||||
@ -438,8 +468,16 @@ namespace Logic.Editor
|
||||
var multilingualOk = await OssManager.Instance.UploadPlayerMultilingualReportAsync(steamId,
|
||||
multilingualPackage.Data, multilingualPackage.Manifest.version);
|
||||
|
||||
_lastObjectKey = multilingualOk.objectKey ?? "";
|
||||
return $"顺序测试完成: steamauth cached={warmup.cached}; ossdata={(normalOk ? "成功" : "失败")} {credentials.objectKey}; bugreport={(bugOk.success ? "成功" : "失败")} {bugOk.objectKey}; multilingualreport={(multilingualOk.success ? "成功" : "失败")} {multilingualOk.objectKey}";
|
||||
var questionnaireBytes = BuildQuestionnairePayloadBytes(steamId);
|
||||
var questionnaireCredentials = await _stsService.RequestStsTokenAsync(steamId, authTicket,
|
||||
"questionnaire", _version);
|
||||
if (!IsQuestionnaireObjectKey(questionnaireCredentials?.objectKey))
|
||||
throw new Exception($"服务端返回了非问卷 objectKey: {questionnaireCredentials?.objectKey}");
|
||||
var questionnaireOk = await _uploadService.UploadFileAsync(questionnaireCredentials,
|
||||
questionnaireBytes, "application/json", MaxQuestionnaireUploadBytes);
|
||||
|
||||
_lastObjectKey = questionnaireCredentials.objectKey ?? "";
|
||||
return $"顺序测试完成: steamauth cached={warmup.cached}; ossdata={(normalOk ? "成功" : "失败")} {credentials.objectKey}; bugreport={(bugOk.success ? "成功" : "失败")} {bugOk.objectKey}; multilingualreport={(multilingualOk.success ? "成功" : "失败")} {multilingualOk.objectKey}; questionnaire={(questionnaireOk ? "成功" : "失败")} {questionnaireCredentials.objectKey}";
|
||||
});
|
||||
}
|
||||
|
||||
@ -511,5 +549,145 @@ namespace Logic.Editor
|
||||
return "(Steam 未初始化)";
|
||||
}
|
||||
}
|
||||
|
||||
private const int MaxQuestionnaireUploadBytes = 512 * 1024;
|
||||
|
||||
private static bool IsQuestionnaireObjectKey(string objectKey)
|
||||
{
|
||||
return !string.IsNullOrEmpty(objectKey)
|
||||
&& objectKey.StartsWith("questionnaire/", StringComparison.Ordinal)
|
||||
&& objectKey.EndsWith(".json", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
private class QuestionnaireTestPayload
|
||||
{
|
||||
public string schema = "th1.questionnaire-answer.v1";
|
||||
public string responseId;
|
||||
public string questionnaireId;
|
||||
public long submittedAtUnix;
|
||||
public string submittedAtUtc;
|
||||
public string createdAtUtc;
|
||||
public string createdAtLocal;
|
||||
public string timezone;
|
||||
public string steamId;
|
||||
public string version;
|
||||
public string unityVersion;
|
||||
public string platform;
|
||||
public string crashSightDeviceId;
|
||||
public string deviceModel;
|
||||
public string deviceName;
|
||||
public string operatingSystem;
|
||||
public string processorType;
|
||||
public int processorCount;
|
||||
public int systemMemorySizeMb;
|
||||
public string graphicsDeviceName;
|
||||
public int graphicsMemorySizeMb;
|
||||
public QuestionnaireTestAnswerSheet answerSheet;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
private class QuestionnaireTestAnswerSheet
|
||||
{
|
||||
public string QuestionnaireId;
|
||||
public long SubmittedAtUnix;
|
||||
public string SubmittedAtUtc;
|
||||
public string LastUploadObjectKey = "";
|
||||
public string LastUploadedAtUtc = "";
|
||||
public string LastUploadError = "";
|
||||
public QuestionnaireTestAnswer[] Answers;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
private class QuestionnaireTestAnswer
|
||||
{
|
||||
public string QuestionId;
|
||||
public int QuestionType;
|
||||
public string[] SelectedOptionIds;
|
||||
public string OpenText;
|
||||
}
|
||||
|
||||
private byte[] BuildQuestionnairePayloadBytes(string steamId)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var questionnaireId = string.IsNullOrWhiteSpace(_questionnaireId)
|
||||
? "upload-flow-test"
|
||||
: _questionnaireId.Trim();
|
||||
var selectedOptionId = string.IsNullOrWhiteSpace(_questionnaireSingleChoiceId)
|
||||
? "test-choice-a"
|
||||
: _questionnaireSingleChoiceId.Trim();
|
||||
var openText = string.IsNullOrWhiteSpace(_questionnaireOpenText)
|
||||
? "测试问卷开放题回答"
|
||||
: _questionnaireOpenText.Trim();
|
||||
var submittedAtUtc = now.UtcDateTime.ToString("O");
|
||||
var answerSheet = new QuestionnaireTestAnswerSheet
|
||||
{
|
||||
QuestionnaireId = questionnaireId,
|
||||
SubmittedAtUnix = now.ToUnixTimeSeconds(),
|
||||
SubmittedAtUtc = submittedAtUtc,
|
||||
Answers = new[]
|
||||
{
|
||||
new QuestionnaireTestAnswer
|
||||
{
|
||||
QuestionId = "upload-flow-single-choice",
|
||||
QuestionType = 1,
|
||||
SelectedOptionIds = new[] { selectedOptionId },
|
||||
OpenText = ""
|
||||
},
|
||||
new QuestionnaireTestAnswer
|
||||
{
|
||||
QuestionId = "upload-flow-open",
|
||||
QuestionType = 0,
|
||||
SelectedOptionIds = Array.Empty<string>(),
|
||||
OpenText = openText
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var payload = new QuestionnaireTestPayload
|
||||
{
|
||||
responseId = Guid.NewGuid().ToString("N"),
|
||||
questionnaireId = questionnaireId,
|
||||
submittedAtUnix = answerSheet.SubmittedAtUnix,
|
||||
submittedAtUtc = submittedAtUtc,
|
||||
createdAtUtc = DateTime.UtcNow.ToString("O"),
|
||||
createdAtLocal = DateTime.Now.ToString("O"),
|
||||
timezone = GetLocalTimezone(),
|
||||
steamId = steamId ?? "",
|
||||
version = string.IsNullOrWhiteSpace(_version) ? PlayerBugReportService.GetCurrentVersion() : _version.Trim(),
|
||||
unityVersion = Application.unityVersion,
|
||||
platform = Application.platform.ToString(),
|
||||
crashSightDeviceId = CrashSightManager.GetCrashSightDeviceId(),
|
||||
deviceModel = SystemInfo.deviceModel,
|
||||
deviceName = SystemInfo.deviceName,
|
||||
operatingSystem = SystemInfo.operatingSystem,
|
||||
processorType = SystemInfo.processorType,
|
||||
processorCount = SystemInfo.processorCount,
|
||||
systemMemorySizeMb = SystemInfo.systemMemorySize,
|
||||
graphicsDeviceName = SystemInfo.graphicsDeviceName,
|
||||
graphicsMemorySizeMb = SystemInfo.graphicsMemorySize,
|
||||
answerSheet = answerSheet
|
||||
};
|
||||
|
||||
var bytes = new UTF8Encoding(false).GetBytes(JsonUtility.ToJson(payload, true));
|
||||
if (bytes.Length > MaxQuestionnaireUploadBytes)
|
||||
throw new Exception($"问卷测试 payload 大小 {bytes.Length} 超过 {MaxQuestionnaireUploadBytes} 字节限制");
|
||||
|
||||
return bytes;
|
||||
}
|
||||
|
||||
private static string GetLocalTimezone()
|
||||
{
|
||||
try
|
||||
{
|
||||
var offset = DateTimeOffset.Now.Offset;
|
||||
var sign = offset < TimeSpan.Zero ? "-" : "+";
|
||||
return $"{TimeZoneInfo.Local.Id} (UTC{sign}{offset.Duration():hh\\:mm})";
|
||||
}
|
||||
catch
|
||||
{
|
||||
return DateTimeOffset.Now.Offset.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -35,8 +35,6 @@ namespace TH1_Logic.Editor
|
||||
"STEAM_CHANNEL",
|
||||
"USE_INPUT",
|
||||
"ENABLE_SPEEDUP",
|
||||
"ENABLE_TRAIN",
|
||||
"ENABLE_AIMODEL",
|
||||
"GAME_AUTO_DEBUG",
|
||||
"CHECK_ACTIONDEFFERENCE",
|
||||
"STEAM_TEST",
|
||||
|
||||
@ -42,8 +42,6 @@ namespace TH1_Logic.Editor
|
||||
"STEAM_CHANNEL",
|
||||
"USE_INPUT",
|
||||
"ENABLE_SPEEDUP",
|
||||
"ENABLE_TRAIN",
|
||||
"ENABLE_AIMODEL",
|
||||
"GAME_AUTO_DEBUG",
|
||||
"CHECK_ACTIONDEFFERENCE",
|
||||
"STEAM_TEST",
|
||||
@ -67,8 +65,6 @@ namespace TH1_Logic.Editor
|
||||
private bool _buildYooAssetAb = true;
|
||||
private bool _showAdvanced;
|
||||
private bool _enableSpeedup;
|
||||
private bool _enableTrain;
|
||||
private bool _enableAiModel;
|
||||
private bool _gameAutoDebug;
|
||||
private bool _checkActionDifference;
|
||||
private bool _steamTest;
|
||||
@ -217,8 +213,6 @@ namespace TH1_Logic.Editor
|
||||
using (new EditorGUI.DisabledScope(_package == PackageProfile.Release))
|
||||
{
|
||||
_enableSpeedup = EditorGUILayout.ToggleLeft("加速模式 ENABLE_SPEEDUP", _enableSpeedup);
|
||||
_enableTrain = EditorGUILayout.ToggleLeft("训练模式 ENABLE_TRAIN", _enableTrain);
|
||||
_enableAiModel = EditorGUILayout.ToggleLeft("AI 模型 ENABLE_AIMODEL", _enableAiModel);
|
||||
_gameAutoDebug = EditorGUILayout.ToggleLeft("自动战斗 GAME_AUTO_DEBUG", _gameAutoDebug);
|
||||
_checkActionDifference = EditorGUILayout.ToggleLeft("MapData 变化检查 CHECK_ACTIONDEFFERENCE", _checkActionDifference);
|
||||
_steamTest = EditorGUILayout.ToggleLeft("Steam 测试窗口 STEAM_TEST", _platform == BuildPlatformProfile.PC && _steamTest);
|
||||
@ -720,8 +714,6 @@ namespace TH1_Logic.Editor
|
||||
private void AddSpecialDefines(HashSet<string> defines)
|
||||
{
|
||||
if (_enableSpeedup) defines.Add("ENABLE_SPEEDUP");
|
||||
if (_enableTrain) defines.Add("ENABLE_TRAIN");
|
||||
if (_enableAiModel) defines.Add("ENABLE_AIMODEL");
|
||||
if (_gameAutoDebug) defines.Add("GAME_AUTO_DEBUG");
|
||||
if (_checkActionDifference) defines.Add("CHECK_ACTIONDEFFERENCE");
|
||||
if (_platform == BuildPlatformProfile.PC && _steamTest) defines.Add("STEAM_TEST");
|
||||
@ -1027,8 +1019,6 @@ namespace TH1_Logic.Editor
|
||||
{
|
||||
if (_package != PackageProfile.Release) return;
|
||||
_enableSpeedup = false;
|
||||
_enableTrain = false;
|
||||
_enableAiModel = false;
|
||||
_gameAutoDebug = false;
|
||||
_checkActionDifference = false;
|
||||
_steamTest = false;
|
||||
|
||||
@ -259,6 +259,7 @@ namespace TH1_Logic.Oss
|
||||
|
||||
public void UpdateSteamAuthWarmup()
|
||||
{
|
||||
if (Logic.AI.AIDirectorBatchRuntime.SkipPresentationWait) return;
|
||||
if (_isSteamAuthWarmupRunning) return;
|
||||
if (DateTime.UtcNow < _nextSteamAuthWarmupTime) return;
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user