diff --git a/.codex/skills/th1-ai-director/SKILL.md b/.codex/skills/th1-ai-director/SKILL.md new file mode 100644 index 000000000..70923ab0a --- /dev/null +++ b/.codex/skills/th1-ai-director/SKILL.md @@ -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//`. Use `-Games 10`, higher `-Turns`, and the normal 17 players for larger AI quality loops. diff --git a/.codex/skills/th1-ai-director/agents/openai.yaml b/.codex/skills/th1-ai-director/agents/openai.yaml new file mode 100644 index 000000000..746d12152 --- /dev/null +++ b/.codex/skills/th1-ai-director/agents/openai.yaml @@ -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." diff --git a/.codex/skills/th1-ai-director/scripts/analyze_ai_director_log.py b/.codex/skills/th1-ai-director/scripts/analyze_ai_director_log.py new file mode 100644 index 000000000..41c600673 --- /dev/null +++ b/.codex/skills/th1-ai-director/scripts/analyze_ai_director_log.py @@ -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(" ") + 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() diff --git a/.gitignore b/.gitignore index f90acf72f..2206fa7d5 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/MD/ChronicIssueList/2026-06-29-ai-director-zero-effect-action-loop.md b/MD/ChronicIssueList/2026-06-29-ai-director-zero-effect-action-loop.md new file mode 100644 index 000000000..e2af88384 --- /dev/null +++ b/MD/ChronicIssueList/2026-06-29-ai-director-zero-effect-action-loop.md @@ -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. diff --git a/MD/ChronicIssueList/index.md b/MD/ChronicIssueList/index.md index 87dd14b50..952ebea9e 100644 --- a/MD/ChronicIssueList/index.md +++ b/MD/ChronicIssueList/index.md @@ -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) | diff --git a/MD/GameMDFramework/00-主文档-游戏架构总览.md b/MD/GameMDFramework/00-主文档-游戏架构总览.md index 6ab727fde..ac971380c 100644 --- a/MD/GameMDFramework/00-主文档-游戏架构总览.md +++ b/MD/GameMDFramework/00-主文档-游戏架构总览.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/ # 玩家逻辑 diff --git a/MD/GameMDFramework/15-服务端-GameUploadFunction.md b/MD/GameMDFramework/15-服务端-GameUploadFunction.md index d4d4d74fa..c6cb8425f 100644 --- a/MD/GameMDFramework/15-服务端-GameUploadFunction.md +++ b/MD/GameMDFramework/15-服务端-GameUploadFunction.md @@ -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`,玩家重新提交时会再次上传。 diff --git a/MD/GameMDFramework/18-AI导演系统策划文档.md b/MD/GameMDFramework/18-AI导演系统策划文档.md new file mode 100644 index 000000000..6293991ed --- /dev/null +++ b/MD/GameMDFramework/18-AI导演系统策划文档.md @@ -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、移动候选、局部搜索半径 | diff --git a/MD/GameMDFramework/19-AI导演系统逻辑语言.md b/MD/GameMDFramework/19-AI导演系统逻辑语言.md new file mode 100644 index 000000000..0471e260d --- /dev/null +++ b/MD/GameMDFramework/19-AI导演系统逻辑语言.md @@ -0,0 +1,2186 @@ +# 19-AI导演系统逻辑语言 + +> 本文档把 [18-AI导演系统策划文档](./18-AI导演系统策划文档.md) 翻译成可落地到代码的逻辑语言。 +> 本文档只描述新 AI 目标实现,不对照既有 AI 实现。 + +--- + +## 1. 文档约定 + +### 1.1 伪代码格式 + +```text +函数 X(ctx, a, b): + 如果 条件: + 返回 value + + 对每个 item in list: + ... + + 返回 value +``` + +### 1.2 动作约束 + +AI 只能选择合法 Action。 + +```text +AIAction: + CommonActionId + CommonActionParams + ActionLogicBase +``` + +执行动作必须走: + +```text +Param.RefreshParams() +ActionLogic.CheckCan(Param) +ActionLogic.CompleteExecute(Param) +``` + +AI 不直接修改: + +```text +MapData +PlayerData +CityData +UnitData +GridData +SkillBase +``` + +### 1.3 决策原则 + +```text +车道之间用固定优先级。 +车道内部用轻量分数排序。 +分数只比较同类动作,不做全局万能评分。 +所有最终动作必须再次 CheckCan。 +同分时使用稳定排序,避免随机和不同步。 +``` + +--- + +## 2. 核心结构 + +### 2.1 AIContext + +```text +结构 AIContext: + Map: MapData + Player: PlayerData + Config: AIConfig + Cache: WorldCache + Actions: ActionPool + Trace: List +``` + +### 2.2 AIConfig + +```text +结构 AIConfig: + EmergencyRange = 3 + FrontRange = 6 + LocalBattleRange = 2 + DevelopmentSearchRange = 6 + MaxActionCount = 4096 + MaxFrontCount = 12 + MaxDevelopmentTargetCount = 20 + LowHpRatio = 0.45 + CriticalHpRatio = 0.25 + HeroLowHpRatio = 0.60 + CityDangerEnemyCount = 2 + CityCriticalEnemyCount = 4 + CityThreatPowerRatio = 1.25 + HeroTaskStartTurn = 8 + HeroTaskInterval = 4 + HeroLevel2MinTurn = 15 + HeroLevel3MinTurn = 25 +``` + +这些是默认值。难度、地图尺寸和关卡规则可以覆盖 Config,但不能改变车道顺序。 + +### 2.3 Lane + +```text +枚举 Lane: + Emergency + HeroManagement + HeroPlaybook + Tactic + UnitOpportunity + Front + Growth + Fallback +``` + +### 2.4 Candidate + +```text +结构 Candidate: + Action: AIAction + Lane: Lane + Priority: float + Reason: string + Unit: UnitData + TargetUnit: UnitData + City: CityData + Grid: GridData + TargetGrid: GridData + IsFallback: bool + +函数 Candidate.IsValid: + 如果 Action == null: + 返回 false + 如果 Action.Param == null: + 返回 false + 如果 Action.ActionLogic == null: + 返回 false + Action.Param.RefreshParams() + 返回 Action.ActionLogic.CheckCan(Action.Param) +``` + +### 2.5 WorldCache + +```text +结构 WorldCache: + SelfUnits: List + SelfHeroes: List + EnemyUnits: List + EnemyHeroes: List + SelfCities: List + EnemyCities: List + SelfTerritoryGridIds: HashSet + VisibleOrKnownGrids: List + + Diplomacy: DiplomacyView + StrategicPosture: StrategicPosture + WarTargetPlayers: List + + CityThreats: List + CityPlans: List + DevelopmentTargets: List + Fronts: List + LocalBattles: List + HeroStates: List + UnitOpportunities: List + + HasCriticalCityThreat: bool + HasAnyEnemyContact: bool + SelfMilitaryValue: float + EnemyMilitaryValue: float +``` + +### 2.6 ActionPool + +```text +结构 ActionPool: + All: List + Attacks: List + AttackAllies: List + AttackGrounds: List + Moves: List + UnitActions: List + CityActions: List + GridActions: List + PlayerActions: List + HeroManagementActions: List + + ByUnit: Map + ByCity: Map> + ByGrid: Map> +``` + +```text +结构 UnitActionBucket: + Attacks + AttackAllies + AttackGrounds + Moves + UnitActions +``` + +--- + +## 3. 总入口 + +### 3.1 Decide + +```text +函数 Decide(map, player, config): + ctx = new AIContext(map, player, config) + + 如果 map == null 或 player == null 或 player.Alive == false: + 返回 NoDecision + + ctx.Cache = BuildWorldCache(ctx) + ctx.Actions = BuildActionPool(ctx) + + 如果 ctx.Actions.All.Count == 0: + 返回 NoDecision + + lanes = [ + TryEmergency, + TryHeroManagement, + TryHeroPlaybook, + TryTactic, + TryUnitOpportunity, + TryFront, + TryGrowth, + TryFallback + ] + + 对每个 lane in lanes: + candidate = lane(ctx) + 如果 candidate.IsValid: + 返回 candidate + + 返回 NoDecision +``` + +### 3.2 ExecuteDecision + +```text +函数 ExecuteDecision(candidate): + 如果 !candidate.IsValid: + 返回 false + + param = candidate.Action.Param + param.RefreshParams() + + 如果 !candidate.Action.ActionLogic.CheckCan(param): + 返回 false + + 返回 candidate.Action.ActionLogic.CompleteExecute(param) +``` + +--- + +## 4. ActionPool + +### 4.1 BuildActionPool + +```text +函数 BuildActionPool(ctx): + pool = new ActionPool + rawActions = GenerateAllLegalLikeActions(ctx.Map, ctx.Player) + + 对每个 rawAction in rawActions 按稳定顺序: + 如果 pool.All.Count >= Config.MaxActionCount: + break + + action = CopyAction(rawAction) + 如果 action == null: + 继续 + + action.Param.RefreshParams() + 如果 !action.ActionLogic.CheckCan(action.Param): + 继续 + + 如果 IsDangerousAction(action): + 继续 + + AddActionToPool(pool, action) + + 返回 pool +``` + +### 4.2 动作分类 + +```text +函数 AddActionToPool(pool, action): + pool.All.Add(action) + id = action.ActionId + + 如果 id.ActionType == UnitAttack: + pool.Attacks.Add(action) + pool.ByUnit[action.UnitId].Attacks.Add(action) + + 如果 id.ActionType == UnitAttackAlly: + pool.AttackAllies.Add(action) + pool.ByUnit[action.UnitId].AttackAllies.Add(action) + + 如果 id.ActionType == UnitAttackGround: + pool.AttackGrounds.Add(action) + pool.ByUnit[action.UnitId].AttackGrounds.Add(action) + + 如果 id.ActionType == UnitMove: + pool.Moves.Add(action) + pool.ByUnit[action.UnitId].Moves.Add(action) + + 如果 id.ActionType == UnitAction 或 UnitSkill: + pool.UnitActions.Add(action) + pool.ByUnit[action.UnitId].UnitActions.Add(action) + + 如果 id.ActionType in [TrainUnit, CityLevelUpAction, CityAction, StartWonder, BuildWonder]: + pool.CityActions.Add(action) + pool.ByCity[action.CityId].Add(action) + + 如果 id.ActionType in [Gain, Build, GridMisc, HakureiEinherjarCityDevelopment]: + pool.GridActions.Add(action) + pool.ByGrid[action.GridId].Add(action) + + 如果 id.ActionType == LearnTech 或 BuyCultureCard: + pool.PlayerActions.Add(action) + + 如果 id.ActionType == PlayerAction: + 如果 id.PlayerActionType in [SelectHero, FinishHeroTask]: + pool.HeroManagementActions.Add(action) + 否则: + pool.PlayerActions.Add(action) +``` + +### 4.3 危险动作过滤 + +```text +函数 IsDangerousAction(action): + id = action.ActionId + + 如果 id.UnitActionType in [Disband, ForceDisband, Demolish, Disperse]: + 返回 true + + 如果 id.GridMiscActionType == Destroy: + 返回 true + + 返回 false +``` + +危险动作后续只能由专门车道显式调用,不能进入普通 AI 兜底。 + +### 4.4 ActionPool 查询 + +```text +函数 FindBestAttack(ctx, unit, target = null): + actions = ctx.Actions.ByUnit[unit.Id].Attacks + best = None + + 对每个 action in actions: + 如果 target != null 且 action.TargetUnit != target: + 继续 + score = ScoreAttackAction(ctx, action) + best = MaxByScore(best, action, score) + + 返回 best.Action +``` + +```text +函数 FindBestMoveToward(ctx, unit, targetGrid): + actions = ctx.Actions.ByUnit[unit.Id].Moves + best = None + + 对每个 action in actions: + endGrid = GetActionEndGrid(action) + score = -Distance(endGrid, targetGrid) + score -= GridThreat(ctx, unit, endGrid) * 0.5 + score += GridOpportunityBonus(ctx, unit, endGrid) + best = MaxByScore(best, action, score) + + 返回 best.Action +``` + +```text +函数 FindUnitAction(ctx, unit, unitActionType): + actions = ctx.Actions.ByUnit[unit.Id].UnitActions + 返回第一个 ActionId.UnitActionType == unitActionType 且 CheckCan 的 action +``` + +--- + +## 5. WorldCache 构建 + +### 5.1 BuildWorldCache + +```text +函数 BuildWorldCache(ctx): + cache = new WorldCache + + CollectWorldObjects(ctx, cache) + BuildDiplomacyView(ctx, cache) + BuildMilitarySummary(ctx, cache) + BuildCityThreats(ctx, cache) + BuildStrategicPosture(ctx, cache) + BuildCityPlans(ctx, cache) + BuildDevelopmentTargets(ctx, cache) + BuildFronts(ctx, cache) + BuildLocalBattles(ctx, cache) + BuildHeroStates(ctx, cache) + + 返回 cache +``` + +`UnitOpportunities` 依赖 ActionPool,可以在 ActionPool 建好后构建: + +```text +函数 BuildPostActionCache(ctx): + BuildUnitOpportunities(ctx, ctx.Cache, ctx.Actions) +``` + +如果实现上 `ActionPool` 先于 `WorldCache`,也可以拆成: + +```text +基础 Cache +→ ActionPool +→ 补充 UnitOpportunity +``` + +### 5.2 CollectWorldObjects + +```text +函数 CollectWorldObjects(ctx, cache): + cache.SelfUnits = map.GetUnitDataListByPlayerId(player.Id).Where(IsAlive) + cache.SelfCities = map.GetCityDataListByPlayerId(player.Id) + cache.SelfTerritoryGridIds = map.GetGridDataSetByPlayerId(player.Id) + cache.VisibleOrKnownGrids = GetVisibleOrKnownGrids(ctx) + + 对每个 unit in map.UnitMap.UnitList: + 如果 unit == null 或 !unit.IsAlive(): + 继续 + + owner = map.GetPlayerDataByUnitId(unit.Id) + + 如果 owner 与 player 同盟: + 如果 owner.Id == player.Id: + 如果 IsHero(unit): + cache.SelfHeroes.Add(unit) + 继续 + + cache.EnemyUnits.Add(unit) + + 如果 IsHero(unit): + cache.EnemyHeroes.Add(unit) + + 对每个 city in map.CityMap.CityList: + owner = map.GetPlayerDataByCityId(city.Id) + 如果 owner 存在 且 owner 与 player 非同盟: + cache.EnemyCities.Add(city) +``` + +### 5.3 BuildDiplomacyView + +```text +结构 DiplomacyView: + Allies + Enemies + WarPlayers + HighTrustPlayers + LowTrustPlayers +``` + +```text +函数 BuildDiplomacyView(ctx, cache): + 对每个 otherPlayer in map.PlayerMap.PlayerList: + 如果 otherPlayer == player 或 !otherPlayer.Alive: + 继续 + + relation = GetDiplomacy(player, otherPlayer) + + 如果 SameUnion(player, otherPlayer): + Diplomacy.Allies.Add(otherPlayer) + 否则: + Diplomacy.Enemies.Add(otherPlayer) + + 如果 relation.State == War: + Diplomacy.WarPlayers.Add(otherPlayer) + + 如果 relation.Feeling >= 80: + Diplomacy.HighTrustPlayers.Add(otherPlayer) + + 如果 relation.Feeling <= 30: + Diplomacy.LowTrustPlayers.Add(otherPlayer) +``` + +### 5.4 BuildMilitarySummary + +```text +函数 BuildMilitarySummary(ctx, cache): + 对每个 unit in cache.SelfUnits: + cache.SelfMilitaryValue += UnitMilitaryValue(ctx, unit) + + 对每个 unit in cache.EnemyUnits: + cache.EnemyMilitaryValue += UnitMilitaryValue(ctx, unit) +``` + +```text +函数 UnitMilitaryValue(ctx, unit): + 如果 unit == null 或 !unit.IsAlive(): + 返回 0 + + value = UnitCostOrMilitary(unit) + + 如果 HealthRatio(unit) <= 0.5: + value *= 0.5 + + 如果 IsHero(unit): + value += HeroValue(unit) + + 返回 max(1, value) +``` + +### 5.5 BuildCityThreats + +```text +结构 CityThreat: + City + CityGrid + IsCapital + HasWall + EnemyUnits + DefenderUnits + EnemyPower + DefenderPower + RescuePower + NearestEnemyDistance + HasEnemyOnTerritory + CanBeThreatenedNextTurn + DangerScore + IsCritical +``` + +```text +函数 BuildCityThreats(ctx, cache): + 对每个 city in cache.SelfCities: + threat = BuildSingleCityThreat(ctx, cache, city) + cache.CityThreats.Add(threat) + cache.HasCriticalCityThreat |= threat.IsCritical + cache.HasAnyEnemyContact |= threat.EnemyUnits.Count > 0 + + cache.CityThreats.SortByDescending(DangerScore) +``` + +```text +函数 BuildSingleCityThreat(ctx, cache, city): + cityGrid = city.Grid(ctx.Map) + threat = new CityThreat(city, cityGrid) + threat.IsCapital = city.IsCapital + threat.HasWall = CityHasWall(city) + + 对每个 enemy in cache.EnemyUnits: + enemyGrid = enemy.Grid(ctx.Map) + d = Distance(cityGrid, enemyGrid) + + 如果 d <= Config.EmergencyRange + AttackRange(enemy): + threat.EnemyUnits.Add(enemy) + threat.EnemyPower += UnitMilitaryValue(ctx, enemy) / max(1, d) + threat.NearestEnemyDistance = min(threat.NearestEnemyDistance, d) + + 如果 enemyGrid.Id in cache.SelfTerritoryGridIds: + threat.HasEnemyOnTerritory = true + + 如果 CanThreatenCityNextTurn(ctx, enemy, city): + threat.CanBeThreatenedNextTurn = true + + 对每个 unit in cache.SelfUnits: + unitGrid = unit.Grid(ctx.Map) + d = Distance(cityGrid, unitGrid) + + 如果 d <= Config.EmergencyRange: + threat.DefenderUnits.Add(unit) + threat.DefenderPower += UnitMilitaryValue(ctx, unit) / max(1, d) + + 如果 d <= Config.FrontRange: + threat.RescuePower += UnitMilitaryValue(ctx, unit) / max(1, d) + + threat.DangerScore = + threat.EnemyPower + - threat.DefenderPower + + (threat.HasEnemyOnTerritory ? 4 : 0) + + (threat.CanBeThreatenedNextTurn ? 6 : 0) + + (threat.IsCapital ? 3 : 0) + - (threat.HasWall ? 2 : 0) + + threat.IsCritical = + threat.CanBeThreatenedNextTurn + 或 threat.EnemyUnits.Count >= Config.CityCriticalEnemyCount + 或 threat.HasEnemyOnTerritory + 或 threat.EnemyPower > threat.DefenderPower * Config.CityThreatPowerRatio + + 返回 threat +``` + +```text +函数 CanThreatenCityNextTurn(ctx, enemy, city): + enemyGrid = enemy.Grid(ctx.Map) + cityGrid = city.Grid(ctx.Map) + + reach = MoveRange(enemy) + AttackRange(enemy) + + 如果 enemy 有 Capture 行动能力: + reach += 1 + + 如果 Distance(enemyGrid, cityGrid) <= reach: + 返回 true + + 返回 false +``` + +### 5.6 BuildStrategicPosture + +```text +枚举 StrategicPosture: + Defense + Expansion + Attack + Development +``` + +```text +函数 BuildStrategicPosture(ctx, cache): + 如果 cache.HasCriticalCityThreat: + cache.StrategicPosture = Defense + 返回 + + 如果 ExistsHighValueVillageOrRuinNearCities(ctx, cache): + cache.StrategicPosture = Expansion + 返回 + + attackTargets = BuildAttackTargetPlayers(ctx, cache) + 如果 attackTargets.Count > 0: + cache.StrategicPosture = Attack + cache.WarTargetPlayers = attackTargets + 返回 + + cache.StrategicPosture = Development +``` + +```text +函数 BuildAttackTargetPlayers(ctx, cache): + targets = [] + + 对每个 enemyPlayer in cache.Diplomacy.Enemies: + 如果 enemyPlayer in cache.Diplomacy.Allies: + 继续 + + nearestDistance = NearestCityDistance(ctx.Player, enemyPlayer) + militaryGap = MilitaryGapWithCounter(ctx, ctx.Player, enemyPlayer) + threat = ThreatFromPlayer(ctx, enemyPlayer) + feeling = GetFeeling(ctx.Player, enemyPlayer) + + 如果 enemyPlayer in Diplomacy.WarPlayers: + targets.Add(enemyPlayer) + 继续 + + 如果 feeling <= 30: + targets.Add(enemyPlayer) + 继续 + + 如果 nearestDistance <= 5 且 militaryGap >= 0: + targets.Add(enemyPlayer) + 继续 + + 如果 threat >= 10 且 militaryGap >= -3 且 nearestDistance <= 7: + targets.Add(enemyPlayer) + 继续 + + 返回 targets 按 AttackTargetScore 降序 +``` + +### 5.7 BuildCityPlans + +```text +枚举 CityPlanKind: + EmergencyDefense + Mobilize + Frontline + BacklineGrowth + Wonder +``` + +```text +结构 CityPlan: + City + CityGrid + Kind + Threat + NeedWall + NeedMilitary + NeedGrowth + NeedWonder + Priority +``` + +```text +函数 BuildCityPlans(ctx, cache): + 对每个 city in cache.SelfCities: + threat = FindThreatForCity(cache, city) + plan = new CityPlan(city, threat) + + 如果 threat.IsCritical: + plan.Kind = EmergencyDefense + 否则如果 threat.DangerScore > 0: + plan.Kind = Mobilize + 否则如果 IsNearAttackFront(ctx, cache, city): + plan.Kind = Frontline + 否则如果 CanReasonablyPursueWonder(ctx, city): + plan.Kind = Wonder + 否则: + plan.Kind = BacklineGrowth + + plan.NeedWall = plan.Kind in [EmergencyDefense, Mobilize, Frontline] 且 !CityHasWall(city) + plan.NeedMilitary = plan.Kind in [EmergencyDefense, Mobilize, Frontline] + plan.NeedGrowth = plan.Kind in [BacklineGrowth, Wonder] + plan.NeedWonder = plan.Kind == Wonder + plan.Priority = ScoreCityPlan(ctx, plan) + + cache.CityPlans.Add(plan) + + cache.CityPlans.SortByDescending(Priority) +``` + +### 5.8 BuildDevelopmentTargets + +```text +枚举 DevelopmentTargetType: + Village + EnemyEmptyCity + Treasure + Ruin + Resource + FogBoundary +``` + +```text +结构 DevelopmentTarget: + Grid + Type + Value + NearestSelfCity + Distance +``` + +```text +函数 BuildDevelopmentTargets(ctx, cache): + targets = [] + + 对每个 grid in cache.VisibleOrKnownGrids: + target = TryBuildDevelopmentTarget(ctx, cache, grid) + 如果 target == null: + 继续 + 如果 GridThreatToAnySelfUnit(ctx, grid) 太高 且 target.Type 不是 EnemyEmptyCity: + 继续 + targets.Add(target) + + targets.SortByDescending(Value - DistancePenalty) + cache.DevelopmentTargets = targets.Take(Config.MaxDevelopmentTargetCount) +``` + +```text +函数 TryBuildDevelopmentTarget(ctx, cache, grid): + 如果 grid 是可占村庄: + 返回 DevelopmentTarget(grid, Village, 900) + + 如果 grid 是敌方空城中心: + 返回 DevelopmentTarget(grid, EnemyEmptyCity, 950) + + 如果 grid 有遗迹或宝物可探索: + 返回 DevelopmentTarget(grid, Treasure, 820) + + 如果 grid 有可采集或可开发高价值资源: + 返回 DevelopmentTarget(grid, Resource, 650 + ResourceValue(ctx, grid)) + + 如果 grid 是安全探索边界: + 返回 DevelopmentTarget(grid, FogBoundary, 420) + + 返回 null +``` + +### 5.9 BuildFronts + +```text +枚举 FrontType: + Defense + Attack + Development + Hold +``` + +```text +结构 Front: + Type + AnchorGrid + TargetGrid + City + TargetCity + TargetPlayer + Pressure + Opportunity + Distance + Priority +``` + +```text +函数 BuildFronts(ctx, cache): + fronts = [] + + fronts.AddRange(BuildDefenseFronts(ctx, cache)) + fronts.AddRange(BuildAttackFronts(ctx, cache)) + fronts.AddRange(BuildDevelopmentFronts(ctx, cache)) + fronts.AddRange(BuildHoldFronts(ctx, cache)) + + fronts.SortByDescending(Priority) + cache.Fronts = fronts.Take(Config.MaxFrontCount) +``` + +```text +函数 BuildDefenseFronts(ctx, cache): + 对每个 threat in cache.CityThreats: + 如果 threat.DangerScore <= 0: + 继续 + yield Front( + Type = Defense, + AnchorGrid = threat.CityGrid, + City = threat.City, + Pressure = threat.DangerScore, + Priority = 1000 + threat.DangerScore + ) +``` + +```text +函数 BuildAttackFronts(ctx, cache): + 对每个 targetCity in cache.EnemyCities: + owner = targetCity.Owner + 如果 owner 不在 cache.WarTargetPlayers 且 cache.StrategicPosture != Attack: + 继续 + + selfCity = NearestSelfCity(targetCity.Grid) + distance = Distance(selfCity.Grid, targetCity.Grid) + opportunity = EstimateCityAttackOpportunity(ctx, targetCity) + + yield Front( + Type = Attack, + AnchorGrid = selfCity.Grid, + TargetGrid = targetCity.Grid, + City = selfCity, + TargetCity = targetCity, + TargetPlayer = owner, + Distance = distance, + Opportunity = opportunity, + Priority = 700 + opportunity - distance + ) +``` + +```text +函数 BuildDevelopmentFronts(ctx, cache): + 对每个 target in cache.DevelopmentTargets: + selfCity = target.NearestSelfCity + yield Front( + Type = Development, + AnchorGrid = selfCity.Grid, + TargetGrid = target.Grid, + City = selfCity, + Distance = target.Distance, + Opportunity = target.Value, + Priority = 500 + target.Value - target.Distance * 8 + ) +``` + +```text +函数 BuildHoldFronts(ctx, cache): + 对每个 city in cache.SelfCities: + 如果 city 是首都 或 高等级后方城市: + yield Front( + Type = Hold, + AnchorGrid = city.Grid, + City = city, + Priority = 300 + CityValue(city) + ) +``` + +### 5.10 BuildLocalBattles + +```text +结构 LocalBattle: + SelfUnit + EnemyUnit + SelfGrid + EnemyGrid + Distance + AttackAction + Value +``` + +```text +函数 BuildLocalBattles(ctx, cache): + battles = [] + + 对每个 self in cache.SelfUnits: + 对每个 enemy in cache.EnemyUnits: + d = Distance(self.Grid, enemy.Grid) + 如果 d > Config.LocalBattleRange + AttackRange(self): + 继续 + + value = EstimateAttackValue(ctx, self, enemy) + 如果 enemy 正在威胁城市: + value += 120 + 如果 enemy 是英雄: + value += 100 + 如果 CanKill(ctx, self, enemy): + value += 160 + + battles.Add(LocalBattle(self, enemy, d, value)) + + cache.LocalBattles = battles.SortByDescending(Value) +``` + +### 5.11 BuildHeroStates + +```text +枚举 HeroContext: + Recovery + Defense + FieldBattle + Siege + Economy + Mobility + Control +``` + +```text +结构 HeroState: + Hero + GiantType + Level + HealthRatio + Context + NearestFront + IsThreatened + IsOnFront +``` + +```text +函数 BuildHeroStates(ctx, cache): + 对每个 hero in cache.SelfHeroes: + state = new HeroState + state.Hero = hero + state.GiantType = GetHeroGiantType(hero) + state.Level = GetHeroLevel(ctx.Player, state.GiantType) + state.HealthRatio = HealthRatio(hero) + state.NearestFront = FindNearestFront(cache.Fronts, hero.Grid) + state.IsOnFront = state.NearestFront != null 且 Distance(hero.Grid, state.NearestFront.AnchorGrid) <= Config.FrontRange + state.IsThreatened = state.HealthRatio <= Config.HeroLowHpRatio 或 GridThreat(ctx, hero, hero.Grid) > 0 + state.Context = ResolveHeroContext(ctx, state) + cache.HeroStates.Add(state) +``` + +```text +函数 ResolveHeroContext(ctx, state): + 如果 state.HealthRatio <= Config.HeroLowHpRatio: + 返回 Recovery + + 如果 state.NearestFront.Type == Defense: + 返回 Defense + + 如果 state.NearestFront.Type == Attack: + 返回 Siege + + 如果 state.IsOnFront 或 HasEnemyNear(ctx, state.Hero, Config.LocalBattleRange): + 返回 FieldBattle + + 如果 HeroRole(state.GiantType) == Economy: + 返回 Economy + + 如果 HeroRole(state.GiantType) == Mobility: + 返回 Mobility + + 如果 HeroRole(state.GiantType) == Control: + 返回 Control + + 返回 FieldBattle +``` + +### 5.12 BuildUnitOpportunities + +```text +结构 UnitOpportunity: + Unit + Action + Type + TargetGrid + TargetUnit + Value +``` + +```text +函数 BuildUnitOpportunities(ctx): + opportunities = [] + + 对每个 action in ctx.Actions.UnitActions: + unit = action.Param.UnitData + type = action.ActionId.UnitActionType + value = ScoreUnitOpportunity(ctx, unit, action, type) + + 如果 value <= 0: + 继续 + + opportunities.Add(UnitOpportunity(unit, action, type, value)) + + ctx.Cache.UnitOpportunities = opportunities.SortByDescending(Value) +``` + +--- + +## 6. Emergency Lane + +### 6.1 TryEmergency + +```text +函数 TryEmergency(ctx): + 对每个 threat in ctx.Cache.CityThreats: + 如果 !threat.IsCritical: + 继续 + + candidate = TryEmergencyAttack(ctx, threat) + 如果 candidate.IsValid: + 返回 candidate + + candidate = TryEmergencyMoveToCity(ctx, threat) + 如果 candidate.IsValid: + 返回 candidate + + candidate = TryEmergencyCityProduction(ctx, threat) + 如果 candidate.IsValid: + 返回 candidate + + 返回 None +``` + +### 6.2 EmergencyAttack + +```text +函数 TryEmergencyAttack(ctx, threat): + best = None + + 对每个 defender in threat.DefenderUnits: + action = FindBestAttackAgainstThreat(ctx, defender, threat) + candidate = Candidate(action, Emergency, ScoreEmergencyAttack(ctx, action, threat), "守军攻击城市威胁") + best = MaxCandidate(best, candidate) + + 返回 best +``` + +```text +函数 ScoreEmergencyAttack(ctx, action, threat): + target = action.TargetUnitData + score = 900 + score += ThreatTargetValue(ctx, target) + 如果 target 下回合可威胁 threat.City: + score += 180 + 如果 CanKillByAction(ctx, action): + score += 160 + 返回 score +``` + +### 6.3 EmergencyMoveToCity + +```text +函数 TryEmergencyMoveToCity(ctx, threat): + best = None + + 对每个 unit in ctx.Cache.SelfUnits: + 如果 !CanUseAsDefender(ctx, unit, threat): + 继续 + + action = FindBestMoveToward(ctx, unit, threat.CityGrid) + candidate = Candidate(action, Emergency, ScoreEmergencyMove(ctx, unit, action, threat), "回防城市") + best = MaxCandidate(best, candidate) + + 返回 best +``` + +```text +函数 ScoreEmergencyMove(ctx, unit, action, threat): + endGrid = GetActionEndGrid(action) + score = 820 + score += UnitMilitaryValue(ctx, unit) + score -= Distance(endGrid, threat.CityGrid) * 20 + score -= GridThreat(ctx, unit, endGrid) * 0.5 + 如果 unit 是英雄: + score += 40 + 返回 score +``` + +### 6.4 EmergencyCityProduction + +```text +函数 TryEmergencyCityProduction(ctx, threat): + plan = FindCityPlan(threat.City) + actions = ctx.Actions.ByCity[threat.City.Id] + best = None + + 对每个 action in actions: + score = ScoreEmergencyCityAction(ctx, action, plan, threat) + candidate = Candidate(action, Emergency, score, "危险城市生产或防御") + best = MaxCandidate(best, candidate) + + 返回 best +``` + +```text +函数 ScoreEmergencyCityAction(ctx, action, plan, threat): + id = action.ActionId + score = 0 + + 如果 id.ActionType == TrainUnit: + score = 760 + TrainUnitDefenseValue(ctx, action, threat) + + 如果 id.ActionType == CityAction 且 id.CityActionType == BuildCityWall: + score = 740 + + 如果 id.ActionType == CityLevelUpAction 且 能选城墙或人口防守收益: + score = 700 + + 如果 id.ActionType in [StartWonder, BuildWonder]: + score = 0 + + 返回 score +``` + +--- + +## 7. HeroManagement Lane + +### 7.1 TryHeroManagement + +```text +函数 TryHeroManagement(ctx): + candidate = TrySelectHero(ctx) + 如果 candidate.IsValid: + 返回 candidate + + candidate = TryForceFinishHeroTask(ctx) + 如果 candidate.IsValid: + 返回 candidate + + candidate = TryDeployOrReviveHero(ctx) + 如果 candidate.IsValid: + 返回 candidate + + 返回 None +``` + +### 7.2 SelectHero + +```text +函数 TrySelectHero(ctx): + action = FindHeroManagementAction(ctx, SelectHero) + 返回 Candidate(action, HeroManagement, 890, "选择英雄") +``` + +### 7.3 FinishHeroTask + +```text +函数 TryForceFinishHeroTask(ctx): + 如果 ctx.Player.Turn < Config.HeroTaskStartTurn: + 返回 None + 如果 ctx.Player.Turn % Config.HeroTaskInterval != 0: + 返回 None + + best = None + + 对每个 action in ctx.Actions.HeroManagementActions: + 如果 action.PlayerActionType != FinishHeroTask: + 继续 + + giant = action.ActionId.GiantType + level = GetHeroLevel(ctx.Player, giant) + + 如果 !CanForceFinishHeroTask(ctx, giant, level): + 继续 + + score = 860 - level * 20 + candidate = Candidate(action, HeroManagement, score, "推进最低等级英雄任务") + best = MaxCandidate(best, candidate) + + 返回 best +``` + +```text +函数 CanForceFinishHeroTask(ctx, giant, level): + 如果 HeroTask 已完成或已强制完成: + 返回 false + + 如果 level == 0: + 返回 true + + 如果 level == 1: + 返回 ctx.Player.Turn >= Config.HeroLevel2MinTurn + + 如果 level == 2: + 返回 ctx.Player.Turn >= Config.HeroLevel3MinTurn + + 返回 false +``` + +### 7.4 DeployOrReviveHero + +```text +函数 TryDeployOrReviveHero(ctx): + best = None + + 对每个 cityPlan in ctx.Cache.CityPlans: + 如果 cityPlan.Kind == EmergencyDefense 且 城市无法安全出英雄: + 继续 + + 对每个 action in ctx.Actions.ByCity[cityPlan.City.Id]: + 如果 action 是英雄出场或复活动作: + score = 780 + ScoreHeroDeployValue(ctx, action, cityPlan) + candidate = Candidate(action, HeroManagement, score, "英雄出场或复活") + best = MaxCandidate(best, candidate) + + 返回 best +``` + +--- + +## 8. HeroPlaybook Lane + +### 8.1 TryHeroPlaybook + +```text +函数 TryHeroPlaybook(ctx): + best = None + + 对每个 state in ctx.Cache.HeroStates: + candidate = EvaluateHeroPlaybook(ctx, state) + best = MaxCandidate(best, candidate) + + 返回 best +``` + +### 8.2 HeroRule + +```text +结构 HeroRule: + GiantType + MinLevel + Context + ActionKind: + UnitAction + AttackEnemy + AttackAlly + AttackGround + MoveToward + MoveRetreat + Hold + UnitActionType + TargetPolicy + Priority + Condition + Reason +``` + +### 8.3 EvaluateHeroPlaybook + +```text +函数 EvaluateHeroPlaybook(ctx, state): + rules = GetHeroRules(state.GiantType) + best = None + + 对每个 rule in rules: + 如果 state.Level < rule.MinLevel: + 继续 + 如果 !HeroContextMatches(ctx, state, rule.Context): + 继续 + 如果 !rule.Condition(ctx, state): + 继续 + + action = BuildHeroAction(ctx, state, rule) + candidate = Candidate(action, HeroPlaybook, ScoreHeroRule(ctx, state, rule, action), rule.Reason) + best = MaxCandidate(best, candidate) + + 如果 best.IsValid: + 返回 best + + 返回 EvaluateGenericHeroRule(ctx, state) +``` + +### 8.4 BuildHeroAction + +```text +函数 BuildHeroAction(ctx, state, rule): + hero = state.Hero + targetUnit = ResolveHeroTargetUnit(ctx, state, rule.TargetPolicy) + targetGrid = ResolveHeroTargetGrid(ctx, state, rule.TargetPolicy) + + 如果 rule.ActionKind == UnitAction: + 返回 FindUnitAction(ctx, hero, rule.UnitActionType) + + 如果 rule.ActionKind == AttackEnemy: + 返回 FindBestAttack(ctx, hero, targetUnit) + + 如果 rule.ActionKind == AttackAlly: + 返回 FindBestAttackAlly(ctx, hero, targetUnit) + + 如果 rule.ActionKind == AttackGround: + 返回 FindBestAttackGround(ctx, hero, targetGrid) + + 如果 rule.ActionKind == MoveToward: + 返回 FindBestMoveToward(ctx, hero, targetGrid) + + 如果 rule.ActionKind == MoveRetreat: + retreatGrid = FindLowestThreatGrid(ctx, hero) + 返回 FindBestMoveToward(ctx, hero, retreatGrid) + + 返回 null +``` + +### 8.5 通用英雄保命 + +```text +函数 EvaluateGenericHeroRule(ctx, state): + hero = state.Hero + + 如果 state.HealthRatio <= Config.CriticalHpRatio: + action = FindUnitAction(ctx, hero, Recover) + 如果 action 可用: + 返回 Candidate(action, HeroPlaybook, 840, "英雄低血恢复") + + retreat = FindLowestThreatGrid(ctx, hero) + move = FindBestMoveToward(ctx, hero, retreat) + 如果 move 可用: + 返回 Candidate(move, HeroPlaybook, 820, "英雄低血撤退") + + attack = FindBestAttack(ctx, hero, BestHeroOrKillableEnemy(ctx, hero)) + 如果 attack 可用: + 返回 Candidate(attack, HeroPlaybook, 760, "英雄通用高价值攻击") + + 返回 None +``` + +### 8.6 默认英雄规则 + +```text +Flandre: + 如果有可击杀高价值敌人 -> AttackEnemy(KillableEnemy) + 如果友军低血且攻击友军能触发收益 -> AttackAlly(InjuredAlly) + +Remilia: + 如果接战且红雾/增益不足 -> UnitAction(REMILIABUFF) + 如果低血且可吸收 -> UnitAction(REMILIAABSORB) + +Sakuya: + 如果有可击杀目标 -> AttackEnemy(KillableEnemy) + 如果残血英雄或关键友军附近有敌人 -> MoveToward(ProtectAllyGrid) + 如果可用保护型友军攻击 -> AttackAlly(InjuredAlly) + +Meiling: + 如果低血 -> UnitAction(Recover) + 如果城市防守缺前排 -> MoveToward(DefenseFront) + +Patchouli: + 如果元素/地面攻击收益可触发 -> AttackGround(BestGroundTarget) + 如果友军缺血且元素治疗可用 -> AttackAlly(InjuredAlly) + 如果危险高 -> MoveRetreat +``` + +```text +Kaguya: + 如果 Lv>=2 且周围友军占敌城或低血 -> UnitAction(KAGUYAFRENCHAROUND) + 如果射程内友军占敌城或低血 -> AttackAlly(BestAlly) + +Reisen: + 如果真实伤害可击杀 -> AttackEnemy(KillableEnemy) + 如果敌人密集且爆破可用 -> UnitAction(ReisenFrenchBoom) + +Tewi: + 如果 Lv>=3 且相邻有作战友军 -> UnitAction(TEWIFRENCHBUFF) + +Eirin: + 如果射程内残血英雄 -> AttackAlly(InjuredHero) + 如果射程内敌方英雄且无治疗目标 -> AttackEnemy(BestEnemyHero) + 如果射程内高价值友军需要治疗或协同 -> AttackAlly(BestAlly) + +Mokou: + 如果 Lv>=3 且低血且敌人密集且友军风险低 -> UnitAction(MOKOUFRENCHBOOM) + 如果蛋形态低血 -> MoveRetreat + 如果蛋形态安全且需要牵制 -> MoveToward(EnemyDenseGrid) +``` + +```text +Kanako: + 如果坐镇且需要移动或接敌 -> UnitAction(KANAKOUNSIT) + 如果安全且可坐镇且附近有战略收益 -> UnitAction(KANAKOSIT) + +Suwako: + 如果可攻击地块并能召唤/压制 -> AttackGround(FarthestUsefulGround) + +Sanae: + 如果射程内残血友军 -> AttackAlly(InjuredAlly) + 如果射程内敌方英雄且无治疗目标 -> AttackEnemy(BestEnemyHero) + +Aya: + 如果二动能追击、回防或进入高收益格 -> UnitAction(AYAMOVEAGAIN) + 移动优先选接近敌人但威胁可控的远端格 + +Momiji: + 如果猎物标记目标可攻击 -> AttackEnemy(PreyTarget) + 如果猎物标记目标附近有安全格 -> MoveToward(PreyGrid) +``` + +```text +Satori: + 如果可攻击未被封禁的敌方英雄 -> AttackEnemy(BestEnemyHeroWithoutSkillBan) + 如果恐惧目标收益高 -> AttackEnemy(FearTarget) + +Koishi: + 如果骨堆友军不满血 -> MoveToward(BonePileAlly) + 如果可友军施法给骨堆目标 -> AttackAlly(BonePileAlly) + +Rin: + 如果有 CorpseBuff 层数且可行动 -> UnitAction(KomeijiRinFire) + 如果城市经验转换收益更高 -> UnitAction(KomeijiRinCityExp) + +Utsuho: + 如果骨堆爆破命中价值高 -> UnitAction(KomeijiBonePileBoom) + 如果移动范围内有敌方城市中心 -> MoveToward(EnemyCityCenter) + +Yuugi: + 如果三格移动可进入高攻击收益格 -> MoveToward(BestAttackGrid) + 否则攻击高价值近战目标 +``` + +```text +Reimu: + 如果关键友军低血或即将承伤 -> AttackAlly(InjuredAlly) + 如果退魔层数/压力过高 -> UnitAction(ReimuPayClearExtermination) + +Sumireko: + 如果灵球/地面目标能控线 -> AttackGround(BestGroundTarget) + +Kasen: + 如果高压战斗且切形态收益高 -> UnitAction(KasenToggleOniForm) + +Aunn: + 如果防守线需要支援 -> MoveToward(DefenseFront) + 保持与支援对象距离不过远 + +Suika: + 如果安全且生成小萃香收益高 -> UnitAction(SuikaCreateMiniByHp) + 如果被围攻或小萃香可解围 -> UnitAction(SuikaShakeOffMinis) +``` + +```text +Byakuren / Miko / Zanmu: + 如果没有专属规则: + 使用 GenericHeroRule + 如果后续补专属技能: + 只增加 HeroRule,不改车道结构 +``` + +--- + +## 9. Tactic Lane + +```text +函数 TryTactic(ctx): + best = None + + 对每个 battle in ctx.Cache.LocalBattles: + action = FindBestAttack(ctx, battle.SelfUnit, battle.EnemyUnit) + candidate = Candidate(action, Tactic, 700 + battle.Value, "局部战斗攻击") + best = MaxCandidate(best, candidate) + + 对每个 action in ctx.Actions.Attacks: + score = ScoreAttackAction(ctx, action) + candidate = Candidate(action, Tactic, score, "普通攻击收益") + best = MaxCandidate(best, candidate) + + 返回 best +``` + +```text +函数 ScoreAttackAction(ctx, action): + attacker = action.UnitData + target = action.TargetUnitData + + score = 620 + score += EstimateDamageValue(ctx, attacker, target) * 2 + score -= EstimateCounterDamageValue(ctx, target, attacker) + score += CounterBonus(ctx, attacker, target) + + 如果 CanKillByAction(ctx, action): + score += 160 + + 如果 target 是英雄: + score += 120 + + 如果 target 正在威胁城市: + score += 120 + + 如果 attacker 站在己方城市中心: + score -= EstimateCounterDamageValue(ctx, target, attacker) + + 如果 与 target 所属玩家好感很高且未交战: + score -= 80 + + 返回 score +``` + +--- + +## 10. UnitOpportunity Lane + +```text +函数 TryUnitOpportunity(ctx): + BuildUnitOpportunities(ctx) + + 对每个 opportunity in ctx.Cache.UnitOpportunities: + candidate = Candidate(opportunity.Action, UnitOpportunity, opportunity.Value, "单位机会") + 如果 candidate.IsValid: + 返回 candidate + + 返回 None +``` + +```text +函数 ScoreUnitOpportunity(ctx, unit, action, type): + 如果 type == Capture: + 返回 ScoreCapture(ctx, unit, action) + 如果 type == Examine: + 返回 ScoreExamine(ctx, unit, action) + 如果 type == Gather: + 返回 ScoreGather(ctx, unit, action) + 如果 type == HeroUpgrade: + 返回 ScoreHeroUpgrade(ctx, unit, action) + 如果 type == Upgrade: + 返回 ScoreUpgrade(ctx, unit, action) + 如果 type == CultureUnitUpgrade: + 返回 ScoreCultureUpgrade(ctx, unit, action) + 如果 type == Recover: + 返回 ScoreRecover(ctx, unit, action) + 返回 0 +``` + +```text +函数 ScoreCapture(ctx, unit, action): + grid = ActionTargetGrid(action) + score = 820 + + 如果 grid 是敌方城市中心: + score += 220 + 如果 grid 是村庄: + score += 180 + 如果 grid 是敌方首都: + score += 120 + 如果 GridThreat(ctx, unit, grid) 高: + score -= 120 + 如果 unit 是危险城市关键守军: + score -= 200 + + 返回 score +``` + +```text +函数 ScoreExamine(ctx, unit, action): + grid = ActionTargetGrid(action) + score = 760 + score += DevelopmentTargetValue(ctx, grid) + score -= GridThreat(ctx, unit, grid) * 0.3 + 返回 score +``` + +```text +函数 ScoreGather(ctx, unit, action): + grid = ActionTargetGrid(action) + score = 680 + ResourceValue(ctx, grid) + 如果 当前战略是 Expansion 或 Development: + score += 80 + 如果 unit 是前线主力且城市危急: + score -= 160 + 返回 score +``` + +```text +函数 ScoreRecover(ctx, unit, action): + hp = HealthRatio(unit) + 如果 hp > Config.LowHpRatio: + 返回 0 + score = 560 + (1 - hp) * 240 + 如果 unit 是英雄: + score += 120 + 如果 unit 当前能击杀高价值目标: + score -= 180 + 返回 score +``` + +```text +函数 ScoreUpgrade(ctx, unit, action): + 如果 unit 当前承担 Emergency 或可击杀高价值目标: + 返回 0 + score = 610 + 如果 升级后解锁克制当前敌军的兵种: + score += 120 + 如果 升级是海军转换且附近有海战目标: + score += 80 + 返回 score +``` + +```text +函数 ScoreHeroUpgrade(ctx, unit, action): + 如果 unit 不是英雄: + 返回 0 + score = 760 + 如果 unit 在前线或即将参战: + score += 100 + 如果 unit 低血: + score += 60 + 返回 score +``` + +--- + +## 11. Front Lane + +```text +函数 TryFront(ctx): + best = None + + 对每个 front in ctx.Cache.Fronts: + 对每个 unit in ctx.Cache.SelfUnits: + 如果 ShouldSkipFrontMove(ctx, unit, front): + 继续 + + action = FindBestMoveToward(ctx, unit, ResolveFrontTarget(front)) + candidate = Candidate(action, Front, ScoreFrontMove(ctx, unit, action, front), "战线移动") + best = MaxCandidate(best, candidate) + + 返回 best +``` + +```text +函数 ShouldSkipFrontMove(ctx, unit, front): + 如果 unit 没有移动行动点: + 返回 true + + 如果 unit 是英雄 且 HeroPlaybook 对它有合法动作: + 返回 true + + 如果 unit 低血 且 front.Type == Attack: + 返回 true + + 如果 unit 正站在己方危险城市中心: + 返回 true + + 返回 false +``` + +```text +函数 ResolveFrontTarget(front): + 如果 front.Type == Defense: + 返回 front.AnchorGrid + 如果 front.TargetGrid != null: + 返回 front.TargetGrid + 返回 front.AnchorGrid +``` + +```text +函数 ScoreFrontMove(ctx, unit, action, front): + endGrid = GetActionEndGrid(action) + target = ResolveFrontTarget(front) + + score = 0 + + 如果 front.Type == Defense: + score = 620 + front.Pressure * 20 + 如果 front.Type == Attack: + score = 560 + front.Opportunity + 如果 front.Type == Development: + score = 500 + front.Opportunity * 0.5 + 如果 front.Type == Hold: + score = 420 + + score -= Distance(endGrid, target) * 16 + score -= GridThreat(ctx, unit, endGrid) * 0.4 + + 如果 endGrid 可在下回合攻击/占领目标: + score += 80 + + 如果 unit 是高机动单位 且 target 较远: + score += 40 + + 返回 score +``` + +--- + +## 12. Growth Lane + +```text +函数 TryGrowth(ctx): + best = None + + best = MaxCandidate(best, TryCityGrowth(ctx)) + best = MaxCandidate(best, TryGridGrowth(ctx)) + best = MaxCandidate(best, TryPlayerGrowth(ctx)) + + 返回 best +``` + +### 12.1 CityGrowth + +```text +函数 TryCityGrowth(ctx): + best = None + + 对每个 action in ctx.Actions.CityActions: + plan = FindCityPlanByAction(ctx, action) + score = ScoreCityGrowth(ctx, action, plan) + candidate = Candidate(action, Growth, score, "城市发展") + best = MaxCandidate(best, candidate) + + 返回 best +``` + +```text +函数 ScoreCityGrowth(ctx, action, plan): + id = action.ActionId + score = 360 + plan.Priority + + 如果 plan.Kind == EmergencyDefense: + 如果 id.ActionType == TrainUnit: + score += TrainUnitDefenseValue(ctx, action, plan.Threat) + 如果 id.ActionType == CityAction 且 id.CityActionType == BuildCityWall: + score += 220 + 如果 id.ActionType in [StartWonder, BuildWonder]: + score -= 300 + + 如果 plan.Kind == Mobilize 或 plan.Kind == Frontline: + 如果 id.ActionType == TrainUnit: + score += TrainUnitWarValue(ctx, action) + 如果 id.ActionType == CityAction 且 id.CityActionType == BuildCityWall: + score += 100 + 如果 id.ActionType == CityLevelUpAction: + score += 50 + + 如果 plan.Kind == BacklineGrowth: + 如果 id.ActionType == CityLevelUpAction: + score += 140 + 如果 id.ActionType == TrainUnit: + score += NeedStandingArmy(ctx, plan.City) ? 80 : 20 + + 如果 plan.Kind == Wonder: + 如果 id.ActionType in [StartWonder, BuildWonder]: + score += 160 + WonderValue(ctx, action) + + 返回 score +``` + +### 12.2 GridGrowth + +```text +函数 TryGridGrowth(ctx): + best = None + + 对每个 action in ctx.Actions.GridActions: + score = ScoreGridGrowth(ctx, action) + candidate = Candidate(action, Growth, score, "地块发展") + best = MaxCandidate(best, candidate) + + 返回 best +``` + +```text +函数 ScoreGridGrowth(ctx, action): + grid = ActionGrid(action) + score = 330 + + 如果 grid 属于危险城市附近 且 action 不是防守/道路/关键资源: + score -= 100 + + 如果 action 是 Build: + score += BuildResourceValue(ctx, action) + + 如果 action 是 Gain: + score += ResourceValue(ctx, grid) + + 如果 action 是 GridMisc: + score += GridMiscValue(ctx, action) + + 如果 grid 接近 DevelopmentTarget: + score += 60 + + 返回 score +``` + +### 12.3 PlayerGrowth + +```text +函数 TryPlayerGrowth(ctx): + best = None + + 对每个 action in ctx.Actions.PlayerActions: + score = ScorePlayerGrowth(ctx, action) + candidate = Candidate(action, Growth, score, "玩家发展") + best = MaxCandidate(best, candidate) + + 返回 best +``` + +```text +函数 ScorePlayerGrowth(ctx, action): + id = action.ActionId + + 如果 id.ActionType == LearnTech: + 返回 ScoreTech(ctx, action) + + 如果 id.ActionType == BuyCultureCard: + 返回 ScoreCultureCard(ctx, action) + + 如果 id.ActionType == PlayerAction: + 返回 ScorePlayerAction(ctx, action) + + 返回 0 +``` + +```text +函数 ScoreTech(ctx, action): + score = 300 + + 如果 ctx.Cache.StrategicPosture == Defense: + score += TechDefenseValue(ctx, action) + + 如果 ctx.Cache.StrategicPosture == Attack: + score += TechMilitaryCounterValue(ctx, action) + + 如果 ctx.Cache.StrategicPosture == Expansion: + score += TechResourceAndMobilityValue(ctx, action) + + 如果 ctx.Cache.StrategicPosture == Development: + score += TechEconomicValue(ctx, action) + + score += TechSuccessorPreviewValue(ctx, action) * 0.5 + + 如果 TechRequiresSeaButNoSeaNearby(ctx, action): + score = 0 + + 返回 score +``` + +```text +函数 ScoreCultureCard(ctx, action): + score = 300 + + 如果 前线吃紧 且文化卡提供即时战力: + score += 120 + + 如果 英雄体系成型 且文化卡强化英雄: + score += 100 + + 如果 安全发展 且文化卡提供长期经济: + score += 80 + + 返回 score +``` + +```text +函数 ScorePlayerAction(ctx, action): + id = action.ActionId.PlayerActionType + + 如果 id 是宝物选项: + 返回 520 + TreasureOptionValue(ctx, action) + + 如果 id == Embassy: + 如果 目标好感 >= 70 且 金币足够: + 返回 360 + 返回 0 + + 如果 id == OfferAlly: + 如果 目标好感 >= 90 且 无直接扩张冲突: + 返回 340 + 返回 0 + + 如果 id == BreakAlly: + 如果 目标好感 <= 35 且 非队友: + 返回 220 + 返回 0 + + 返回 180 +``` + +--- + +## 13. Fallback + +```text +函数 TryFallback(ctx): + order = [ + ctx.Actions.Attacks, + ctx.Actions.UnitActions, + ctx.Actions.Moves, + ctx.Actions.CityActions, + ctx.Actions.GridActions, + ctx.Actions.PlayerActions, + ctx.Actions.All + ] + + 对每个 list in order: + 对每个 action in list: + candidate = Candidate(action, Fallback, 1, "兜底合法动作") + 如果 candidate.IsValid: + candidate.IsFallback = true + 返回 candidate + + 返回 None +``` + +Fallback 的目标是不断回合,不负责聪明。大量进入 Fallback 说明前面车道缺规则。 + +--- + +## 14. 评分辅助函数 + +### 14.1 稳定排序 + +```text +函数 MaxCandidate(a, b): + 如果 a 无效: + 返回 b + 如果 b 无效: + 返回 a + 如果 b.Priority > a.Priority: + 返回 b + 如果 b.Priority == a.Priority 且 StableActionKey(b.Action) < StableActionKey(a.Action): + 返回 b + 返回 a +``` + +```text +函数 StableActionKey(action): + 返回 ( + action.ActionId.ActionType, + action.Param.PlayerId, + action.Param.CityId, + action.Param.UnitId, + action.Param.GridId, + action.Param.TargetUnitId, + action.Param.TargetGridId, + action.ActionId 子类型字段 + ) +``` + +### 14.2 伤害和威胁 + +```text +函数 EstimateDamageValue(ctx, attacker, target): + damage = EstimateDamage(ctx, attacker, target) + return damage / MaxHealth(target) * UnitMilitaryValue(ctx, target) +``` + +```text +函数 GridThreat(ctx, unit, grid): + threat = 0 + + 对每个 enemy in ctx.Cache.EnemyUnits: + enemyGrid = enemy.Grid + 如果 Distance(enemyGrid, grid) <= MoveRange(enemy) + AttackRange(enemy): + threat += EstimateDamageValue(ctx, enemy, unit) + + 返回 threat +``` + +```text +函数 CanKill(ctx, attacker, target): + return EstimateDamage(ctx, attacker, target) >= target.Health +``` + +### 14.3 资源和建设价值 + +```text +函数 ResourceValue(ctx, grid): + value = 0 + + 如果 grid 资源能补当前短缺: + value += 100 + + 如果 grid 资源可被当前科技开发: + value += 80 + + 如果 grid 靠近后方安全城市: + value += 40 + + 如果 grid 处于高威胁区: + value -= 80 + + 返回 value +``` + +```text +函数 BuildResourceValue(ctx, action): + grid = ActionGrid(action) + value = ResourceValue(ctx, grid) + + 如果建筑能形成加工链: + value += 60 + + 如果建筑是阵营特色高收益建筑: + value += 80 + + 返回 value +``` + +### 14.4 训练单位价值 + +```text +函数 TrainUnitDefenseValue(ctx, action, threat): + unitType = action.ActionId.UnitFullType + score = UnitBaseDefenseValue(unitType) + + 对每个 enemy in threat.EnemyUnits: + score += CounterValue(unitType, enemy.UnitFullType) + score += SimulatedFightValue(unitType, enemy) + + 返回 score +``` + +```text +函数 TrainUnitWarValue(ctx, action): + unitType = action.ActionId.UnitFullType + score = UnitBaseMilitaryValue(unitType) + + 对每个 enemy in 当前进攻目标玩家单位: + score += CounterValue(unitType, enemy.UnitFullType) + + 返回 score +``` + +--- + +## 15. 正确性约束 + +### 15.1 权威行为 + +```text +所有最终动作: + 必须来自 ActionPool + 必须 CheckCan + 必须 CompleteExecute +``` + +AI 不复制: + +```text +资源扣除 +行动点扣除 +城市易主 +单位死亡 +技能触发 +网络同步 +回放记录 +``` + +### 15.2 确定性 + +禁止在权威选择路径使用: + +```text +UnityEngine.Random +当前系统时间 +未排序 Dictionary/HashSet 迭代结果作为最终选择 +真实 Main.MapData 假设 +UI/Renderer 状态 +``` + +同分必须用稳定 key 解决。 + +### 15.3 技能安全 + +英雄和单位技能只通过 Action 入口触发: + +```text +UnitAttack +UnitAttackAlly +UnitAttackGround +UnitAction +UnitMove +``` + +AI 不直接调用 `SkillBase` 的效果方法。 + +--- + +## 16. 性能约束 + +```text +每次 Decide: + 构建一次基础 WorldCache + 构建一次 ActionPool + 补一次 UnitOpportunity + 每个车道只查缓存和 ActionPool +``` + +复杂度目标: + +```text +CityThreat: SelfCities * nearby EnemyUnits +LocalBattle: SelfUnits * nearby EnemyUnits +Front: SelfCities * EnemyCities + DevelopmentTargets +ActionPool: MaxActionCount 上限 +Growth: 只遍历合法动作 +``` + +性能保护: + +```text +限制移动候选数量 +限制 DevelopmentTarget TopN +限制 Front TopN +限制 LocalBattle 搜索半径 +科技只看当前可学和一层后继预览 +``` + +--- + +## 17. 测试脚本标准 + +### 17.1 城市防守 + +```text +给 AI 城市附近放敌军。 +期望: + Emergency 返回攻击威胁、回防、或危险城市生产。 +``` + +### 17.2 占领扩张 + +```text +给 AI 单位脚下放村庄、敌空城、遗迹。 +期望: + 无更高车道时 UnitOpportunity 执行占领或探索。 +``` + +### 17.3 英雄机制 + +```text +给 AI 放残血友军、敌方英雄、地面攻击目标、自爆窗口。 +期望: + HeroPlaybook 选择对应英雄动作。 +``` + +### 17.4 战线移动 + +```text +无局部战斗、无机会动作。 +期望: + DefenseFront 优先于 AttackFront。 + AttackFront 优先于 DevelopmentFront。 + DevelopmentFront 优先于 HoldFront。 +``` + +### 17.5 内政发展 + +```text +安全局面。 +期望: + 后方城市升级、建设、科技文化。 + 前线城市生产军力。 +``` + +### 17.6 性能 + +```text +中大型地图 AI 连续执行。 +期望: + 单次 Decide 不重复全量生成动作。 + 候选数量不长期超过 MaxActionCount。 + 无明显卡顿尖峰。 +``` diff --git a/MD/GameMDFramework/20-AI导演系统测试与诊断流程.md b/MD/GameMDFramework/20-AI导演系统测试与诊断流程.md new file mode 100644 index 000000000..0650ccace --- /dev/null +++ b/MD/GameMDFramework/20-AI导演系统测试与诊断流程.md @@ -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 日志再修 +``` diff --git a/Tools/OSS/game-upload-function/README.md b/Tools/OSS/game-upload-function/README.md index 7ce94afb4..553b38d02 100644 --- a/Tools/OSS/game-upload-function/README.md +++ b/Tools/OSS/game-upload-function/README.md @@ -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)和目标路径,防止客户端篡改上传目标。 - **版本隔离**:不同版本的客户端使用不同路径前缀,令牌不可跨版本复用。 diff --git a/Tools/OSS/game-upload-function/check-upload-contract.js b/Tools/OSS/game-upload-function/check-upload-contract.js new file mode 100644 index 000000000..7cfdbe170 --- /dev/null +++ b/Tools/OSS/game-upload-function/check-upload-contract.js @@ -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'); diff --git a/Tools/OSS/game-upload-function/package.json b/Tools/OSS/game-upload-function/package.json index 7984a274c..86656f10f 100644 --- a/Tools/OSS/game-upload-function/package.json +++ b/Tools/OSS/game-upload-function/package.json @@ -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": [], diff --git a/Tools/PlayerQuestionnaireViewer/README.md b/Tools/PlayerQuestionnaireViewer/README.md new file mode 100644 index 000000000..26685ff65 --- /dev/null +++ b/Tools/PlayerQuestionnaireViewer/README.md @@ -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/`,两者都只应保留在本机。 diff --git a/Tools/PlayerMultilingualReportViewer/config.local.json b/Tools/PlayerQuestionnaireViewer/config.example.json similarity index 50% rename from Tools/PlayerMultilingualReportViewer/config.local.json rename to Tools/PlayerQuestionnaireViewer/config.example.json index 95696d039..8c3882a34 100644 --- a/Tools/PlayerMultilingualReportViewer/config.local.json +++ b/Tools/PlayerQuestionnaireViewer/config.example.json @@ -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 -} \ No newline at end of file +} diff --git a/Tools/PlayerQuestionnaireViewer/player_questionnaire_viewer.py b/Tools/PlayerQuestionnaireViewer/player_questionnaire_viewer.py new file mode 100644 index 000000000..894bf0f0a --- /dev/null +++ b/Tools/PlayerQuestionnaireViewer/player_questionnaire_viewer.py @@ -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("<>", 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("<>", 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("", 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("", 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("<>", 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() diff --git a/Tools/PlayerQuestionnaireViewer/启动玩家问卷查看器.bat b/Tools/PlayerQuestionnaireViewer/启动玩家问卷查看器.bat new file mode 100644 index 000000000..776faf165 --- /dev/null +++ b/Tools/PlayerQuestionnaireViewer/启动玩家问卷查看器.bat @@ -0,0 +1,5 @@ +@echo off +title TH1 Player Questionnaire Viewer +cd /d "%~dp0" +python player_questionnaire_viewer.py +pause diff --git a/Tools/RunAIDirectorBatch.ps1 b/Tools/RunAIDirectorBatch.ps1 new file mode 100644 index 000000000..95a456691 --- /dev/null +++ b/Tools/RunAIDirectorBatch.ps1 @@ -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 diff --git a/Unity/Assets/Scripts/TH1_Core/Managers/PresentationManager.cs b/Unity/Assets/Scripts/TH1_Core/Managers/PresentationManager.cs index 0104bf6db..3599d7622 100644 --- a/Unity/Assets/Scripts/TH1_Core/Managers/PresentationManager.cs +++ b/Unity/Assets/Scripts/TH1_Core/Managers/PresentationManager.cs @@ -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; diff --git a/Unity/Assets/Scripts/TH1_Logic/AI/AIActionGenerator.cs b/Unity/Assets/Scripts/TH1_Logic/AI/AIActionGenerator.cs index 530aa0c53..ed29d4777 100644 --- a/Unity/Assets/Scripts/TH1_Logic/AI/AIActionGenerator.cs +++ b/Unity/Assets/Scripts/TH1_Logic/AI/AIActionGenerator.cs @@ -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) diff --git a/Unity/Assets/Scripts/TH1_Logic/AI/AILogic.cs b/Unity/Assets/Scripts/TH1_Logic/AI/AILogic.cs index e0322c1a5..839b009e8 100644 --- a/Unity/Assets/Scripts/TH1_Logic/AI/AILogic.cs +++ b/Unity/Assets/Scripts/TH1_Logic/AI/AILogic.cs @@ -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 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 _actionBitCodec; - private int _sameCount; - private List> _nodeRecords; + private int _kernelVersion; public static uint CurrentAIPlayerId; public static Dictionary> AIRecordsDict; - - public AILogic() { AIRecordsDict = new Dictionary>(); AILogicState = AILogicState.Prepare; - RecordActions = new List(); - _scoreCalculator = new AIActionScoreCalculator(); - _cfg = TH1Resource.ResourceLoader.Load("Export/AIConfig"); - _generator = new AIActionGenerator(); - - _logicObject = GameObject.Find("AIBT"); - _btOwner = _logicObject.GetComponent(); - var data = _btOwner.blackboard.GetVariable("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("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>(); - 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()); - } - _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 GetCurrentAIRecords() { return AIRecordsDict.GetValueOrDefault(CurrentAIPlayerId); } - + public static List GetAIRecords(uint playerId) { return AIRecordsDict.GetValueOrDefault(playerId); } } - public class AIStateRecord { public uint ID; public string Desc; public bool Result; } - - + public class AIRecord { public List StateRecords; public AIActionBase Action; public bool IsFoldout; - public AIRecord() { IsFoldout = false; StateRecords = new List(); } - + public string GetDesc() { if (Action == null) return "暂无动作"; diff --git a/Unity/Assets/Scripts/TH1_Logic/AI/BehaviourTree.meta b/Unity/Assets/Scripts/TH1_Logic/AI/BehaviourTree.meta new file mode 100644 index 000000000..a2d07f640 --- /dev/null +++ b/Unity/Assets/Scripts/TH1_Logic/AI/BehaviourTree.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: b10c1828bae40bd4f907f57b7f1faf76 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Unity/Assets/Scripts/TH1_Logic/Core/MainEditor.cs b/Unity/Assets/Scripts/TH1_Logic/AI/BehaviourTree/MainEditor.cs similarity index 100% rename from Unity/Assets/Scripts/TH1_Logic/Core/MainEditor.cs rename to Unity/Assets/Scripts/TH1_Logic/AI/BehaviourTree/MainEditor.cs diff --git a/Unity/Assets/Scripts/TH1_Logic/Core/MainEditor.cs.meta b/Unity/Assets/Scripts/TH1_Logic/AI/BehaviourTree/MainEditor.cs.meta similarity index 100% rename from Unity/Assets/Scripts/TH1_Logic/Core/MainEditor.cs.meta rename to Unity/Assets/Scripts/TH1_Logic/AI/BehaviourTree/MainEditor.cs.meta diff --git a/Unity/Assets/Scripts/TH1_Logic/AI/Director.meta b/Unity/Assets/Scripts/TH1_Logic/AI/Director.meta new file mode 100644 index 000000000..d77687052 --- /dev/null +++ b/Unity/Assets/Scripts/TH1_Logic/AI/Director.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 4b6d5f786f414f8391d3f4bc12f6c4e1 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Unity/Assets/Scripts/TH1_Logic/AI/Director/AIDirectorActionIndex.cs b/Unity/Assets/Scripts/TH1_Logic/AI/Director/AIDirectorActionIndex.cs new file mode 100644 index 000000000..564d278a2 --- /dev/null +++ b/Unity/Assets/Scripts/TH1_Logic/AI/Director/AIDirectorActionIndex.cs @@ -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 AllActions = new(); + public readonly List AttackActions = new(); + public readonly List AttackAllyActions = new(); + public readonly List AttackGroundActions = new(); + public readonly List MoveActions = new(); + public readonly List UnitActions = new(); + public readonly List CityActions = new(); + public readonly List GridActions = new(); + public readonly List PlayerActions = new(); + public readonly List HeroManagementActions = new(); + + private readonly Dictionary> _unitActionsByUnit = new(); + private readonly Dictionary> _attacksByUnit = new(); + private readonly Dictionary> _movesByUnit = new(); + private readonly Dictionary> _attackAlliesByUnit = new(); + private readonly Dictionary> _attackGroundByUnit = new(); + private readonly Dictionary> _cityActionsByCity = new(); + private readonly Dictionary> _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 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 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> dict, AIActionBase action) + { + var unit = action.Param.UnitData; + if (unit == null) return; + if (!dict.TryGetValue(unit.Id, out var list)) + { + list = new List(); + dict[unit.Id] = list; + } + + list.Add(action); + } + + private static void AddByCity(Dictionary> dict, AIActionBase action) + { + var city = action.Param.CityData; + if (city == null) return; + if (!dict.TryGetValue(city.Id, out var list)) + { + list = new List(); + dict[city.Id] = list; + } + + list.Add(action); + } + + private static void AddByGrid(Dictionary> 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(); + 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 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 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")); + } + } +} diff --git a/Unity/Assets/Scripts/TH1_Logic/AITrain/ModelInference.cs.meta b/Unity/Assets/Scripts/TH1_Logic/AI/Director/AIDirectorActionIndex.cs.meta similarity index 83% rename from Unity/Assets/Scripts/TH1_Logic/AITrain/ModelInference.cs.meta rename to Unity/Assets/Scripts/TH1_Logic/AI/Director/AIDirectorActionIndex.cs.meta index fa97d64fa..b51ea1c28 100644 --- a/Unity/Assets/Scripts/TH1_Logic/AITrain/ModelInference.cs.meta +++ b/Unity/Assets/Scripts/TH1_Logic/AI/Director/AIDirectorActionIndex.cs.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 12611efd81e4e39409aab374a47d8b98 +guid: ed15bd0d18204bc7ad370bcd6ece3407 MonoImporter: externalObjects: {} serializedVersion: 2 diff --git a/Unity/Assets/Scripts/TH1_Logic/AI/Director/AIDirectorDiagnostics.cs b/Unity/Assets/Scripts/TH1_Logic/AI/Director/AIDirectorDiagnostics.cs new file mode 100644 index 000000000..f98a7068b --- /dev/null +++ b/Unity/Assets/Scripts/TH1_Logic/AI/Director/AIDirectorDiagnostics.cs @@ -0,0 +1,1134 @@ +using System; +using System.Collections.Generic; +using System.IO; +using Logic.Action; +using Logic.CrashSight; +using Logic.Pool; +using RuntimeData; +using TH1_Logic.Action; +using UnityEngine; + +namespace Logic.AI.Director +{ +#if TH1_AI_DIRECTOR_DIAGNOSTICS || UNITY_EDITOR + public static class AIDirectorDiagnostics + { + private const string SchemaVersion = "1.0"; + private const int MaxItemsPerSection = 32; + +#if UNITY_EDITOR + private static bool _enabled = true; +#else + private static bool _enabled; +#endif + private static string _sessionId; + private static string _currentLogPath; + private static int _eventSequence; + private static int _decisionSequence; + private static int _sessionMapObjectHash; + + public static bool Enabled => _enabled; + public static string CurrentLogPath => EnsureSession(); + + public static void SetEnabled(bool enabled) + { + _enabled = enabled; + if (enabled) EnsureSession(); + } + + public static void Enable() + { + SetEnabled(true); + } + + public static void Disable() + { + SetEnabled(false); + } + + public static void BeginNewSession() + { + ResetSession(); + if (_enabled) EnsureSession(); + } + + private static void ResetSession() + { + _sessionId = null; + _currentLogPath = null; + _eventSequence = 0; + _decisionSequence = 0; + _sessionMapObjectHash = 0; + } + + public static void RecordTurnStart(MapData map, PlayerData player) + { + if (!_enabled || map == null || player == null) return; + var record = CreateBaseRecord("TurnStart", map, player); + record.turnSummary = BuildOutcomeSnapshot(CaptureOutcomeProbe(map, player, null)); + WriteRecord(record); + } + + public static void RecordTurnEnd(MapData map, PlayerData player) + { + if (!_enabled || map == null || player == null) return; + var record = CreateBaseRecord("TurnEnd", map, player); + record.turnSummary = BuildOutcomeSnapshot(CaptureOutcomeProbe(map, player, null)); + WriteRecord(record); + } + + public static void RecordDecision(MapData map, PlayerData player, AIDirectorDecision decision) + { + if (!_enabled || map == null || player == null || decision == null) return; + + var record = CreateBaseRecord("Decision", map, player); + record.decisionSequence = ++_decisionSequence; + record.decision = BuildDecisionSnapshot(decision); + record.actionPool = BuildActionPoolSnapshot(decision.ActionIndex); + if (!Logic.AI.AIDirectorBatchRuntime.CompactDiagnostics) + { + record.cache = BuildCacheSnapshot(map, player, decision.Cache); + record.lanes = BuildLaneSnapshots(decision); + record.trace = CopyTrace(decision.Trace); + } + else if (!decision.HasAction || decision.Candidate?.IsFallback == true) + { + record.trace = CopyTrace(decision.Trace); + } + WriteRecord(record); + } + + public static AIDirectorOutcomeProbe CaptureOutcomeProbe(MapData map, PlayerData player, AIActionBase action) + { + if (!_enabled || map == null || player == null) return null; + var probe = BuildBaseOutcomeProbe(map, player); + FillActionObjectProbe(map, action, probe); + return probe; + } + + public static void RecordExecution( + MapData map, + PlayerData player, + int actionIndex, + AIActionBase action, + bool executed, + AIDirectorOutcomeProbe before, + AIDirectorOutcomeProbe after) + { + if (!_enabled || map == null || player == null || action == null) return; + + var record = CreateBaseRecord("Execution", map, player); + record.actionIndexInTurn = actionIndex; + record.execution = new ExecutionSnapshot + { + executed = executed, + action = BuildActionSnapshot(action), + before = BuildOutcomeSnapshot(before), + after = BuildOutcomeSnapshot(after), + delta = BuildOutcomeDelta(before, after), + isInSight = action.IsInSight, + duration = action.Duration + }; + WriteRecord(record); + } + + private static DiagnosticRecord CreateBaseRecord(string eventType, MapData map, PlayerData player) + { + PrepareSession(map); + return new DiagnosticRecord + { + schemaVersion = SchemaVersion, + sessionId = _sessionId, + eventSequence = ++_eventSequence, + eventType = eventType, + timeLocal = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff"), + frame = Time.frameCount, + mapId = map?.MapID ?? 0, + curPlayerId = map?.Net?.CurPlayerId ?? 0, + netActionCount = map?.Net?.Actions?.Count ?? 0, + playerId = player?.Id ?? 0, + playerTurn = player?.Turn ?? 0, + playerCoin = player?.PlayerCoin ?? 0, + playerCulture = player?.PlayerCultureInfo?.PlayerCulture ?? 0, + selfCities = CountSelfCities(map, player), + selfUnits = CountSelfUnits(map, player), + kernel = "Director" + }; + } + + private static void PrepareSession(MapData map) + { + var mapHash = map?.GetHashCode() ?? 0; + if (mapHash != 0 && _sessionMapObjectHash != 0 && mapHash != _sessionMapObjectHash) ResetSession(); + if (mapHash != 0 && _sessionMapObjectHash == 0) _sessionMapObjectHash = mapHash; + EnsureSession(); + } + + private static DecisionSnapshot BuildDecisionSnapshot(AIDirectorDecision decision) + { + var candidate = decision.Candidate; + return new DecisionSnapshot + { + hasAction = decision.HasAction, + decideMs = (float)decision.DecideMs, + lane = candidate?.Lane.ToString() ?? AIDirectorLane.None.ToString(), + priority = candidate?.Priority ?? 0f, + reason = candidate?.Reason ?? string.Empty, + isFallback = candidate?.IsFallback ?? false, + action = BuildActionSnapshot(candidate?.AIAction) + }; + } + + private static List BuildLaneSnapshots(AIDirectorDecision decision) + { + var result = new List(); + if (decision?.LaneDiagnostics == null) return result; + foreach (var lane in decision.LaneDiagnostics) + { + if (lane == null) continue; + var snapshot = new LaneSnapshot + { + lane = lane.lane.ToString(), + selected = lane.selected, + note = lane.note ?? string.Empty, + candidates = new List() + }; + + foreach (var candidate in lane.candidates) + { + if (candidate == null) continue; + snapshot.candidates.Add(BuildCandidateSnapshot(candidate)); + } + + result.Add(snapshot); + } + + return result; + } + + private static CandidateSnapshot BuildCandidateSnapshot(AIDirectorCandidateDiagnostic diagnostic) + { + var candidate = diagnostic.candidate; + return new CandidateSnapshot + { + source = diagnostic.source ?? string.Empty, + isValid = diagnostic.isValid, + rejectReason = diagnostic.rejectReason ?? string.Empty, + priority = diagnostic.priority, + lane = candidate?.Lane.ToString() ?? AIDirectorLane.None.ToString(), + reason = candidate?.Reason ?? string.Empty, + action = BuildActionSnapshot(candidate?.AIAction), + scoreTerms = BuildScoreTerms(candidate?.ScoreTerms) + }; + } + + private static List BuildScoreTerms(List terms) + { + var result = new List(); + if (terms == null) return result; + for (var i = 0; i < terms.Count && i < MaxItemsPerSection; i++) + { + var term = terms[i]; + if (term == null) continue; + result.Add(new ScoreTermSnapshot + { + name = term.Name ?? string.Empty, + value = term.Value + }); + } + + return result; + } + + private static CacheSnapshot BuildCacheSnapshot(MapData map, PlayerData player, AIDirectorWorldCache cache) + { + if (cache == null) return null; + + return new CacheSnapshot + { + strategicPosture = cache.StrategicPosture.ToString(), + hasCriticalCityThreat = cache.HasCriticalCityThreat, + hasAnyEnemyContact = cache.HasAnyEnemyContact, + selfMilitary = cache.SelfMilitary, + enemyMilitary = cache.EnemyMilitary, + selfUnitCount = cache.SelfUnits.Count, + selfHeroCount = cache.SelfHeroes.Count, + enemyUnitCount = cache.EnemyUnits.Count, + enemyHeroCount = cache.EnemyHeroes.Count, + selfCityCount = cache.SelfCities.Count, + enemyCityCount = cache.EnemyCities.Count, + enemyPlayerCount = cache.EnemyPlayers.Count, + alliedPlayerCount = cache.AlliedPlayers.Count, + warTargetPlayerCount = cache.WarTargetPlayers.Count, + cityThreats = BuildCityThreats(cache.CityThreats), + cityPlans = BuildCityPlans(cache.CityPlans), + fronts = BuildFronts(cache.Fronts), + developmentTargets = BuildDevelopmentTargets(cache.DevelopmentTargets), + localBattles = BuildLocalBattles(cache.LocalBattles), + heroStates = BuildHeroStates(cache.HeroStates), + unitOpportunities = BuildUnitOpportunities(cache.UnitOpportunities), + diplomacy = BuildDiplomacy(map, player, cache) + }; + } + + private static ActionPoolSnapshot BuildActionPoolSnapshot(AIDirectorActionIndex index) + { + if (index == null) return null; + return new ActionPoolSnapshot + { + all = index.AllActions.Count, + attacks = index.AttackActions.Count, + attackAllies = index.AttackAllyActions.Count, + attackGrounds = index.AttackGroundActions.Count, + moves = index.MoveActions.Count, + unitActions = index.UnitActions.Count, + cityActions = index.CityActions.Count, + gridActions = index.GridActions.Count, + playerActions = index.PlayerActions.Count, + heroManagementActions = index.HeroManagementActions.Count + }; + } + + private static ActionSnapshot BuildActionSnapshot(AIActionBase action) + { + if (action?.ActionLogic?.ActionId == null) return null; + var id = action.ActionLogic.ActionId; + var param = action.Param; + return new ActionSnapshot + { + stableKey = AIDirectorActionIndex.StableActionKey(action), + actionType = id.ActionType.ToString(), + unitActionType = id.UnitActionType.ToString(), + playerActionType = id.PlayerActionType.ToString(), + cityActionType = id.CityActionType.ToString(), + cityLevelUpActionType = id.CityLevelUpActionType.ToString(), + gridMiscActionType = id.GridMiscActionType.ToString(), + techType = id.TechType.ToString(), + cultureCardType = id.CultureCardType.ToString(), + resourceType = id.ResourceType.ToString(), + unitType = id.UnitType.ToString(), + giantType = id.GiantType.ToString(), + unitLevel = id.UnitLevel, + wonderType = id.WonderType.ToString(), + skillType = id.SkillType.ToString(), + mainObjectType = param?.MainObjectType.ToString() ?? string.Empty, + playerId = param?.PlayerId ?? 0, + unitId = param?.UnitId ?? 0, + cityId = param?.CityId ?? 0, + gridId = param?.GridId ?? 0, + targetUnitId = param?.TargetUnitId ?? 0, + targetGridId = param?.TargetGridId ?? 0, + targetPlayerId = param?.TargetPlayerId ?? 0 + }; + } + + private static List BuildCityThreats(List threats) + { + var result = new List(); + if (threats == null) return result; + for (var i = 0; i < threats.Count && i < MaxItemsPerSection; i++) + { + var threat = threats[i]; + if (threat == null) continue; + result.Add(new CityThreatSnapshot + { + cityId = threat.City?.Id ?? 0, + cityGridId = threat.CityGrid?.Id ?? 0, + enemyCount = threat.EnemyUnits.Count, + defenderCount = threat.Defenders.Count, + enemyPower = threat.EnemyPower, + defenderPower = threat.DefenderPower, + rescuePower = threat.RescuePower, + nearestEnemyDistance = threat.NearestEnemyDistance, + isCritical = threat.IsCritical, + isCapital = threat.IsCapital, + hasWall = threat.HasWall, + hasEnemyOnTerritory = threat.HasEnemyOnTerritory, + canBeThreatenedNextTurn = threat.CanBeThreatenedNextTurn, + dangerScore = threat.DangerScore + }); + } + + return result; + } + + private static List BuildCityPlans(List plans) + { + var result = new List(); + if (plans == null) return result; + for (var i = 0; i < plans.Count && i < MaxItemsPerSection; i++) + { + var plan = plans[i]; + if (plan == null) continue; + result.Add(new CityPlanSnapshot + { + cityId = plan.City?.Id ?? 0, + cityGridId = plan.CityGrid?.Id ?? 0, + kind = plan.Kind.ToString(), + needWall = plan.NeedWall, + needMilitary = plan.NeedMilitary, + needGrowth = plan.NeedGrowth, + needWonder = plan.NeedWonder, + priority = plan.Priority, + dangerScore = plan.Threat?.DangerScore ?? 0f + }); + } + + return result; + } + + private static List BuildFronts(List fronts) + { + var result = new List(); + if (fronts == null) return result; + for (var i = 0; i < fronts.Count && i < MaxItemsPerSection; i++) + { + var front = fronts[i]; + if (front == null) continue; + result.Add(new FrontSnapshot + { + frontType = front.FrontType.ToString(), + selfCityId = front.SelfCity?.Id ?? 0, + targetCityId = front.TargetCity?.Id ?? 0, + anchorGridId = front.AnchorGrid?.Id ?? 0, + targetGridId = front.TargetGrid?.Id ?? 0, + pressure = front.Pressure, + opportunity = front.Opportunity, + distance = front.Distance, + selfUnitCount = front.SelfUnits.Count, + enemyUnitCount = front.EnemyUnits.Count + }); + } + + return result; + } + + private static List BuildDevelopmentTargets(List targets) + { + var result = new List(); + if (targets == null) return result; + for (var i = 0; i < targets.Count && i < MaxItemsPerSection; i++) + { + var target = targets[i]; + if (target == null) continue; + result.Add(new DevelopmentTargetSnapshot + { + targetType = target.TargetType.ToString(), + gridId = target.Grid?.Id ?? 0, + resourceType = target.Grid?.Resource.ToString() ?? ResourceType.None.ToString(), + nearestSelfCityId = target.NearestSelfCity?.Id ?? 0, + distance = target.Distance, + value = target.Value + }); + } + + return result; + } + + private static List BuildLocalBattles(List battles) + { + var result = new List(); + if (battles == null) return result; + for (var i = 0; i < battles.Count && i < MaxItemsPerSection; i++) + { + var battle = battles[i]; + if (battle == null) continue; + result.Add(new LocalBattleSnapshot + { + selfUnitId = battle.SelfUnit?.Id ?? 0, + enemyUnitId = battle.EnemyUnit?.Id ?? 0, + selfGridId = battle.SelfGrid?.Id ?? 0, + enemyGridId = battle.EnemyGrid?.Id ?? 0, + distance = battle.Distance, + value = battle.Value + }); + } + + return result; + } + + private static List BuildHeroStates(List states) + { + var result = new List(); + if (states == null) return result; + for (var i = 0; i < states.Count && i < MaxItemsPerSection; i++) + { + var state = states[i]; + if (state == null) continue; + result.Add(new HeroStateSnapshot + { + heroUnitId = state.Hero?.Id ?? 0, + giantType = state.GiantType.ToString(), + level = state.Level, + role = state.Role.ToString(), + context = state.Context.ToString(), + frontType = state.Front?.FrontType.ToString() ?? AIDirectorFrontType.None.ToString(), + healthRatio = state.HealthRatio, + isThreatened = state.IsThreatened, + isOnFront = state.IsOnFront + }); + } + + return result; + } + + private static List BuildUnitOpportunities(List opportunities) + { + var result = new List(); + if (opportunities == null) return result; + for (var i = 0; i < opportunities.Count && i < MaxItemsPerSection; i++) + { + var opportunity = opportunities[i]; + if (opportunity == null) continue; + result.Add(new UnitOpportunitySnapshot + { + unitId = opportunity.Unit?.Id ?? 0, + opportunityType = opportunity.OpportunityType.ToString(), + value = opportunity.Value, + action = BuildActionSnapshot(opportunity.Action) + }); + } + + return result; + } + + private static List BuildDiplomacy(MapData map, PlayerData player, AIDirectorWorldCache cache) + { + var result = new List(); + if (map?.PlayerMap?.PlayerDataList == null || player == null) return result; + foreach (var other in map.PlayerMap.PlayerDataList) + { + if (other == null || other.Id == player.Id) continue; + if (!player.GetCountryDiplomacyInfo(other.Id, out var info) || info == null) continue; + result.Add(new DiplomacySnapshot + { + playerId = other.Id, + state = info.DiplomacyState.ToString(), + feeling = info.FeelingValue, + isTeammate = info.IsTeammate, + isAlliedByCache = cache?.AlliedPlayers?.Contains(other) ?? false, + isWarTargetByCache = cache?.WarTargetPlayers?.Contains(other) ?? false + }); + } + + return result; + } + + private static List CopyTrace(List trace) + { + var result = new List(); + if (trace == null) return result; + for (var i = 0; i < trace.Count && i < MaxItemsPerSection; i++) result.Add(trace[i]); + return result; + } + + private static int CountSelfCities(MapData map, PlayerData player) + { + if (map == null || player == null) return 0; + using var handle = THCollectionPool.GetListHandle(out var cities); + map.GetCityDataListByPlayerId(player.Id, cities); + return cities.Count; + } + + private static int CountSelfUnits(MapData map, PlayerData player) + { + if (map == null || player == null) return 0; + using var handle = THCollectionPool.GetListHandle(out var units); + map.GetUnitDataListByPlayerId(player.Id, units); + return units.Count; + } + + private static string EnsureSession() + { + if (!string.IsNullOrEmpty(_currentLogPath)) return _currentLogPath; + + _sessionId = DateTime.Now.ToString("yyyyMMdd_HHmmss"); + var directory = GetLogDirectory(); + Directory.CreateDirectory(directory); + _currentLogPath = Path.Combine(directory, $"ai_director_{_sessionId}.jsonl"); + WriteRecord(new DiagnosticRecord + { + schemaVersion = SchemaVersion, + sessionId = _sessionId, + eventSequence = ++_eventSequence, + eventType = "SessionStart", + timeLocal = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff"), + frame = Time.frameCount, + kernel = "Director", + logPath = _currentLogPath + }); + return _currentLogPath; + } + + private static AIDirectorOutcomeProbe BuildBaseOutcomeProbe(MapData map, PlayerData player) + { + var probe = new AIDirectorOutcomeProbe + { + NetActionCount = map?.Net?.Actions?.Count ?? 0, + PlayerId = player?.Id ?? 0, + PlayerTurn = player?.Turn ?? 0, + PlayerCoin = player?.PlayerCoin ?? 0, + PlayerTechPoint = player?.PlayerTechPoint ?? 0, + PlayerCulture = player?.PlayerCultureInfo?.PlayerCulture ?? 0, + CultureCardCount = player?.PlayerCultureInfo?.CultureCardList?.Count ?? 0, + PlayerScore = player?.PlayerScore ?? 0 + }; + + if (map == null || player == null) return probe; + + using var cityHandle = THCollectionPool.GetListHandle(out var cities); + map.GetCityDataListByPlayerId(player.Id, cities); + probe.CityCount = cities.Count; + + using var unitHandle = THCollectionPool.GetListHandle(out var units); + map.GetUnitDataListByPlayerId(player.Id, units); + probe.UnitCount = units.Count; + foreach (var unit in units) + { + if (unit == null || !unit.IsAlive()) continue; + probe.SelfMilitary += AIDirectorMath.UnitPower(unit); + if (unit.TreatedAsHero(map, unit)) probe.HeroCount++; + } + + if (map.UnitMap?.UnitList != null) + { + foreach (var unit in map.UnitMap.UnitList) + { + if (unit == null || !unit.IsAlive()) continue; + if (AIDirectorMath.IsEnemy(map, player, unit)) probe.EnemyMilitary += AIDirectorMath.UnitPower(unit); + } + } + + foreach (var city in cities) + { + if (city == null || !map.GetGridDataByCityId(city.Id, out var cityGrid)) continue; + var enemyCount = 0; + var enemyPower = 0f; + var defenderPower = 0f; + + if (map.UnitMap?.UnitList != null) + { + foreach (var unit in map.UnitMap.UnitList) + { + if (unit == null || !unit.IsAlive()) continue; + if (!map.GetGridDataByUnitId(unit.Id, out var unitGrid)) continue; + var distance = AIDirectorMath.Distance(map, cityGrid, unitGrid); + if (distance > 5) continue; + if (AIDirectorMath.IsEnemy(map, player, unit)) + { + enemyCount++; + enemyPower += AIDirectorMath.UnitPower(unit) / Mathf.Max(1, distance); + } + else if (map.GetPlayerDataByUnitId(unit.Id, out var owner) && owner != null && map.SameUnion(player.Id, owner.Id)) + { + defenderPower += AIDirectorMath.UnitPower(unit) / Mathf.Max(1, distance); + } + } + } + + if (enemyCount <= 0) continue; + probe.CityThreatCount++; + var danger = enemyPower - defenderPower; + if (danger > probe.MaxCityDangerScore) probe.MaxCityDangerScore = danger; + if (danger > 0f) probe.CriticalCityThreatCount++; + } + + if (probe.CriticalCityThreatCount > 0) probe.StrategicPosture = AIDirectorStrategicPosture.Defense.ToString(); + else if (probe.EnemyMilitary > 0f && probe.SelfMilitary >= probe.EnemyMilitary) probe.StrategicPosture = AIDirectorStrategicPosture.Attack.ToString(); + else probe.StrategicPosture = AIDirectorStrategicPosture.Development.ToString(); + return probe; + } + + private static void FillActionObjectProbe(MapData map, AIActionBase action, AIDirectorOutcomeProbe probe) + { + if (map == null || action?.Param == null || probe == null) return; + var param = action.Param; + FillUnitProbe(map, param.UnitId, true, probe); + FillUnitProbe(map, param.TargetUnitId, false, probe); + FillCityProbe(map, param.CityId, true, probe); + var targetCityId = 0u; + if (param.TargetGridId != 0 && map.GetCityDataByGid(param.TargetGridId, out var targetCity) && targetCity != null) targetCityId = targetCity.Id; + if (targetCityId == 0 && param.GridId != 0 && map.GetCityDataByGid(param.GridId, out var gridCity) && gridCity != null) targetCityId = gridCity.Id; + FillCityProbe(map, targetCityId, false, probe); + } + + private static void FillUnitProbe(MapData map, uint unitId, bool self, AIDirectorOutcomeProbe probe) + { + if (unitId == 0) return; + if (self) probe.UnitId = unitId; + else probe.TargetUnitId = unitId; + + UnitData unit = null; + var alive = map.UnitMap != null + && map.UnitMap.GetUnitDataByUnitId(unitId, out unit) + && unit != null + && unit.IsAlive(); + var health = alive ? unit.Health : 0; + var gridId = 0u; + if (alive && map.GetGridDataByUnitId(unitId, out var grid) && grid != null) gridId = grid.Id; + + if (self) + { + probe.UnitAlive = alive; + probe.UnitHealth = health; + probe.UnitGridId = gridId; + } + else + { + probe.TargetUnitAlive = alive; + probe.TargetUnitHealth = health; + probe.TargetUnitGridId = gridId; + } + } + + private static void FillCityProbe(MapData map, uint cityId, bool self, AIDirectorOutcomeProbe probe) + { + if (cityId == 0) return; + if (!map.CityMap.GetCityById(cityId, out var city) || city == null) return; + var ownerId = map.GetPlayerDataByCityId(cityId, out var owner) && owner != null ? owner.Id : 0; + + if (self) + { + probe.CityId = cityId; + probe.CityOwnerId = ownerId; + probe.CityLevel = city.Level; + } + else + { + probe.TargetCityId = cityId; + probe.TargetCityOwnerId = ownerId; + probe.TargetCityLevel = city.Level; + } + } + + private static OutcomeSnapshot BuildOutcomeSnapshot(AIDirectorOutcomeProbe probe) + { + if (probe == null) return null; + return new OutcomeSnapshot + { + netActionCount = probe.NetActionCount, + playerId = probe.PlayerId, + playerTurn = probe.PlayerTurn, + playerCoin = probe.PlayerCoin, + playerTechPoint = probe.PlayerTechPoint, + playerCulture = probe.PlayerCulture, + cultureCardCount = probe.CultureCardCount, + playerScore = probe.PlayerScore, + cityCount = probe.CityCount, + unitCount = probe.UnitCount, + heroCount = probe.HeroCount, + selfMilitary = probe.SelfMilitary, + enemyMilitary = probe.EnemyMilitary, + criticalCityThreatCount = probe.CriticalCityThreatCount, + cityThreatCount = probe.CityThreatCount, + maxCityDangerScore = probe.MaxCityDangerScore, + strategicPosture = probe.StrategicPosture, + unitId = probe.UnitId, + unitAlive = probe.UnitAlive, + unitHealth = probe.UnitHealth, + unitGridId = probe.UnitGridId, + targetUnitId = probe.TargetUnitId, + targetUnitAlive = probe.TargetUnitAlive, + targetUnitHealth = probe.TargetUnitHealth, + targetUnitGridId = probe.TargetUnitGridId, + cityId = probe.CityId, + cityOwnerId = probe.CityOwnerId, + cityLevel = probe.CityLevel, + targetCityId = probe.TargetCityId, + targetCityOwnerId = probe.TargetCityOwnerId, + targetCityLevel = probe.TargetCityLevel + }; + } + + private static OutcomeDeltaSnapshot BuildOutcomeDelta(AIDirectorOutcomeProbe before, AIDirectorOutcomeProbe after) + { + if (before == null || after == null) return null; + return new OutcomeDeltaSnapshot + { + netActionDelta = after.NetActionCount - before.NetActionCount, + coinDelta = after.PlayerCoin - before.PlayerCoin, + techPointDelta = after.PlayerTechPoint - before.PlayerTechPoint, + cultureDelta = after.PlayerCulture - before.PlayerCulture, + cultureCardDelta = after.CultureCardCount - before.CultureCardCount, + scoreDelta = after.PlayerScore - before.PlayerScore, + cityDelta = after.CityCount - before.CityCount, + unitDelta = after.UnitCount - before.UnitCount, + heroDelta = after.HeroCount - before.HeroCount, + selfMilitaryDelta = after.SelfMilitary - before.SelfMilitary, + enemyMilitaryDelta = after.EnemyMilitary - before.EnemyMilitary, + criticalCityThreatDelta = after.CriticalCityThreatCount - before.CriticalCityThreatCount, + cityThreatDelta = after.CityThreatCount - before.CityThreatCount, + maxCityDangerScoreDelta = after.MaxCityDangerScore - before.MaxCityDangerScore, + unitMoved = before.UnitGridId != after.UnitGridId, + unitHealthDelta = after.UnitHealth - before.UnitHealth, + unitDied = before.UnitAlive && !after.UnitAlive, + targetUnitHealthDelta = after.TargetUnitHealth - before.TargetUnitHealth, + targetUnitDied = before.TargetUnitAlive && !after.TargetUnitAlive, + cityOwnerChanged = before.CityOwnerId != after.CityOwnerId, + targetCityOwnerChanged = before.TargetCityOwnerId != after.TargetCityOwnerId, + cityLevelDelta = after.CityLevel - before.CityLevel, + targetCityLevelDelta = after.TargetCityLevel - before.TargetCityLevel + }; + } + + private static string GetLogDirectory() + { +#if UNITY_EDITOR + return Path.GetFullPath(Path.Combine(Application.dataPath, "../Logs/AI_Director_Diagnostics")); +#else + return Path.Combine(Application.persistentDataPath, "AI_Director_Diagnostics"); +#endif + } + + private static void WriteRecord(DiagnosticRecord record) + { + try + { + var path = EnsureSession(); + File.AppendAllText(path, JsonUtility.ToJson(record, false) + Environment.NewLine); + } + catch (Exception e) + { + LogSystem.LogWarning($"AI Director diagnostics write failed: {e.Message}"); + } + } + + [Serializable] + private sealed class DiagnosticRecord + { + public string schemaVersion; + public string sessionId; + public int eventSequence; + public string eventType; + public string timeLocal; + public int frame; + public uint mapId; + public uint curPlayerId; + public int netActionCount; + public uint playerId; + public uint playerTurn; + public int playerCoin; + public int playerCulture; + public int selfCities; + public int selfUnits; + public string kernel; + public string logPath; + public int decisionSequence; + public int actionIndexInTurn; + public DecisionSnapshot decision; + public ExecutionSnapshot execution; + public CacheSnapshot cache; + public ActionPoolSnapshot actionPool; + public List lanes; + public OutcomeSnapshot turnSummary; + public List trace; + } + + [Serializable] + private sealed class DecisionSnapshot + { + public bool hasAction; + public float decideMs; + public string lane; + public float priority; + public string reason; + public bool isFallback; + public ActionSnapshot action; + } + + [Serializable] + private sealed class ExecutionSnapshot + { + public bool executed; + public ActionSnapshot action; + public OutcomeSnapshot before; + public OutcomeSnapshot after; + public OutcomeDeltaSnapshot delta; + public bool isInSight; + public float duration; + } + + [Serializable] + private sealed class LaneSnapshot + { + public string lane; + public bool selected; + public string note; + public List candidates; + } + + [Serializable] + private sealed class CandidateSnapshot + { + public string source; + public bool isValid; + public string rejectReason; + public float priority; + public string lane; + public string reason; + public ActionSnapshot action; + public List scoreTerms; + } + + [Serializable] + private sealed class ScoreTermSnapshot + { + public string name; + public float value; + } + + [Serializable] + private sealed class OutcomeSnapshot + { + 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; + } + + [Serializable] + private sealed class OutcomeDeltaSnapshot + { + public int netActionDelta; + public int coinDelta; + public int techPointDelta; + public int cultureDelta; + public int cultureCardDelta; + public int scoreDelta; + public int cityDelta; + public int unitDelta; + public int heroDelta; + public float selfMilitaryDelta; + public float enemyMilitaryDelta; + public int criticalCityThreatDelta; + public int cityThreatDelta; + public float maxCityDangerScoreDelta; + public bool unitMoved; + public int unitHealthDelta; + public bool unitDied; + public int targetUnitHealthDelta; + public bool targetUnitDied; + public bool cityOwnerChanged; + public bool targetCityOwnerChanged; + public int cityLevelDelta; + public int targetCityLevelDelta; + } + + [Serializable] + private sealed class CacheSnapshot + { + public string strategicPosture; + public bool hasCriticalCityThreat; + public bool hasAnyEnemyContact; + public float selfMilitary; + public float enemyMilitary; + public int selfUnitCount; + public int selfHeroCount; + public int enemyUnitCount; + public int enemyHeroCount; + public int selfCityCount; + public int enemyCityCount; + public int enemyPlayerCount; + public int alliedPlayerCount; + public int warTargetPlayerCount; + public List cityThreats; + public List cityPlans; + public List fronts; + public List developmentTargets; + public List localBattles; + public List heroStates; + public List unitOpportunities; + public List diplomacy; + } + + [Serializable] + private sealed class ActionPoolSnapshot + { + public int all; + public int attacks; + public int attackAllies; + public int attackGrounds; + public int moves; + public int unitActions; + public int cityActions; + public int gridActions; + public int playerActions; + public int heroManagementActions; + } + + [Serializable] + private sealed class ActionSnapshot + { + public string stableKey; + public string actionType; + public string unitActionType; + public string playerActionType; + public string cityActionType; + public string cityLevelUpActionType; + public string gridMiscActionType; + public string techType; + public string cultureCardType; + public string resourceType; + public string unitType; + public string giantType; + public uint unitLevel; + public string wonderType; + public string skillType; + public string mainObjectType; + public uint playerId; + public uint unitId; + public uint cityId; + public uint gridId; + public uint targetUnitId; + public uint targetGridId; + public uint targetPlayerId; + } + + [Serializable] + private sealed class CityThreatSnapshot + { + public uint cityId; + public uint cityGridId; + public int enemyCount; + public int defenderCount; + public float enemyPower; + public float defenderPower; + public float rescuePower; + public int nearestEnemyDistance; + public bool isCritical; + public bool isCapital; + public bool hasWall; + public bool hasEnemyOnTerritory; + public bool canBeThreatenedNextTurn; + public float dangerScore; + } + + [Serializable] + private sealed class CityPlanSnapshot + { + public uint cityId; + public uint cityGridId; + public string kind; + public bool needWall; + public bool needMilitary; + public bool needGrowth; + public bool needWonder; + public float priority; + public float dangerScore; + } + + [Serializable] + private sealed class FrontSnapshot + { + public string frontType; + public uint selfCityId; + public uint targetCityId; + public uint anchorGridId; + public uint targetGridId; + public float pressure; + public float opportunity; + public int distance; + public int selfUnitCount; + public int enemyUnitCount; + } + + [Serializable] + private sealed class DevelopmentTargetSnapshot + { + public string targetType; + public uint gridId; + public string resourceType; + public uint nearestSelfCityId; + public int distance; + public float value; + } + + [Serializable] + private sealed class LocalBattleSnapshot + { + public uint selfUnitId; + public uint enemyUnitId; + public uint selfGridId; + public uint enemyGridId; + public int distance; + public float value; + } + + [Serializable] + private sealed class HeroStateSnapshot + { + public uint heroUnitId; + public string giantType; + public uint level; + public string role; + public string context; + public string frontType; + public float healthRatio; + public bool isThreatened; + public bool isOnFront; + } + + [Serializable] + private sealed class UnitOpportunitySnapshot + { + public uint unitId; + public string opportunityType; + public float value; + public ActionSnapshot action; + } + + [Serializable] + private sealed class DiplomacySnapshot + { + public uint playerId; + public string state; + public float feeling; + public bool isTeammate; + public bool isAlliedByCache; + public bool isWarTargetByCache; + } + } +#else + public static class AIDirectorDiagnostics + { + public static bool Enabled => false; + public static string CurrentLogPath => string.Empty; + public static void SetEnabled(bool enabled) { } + public static void Enable() { } + public static void Disable() { } + public static void BeginNewSession() { } + public static void RecordTurnStart(MapData map, PlayerData player) { } + public static void RecordTurnEnd(MapData map, PlayerData player) { } + public static void RecordDecision(MapData map, PlayerData player, AIDirectorDecision decision) { } + public static AIDirectorOutcomeProbe CaptureOutcomeProbe(MapData map, PlayerData player, AIActionBase action) { return null; } + public static void RecordExecution(MapData map, PlayerData player, int actionIndex, AIActionBase action, bool executed, AIDirectorOutcomeProbe before, AIDirectorOutcomeProbe after) { } + } +#endif +} diff --git a/Unity/Assets/Scripts/TH1_Logic/AI/Director/AIDirectorDiagnostics.cs.meta b/Unity/Assets/Scripts/TH1_Logic/AI/Director/AIDirectorDiagnostics.cs.meta new file mode 100644 index 000000000..2278386cc --- /dev/null +++ b/Unity/Assets/Scripts/TH1_Logic/AI/Director/AIDirectorDiagnostics.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9f7a1d2c3b4e5f608192a3b4c5d6e7f8 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Unity/Assets/Scripts/TH1_Logic/AI/Director/AIDirectorHeroRuleEvaluator.cs b/Unity/Assets/Scripts/TH1_Logic/AI/Director/AIDirectorHeroRuleEvaluator.cs new file mode 100644 index 000000000..8be362c0b --- /dev/null +++ b/Unity/Assets/Scripts/TH1_Logic/AI/Director/AIDirectorHeroRuleEvaluator.cs @@ -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; + } + } +} diff --git a/Unity/Assets/Scripts/TH1_Logic/AI/Director/AIDirectorHeroRuleEvaluator.cs.meta b/Unity/Assets/Scripts/TH1_Logic/AI/Director/AIDirectorHeroRuleEvaluator.cs.meta new file mode 100644 index 000000000..31851576f --- /dev/null +++ b/Unity/Assets/Scripts/TH1_Logic/AI/Director/AIDirectorHeroRuleEvaluator.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 6be9bf430e0644149adf7986bb5a03aa +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Unity/Assets/Scripts/TH1_Logic/AI/Director/AIDirectorLogic.cs b/Unity/Assets/Scripts/TH1_Logic/AI/Director/AIDirectorLogic.cs new file mode 100644 index 000000000..54c7bef37 --- /dev/null +++ b/Unity/Assets/Scripts/TH1_Logic/AI/Director/AIDirectorLogic.cs @@ -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); + } + } +} diff --git a/Unity/Assets/Scripts/TH1_Logic/AI/Director/AIDirectorLogic.cs.meta b/Unity/Assets/Scripts/TH1_Logic/AI/Director/AIDirectorLogic.cs.meta new file mode 100644 index 000000000..aa0edde3f --- /dev/null +++ b/Unity/Assets/Scripts/TH1_Logic/AI/Director/AIDirectorLogic.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8d5fa376f9ee4aa588d02f959ba8a99e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Unity/Assets/Scripts/TH1_Logic/AI/Director/AIDirectorTypes.cs b/Unity/Assets/Scripts/TH1_Logic/AI/Director/AIDirectorTypes.cs new file mode 100644 index 000000000..20c688b5f --- /dev/null +++ b/Unity/Assets/Scripts/TH1_Logic/AI/Director/AIDirectorTypes.cs @@ -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 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 SelfUnits = new(); + public readonly List SelfHeroes = new(); + public readonly List EnemyUnits = new(); + public readonly List EnemyHeroes = new(); + public readonly List SelfCities = new(); + public readonly List EnemyCities = new(); + public readonly List EnemyPlayers = new(); + public readonly List AlliedPlayers = new(); + public readonly List WarTargetPlayers = new(); + public readonly List HighTrustPlayers = new(); + public readonly List LowTrustPlayers = new(); + public readonly List CityThreats = new(); + public readonly List CityPlans = new(); + public readonly List DevelopmentTargets = new(); + public readonly List Fronts = new(); + public readonly List HeroStates = new(); + public readonly List LocalBattles = new(); + public readonly List UnitOpportunities = new(); + public readonly HashSet 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 EnemyUnits = new(); + public readonly List 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 SelfUnits = new(); + public readonly List 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 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 Trace = new(); + public readonly List 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 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 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 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 + }); + } + } +} diff --git a/Unity/Assets/Scripts/TH1_Logic/AI/Director/AIDirectorTypes.cs.meta b/Unity/Assets/Scripts/TH1_Logic/AI/Director/AIDirectorTypes.cs.meta new file mode 100644 index 000000000..5afe66834 --- /dev/null +++ b/Unity/Assets/Scripts/TH1_Logic/AI/Director/AIDirectorTypes.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7b1d6241b76d4fd99e6b5a9c9a84c4a5 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Unity/Assets/Scripts/TH1_Logic/AI/Director/AIDirectorWorldCacheBuilder.cs b/Unity/Assets/Scripts/TH1_Logic/AI/Director/AIDirectorWorldCacheBuilder.cs new file mode 100644 index 000000000..c28260194 --- /dev/null +++ b/Unity/Assets/Scripts/TH1_Logic/AI/Director/AIDirectorWorldCacheBuilder.cs @@ -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 _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(); + 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(); + var bCities = new List(); + 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(); + 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; + } + } +} diff --git a/Unity/Assets/Scripts/TH1_Logic/AI/Director/AIDirectorWorldCacheBuilder.cs.meta b/Unity/Assets/Scripts/TH1_Logic/AI/Director/AIDirectorWorldCacheBuilder.cs.meta new file mode 100644 index 000000000..71afbcc55 --- /dev/null +++ b/Unity/Assets/Scripts/TH1_Logic/AI/Director/AIDirectorWorldCacheBuilder.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 06a6c26852ce48a19c34a312f684d5e5 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Unity/Assets/Scripts/TH1_Logic/AI/Kernel.meta b/Unity/Assets/Scripts/TH1_Logic/AI/Kernel.meta new file mode 100644 index 000000000..fbdf1cbc9 --- /dev/null +++ b/Unity/Assets/Scripts/TH1_Logic/AI/Kernel.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 7d15e9fe8d704a21bdcbdb2229323e8a +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Unity/Assets/Scripts/TH1_Logic/AI/Kernel/AIKernel.cs b/Unity/Assets/Scripts/TH1_Logic/AI/Kernel/AIKernel.cs new file mode 100644 index 000000000..d22e99628 --- /dev/null +++ b/Unity/Assets/Scripts/TH1_Logic/AI/Kernel/AIKernel.cs @@ -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; + } +} diff --git a/Unity/Assets/Scripts/TH1_Logic/AI/Kernel/AIKernel.cs.meta b/Unity/Assets/Scripts/TH1_Logic/AI/Kernel/AIKernel.cs.meta new file mode 100644 index 000000000..2a6312174 --- /dev/null +++ b/Unity/Assets/Scripts/TH1_Logic/AI/Kernel/AIKernel.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 0db393964b7c492c9056effc73908b8b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Unity/Assets/Scripts/TH1_Logic/AI/Kernel/AIKernelRegistry.cs b/Unity/Assets/Scripts/TH1_Logic/AI/Kernel/AIKernelRegistry.cs new file mode 100644 index 000000000..60da799eb --- /dev/null +++ b/Unity/Assets/Scripts/TH1_Logic/AI/Kernel/AIKernelRegistry.cs @@ -0,0 +1,44 @@ +using System; + +namespace Logic.AI +{ + public static class AIKernelRegistry + { + private static Func _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() where TKernel : IAIKernel, new() + { + CurrentKernelType = new TKernel().KernelType; + _kernelFactory = () => new TKernel(); + Version++; + } + + public static void Register(AIKernelType kernelType, Func 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; + } + } +} diff --git a/Unity/Assets/Scripts/TH1_Logic/AI/Kernel/AIKernelRegistry.cs.meta b/Unity/Assets/Scripts/TH1_Logic/AI/Kernel/AIKernelRegistry.cs.meta new file mode 100644 index 000000000..83071fa5f --- /dev/null +++ b/Unity/Assets/Scripts/TH1_Logic/AI/Kernel/AIKernelRegistry.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 00a9e2adf9d64061bb9b489d14ed0d2b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Unity/Assets/Scripts/TH1_Logic/AI/Kernel/BehaviourTreeAIKernel.cs b/Unity/Assets/Scripts/TH1_Logic/AI/Kernel/BehaviourTreeAIKernel.cs new file mode 100644 index 000000000..146f93afd --- /dev/null +++ b/Unity/Assets/Scripts/TH1_Logic/AI/Kernel/BehaviourTreeAIKernel.cs @@ -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> _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(); + if (_btOwner == null) + { + LogSystem.LogError("AIBT BehaviourTreeOwner missing, BehaviourTreeAIKernel can not run."); + return; + } + + var dataVariable = _btOwner.blackboard.GetVariable("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()); + } + + _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; + } + } +} diff --git a/Unity/Assets/Scripts/TH1_Logic/AI/Kernel/BehaviourTreeAIKernel.cs.meta b/Unity/Assets/Scripts/TH1_Logic/AI/Kernel/BehaviourTreeAIKernel.cs.meta new file mode 100644 index 000000000..1c1563a6d --- /dev/null +++ b/Unity/Assets/Scripts/TH1_Logic/AI/Kernel/BehaviourTreeAIKernel.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 3a249f4d5a1140d99d7592229e8503b7 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Unity/Assets/Scripts/TH1_Logic/AI/Kernel/DirectorAIKernel.cs b/Unity/Assets/Scripts/TH1_Logic/AI/Kernel/DirectorAIKernel.cs new file mode 100644 index 000000000..d023232f3 --- /dev/null +++ b/Unity/Assets/Scripts/TH1_Logic/AI/Kernel/DirectorAIKernel.cs @@ -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; + } + } +} diff --git a/Unity/Assets/Scripts/TH1_Logic/AI/Kernel/DirectorAIKernel.cs.meta b/Unity/Assets/Scripts/TH1_Logic/AI/Kernel/DirectorAIKernel.cs.meta new file mode 100644 index 000000000..b9a10b6b7 --- /dev/null +++ b/Unity/Assets/Scripts/TH1_Logic/AI/Kernel/DirectorAIKernel.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 3b4547bbdfd743ac978e45ddc16fef08 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Unity/Assets/Scripts/TH1_Logic/AITrain.meta b/Unity/Assets/Scripts/TH1_Logic/AITrain.meta deleted file mode 100644 index c91649055..000000000 --- a/Unity/Assets/Scripts/TH1_Logic/AITrain.meta +++ /dev/null @@ -1,3 +0,0 @@ -fileFormatVersion: 2 -guid: 8aadc7c4125b49b7af9d56af927fd52d -timeCreated: 1764056674 \ No newline at end of file diff --git a/Unity/Assets/Scripts/TH1_Logic/AITrain/ModelInference.cs b/Unity/Assets/Scripts/TH1_Logic/AITrain/ModelInference.cs deleted file mode 100644 index f9fac8515..000000000 --- a/Unity/Assets/Scripts/TH1_Logic/AITrain/ModelInference.cs +++ /dev/null @@ -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 -{ - /// - /// ONNX模型推理类 - /// 基于train.py的配置: STATE_DIM=802, ACTION_DIM=8 - /// - /// 依赖: Microsoft.ML.OnnxRuntime - /// 安装命令: 在Unity中通过NuGet安装 Microsoft.ML.OnnxRuntime 包 - /// - 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; - - - /// - /// 加载ONNX模型 - /// - /// ONNX模型文件路径 - /// 是否使用GPU (默认false,需要CUDA支持) - 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(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(); - } - - /// - /// 卸载模型 - /// - public void UnloadModel() - { - session?.Dispose(); - session = null; - } - - /// - /// 推理 - 从合法actions中选择最佳action - /// - /// 状态向量 (802维) - /// 合法动作列表,每个动作是8维float数组 - /// 选择的最佳动作在列表中的索引,失败返回-1 - public int Predict(float[] state, List> 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(new[] { 1, STATE_DIM }); - for (int i = 0; i < STATE_DIM; i++) - { - inputTensor[0, i] = state[i]; - } - - // 创建输入容器 - var inputs = new List - { - NamedOnnxValue.CreateFromTensor(inputName, inputTensor) - }; - - // 执行推理 - float[] predictedAction; - using (var results = session.Run(inputs)) - { - // 获取输出 - var outputTensor = results.First().AsTensor(); - - // 提取预测的动作向量 - 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; - } - } -} - diff --git a/Unity/Assets/Scripts/TH1_Logic/AITrain/TH1.Hotfix.asmref b/Unity/Assets/Scripts/TH1_Logic/AITrain/TH1.Hotfix.asmref deleted file mode 100644 index 4c8dc3500..000000000 --- a/Unity/Assets/Scripts/TH1_Logic/AITrain/TH1.Hotfix.asmref +++ /dev/null @@ -1,3 +0,0 @@ -{ - "reference": "TH1.Hotfix" -} diff --git a/Unity/Assets/Scripts/TH1_Logic/AITrain/TH1.Hotfix.asmref.meta b/Unity/Assets/Scripts/TH1_Logic/AITrain/TH1.Hotfix.asmref.meta deleted file mode 100644 index 198cac815..000000000 --- a/Unity/Assets/Scripts/TH1_Logic/AITrain/TH1.Hotfix.asmref.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: d2ae4cde6abf41d5afd19d21acf5b44f -AssemblyDefinitionReferenceImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Unity/Assets/Scripts/TH1_Logic/AITrain/TrainingDataRecorder.cs b/Unity/Assets/Scripts/TH1_Logic/AITrain/TrainingDataRecorder.cs deleted file mode 100644 index 26b8fd806..000000000 --- a/Unity/Assets/Scripts/TH1_Logic/AITrain/TrainingDataRecorder.cs +++ /dev/null @@ -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> _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(); - } - - 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(); - } - } - -} \ No newline at end of file diff --git a/Unity/Assets/Scripts/TH1_Logic/AITrain/TrainingDataRecorder.cs.meta b/Unity/Assets/Scripts/TH1_Logic/AITrain/TrainingDataRecorder.cs.meta deleted file mode 100644 index 991b265e9..000000000 --- a/Unity/Assets/Scripts/TH1_Logic/AITrain/TrainingDataRecorder.cs.meta +++ /dev/null @@ -1,3 +0,0 @@ -fileFormatVersion: 2 -guid: 7caddcfed4744645ae284218e3b4d1f5 -timeCreated: 1764579480 \ No newline at end of file diff --git a/Unity/Assets/Scripts/TH1_Logic/AITrain/TrainingState.cs b/Unity/Assets/Scripts/TH1_Logic/AITrain/TrainingState.cs deleted file mode 100644 index bb4bcace2..000000000 --- a/Unity/Assets/Scripts/TH1_Logic/AITrain/TrainingState.cs +++ /dev/null @@ -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(out var state); - - // 1. 己方信息 - - using var pooledSelfUnits = THCollectionPool.GetHashSetHandle(out var selfUnits); - map.GetUnitDataListByPlayerId(selfPlayer.Id, selfUnits); - using var pooledSelfCities = THCollectionPool.GetHashSetHandle(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(out var units); - using var pooledCities = THCollectionPool.GetListHandle(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 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 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> GetAllActionBitCodecForUse(MapData mapData, PlayerData selfPlayer, out List actions) - { - var packedList = new List>(); - 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> GetAllActionBitCodec(MapData mapData, PlayerData selfPlayer, out List actions) - { - var packedList = new List>(); - 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 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(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(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(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($"CommonIdData/CommonIdData"); - var data = asset?.bytes ?? Array.Empty(); - _actionLogicIdData = TH1Serialization.Deserialize(data) ?? new ActionLogicIdData(); - } - } - - - [MemoryPackable] - public partial class ActionLogicIdData - { - public List ActionIdList = new(); - private Dictionary _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 packed) - { - packed = new List(); - 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 packed) - { - packed = new List(); - 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 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; - } - } -} diff --git a/Unity/Assets/Scripts/TH1_Logic/AITrain/TrainingState.cs.meta b/Unity/Assets/Scripts/TH1_Logic/AITrain/TrainingState.cs.meta deleted file mode 100644 index 806b3225f..000000000 --- a/Unity/Assets/Scripts/TH1_Logic/AITrain/TrainingState.cs.meta +++ /dev/null @@ -1,3 +0,0 @@ -fileFormatVersion: 2 -guid: 199f5199fd66439f87cec581d0b58eba -timeCreated: 1764056709 \ No newline at end of file diff --git a/Unity/Assets/Scripts/TH1_Logic/Action/ActionLogic.cs b/Unity/Assets/Scripts/TH1_Logic/Action/ActionLogic.cs index 36530c32a..7b7e8b321 100644 --- a/Unity/Assets/Scripts/TH1_Logic/Action/ActionLogic.cs +++ b/Unity/Assets/Scripts/TH1_Logic/Action/ActionLogic.cs @@ -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; diff --git a/Unity/Assets/Scripts/TH1_Logic/Action/PlayerActionLogic.cs b/Unity/Assets/Scripts/TH1_Logic/Action/PlayerActionLogic.cs index c1389902d..bf0d64840 100644 --- a/Unity/Assets/Scripts/TH1_Logic/Action/PlayerActionLogic.cs +++ b/Unity/Assets/Scripts/TH1_Logic/Action/PlayerActionLogic.cs @@ -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,是否已经出了英雄,且拥有空的槽位 diff --git a/Unity/Assets/Scripts/TH1_Logic/Core/GameLogic.cs b/Unity/Assets/Scripts/TH1_Logic/Core/GameLogic.cs index ed2986b6b..c3d79b2e2 100644 --- a/Unity/Assets/Scripts/TH1_Logic/Core/GameLogic.cs +++ b/Unity/Assets/Scripts/TH1_Logic/Core/GameLogic.cs @@ -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 } diff --git a/Unity/Assets/Scripts/TH1_Logic/Core/Main.cs b/Unity/Assets/Scripts/TH1_Logic/Core/Main.cs index 8d3ddf3fe..465887e71 100644 --- a/Unity/Assets/Scripts/TH1_Logic/Core/Main.cs +++ b/Unity/Assets/Scripts/TH1_Logic/Core/Main.cs @@ -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(); diff --git a/Unity/Assets/Scripts/TH1_Logic/Editor/AIDirectorBatchRunner.cs b/Unity/Assets/Scripts/TH1_Logic/Editor/AIDirectorBatchRunner.cs new file mode 100644 index 000000000..4c13bf15b --- /dev/null +++ b/Unity/Assets/Scripts/TH1_Logic/Editor/AIDirectorBatchRunner.cs @@ -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 onException) + { + var stack = new Stack(); + 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(); + 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 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(json); + options.Normalize(); + return options; + } + + private static List ReadResults() + { + var json = SessionState.GetString(ResultsKey, string.Empty); + if (string.IsNullOrWhiteSpace(json)) return new List(); + if (json.TrimStart().StartsWith("[", StringComparison.Ordinal)) return new List(); + var list = JsonUtility.FromJson(json); + return list?.results ?? new List(); + } + + private static BatchOptions ReadOptionsSafe() + { + try + { + return ReadOptions(); + } + catch + { + var options = ParseOptionsFromCommandLine(); + options.Normalize(); + return options; + } + } + + private static List ReadResultsSafe() + { + try + { + return ReadResults(); + } + catch + { + return new List(); + } + } + + 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 BuildPlayerResults(MapData map) + { + var results = new List(); + 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 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 results; + } + + [Serializable] + public class BatchResultList + { + public List 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 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; + } +} diff --git a/Unity/Assets/Scripts/TH1_Logic/Editor/AIDirectorBatchRunner.cs.meta b/Unity/Assets/Scripts/TH1_Logic/Editor/AIDirectorBatchRunner.cs.meta new file mode 100644 index 000000000..a917e133c --- /dev/null +++ b/Unity/Assets/Scripts/TH1_Logic/Editor/AIDirectorBatchRunner.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4ea05ed6538d4a439ba76df86c185fe4 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Unity/Assets/Scripts/TH1_Logic/Editor/AITrainEditorWindow.cs b/Unity/Assets/Scripts/TH1_Logic/Editor/AITrainEditorWindow.cs deleted file mode 100644 index 259e1b428..000000000 --- a/Unity/Assets/Scripts/TH1_Logic/Editor/AITrainEditorWindow.cs +++ /dev/null @@ -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(); - 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(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($"CommonIdData/CommonIdData"); - return asset?.bytes ?? Array.Empty(); - } - - 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}"); - } - } - } -} \ No newline at end of file diff --git a/Unity/Assets/Scripts/TH1_Logic/Editor/AITrainEditorWindow.cs.meta b/Unity/Assets/Scripts/TH1_Logic/Editor/AITrainEditorWindow.cs.meta deleted file mode 100644 index 0353b4569..000000000 --- a/Unity/Assets/Scripts/TH1_Logic/Editor/AITrainEditorWindow.cs.meta +++ /dev/null @@ -1,3 +0,0 @@ -fileFormatVersion: 2 -guid: 5b0319a8d0354ad2a6899d5f6ddaf928 -timeCreated: 1764405047 \ No newline at end of file diff --git a/Unity/Assets/Scripts/TH1_Logic/Editor/BuildEditor.cs b/Unity/Assets/Scripts/TH1_Logic/Editor/BuildEditor.cs index 4062284a6..07b974063 100644 --- a/Unity/Assets/Scripts/TH1_Logic/Editor/BuildEditor.cs +++ b/Unity/Assets/Scripts/TH1_Logic/Editor/BuildEditor.cs @@ -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($"AI模型已开启:"); - if (InspectorUtils.InspectorButtonWithTextWidth($"关闭AI模型")) RemoveDefine(ENABLE_AIMODEL); -#else - InspectorUtils.InspectorTextWidthRich($"AI模型已关闭:"); - if (InspectorUtils.InspectorButtonWithTextWidth($"开启AI模型")) AddDefine(ENABLE_AIMODEL); -#endif - EditorGUILayout.EndHorizontal(); - - - EditorGUILayout.BeginHorizontal(); -#if ENABLE_TRAIN - InspectorUtils.InspectorTextWidthRich($"训练模式已开启:"); - if (InspectorUtils.InspectorButtonWithTextWidth($"关闭训练模式")) RemoveDefine(ENABLE_TRAIN); -#else - InspectorUtils.InspectorTextWidthRich($"训练模式已关闭:"); - if (InspectorUtils.InspectorButtonWithTextWidth($"开启训练模式")) AddDefine(ENABLE_TRAIN); -#endif - EditorGUILayout.EndHorizontal(); - - EditorGUILayout.BeginHorizontal(); #if ENABLE_SPEEDUP InspectorUtils.InspectorTextWidthRich($"加速模式已开启:"); @@ -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 } } } -} \ No newline at end of file +} diff --git a/Unity/Assets/Scripts/TH1_Logic/Editor/SteamEditorWindow.cs b/Unity/Assets/Scripts/TH1_Logic/Editor/SteamEditorWindow.cs index c021e7bee..f6c1baae9 100644 --- a/Unity/Assets/Scripts/TH1_Logic/Editor/SteamEditorWindow.cs +++ b/Unity/Assets/Scripts/TH1_Logic/Editor/SteamEditorWindow.cs @@ -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(), + 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(); + } + } } } diff --git a/Unity/Assets/Scripts/TH1_Logic/Editor/TH1MigrationCommandLine.cs b/Unity/Assets/Scripts/TH1_Logic/Editor/TH1MigrationCommandLine.cs index bec2019dd..86ce5d9f7 100644 --- a/Unity/Assets/Scripts/TH1_Logic/Editor/TH1MigrationCommandLine.cs +++ b/Unity/Assets/Scripts/TH1_Logic/Editor/TH1MigrationCommandLine.cs @@ -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", diff --git a/Unity/Assets/Scripts/TH1_Logic/Editor/TH1UnifiedBuildWindow.cs b/Unity/Assets/Scripts/TH1_Logic/Editor/TH1UnifiedBuildWindow.cs index 8bc71850a..80fa5c71d 100644 --- a/Unity/Assets/Scripts/TH1_Logic/Editor/TH1UnifiedBuildWindow.cs +++ b/Unity/Assets/Scripts/TH1_Logic/Editor/TH1UnifiedBuildWindow.cs @@ -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 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; diff --git a/Unity/Assets/Scripts/TH1_Logic/Oss/OssManager.cs b/Unity/Assets/Scripts/TH1_Logic/Oss/OssManager.cs index 22ba3ff0c..008bb40e8 100644 --- a/Unity/Assets/Scripts/TH1_Logic/Oss/OssManager.cs +++ b/Unity/Assets/Scripts/TH1_Logic/Oss/OssManager.cs @@ -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;