This commit is contained in:
daixiawu 2026-06-29 20:40:38 +08:00
commit 6d79d3df46
68 changed files with 10414 additions and 1325 deletions

View File

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

View File

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

View File

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

8
.gitignore vendored
View File

@ -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/

View File

@ -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.

View File

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

View File

@ -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/ # 玩家逻辑

View File

@ -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`,玩家重新提交时会再次上传。

View File

@ -0,0 +1,553 @@
# 18-AI导演系统策划文档
> 本文档定义 TH1 新 AI Director 的目标策划方案。
> 它以 TH1 的回合制 4X 玩法、Action 权威行为层、英雄系统、城市发展、科技文化外交为基础,不依赖既有 AI 实现结构。
---
## 1. 方案定位
AI Director 是一套“局势缓存 + 分层规则 + 合法 Action 执行”的回合 AI。
它的职责是:
- 读懂当前 `MapData` 局势。
- 判断当前回合最该解决的问题。
- 从合法 Action 池中选出一个动作。
- 通过 `CommonActionParams``CheckCan``CompleteExecute` 执行。
- 执行后重新读局势,再决定下一步。
它不是:
- 不是 PPT 细节的逐条搬运。
- 不是机器学习黑盒。
- 不是所有动作统一套一个万能评分。
- 不是绕过 Action 层直接改 `MapData` 的独立系统。
AI 的核心表现目标:
| 目标 | 表现 |
|---|---|
| 活下来 | 不轻易丢首都、空城和关键英雄 |
| 会扩张 | 能抢村庄、遗迹、资源和可开发区域 |
| 会打仗 | 会防守、会压线、会攻击高价值目标 |
| 会发展 | 安全时能建设、升级、学科技、买文化卡 |
| 会用英雄 | 英雄按自身机制行动,而不是当普通兵 |
| 性能稳定 | 每次决策只做轻量缓存和候选筛选 |
---
## 2. 一次 AI 动作的逻辑线
AI 不是一次性规划完整回合,而是重复执行“读局势,选一个 Action”。
```text
构建局势缓存
→ 构建合法动作池
→ 按优先级车道选择一个动作
→ 校验并执行 Action
→ 动作改变 MapData
→ 再次进入下一次决策
```
一个普通兵为什么会动起来:
```text
如果城市危险,它去救城。
否则如果眼前能打有价值目标,它攻击。
否则如果脚下或附近有占领、遗迹、资源、恢复、升级机会,它做机会动作。
否则如果它空闲,它向防守、进攻或发展战线移动。
否则它交给内政或兜底逻辑。
```
一个城市为什么会生产:
```text
如果城市会被威胁,优先守城和出防守兵。
如果城市靠近前线,优先提供军力。
如果城市安全,优先升级、建设、资源和奇观。
```
一个英雄为什么会放技能:
```text
先判断英雄当前上下文。
再查英雄专属 Playbook。
如果专属动作有合法 Action 且有局部价值,就执行。
否则才退回普通攻击、移动或恢复。
```
---
## 3. 决策骨架
Director 使用固定优先级车道。车道之间不做统一评分,车道内部只做同类轻量排序。
| 顺序 | 车道 | 策略目标 |
|---|---|---|
| 1 | Emergency | 阻止立即丢城、被偷家、关键单位暴毙 |
| 2 | HeroManagement | 处理选英雄、英雄任务、出场和复活节奏 |
| 3 | HeroPlaybook | 发挥英雄机制和阵营特色 |
| 4 | Tactic | 处理当前可攻击或近距离交战 |
| 5 | UnitOpportunity | 抢占领、遗迹、采集、恢复、升级等确定收益 |
| 6 | Front | 把空闲单位送往正确战略方向 |
| 7 | Growth | 推进城市、地块、科技、文化、外交 |
| 8 | Fallback | 防止规则缺口导致 AI 停摆 |
车道顺序的核心含义:
```text
活命 > 英雄体系 > 英雄特色 > 当前战斗 > 短期机会 > 战略移动 > 长期发展 > 兜底
```
---
## 4. 局势缓存
局势缓存只服务本次决策,不维护跨回合大型状态机。
### 4.1 基础集合
| 数据 | 用途 |
|---|---|
| 己方单位 | 行动主体、守军、进攻力量、探索力量 |
| 己方英雄 | 构建 HeroState触发 HeroPlaybook |
| 己方城市 | 构建 CityThreat、CityPlan、Front |
| 己方领土 | 判断入侵、资源、可建设范围 |
| 敌方单位 | 构建威胁、局部战斗、目标价值 |
| 敌方英雄 | 判断高价值目标和控制优先级 |
| 敌方城市 | 构建进攻战线和地缘距离 |
| 可见资源/遗迹/村庄 | 构建发展目标和机会动作 |
### 4.2 局势指标
缓存中保留以下轻量指标:
| 指标 | 含义 |
|---|---|
| MilitaryValue | 单位战斗价值,普通兵按成本和血量,英雄单独加权 |
| CounterValue | 兵种克制价值,来自 TH1 克制关系和特殊兵种映射 |
| ThreatValue | 单位或格子可能受到的伤害压力 |
| AttackValue | 某单位攻击某目标的局部收益 |
| CityThreat | 城市周围敌我压力 |
| DevelopmentValue | 村庄、遗迹、资源、边界探索的价值 |
| FrontValue | 防守、进攻、发展的战线价值 |
这些指标不追求完美数学最优,只用于排序同类选择。
### 4.3 性能边界
缓存构建必须遵守:
- 不深拷贝 `MapData`
- 不在每个车道重复全量扫描地图。
- 不保存跨多回合的大型黑板式复杂状态。
- 不建立永久军团状态机。
- 大范围搜索只保留 TopN 目标。
---
## 5. 战略态势
战略态势是本回合国家层面的粗判断。
```text
Defense
Expansion
Attack
Development
```
### 5.1 Defense
进入 Defense 的典型条件:
- 有城市 `CityThreat` 达到危险。
- 敌方单位进入己方领土。
- 敌军压力显著高于附近守军。
- 敌军下回合可能威胁城市中心。
- 多个敌国同时压境。
Defense 的行为倾向:
- Emergency 车道优先回防。
- 城市优先训练防守单位、建城墙、移动占城格单位。
- 科技优先防御、基础兵种、移动和克制。
- 英雄优先治疗、保护、控场、守城。
### 5.2 Expansion
进入 Expansion 的典型条件:
- 近距离有可达村庄。
- 附近有高价值遗迹、资源、边界探索点。
- 当前没有严重城市威胁。
- 军事上暂时不适合主动进攻。
Expansion 的行为倾向:
- UnitOpportunity 优先占领和探索。
- Development Front 指向村庄、遗迹、资源和边界。
- 城市优先增长和基础建设。
- 科技优先资源开发、移动能力和道路。
### 5.3 Attack
进入 Attack 的典型条件:
- 与某国战争或关系极差。
- 敌城距离足够近。
- 我方军事价值和克制价值不劣。
- 附近没有更高优先级扩张目标。
Attack 的行为倾向:
- Front 指向进攻目标城市。
- Tactic 优先击杀高价值目标和守城单位。
- 城市优先训练克制兵种和攻城相关单位。
- 科技优先军事克制、攻击、移动、海战和攻城。
### 5.4 Development
进入 Development 的典型条件:
- 无严重城市威胁。
- 无明确进攻窗口。
- 无近距离高价值扩张目标。
- 经济、科技、文化或城市等级落后。
Development 的行为倾向:
- Growth 车道推进城市建设、资源开发、科技和文化。
- Front 可生成低优先级发展战线。
- 外交倾向稳定关系和建立使馆。
---
## 6. 城市计划
每座己方城市生成一个 CityPlan。
| 城市计划 | 触发局势 | 行为倾向 |
|---|---|---|
| EmergencyDefense | 城市会被立即威胁 | 守城、建城墙、训练防守兵 |
| Mobilize | 前线压力存在但未立即丢城 | 训练克制兵、支援前线 |
| Frontline | 离敌城或交战边境近 | 训练作战单位、复活英雄、补军力 |
| BacklineGrowth | 后方安全 | 升级、建设、资源、科技文化 |
| Wonder | 后方安全且奇观条件明显 | 启动或完成奇观 |
城市策略保持简单:
```text
危险城市解决生存。
前线城市提供军力。
后方城市滚长期收益。
```
---
## 7. 战线与单位意图
不建立大型跨回合军团状态机。第一版用轻量 Front 表达单位移动方向。
| Front 类型 | 来源 | 目标 |
|---|---|---|
| DefenseFront | 危险城市 | 城市中心、防守锚点 |
| AttackFront | 目标敌城、敌军集结 | 敌城中心、敌军方向 |
| DevelopmentFront | 村庄、遗迹、资源、边界 | 可扩张目标 |
| HoldFront | 安全期驻扎 | 关键城市或交通点 |
单位每次决策只生成当前意图:
| UnitIntent | 含义 |
|---|---|
| DefendCity | 回防、攻击威胁城市的敌人 |
| FightLocal | 当前附近有可打目标 |
| TakeOpportunity | 占领、遗迹、采集、恢复、升级 |
| MoveFront | 向 Front 移动 |
| Hold | 不离开关键格,等待或恢复 |
这种设计保留 PPT 中“国家、城市、军团、自由人”的策划意图,但去掉跨回合军团编制和复杂重组。
---
## 8. 普通单位策略
普通单位的行为链:
```text
城市危机可参与 → DefendCity
附近有高价值战斗 → FightLocal
脚下或附近有确定收益 → TakeOpportunity
没有更高优先级 → MoveFront
位置关键或低血 → Hold / Recover
```
### 8.1 战斗目标
攻击目标按以下因素排序:
- 能否击杀。
- 是否威胁城市。
- 是否是英雄。
- 目标军事价值。
- 目标残血程度。
- 我方反伤风险。
- 外交关系和是否交战。
### 8.2 机会动作
机会动作优先级:
```text
占敌城或村庄
→ 开遗迹或宝物
→ 采集高价值资源
→ 英雄升级
→ 普通升级或文化升级
→ 低血恢复
```
机会动作必须让位于 Emergency 和明显击杀。
### 8.3 移动
移动不追求完整路径规划,只选择当前可达格中的最优格。
移动格评估因素:
- 是否接近目标 Front。
- 是否降低城市危机。
- 是否进入敌方高威胁格。
- 是否下回合能攻击或占领。
- 是否堵住己方城市中心。
- 是否保护英雄或关键单位。
---
## 9. 英雄策略
英雄不走普通单位公式。英雄由 HeroPlaybook 驱动。
HeroPlaybook 的判断顺序:
```text
保命
→ 救关键友军
→ 使用高价值专属动作
→ 攻击高价值目标
→ 站位到正确 Front
→ 普通恢复或等待
```
英雄动作只通过现有 Action 入口落地:
| 英雄行为 | Action 入口 |
|---|---|
| 攻击敌人 | UnitAttack |
| 治疗、保护、喂养、友军互动 | UnitAttackAlly |
| 攻击地块、召唤、灵球、特殊落点 | UnitAttackGround |
| 自身主动、切形态、爆破、坐镇、转换资源 | UnitAction |
| 重新站位 | UnitMove |
### 9.1 红魔馆 / Egyptian
| 英雄 | AI 意图 |
|---|---|
| Flandre | 优先斩杀可杀高价值目标;特殊友军攻击只在能触发收益时使用 |
| Remilia | 接战时铺血雾或增益;低血或可吸收时续航 |
| Sakuya | 守护残血关键友军;有收割窗口时补刀 |
| Meiling | 低血恢复,安全时承担前排和防守 |
| Patchouli | 利用元素、地面和友军施法收益;危险时避免深入 |
### 9.2 永远亭 / French
| 英雄 | AI 意图 |
|---|---|
| Kaguya | 给前线友军提供协同和团队收益 |
| Reisen | 能击杀或爆破时优先输出 |
| Tewi | 接战阶段给相邻友军增益 |
| Eirin | 优先治疗残血英雄,其次治疗高价值友军 |
| Mokou | 残血且敌人密集时自爆换收益;蛋形态按危险密度移动 |
### 9.3 守矢 / Germany
| 英雄 | AI 意图 |
|---|---|
| Kanako | 安全时坐镇;需要移动或接敌时取消坐镇 |
| Suwako | 用地面攻击和召唤制造前线压力 |
| Sanae | 治疗友军,优先处理残血和英雄威胁 |
| Aya | 用高机动追击、回防和踩路径收益 |
| Momiji | 围绕猎物标记移动和攻击 |
### 9.4 地灵殿 / Indian
| 英雄 | AI 意图 |
|---|---|
| Satori | 压制敌方核心英雄,优先封禁或恐惧相关目标 |
| Koishi | 靠近安全前线和骨堆目标,制造恐惧/治疗价值 |
| Rin | 有尸火层数时转换输出或资源 |
| Utsuho | 骨堆、敌城中心和高密度目标优先 |
| Yuugi | 通过推进和强位移压线,寻找高收益移动格 |
### 9.5 博丽 / Norway
| 英雄 | AI 意图 |
|---|---|
| Reimu | 保护关键友军;退魔压力高时净化 |
| Sumireko | 用灵球和地面攻击控制战线 |
| Kasen | 高压战斗时切换形态,利用鬼形态压制 |
| Aunn | 靠近防守线,保持双体和支援价值 |
| Suika | 安全时生成小萃香,被围攻时解围 |
### 9.6 后续阵营通用规则
`BritishByakuren``PersianMiko``ByzantineZanmu` 等已在游戏数据中存在时AI 不因为缺少专属 Playbook 而停摆。
默认规则:
- 按英雄等级和技能入口识别可用 Action。
- 治疗和保护类动作优先给残血英雄和高价值友军。
- 地面攻击类动作优先选敌军密集、敌城中心或可触发召唤的格子。
- 自身主动类动作只在满足局部价值时使用。
- 没有专属规则时退回通用英雄攻击、恢复和 Front 站位。
---
## 10. 科技、文化、外交
科技、文化、外交属于长期收益,不和 Emergency、Tactic、HeroPlaybook 抢优先级。
### 10.1 科技方向
| 局势 | 科技倾向 |
|---|---|
| Defense | 防御、基础兵种、城墙、克制、移动 |
| Attack | 军事克制、攻击、移动、攻城、海战 |
| Expansion | 资源开发、探索、道路、浅海/山地通行 |
| Development | 经济、城市升级、文化、奇观条件 |
科技选择可以看当前科技,也可以看一层后继科技,但不做完整科技树搜索。
### 10.2 文化卡
文化卡按当前阶段选方向:
- 前线吃紧:单位强化、购买强单位、即时战力。
- 英雄体系成型:英雄折扣、英雄强化、英雄相关收益。
- 安全发展:长期经济、资源解锁、文化收益。
### 10.3 外交
外交保持简单:
- 高好感结盟请求优先接受。
- 好感高且资源允许时建立使馆。
- 好感极高且没有直接扩张冲突时尝试结盟。
- 好感极低或已交战国家可进入 Attack 候选。
- 队友和盟友不能被普通进攻逻辑选为目标。
---
## 11. 合法动作池
Director 不直接推演行为结果,而是从合法 Action 池中选择。
动作池需要覆盖:
| 分类 | 动作 |
|---|---|
| 单位战斗 | UnitAttack、UnitAttackAlly、UnitAttackGround |
| 单位移动 | UnitMove |
| 单位行为 | Capture、Examine、Gather、Recover、Upgrade、HeroUpgrade、CultureUnitUpgrade、英雄主动 |
| 城市行为 | TrainUnit、CityLevelUpAction、CityAction、StartWonder、BuildWonder |
| 地块行为 | Gain、Build、GridMisc |
| 玩家行为 | LearnTech、BuyCultureCard、PlayerAction |
| 英雄管理 | SelectHero、FinishHeroTask、出场、复活 |
危险动作默认不进入普通 AI 选择:
- 解散。
- 强制解散。
- 拆除。
- 驱散。
- 摧毁建筑。
除非后续单独设计明确的“牺牲/拆除/清场”规则。
---
## 12. 工程落地边界
AI 可以重新实现,但必须遵守 TH1 的游戏架构:
- 权威行为仍走 Action 层。
- 参数仍用 `CommonActionParams`
- 合法性仍由 `CheckCan` 兜底。
- 执行仍走 `CompleteExecute`
- 技能效果仍由 `SkillBase` 生命周期触发。
- 城市、单位、玩家、格子的真实修改仍由现有逻辑模块承担。
- 多人和回放不能依赖本地随机数、当前时间或不稳定遍历顺序。
这意味着 AI 文档定义“怎么选动作”,不定义“动作怎么改游戏数据”。
---
## 13. 性能目标
一次决策的成本应接近:
```text
收集基础实体
+ 局部距离和威胁缓存
+ 合法动作枚举
+ 各车道轻量排序
```
性能原则:
- 城市威胁只看城市周围有限范围。
- 局部战斗只看可接触范围。
- Front 只保留少量高价值目标。
- DevelopmentTarget 只保留 TopN。
- 行动候选只生成一次。
- 车道只查缓存和动作池。
如果卡顿,优先削减:
```text
移动候选数量
发展目标数量
局部战斗搜索半径
科技/建筑候选遍历次数
```
---
## 14. 测试标准
第一轮测试不只看胜负,先看行为线。
| 测试项 | 通过标准 |
|---|---|
| 回合稳定 | AI 能连续执行并正常结束回合 |
| 城市防守 | 城市危险时优先攻击威胁或回防 |
| 占领扩张 | 能占村、占城、开遗迹时不会长期无视 |
| 局部战斗 | 能打高价值目标、残血目标和威胁城市目标 |
| 英雄表现 | 英雄会治疗、保护、地面攻击、自爆、坐镇或控场 |
| 战线移动 | 空闲单位能向防守、进攻、发展目标移动 |
| 城市发展 | 安全城市能生产、升级、建设、科技文化 |
| 外交行为 | 高好感不乱开战,必要时建立使馆或结盟 |
| 性能 | 单个 AI 动作决策无明显卡顿 |
问题定位:
| 现象 | 优先检查 |
|---|---|
| 城市被偷 | CityThreat、Emergency、城市计划 |
| 能占不占 | UnitOpportunity、DevelopmentTarget |
| 英雄不放技能 | HeroState、HeroPlaybook、ActionPool |
| 单位乱走 | Front、GridThreat、MoveTarget |
| 城市不发展 | CityPlan、Growth、ActionPool |
| 科技乱学 | StrategicPosture、TechScore |
| 回合慢 | ActionPool、移动候选、局部搜索半径 |

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,691 @@
# 20-AI导演系统测试与诊断流程
> 本文档定义新 AI Director 的本地测试流程和诊断日志阅读方式。
> 目标不是只看胜负,而是能逐回合判断 AI 为什么这么做、缓存是否正确、决策是否聪明、执行是否成功。
---
## 1. 测试目标
AI 测试必须回答四个问题:
```text
局势缓存是否正确读懂了地图
战略和车道是否选对了问题
候选 Action 是否覆盖了该做的事
最终执行是否真的改变了 MapData
```
如果 AI 行为不对,先不要直接改分数。按日志定位问题属于哪一层:
| 问题层 | 典型表现 | 优先处理 |
|---|---|---|
| 缓存错误 | 城市明明危险但 `cityThreats` 不危险 | 修缓存逻辑,必要时回写 18/19 |
| 动作池缺失 | 日志里没有想要的 Action | 查 Action 生成、参数、`CheckCan` |
| 车道错误 | 有城市危机却走 Growth | 修车道条件或优先级 |
| 评分错误 | 车道对了但目标很蠢 | 修同类动作排序 |
| 执行失败 | `executed=false` | 查 `CompleteExecute`、参数刷新、网络状态 |
---
## 2. 诊断开关
诊断入口:
```csharp
Logic.AI.Director.AIDirectorDiagnostics.Enable();
Logic.AI.Director.AIDirectorDiagnostics.Disable();
Logic.AI.Director.AIDirectorDiagnostics.BeginNewSession();
var path = Logic.AI.Director.AIDirectorDiagnostics.CurrentLogPath;
```
当前策略:
| 环境 | 行为 |
|---|---|
| Unity Editor | 默认启用,方便本地跑局直接产生日志 |
| 非 Editor 普通包 | 默认 no-op无日志成本 |
| 非 Editor 诊断包 | 加 `TH1_AI_DIRECTOR_DIAGNOSTICS` 宏后可启用 |
如果 Editor 下临时不想产生日志,在测试入口或控制台调用:
```csharp
AIDirectorDiagnostics.Disable();
```
如果要从某一局重新开始单独记录:
```csharp
AIDirectorDiagnostics.BeginNewSession();
```
---
## 3. 文件结构
日志采用 JSONL一行一个事件。
Editor 路径:
```text
Unity/Logs/AI_Director_Diagnostics/ai_director_yyyyMMdd_HHmmss.jsonl
```
Player 路径:
```text
Application.persistentDataPath/AI_Director_Diagnostics/ai_director_yyyyMMdd_HHmmss.jsonl
```
文件组织选择:
```text
一局一个文件
同一局内所有 AI 玩家写入同一个文件
每次 AI 决策和执行各写一行
```
这样做的原因:
- 能按 `eventSequence` 看完整时间线。
- 能看不同 AI 玩家之间的连续因果。
- 不会每回合生成大量小文件。
- JSONL 可追加,崩溃前的数据也能保留。
当运行中检测到 `MapData` 实例变化时,会自动开启新 session。
---
## 4. 事件类型
| eventType | 含义 |
|---|---|
| SessionStart | 新日志文件开始 |
| TurnStart | 某个 AI 玩家开始本次 AI 逻辑 |
| Decision | Director 完成一次思考并选出一个候选动作 |
| Execution | `AILogic` 执行该动作后的结果 |
| TurnEnd | 某个 AI 玩家结束本次 AI 逻辑 |
一次正常 AI 行动线大致是:
```text
TurnStart
Decision
Execution
Decision
Execution
...
Decision(hasAction=false) 或 AI 无可用动作
TurnEnd
```
---
## 5. 关键字段
### 5.1 顶层字段
| 字段 | 用途 |
|---|---|
| eventSequence | 全文件递增序号,用来还原时间线 |
| mapId | 当前地图 ID |
| curPlayerId | `MapData.Net.CurPlayerId` |
| netActionCount | 当前权威 Action 日志数量 |
| playerId | 当前 AI 玩家 |
| playerTurn | 当前 AI 玩家的回合数 |
| playerCoin / playerCulture | 决策当时资源 |
| selfCities / selfUnits | 当前己方城市和单位数量 |
### 5.2 decision
| 字段 | 用途 |
|---|---|
| hasAction | 本次是否选出动作 |
| decideMs | 本次 Director 决策耗时 |
| lane | 选中车道 |
| priority | 车道内优先级 |
| reason | 选中原因 |
| isFallback | 是否兜底动作 |
| action | 最终候选 Action 摘要 |
重点看:
```text
lane 是否符合局势
reason 是否能解释行为
priority 是否明显异常
action 是否是你预期的动作类型和目标
decideMs 是否出现异常尖峰
```
### 5.3 cache
| 字段 | 用途 |
|---|---|
| strategicPosture | 国家级态势 |
| hasCriticalCityThreat | 是否存在危急城市 |
| hasAnyEnemyContact | 是否接敌 |
| selfMilitary / enemyMilitary | 粗略军力 |
| cityThreats | 城市威胁 TopN |
| cityPlans | 城市计划 TopN |
| fronts | 战线 TopN |
| developmentTargets | 扩张目标 TopN |
| localBattles | 局部战斗 TopN |
| heroStates | 英雄状态 |
| unitOpportunities | 单位机会动作 |
| diplomacy | 外交视图 |
重点看:
```text
地图上的事实是否被 cache 正确表达
最危险城市是否排在 cityThreats 前面
英雄的 context 是否符合当前战况
Front 指向是否符合防守、进攻、发展目标
```
### 5.4 actionPool
| 字段 | 用途 |
|---|---|
| all | 合法候选总数 |
| attacks | 攻击候选 |
| moves | 移动候选 |
| unitActions | 单位行为 |
| cityActions | 城市行为 |
| gridActions | 地块行为 |
| playerActions | 玩家行为 |
| heroManagementActions | 英雄管理行为 |
重点看:
```text
如果 AI 没做某事,先看 actionPool 是否真的有这类动作
如果 all 长期接近 MaxGeneratedActions说明候选生成可能过多
如果 all 为 0说明 Action 生成或 CheckCan 有大问题
```
### 5.5 lanes
`lanes` 是诊断自循环最关键的数据。它记录每个车道看到的 TopN 候选,而不是只记录最终选择。
| 字段 | 用途 |
|---|---|
| lane | 车道名 |
| selected | 最终是否选中了该车道 |
| note | 车道结果说明 |
| candidates | 该车道候选 TopN |
`candidates` 字段:
| 字段 | 用途 |
|---|---|
| source | 候选来源,例如 LocalBattle、CityGrowth、HeroRule |
| isValid | 候选是否可执行 |
| rejectReason | 无效或未生成原因 |
| priority | 当前候选分数 |
| reason | 候选自然语言原因 |
| action | 候选 Action 摘要 |
| scoreTerms | 分数组成 |
`scoreTerms` 用来回答“为什么这个候选分高”:
```text
base
danger
targetValue
distance
hero
attackScore
cityGrowth
gridGrowth
playerGrowth
```
重点看:
```text
最终 action 是否真的是所在 lane 的最高优先级候选
未选动作是否进入过候选列表
候选无效是 NoAction、InvalidCandidate还是 CheckCan 过滤
分数是被哪个 scoreTerms 拉高或拉低
```
### 5.6 execution
| 字段 | 用途 |
|---|---|
| executed | `CompleteExecute` 返回值 |
| action | 实际执行 Action 摘要 |
| before | 执行前局势摘要 |
| after | 执行后局势摘要 |
| delta | 执行前后变化 |
| isInSight | 是否在玩家视野内 |
| duration | 本次 AI 行为等待时长 |
`before / after` 包含:
```text
资源coin / techPoint / culture / cultureCardCount
规模cityCount / unitCount / heroCount
军力selfMilitary / enemyMilitary
威胁criticalCityThreatCount / cityThreatCount / maxCityDangerScore
关键对象:单位血量、位置、死亡,城市归属、等级
```
`delta` 包含:
```text
netActionDelta
coinDelta / techPointDelta / cultureDelta
cityDelta / unitDelta / heroDelta
selfMilitaryDelta / enemyMilitaryDelta
criticalCityThreatDelta / cityThreatDelta
unitMoved / unitDied / targetUnitDied
cityOwnerChanged / targetCityOwnerChanged
```
重点看:
```text
Decision 和 Execution 的 action 是否一致
executed 是否为 true
netActionDelta 是否推进
delta 是否符合动作意图
攻击是否造成 targetUnitHealthDelta 或 targetUnitDied
回防是否降低 criticalCityThreatDelta 或 maxCityDangerScoreDelta
占领是否造成 cityOwnerChanged 或 cityDelta
```
---
## 6. 单回合阅读流程
看一个 AI 回合时按这个顺序读:
```text
1. 找到该 playerId 的 TurnStart
2. 顺着 eventSequence 看每个 Decision
3. 先看 cache.strategicPosture 和 cityThreats
4. 再看 decision.lane / reason / action
5. 对照 actionPool 判断是不是没有可用动作
6. 看 lanes确认没选的候选为什么没赢
7. 看紧随其后的 Execution 是否成功
8. 看 execution.delta 判断动作结果是否赚
9. 重复直到 TurnEnd
```
判断一句话:
```text
如果 cache 对lane 对lanes 候选解释合理action 合理executed=truedelta 符合意图,这一步就是正确的。
```
如果行为看起来笨,但上面五项都对,说明 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 日志再修
```

View File

@ -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)和目标路径,防止客户端篡改上传目标。
- **版本隔离**:不同版本的客户端使用不同路径前缀,令牌不可跨版本复用。

View File

@ -0,0 +1,31 @@
'use strict';
const fs = require('fs');
const path = require('path');
const source = fs.readFileSync(path.join(__dirname, 'index.js'), 'utf8');
const checks = [
['ossdata type', /ossdata:\s*{[\s\S]*?extension:\s*'dat'[\s\S]*?maxUploadSize:\s*MAX_STANDARD_UPLOAD_SIZE/],
['collectdata type', /collectdata:\s*{[\s\S]*?extension:\s*'dat'[\s\S]*?buildPathPrefix:\s*versionSegment\s*=>\s*`collect\/\$\{versionSegment\}`/],
['bugreport type', /bugreport:\s*{[\s\S]*?extension:\s*'zip'[\s\S]*?uniqueObjectKey:\s*true[\s\S]*?buildPathPrefix:\s*versionSegment\s*=>\s*`bugreport\/\$\{versionSegment\}`/],
['multilingualreport type', /multilingualreport:\s*{[\s\S]*?extension:\s*'zip'[\s\S]*?uniqueObjectKey:\s*true[\s\S]*?buildPathPrefix:\s*versionSegment\s*=>\s*`multilingualreport\/\$\{versionSegment\}`/],
['questionnaire type', /questionnaire:\s*{[\s\S]*?extension:\s*'json'[\s\S]*?maxUploadSize:\s*MAX_QUESTIONNAIRE_UPLOAD_SIZE[\s\S]*?uniqueObjectKey:\s*true[\s\S]*?buildPathPrefix:\s*versionSegment\s*=>\s*`questionnaire\/\$\{versionSegment\}`/],
['questionnaire size', /const MAX_QUESTIONNAIRE_UPLOAD_SIZE\s*=\s*512\s*\*\s*1024/],
['exact key post policy', /\['eq', '\$key', objectKey\]/],
['content length range post policy', /\['content-length-range', 1, maxUploadSize\]/],
];
let failed = false;
for (const [name, pattern] of checks) {
if (!pattern.test(source)) {
console.error(`contract check failed: ${name}`);
failed = true;
}
}
if (failed) {
process.exit(1);
}
console.log('upload contract check passed');

View File

@ -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": [],

View File

@ -0,0 +1,12 @@
# TH1 玩家问卷查看器
独立于玩家 Bug 查看器的本地工具,用于从 OSS 拉取玩家提交的 `questionnaire/` JSON 答卷,按版本、问卷 ID、SteamID 和题目 ID 筛选查看。
## 使用
1. 运行 `启动玩家问卷查看器.bat`
2. 工具会自动读取 Unity OSS 编辑器保存的 `AccessKey ID` / `AccessKey Secret`,确认 `Endpoint``Bucket`
3. 点击「更新内容」拉取 `questionnaire/` 下的 JSON。
4. 选择条目查看问卷 ID、提交时间、版本、SteamID、CrashSight 设备 ID、设备信息和答案明细。
也可以用 `config.local.json` 或环境变量覆盖 OSS 配置。本地下载缓存放在 `Data/`,两者都只应保留在本机。

View File

@ -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
}
}

View File

@ -0,0 +1,504 @@
import base64
import datetime as dt
import hashlib
import hmac
import json
import os
import sys
import threading
import urllib.parse
import urllib.request
import xml.etree.ElementTree as ET
from dataclasses import dataclass
from pathlib import Path
from tkinter import BOTH, END, LEFT, RIGHT, X, Y, BooleanVar, StringVar, Tk, messagebox
from tkinter import ttk
APP_DIR = Path(__file__).resolve().parent
TOOLS_DIR = APP_DIR.parent
if str(TOOLS_DIR) not in sys.path:
sys.path.insert(0, str(TOOLS_DIR))
from oss_viewer_config import merge_default_oss_config, merge_local_oss_config
DATA_DIR = APP_DIR / "Data"
CONFIG_PATH = APP_DIR / "config.local.json"
OSS_PREFIX = "questionnaire/"
def default_config() -> dict:
return {
"access_key_id": "",
"access_key_secret": "",
"endpoint": "oss-cn-shanghai.aliyuncs.com",
"bucket": "th1-oss",
"skip_existing_downloads": True,
}
def load_config() -> dict:
cfg = merge_default_oss_config(default_config())
if CONFIG_PATH.exists():
try:
cfg = merge_local_oss_config(cfg, json.loads(CONFIG_PATH.read_text(encoding="utf-8")))
except Exception:
pass
return cfg
def save_config(cfg: dict) -> None:
CONFIG_PATH.write_text(json.dumps(cfg, ensure_ascii=False, indent=2), encoding="utf-8")
def human_bytes(value: int) -> str:
if value >= 1024 * 1024:
return f"{value / 1024 / 1024:.2f} MB"
if value >= 1024:
return f"{value / 1024:.1f} KB"
return f"{value} B"
def parse_oss_time(value: str) -> str:
if not value:
return ""
try:
return dt.datetime.fromisoformat(value.replace("Z", "+00:00")).astimezone().strftime("%Y-%m-%d %H:%M")
except Exception:
return value[:16]
class OssClient:
def __init__(self, access_key_id: str, access_key_secret: str, endpoint: str, bucket: str):
self.access_key_id = access_key_id.strip()
self.access_key_secret = access_key_secret.strip()
self.endpoint = endpoint.strip()
self.bucket = bucket.strip()
def _sign(self, verb: str, date: str, canonicalized_resource: str) -> str:
string_to_sign = f"{verb}\n\n\n{date}\n{canonicalized_resource}"
digest = hmac.new(
self.access_key_secret.encode("utf-8"),
string_to_sign.encode("utf-8"),
hashlib.sha1,
).digest()
return base64.b64encode(digest).decode("ascii")
def _request(self, verb: str, url: str, canonicalized_resource: str) -> bytes:
now = dt.datetime.now(dt.timezone.utc)
date = now.strftime("%a, %d %b %Y %H:%M:%S GMT")
signature = self._sign(verb, date, canonicalized_resource)
request = urllib.request.Request(url, method=verb)
request.add_header("Date", date)
request.add_header("Authorization", f"OSS {self.access_key_id}:{signature}")
with urllib.request.urlopen(request, timeout=60) as response:
return response.read()
def list_objects(self, prefix: str) -> list[str]:
keys: list[str] = []
marker = ""
while True:
query = f"prefix={urllib.parse.quote(prefix)}&max-keys=1000"
canonicalized_resource = f"/{self.bucket}/"
if marker:
query += f"&marker={urllib.parse.quote(marker)}"
url = f"https://{self.bucket}.{self.endpoint}/?{query}"
xml_bytes = self._request("GET", url, canonicalized_resource)
root = ET.fromstring(xml_bytes)
ns = ""
if root.tag.startswith("{"):
ns = root.tag.split("}", 1)[0] + "}"
for content in root.findall(f"{ns}Contents"):
key = content.findtext(f"{ns}Key") or ""
if key and not key.endswith("/"):
keys.append(key)
is_truncated = (root.findtext(f"{ns}IsTruncated") or "").lower() == "true"
marker = root.findtext(f"{ns}NextMarker") or (keys[-1] if keys else "")
if not is_truncated or not marker:
break
return keys
def download_object(self, object_key: str, local_path: Path) -> None:
encoded_key = "/".join(urllib.parse.quote(part) for part in object_key.split("/"))
url = f"https://{self.bucket}.{self.endpoint}/{encoded_key}"
canonicalized_resource = f"/{self.bucket}/{object_key}"
data = self._request("GET", url, canonicalized_resource)
local_path.parent.mkdir(parents=True, exist_ok=True)
local_path.write_bytes(data)
@dataclass
class ReportEntry:
local_path: Path
object_key: str
response_id: str
questionnaire_id: str
version: str
steam_id: str
submitted_at_utc: str
created_at_utc: str
answer_count: int
question_ids: str
size: int
payload: dict
def local_path_for_key(object_key: str) -> Path:
relative = object_key[len(OSS_PREFIX):] if object_key.startswith(OSS_PREFIX) else object_key
return DATA_DIR / Path(*relative.split("/"))
def read_report(local_path: Path) -> ReportEntry | None:
try:
payload = json.loads(local_path.read_text(encoding="utf-8"))
sheet = payload.get("answerSheet") or {}
answers = sheet.get("Answers") or sheet.get("answers") or []
parts = local_path.relative_to(DATA_DIR).parts
version = payload.get("version") or (parts[0] if len(parts) > 0 else "")
steam_id = payload.get("steamId") or (parts[1] if len(parts) > 1 else "")
object_key = OSS_PREFIX + "/".join(parts).replace("\\", "/")
question_ids = ", ".join(
str(answer.get("QuestionId") or answer.get("questionId") or "")
for answer in answers
if isinstance(answer, dict) and (answer.get("QuestionId") or answer.get("questionId"))
)
return ReportEntry(
local_path=local_path,
object_key=object_key,
response_id=payload.get("responseId", local_path.stem),
questionnaire_id=payload.get("questionnaireId") or sheet.get("QuestionnaireId") or "",
version=version,
steam_id=steam_id,
submitted_at_utc=payload.get("submittedAtUtc") or sheet.get("SubmittedAtUtc") or "",
created_at_utc=payload.get("createdAtUtc", ""),
answer_count=len(answers),
question_ids=question_ids,
size=local_path.stat().st_size,
payload=payload,
)
except Exception:
return None
def load_reports() -> list[ReportEntry]:
DATA_DIR.mkdir(parents=True, exist_ok=True)
reports = [entry for entry in (read_report(path) for path in DATA_DIR.rglob("*.json")) if entry]
reports.sort(
key=lambda item: item.submitted_at_utc or item.created_at_utc or str(item.local_path.stat().st_mtime),
reverse=True,
)
return reports
def download_reports(cfg: dict) -> tuple[int, int, int]:
client = OssClient(
cfg["access_key_id"],
cfg["access_key_secret"],
cfg["endpoint"],
cfg["bucket"],
)
keys = [key for key in client.list_objects(OSS_PREFIX) if key.endswith(".json")]
downloaded = 0
skipped = 0
failed = 0
for key in keys:
local_path = local_path_for_key(key)
if cfg.get("skip_existing_downloads", True) and local_path.exists():
skipped += 1
continue
try:
client.download_object(key, local_path)
downloaded += 1
except Exception:
failed += 1
return downloaded, skipped, failed
def format_answer(answer: dict) -> str:
question_id = answer.get("QuestionId") or answer.get("questionId") or ""
question_type = answer.get("QuestionType") or answer.get("questionType") or ""
selected = answer.get("SelectedOptionIds") or answer.get("selectedOptionIds") or []
open_text = answer.get("OpenText") or answer.get("openText") or ""
lines = [f"题目: {question_id}", f"类型: {question_type}"]
if selected:
lines.append("选项: " + ", ".join(str(item) for item in selected))
if open_text:
lines.append("文本:")
lines.append(str(open_text))
return "\n".join(lines)
class PlayerQuestionnaireViewer(Tk):
def __init__(self):
super().__init__()
self.title("TH1 玩家问卷查看器")
self.geometry("1180x760")
self.minsize(940, 580)
self.cfg = load_config()
self.reports: list[ReportEntry] = []
self.filtered_reports: list[ReportEntry] = []
self.selected_report: ReportEntry | None = None
self.access_key_id = StringVar(value=self.cfg.get("access_key_id", ""))
self.access_key_secret = StringVar(value=self.cfg.get("access_key_secret", ""))
self.endpoint = StringVar(value=self.cfg.get("endpoint", "oss-cn-shanghai.aliyuncs.com"))
self.bucket = StringVar(value=self.cfg.get("bucket", "th1-oss"))
self.skip_existing = BooleanVar(value=bool(self.cfg.get("skip_existing_downloads", True)))
self.version_filter = StringVar(value="全部版本")
self.questionnaire_filter = StringVar(value="全部问卷")
self.steam_filter = StringVar(value="")
self.question_filter = StringVar(value="")
self.status_text = StringVar(value="就绪")
self.answer_box = None
self._build_ui()
self.refresh_local_reports()
def _build_ui(self) -> None:
config_frame = ttk.LabelFrame(self, text="OSS")
config_frame.pack(fill=X, padx=10, pady=(10, 6))
ttk.Label(config_frame, text="AccessKey ID").grid(row=0, column=0, sticky="w", padx=8, pady=4)
ttk.Entry(config_frame, textvariable=self.access_key_id, width=32).grid(row=0, column=1, sticky="ew", padx=4)
ttk.Label(config_frame, text="AccessKey Secret").grid(row=0, column=2, sticky="w", padx=8)
ttk.Entry(config_frame, textvariable=self.access_key_secret, show="*", width=32).grid(row=0, column=3, sticky="ew", padx=4)
ttk.Label(config_frame, text="Endpoint").grid(row=1, column=0, sticky="w", padx=8, pady=4)
ttk.Entry(config_frame, textvariable=self.endpoint, width=32).grid(row=1, column=1, sticky="ew", padx=4)
ttk.Label(config_frame, text="Bucket").grid(row=1, column=2, sticky="w", padx=8)
ttk.Entry(config_frame, textvariable=self.bucket, width=32).grid(row=1, column=3, sticky="ew", padx=4)
ttk.Checkbutton(config_frame, text="跳过已下载 json", variable=self.skip_existing).grid(row=2, column=1, sticky="w", padx=4)
ttk.Button(config_frame, text="保存配置", command=self.save_current_config).grid(row=2, column=2, sticky="e", padx=4, pady=4)
ttk.Button(config_frame, text="更新内容", command=self.update_from_oss).grid(row=2, column=3, sticky="w", padx=4, pady=4)
config_frame.columnconfigure(1, weight=1)
config_frame.columnconfigure(3, weight=1)
filter_frame = ttk.Frame(self)
filter_frame.pack(fill=X, padx=10, pady=(0, 6))
ttk.Label(filter_frame, text="版本").pack(side=LEFT)
self.version_combo = ttk.Combobox(filter_frame, textvariable=self.version_filter, state="readonly", width=15)
self.version_combo.pack(side=LEFT, padx=(4, 10))
self.version_combo.bind("<<ComboboxSelected>>", lambda _event: self.apply_filters())
ttk.Label(filter_frame, text="问卷").pack(side=LEFT)
self.questionnaire_combo = ttk.Combobox(filter_frame, textvariable=self.questionnaire_filter, state="readonly", width=22)
self.questionnaire_combo.pack(side=LEFT, padx=(4, 10))
self.questionnaire_combo.bind("<<ComboboxSelected>>", lambda _event: self.apply_filters())
ttk.Label(filter_frame, text="题目ID").pack(side=LEFT)
question_entry = ttk.Entry(filter_frame, textvariable=self.question_filter, width=14)
question_entry.pack(side=LEFT, padx=4)
question_entry.bind("<KeyRelease>", lambda _event: self.apply_filters())
ttk.Label(filter_frame, text="SteamID").pack(side=LEFT)
steam_entry = ttk.Entry(filter_frame, textvariable=self.steam_filter, width=22)
steam_entry.pack(side=LEFT, padx=4)
steam_entry.bind("<KeyRelease>", lambda _event: self.apply_filters())
ttk.Button(filter_frame, text="刷新本地", command=self.refresh_local_reports).pack(side=LEFT, padx=8)
ttk.Button(filter_frame, text="打开下载目录", command=self.open_data_dir).pack(side=LEFT)
pane = ttk.PanedWindow(self, orient="horizontal")
pane.pack(fill=BOTH, expand=True, padx=10, pady=(0, 6))
list_frame = ttk.Frame(pane)
self.tree = ttk.Treeview(
list_frame,
columns=("submitted", "version", "questionnaire", "steam", "answers", "size"),
show="headings",
selectmode="browse",
)
for col, text, width in (
("submitted", "提交时间", 140),
("version", "版本", 86),
("questionnaire", "问卷ID", 170),
("steam", "SteamID", 150),
("answers", "答案", 58),
("size", "大小", 78),
):
self.tree.heading(col, text=text)
self.tree.column(col, width=width, anchor="w")
self.tree.pack(side=LEFT, fill=BOTH, expand=True)
scrollbar = ttk.Scrollbar(list_frame, orient="vertical", command=self.tree.yview)
scrollbar.pack(side=RIGHT, fill=Y)
self.tree.configure(yscrollcommand=scrollbar.set)
self.tree.bind("<<TreeviewSelect>>", self.on_select_report)
pane.add(list_frame, weight=3)
preview_frame = ttk.Frame(pane)
self.preview = ttk.Treeview(preview_frame, columns=("field", "value"), show="headings", height=13)
self.preview.heading("field", text="字段")
self.preview.heading("value", text="内容")
self.preview.column("field", width=120, anchor="w")
self.preview.column("value", width=500, anchor="w")
self.preview.pack(fill=X)
self._build_answer_box(preview_frame)
pane.add(preview_frame, weight=4)
status = ttk.Label(self, textvariable=self.status_text, anchor="w")
status.pack(fill=X, padx=10, pady=(0, 8))
def _build_answer_box(self, parent) -> None:
import tkinter as tk
answer_frame = ttk.LabelFrame(parent, text="答案明细")
answer_frame.pack(fill=BOTH, expand=True, pady=(8, 0))
self.answer_box = tk.Text(answer_frame, wrap="word", height=20)
self.answer_box.pack(fill=BOTH, expand=True)
self.answer_box.configure(state="disabled")
def current_config(self) -> dict:
return {
"access_key_id": self.access_key_id.get(),
"access_key_secret": self.access_key_secret.get(),
"endpoint": self.endpoint.get(),
"bucket": self.bucket.get(),
"skip_existing_downloads": self.skip_existing.get(),
}
def save_current_config(self) -> None:
self.cfg = self.current_config()
save_config(self.cfg)
self.status_text.set(f"配置已保存到 {CONFIG_PATH}")
def update_from_oss(self) -> None:
cfg = self.current_config()
if not cfg["access_key_id"] or not cfg["access_key_secret"]:
messagebox.showerror("缺少配置", "请先填写 AccessKey ID 和 AccessKey Secret。")
return
self.save_current_config()
self.status_text.set("正在从 OSS 更新问卷答卷...")
self._run_worker(lambda: download_reports(cfg), self._after_download)
def _after_download(self, result) -> None:
if isinstance(result, Exception):
messagebox.showerror("更新失败", str(result))
self.status_text.set(f"更新失败: {result}")
return
downloaded, skipped, failed = result
self.refresh_local_reports()
self.status_text.set(f"更新完成: 下载 {downloaded}, 跳过 {skipped}, 失败 {failed};本地缓存 {len(self.reports)}")
def refresh_local_reports(self) -> None:
self.reports = load_reports()
versions = ["全部版本"] + sorted({entry.version for entry in self.reports if entry.version}, reverse=True)
questionnaires = ["全部问卷"] + sorted({entry.questionnaire_id for entry in self.reports if entry.questionnaire_id})
self.version_combo.configure(values=versions)
self.questionnaire_combo.configure(values=questionnaires)
if self.version_filter.get() not in versions:
self.version_filter.set("全部版本")
if self.questionnaire_filter.get() not in questionnaires:
self.questionnaire_filter.set("全部问卷")
self.apply_filters()
def apply_filters(self) -> None:
version = self.version_filter.get()
questionnaire_id = self.questionnaire_filter.get()
steam = self.steam_filter.get().strip()
question_id = self.question_filter.get().strip()
self.filtered_reports = []
for report in self.reports:
if version and version != "全部版本" and report.version != version:
continue
if questionnaire_id and questionnaire_id != "全部问卷" and report.questionnaire_id != questionnaire_id:
continue
if steam and steam not in report.steam_id:
continue
if question_id and question_id not in report.question_ids:
continue
self.filtered_reports.append(report)
for item in self.tree.get_children():
self.tree.delete(item)
for index, report in enumerate(self.filtered_reports):
self.tree.insert(
"",
END,
iid=str(index),
values=(
parse_oss_time(report.submitted_at_utc or report.created_at_utc),
report.version,
report.questionnaire_id,
report.steam_id,
report.answer_count,
human_bytes(report.size),
),
)
self.status_text.set(f"本地缓存 {len(self.reports)} 条,当前筛选 {len(self.filtered_reports)}")
def on_select_report(self, _event=None) -> None:
selection = self.tree.selection()
if not selection:
self.selected_report = None
return
index = int(selection[0])
if index < 0 or index >= len(self.filtered_reports):
return
self.selected_report = self.filtered_reports[index]
self.render_preview(self.selected_report)
def render_preview(self, report: ReportEntry) -> None:
for item in self.preview.get_children():
self.preview.delete(item)
rows = [
("ResponseID", report.response_id),
("问卷ID", report.questionnaire_id),
("版本", report.version),
("SteamID", report.steam_id),
("提交时间", parse_oss_time(report.submitted_at_utc)),
("创建时间", parse_oss_time(report.created_at_utc)),
("本地时间", report.payload.get("createdAtLocal", "")),
("时区", report.payload.get("timezone", "")),
("CrashSight设备ID", report.payload.get("crashSightDeviceId", "")),
("设备名", report.payload.get("deviceName", "")),
("设备型号", report.payload.get("deviceModel", "")),
("系统", report.payload.get("operatingSystem", "")),
("CPU", report.payload.get("processorType", "")),
("内存", f"{report.payload.get('systemMemorySizeMb', '')} MB"),
("显卡", report.payload.get("graphicsDeviceName", "")),
("显存", f"{report.payload.get('graphicsMemorySizeMb', '')} MB"),
("OSS Key", report.object_key),
("本地文件", str(report.local_path)),
]
for field, value in rows:
self.preview.insert("", END, values=(field, value))
sheet = report.payload.get("answerSheet") or {}
answers = sheet.get("Answers") or sheet.get("answers") or []
details = []
for index, answer in enumerate(answers, start=1):
if isinstance(answer, dict):
details.append(f"[{index}]\n{format_answer(answer)}")
self._set_text("\n\n".join(details))
def _set_text(self, value: str) -> None:
self.answer_box.configure(state="normal")
self.answer_box.delete("1.0", END)
self.answer_box.insert("1.0", value or "")
self.answer_box.configure(state="disabled")
def open_data_dir(self) -> None:
DATA_DIR.mkdir(parents=True, exist_ok=True)
os.startfile(DATA_DIR)
def _run_worker(self, func, callback) -> None:
def runner():
try:
result = func()
except Exception as exc:
result = exc
self.after(0, lambda: callback(result))
threading.Thread(target=runner, daemon=True).start()
if __name__ == "__main__":
PlayerQuestionnaireViewer().mainloop()

View File

@ -0,0 +1,5 @@
@echo off
title TH1 Player Questionnaire Viewer
cd /d "%~dp0"
python player_questionnaire_viewer.py
pause

View File

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

View File

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

View File

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

View File

@ -2,21 +2,16 @@
* @Author:
* @Description: AI
* @Date: 20250401 14:04:01
* @Modify:
* @Modify:
*/
using System.Collections.Generic;
using Logic.Action;
using Logic.AI.Director;
using Logic.CrashSight;
using NodeCanvas.BehaviourTrees;
using NodeCanvas.Framework;
using UnityEngine;
using RuntimeData;
using TH1_Core.Managers;
using TH1_Logic.AITrain;
using Unity.VisualScripting;
using UnityEngine;
namespace Logic.AI
{
@ -29,7 +24,6 @@ namespace Logic.AI
Finished,
}
public enum AIActionType
{
Grid,
@ -39,71 +33,53 @@ namespace Logic.AI
Max,
}
public class AILogic
{
public AILogicState AILogicState;
public PlayerData PlayerData => _playerData;
private float _targetTime;
private bool _isWaitFrame;
private AIActionScoreCalculator _scoreCalculator;
private AIActionGenerator _generator;
private List<AIActionBase> RecordActions;
private AIActionBase MaxScoreAction;
private IAIKernel _kernel;
private AILogicContext _context;
private MapData _mapData;
private PlayerData _playerData;
private AIConfigAsset _cfg;
private GameObject _logicObject;
private BehaviourTreeOwner _btOwner;
private AICalculatorData _data;
private int _actionCount;
private List<float> _actionBitCodec;
private int _sameCount;
private List<List<uint>> _nodeRecords;
private int _kernelVersion;
public static uint CurrentAIPlayerId;
public static Dictionary<uint, List<AIRecord>> AIRecordsDict;
public AILogic()
{
AIRecordsDict = new Dictionary<uint, List<AIRecord>>();
AILogicState = AILogicState.Prepare;
RecordActions = new List<AIActionBase>();
_scoreCalculator = new AIActionScoreCalculator();
_cfg = TH1Resource.ResourceLoader.Load<AIConfigAsset>("Export/AIConfig");
_generator = new AIActionGenerator();
_logicObject = GameObject.Find("AIBT");
_btOwner = _logicObject.GetComponent<BehaviourTreeOwner>();
var data = _btOwner.blackboard.GetVariable<AICalculatorData>("Data");
if (data.value == null)
{
_data = new AICalculatorData();
data.value = _data;
}
else _data = data.value;
RebuildKernel();
}
public void RebuildKernel()
{
_context = new AILogicContext
{
Data = new AICalculatorData(),
Generator = new AIActionGenerator(),
ScoreCalculator = new AIActionScoreCalculator(),
Config = TH1Resource.ResourceLoader.Load<AIConfigAsset>("Export/AIConfig")
};
_kernel = AIKernelRegistry.Create();
_kernel.Initialize(_context);
_kernelVersion = AIKernelRegistry.Version;
}
// 开始 AI 逻辑
public void StartAILogic(MapData mapData, PlayerData playerData)
{
if (_kernel == null || _kernel.KernelType != AIKernelRegistry.CurrentKernelType || _kernelVersion != AIKernelRegistry.Version) RebuildKernel();
AILogicState = AILogicState.Playing;
_actionCount = 0;
_mapData = mapData;
_playerData = playerData;
var aiDiff = _cfg.GetAIDiffInfo(_mapData.MapConfig.AIDiff);
_data.AiDiffInfo = aiDiff;
_generator.Init(_mapData, _playerData);
_data.Refresh(mapData, playerData);
_btOwner.StopBehaviour();
_btOwner.StartBehaviour();
MainEditor.Instance.Data = _data;
_kernel.StartTurn(_mapData, _playerData);
#if UNITY_EDITOR
CurrentAIPlayerId = _playerData.Id;
@ -112,14 +88,14 @@ namespace Logic.AI
#endif
}
// 结束 AI 逻辑
public void FinishAILogic()
{
_kernel?.FinishTurn();
_playerData = null;
_mapData = null;
AILogicState = AILogicState.Prepare;
}
// 更新 AI 逻辑
public void Update()
{
if (AILogicState == AILogicState.Finished || AILogicState == AILogicState.Prepare) return;
@ -127,291 +103,120 @@ namespace Logic.AI
#if ENABLE_SPEEDUP
if (AILogicState == AILogicState.Pausing)
#else
if (AILogicState == AILogicState.Pausing && !PresentationManager.Busy)
if (AILogicState == AILogicState.Pausing && (AIDirectorBatchRuntime.SkipPresentationWait || !PresentationManager.Busy))
#endif
{
_targetTime -= Time.deltaTime;
if (_targetTime <= 0) AILogicState = AILogicState.Playing;
// if (!_isWaitFrame) _isWaitFrame = true;
// else
// {
// _targetTime -= Time.deltaTime;
// if (_targetTime <= 0) AILogicState = AILogicState.Playing;
// }
}
if (AILogicState == AILogicState.Playing)
if (AILogicState != AILogicState.Playing) return;
if (_actionCount > 200)
{
if (_actionCount > 200)
{
LogSystem.LogError($"AI 行为次数过多,可能进入死循环,强制结束 AI 逻辑 最终记录点为:{MainEditor.Instance.BTNodeId}");
AILogicState = AILogicState.Finished;
return;
}
#if ENABLE_AIMODEL
AIModelExecute();
var btNodeId = _kernel?.KernelType == AIKernelType.BehaviourTree ? MainEditor.Instance.BTNodeId : 0;
LogSystem.LogError($"AI 行为次数过多,可能进入死循环,强制结束 AI 逻辑 最终记录点为:{btNodeId}");
AILogicState = AILogicState.Finished;
return;
#endif
var index = 0;
_nodeRecords ??= new List<List<uint>>();
for (int i = 0; i < _nodeRecords.Count; i++) _nodeRecords[i].Clear();
var nodeRecordsUsed = 0;
while (true)
{
if (MainEditor.Instance.IsEditor && !MainEditor.Instance.IsGo) return;
index++;
if (index > nodeRecordsUsed)
{
nodeRecordsUsed = index;
if (index > _nodeRecords.Count) _nodeRecords.Add(new List<uint>());
}
_data.ClearCache();
_nodeRecords[index - 1].Add(MainEditor.Instance.BTNodeId);
_btOwner.UpdateBehaviour();
MainEditor.Instance.IsGo = false;
_nodeRecords[index - 1].Add(MainEditor.Instance.BTNodeId);
if (_data.MaxAiAction != null || _data.IsFinish) break;
if (index > 150)
{
LogSystem.LogError($"死循环了,最终记录点为:{MainEditor.Instance.BTNodeId}");
break;
}
}
if (_data.MaxAiAction == null || index > 100) AILogicState = AILogicState.Finished;
else
{
#if ENABLE_TRAIN
bool isPack = TrainingState.Instance.GetActionBitCodec(_data.MaxAiAction.ActionLogic.ActionId, _data.MaxAiAction.Param, out var trainPacked);
var curPlayer = _data.MaxAiAction.Param.MapData.CurPlayer;
var beforeScore = TrainingState.Instance.GetMapScore(_data.MaxAiAction.Param.MapData, curPlayer);
var state = TrainingState.Instance.GetMapState(_data.MaxAiAction.Param.MapData, curPlayer);
var validActions = TrainingState.Instance.GetAllActionBitCodec(_data.MaxAiAction.Param.MapData, curPlayer, out var actions);
if (isPack)
{
bool found = false;
foreach (var action in validActions)
{
if (action.Count != trainPacked.Count) continue;
for (int i = 0; i < trainPacked.Count; i++)
{
if (action[i] - trainPacked[i] > 0.001f) break;
if (i == trainPacked.Count - 1) found = true;
}
}
if (!found)
{
validActions.Add(trainPacked);
LogSystem.LogError($"训练数据出错: {_data.MaxAiAction.ActionLogic.ActionId.GetStringLog()}");
}
}
#endif
TrainingState.Instance.GetActionBitCodecWithoutLimit(_data.MaxAiAction.ActionLogic.ActionId, _data.MaxAiAction.Param, out var packed);
if (_actionBitCodec != null && _actionBitCodec.Count == packed.Count)
{
bool isSame = true;
for (int i = 0; i < packed.Count; i++)
{
if (Mathf.Approximately(packed[i], _actionBitCodec[i])) continue;
isSame = false;
break;
}
if (isSame)
{
_sameCount++;
if(_sameCount > 5) LogSystem.LogError($"存在相似action ,记录点为:{MainEditor.Instance.BTNodeId} ," +
$"Action为:{_data.MaxAiAction.ActionLogic.ActionId.GetStringLog()} " +
$"重复次数 :{_sameCount}");
}
else
{
_sameCount = 0;
}
}
_data.MaxAiAction.ActionLogic.CompleteExecute(_data.MaxAiAction.Param);
_actionBitCodec = packed;
_actionCount++;
#if ENABLE_TRAIN
if (isPack)
{
var afterScore = TrainingState.Instance.GetMapScore(_data.MaxAiAction.Param.MapData, curPlayer);
var reward = afterScore - beforeScore;
if (_data.MaxAiAction.ActionLogic.ActionId.UnitActionType == UnitActionType.Capture)
{
reward += 10;
}
if (_data.MaxAiAction.ActionLogic.ActionId.UnitActionType == UnitActionType.Upgrade)
{
reward += 10;
}
if (_data.MaxAiAction.ActionLogic.ActionId.UnitActionType == UnitActionType.CultureUnitUpgrade)
{
reward += 10;
}
if (_data.MaxAiAction.ActionLogic.ActionId.UnitActionType == UnitActionType.HeroUpgrade)
{
reward += 10;
}
if (_data.MaxAiAction.ActionLogic.ActionId.UnitActionType == UnitActionType.Gather)
{
reward += 10;
}
if (_data.MaxAiAction.ActionLogic.ActionId.UnitActionType == UnitActionType.Examine)
{
reward += 10;
}
var actionsArray = new float[validActions.Count][];
for (int ai = 0; ai < validActions.Count; ai++) actionsArray[ai] = validActions[ai].ToArray();
TrainingDataRecorder.Instance.RecordStep(curPlayer.Id, state, actionsArray, packed.ToArray(), reward);
}
#endif
_data.MaxAiAction.CheckIsActionDuration();
_targetTime = Mathf.Max(_data.MaxAiAction.Duration, 0f);
#if UNITY_EDITOR
var records = GetCurrentAIRecords();
if (records != null && records.Count != 0)
{
var record = records[^1];
record.Action = _data.MaxAiAction;
}
#endif
_isWaitFrame = false;
MainEditor.Instance.OnActionExcuted();
_data.MaxAiAction = null;
AILogicState = AILogicState.Pausing;
}
}
}
private void AIModelExecute()
{
#if ENABLE_AIMODEL
var predictState = TrainingState.Instance.GetMapState(_data.Map, _data.Map.CurPlayer);
var predictActionsId = TrainingState.Instance.GetAllActionBitCodecForUse(_data.Map, _data.Map.CurPlayer, out var predictActions);
if (predictActionsId.Count == 0)
var update = _kernel.Update();
if (update.Result == AIKernelUpdateResult.None) return;
if (update.Result == AIKernelUpdateResult.Finished || update.Action == null)
{
AILogicState = AILogicState.Finished;
return;
}
else
{
var predictIndex = ModelInference.Instance.Predict(predictState, predictActionsId);
// for (int i = 0; i < predictActions.Count; i++)
// {
// if (predictActions[i].ActionLogic.ActionId.UnitActionType == UnitActionType.Capture)
// predictIndex = i;
// }
if (!TrainingState.Instance.GetActionFromBitCodec(predictActionsId[predictIndex], _data.Map, out var actionId, out var param))
{
LogSystem.LogError($"反序列化 Action 失败");
}
else
{
_data.MaxAiAction = new AIActionBase(param, ActionLogicFactory.GetActionLogic(actionId));
#if ENABLE_TRAIN
bool isPack = TrainingState.Instance.GetActionBitCodec(_data.MaxAiAction.ActionLogic.ActionId, _data.MaxAiAction.Param, out var packed);
var curPlayer = _data.MaxAiAction.Param.MapData.CurPlayer;
var beforeScore = TrainingState.Instance.GetMapScore(_data.MaxAiAction.Param.MapData, curPlayer);
var state = TrainingState.Instance.GetMapState(_data.MaxAiAction.Param.MapData, curPlayer);
var validActions = TrainingState.Instance.GetAllActionBitCodec(_data.MaxAiAction.Param.MapData, curPlayer, out var actions);
if (isPack)
{
bool found = false;
foreach (var action in validActions)
{
if (action.Count != packed.Count) continue;
for (int i = 0; i < packed.Count; i++)
{
if (action[i] - packed[i] > 0.001f) break;
if (i == packed.Count - 1) found = true;
}
}
if (!found)
{
validActions.Add(packed);
LogSystem.LogError($"训练数据出错: {_data.MaxAiAction.ActionLogic.ActionId.GetStringLog()}");
}
}
ExecuteAction(update.Action);
}
private void ExecuteAction(AIActionBase action)
{
action.Param.MapData = _mapData;
action.Param.RefreshParams();
#if TH1_AI_DIRECTOR_DIAGNOSTICS || UNITY_EDITOR
var actionIndex = _actionCount + 1;
var before = AIDirectorDiagnostics.CaptureOutcomeProbe(_mapData, _playerData, action);
var executed = action.ActionLogic.CompleteExecute(action.Param);
var after = AIDirectorDiagnostics.CaptureOutcomeProbe(_mapData, _playerData, action);
if (_kernel?.KernelType == AIKernelType.Director)
{
AIDirectorDiagnostics.RecordExecution(
_mapData,
_playerData,
actionIndex,
action,
executed,
before,
after);
}
#else
action.ActionLogic.CompleteExecute(action.Param);
#endif
_data.MaxAiAction.ActionLogic.CompleteExecute(_data.MaxAiAction.Param);
#if ENABLE_TRAIN
if (isPack)
{
var afterScore = TrainingState.Instance.GetMapScore(_data.MaxAiAction.Param.MapData, curPlayer);
var reward = afterScore - beforeScore;
if (_data.MaxAiAction.ActionLogic.ActionId.UnitActionType == UnitActionType.Capture)
{
LogSystem.LogError($"占领!!!");
reward += 10;
}
var actionsArray2 = new float[validActions.Count][];
for (int ai = 0; ai < validActions.Count; ai++) actionsArray2[ai] = validActions[ai].ToArray();
TrainingDataRecorder.Instance.RecordStep(curPlayer.Id, state, actionsArray2, packed.ToArray(), reward);
}
#endif
LogSystem.LogWarning($"{_data.MaxAiAction.ActionLogic.ActionId.GetStringLog()}");
_data.MaxAiAction.CheckIsActionInPlayerSight();
_data.MaxAiAction.CheckIsActionDuration();
_targetTime = Mathf.Max(_data.MaxAiAction.Duration, 0f);
_isWaitFrame = false;
_data.MaxAiAction = null;
AILogicState = AILogicState.Pausing;
}
action.CheckIsActionDuration();
_targetTime = Mathf.Max(action.Duration, 0f);
#if UNITY_EDITOR
var records = GetCurrentAIRecords();
if (records != null && records.Count != 0)
{
var record = records[^1];
record.Action = action;
}
#endif
_actionCount++;
if (_kernel?.KernelType == AIKernelType.BehaviourTree) MainEditor.Instance.OnActionExcuted();
AILogicState = AILogicState.Pausing;
}
public static void RegisterKernel(AIKernelType kernelType)
{
AIKernelRegistry.Register(kernelType);
}
public static void UseBehaviourTreeKernel()
{
AIKernelRegistry.Register(AIKernelType.BehaviourTree);
}
public static void UseDirectorKernel()
{
AIKernelRegistry.Register(AIKernelType.Director);
}
public static List<AIRecord> GetCurrentAIRecords()
{
return AIRecordsDict.GetValueOrDefault(CurrentAIPlayerId);
}
public static List<AIRecord> GetAIRecords(uint playerId)
{
return AIRecordsDict.GetValueOrDefault(playerId);
}
}
public class AIStateRecord
{
public uint ID;
public string Desc;
public bool Result;
}
public class AIRecord
{
public List<AIStateRecord> StateRecords;
public AIActionBase Action;
public bool IsFoldout;
public AIRecord()
{
IsFoldout = false;
StateRecords = new List<AIStateRecord>();
}
public string GetDesc()
{
if (Action == null) return "暂无动作";

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: b10c1828bae40bd4f907f57b7f1faf76
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 4b6d5f786f414f8391d3f4bc12f6c4e1
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,450 @@
using System.Collections.Generic;
using Logic.Action;
using RuntimeData;
using TH1_Logic.Action;
using UnityEngine;
namespace Logic.AI.Director
{
public sealed class AIDirectorActionIndex
{
public readonly List<AIActionBase> AllActions = new();
public readonly List<AIActionBase> AttackActions = new();
public readonly List<AIActionBase> AttackAllyActions = new();
public readonly List<AIActionBase> AttackGroundActions = new();
public readonly List<AIActionBase> MoveActions = new();
public readonly List<AIActionBase> UnitActions = new();
public readonly List<AIActionBase> CityActions = new();
public readonly List<AIActionBase> GridActions = new();
public readonly List<AIActionBase> PlayerActions = new();
public readonly List<AIActionBase> HeroManagementActions = new();
private readonly Dictionary<uint, List<AIActionBase>> _unitActionsByUnit = new();
private readonly Dictionary<uint, List<AIActionBase>> _attacksByUnit = new();
private readonly Dictionary<uint, List<AIActionBase>> _movesByUnit = new();
private readonly Dictionary<uint, List<AIActionBase>> _attackAlliesByUnit = new();
private readonly Dictionary<uint, List<AIActionBase>> _attackGroundByUnit = new();
private readonly Dictionary<uint, List<AIActionBase>> _cityActionsByCity = new();
private readonly Dictionary<uint, List<AIActionBase>> _gridActionsByGrid = new();
public static AIDirectorActionIndex Build(AIDirectorContext ctx)
{
var index = new AIDirectorActionIndex();
if (ctx?.Map == null || ctx.Player == null) return index;
var generated = AIActionGenerator.GeneratorAllActionIdsForUse(ctx.Map, ctx.Player);
if (generated == null) return index;
var limit = ctx.Config.MaxGeneratedActions <= 0 ? int.MaxValue : ctx.Config.MaxGeneratedActions;
foreach (var action in generated)
{
if (action?.ActionLogic?.ActionId == null || action.Param == null) continue;
if (index.AllActions.Count >= limit) break;
var copied = CopyAction(action);
if (copied == null) continue;
if (!copied.ActionLogic.CheckCan(copied.Param)) continue;
if (IsDangerousAction(copied)) continue;
index.Add(copied);
}
index.SortAll();
return index;
}
public AIDirectorActionCandidate Candidate(AIActionBase action, AIDirectorLane lane, string reason, float priority, bool fallback = false)
{
if (action == null) return AIDirectorActionCandidate.Invalid(lane, reason, priority, fallback);
var copied = CopyAction(action);
if (copied == null || !copied.ActionLogic.CheckCan(copied.Param)) return AIDirectorActionCandidate.Invalid(lane, reason, priority, fallback);
return new AIDirectorActionCandidate(copied, lane, reason, priority, fallback);
}
public AIActionBase FindUnitAction(UnitData unit, UnitActionType type)
{
if (unit == null) return null;
if (!_unitActionsByUnit.TryGetValue(unit.Id, out var actions)) return null;
foreach (var action in actions)
{
if (action.ActionLogic.ActionId.UnitActionType == type) return action;
}
return null;
}
public AIActionBase FindBestAttack(UnitData unit, UnitData target = null)
{
if (unit == null) return null;
if (!_attacksByUnit.TryGetValue(unit.Id, out var actions)) return null;
AIActionBase best = null;
var bestScore = float.MinValue;
foreach (var action in actions)
{
var targetUnit = action.Param.TargetUnitData;
if (target != null && targetUnit?.Id != target.Id) continue;
var score = ScoreAttackTarget(targetUnit);
if (score <= bestScore) continue;
bestScore = score;
best = action;
}
return best;
}
public AIActionBase FindBestAttackAlly(UnitData unit, UnitData target = null)
{
if (unit == null) return null;
if (!_attackAlliesByUnit.TryGetValue(unit.Id, out var actions)) return null;
AIActionBase best = null;
var bestScore = float.MinValue;
foreach (var action in actions)
{
var targetUnit = action.Param.TargetUnitData;
if (target != null && targetUnit?.Id != target.Id) continue;
var score = targetUnit == null ? 0f : (1f - AIDirectorMath.HealthRatio(targetUnit)) * 100f + AIDirectorMath.UnitPower(targetUnit);
if (score <= bestScore) continue;
bestScore = score;
best = action;
}
return best;
}
public AIActionBase FindBestAttackGround(UnitData unit, GridData targetGrid = null)
{
if (unit == null) return null;
if (!_attackGroundByUnit.TryGetValue(unit.Id, out var actions)) return null;
foreach (var action in actions)
{
if (targetGrid == null) return action;
var grid = action.Param.TargetGridData ?? action.Param.GridData;
if (grid?.Id == targetGrid.Id) return action;
if (action.Param.TargetGridId == targetGrid.Id || action.Param.GridId == targetGrid.Id) return action;
}
return null;
}
public AIActionBase FindHeroManagementAction(PlayerActionType type)
{
foreach (var action in HeroManagementActions)
{
if (action.ActionLogic.ActionId.PlayerActionType == type) return action;
}
return null;
}
public IEnumerable<AIActionBase> GetCityActions(CityData city)
{
if (city == null) yield break;
if (!_cityActionsByCity.TryGetValue(city.Id, out var actions)) yield break;
foreach (var action in actions) yield return action;
}
public IEnumerable<AIActionBase> GetGridActions(GridData grid)
{
if (grid == null) yield break;
if (!_gridActionsByGrid.TryGetValue(grid.Id, out var actions)) yield break;
foreach (var action in actions) yield return action;
}
public AIActionBase FindBestHeroTaskFinish(PlayerData player)
{
if (player?.PlayerHeroData == null) return null;
AIActionBase best = null;
var bestLevel = uint.MaxValue;
foreach (var action in HeroManagementActions)
{
var id = action.ActionLogic.ActionId;
if (id.PlayerActionType != PlayerActionType.FinishHeroTask) continue;
var level = player.PlayerHeroData.GetHeroLevel(id.GiantType);
if (!CanForceHeroTaskNow(player, id.GiantType, level)) continue;
if (level >= bestLevel) continue;
bestLevel = level;
best = action;
}
return best;
}
public AIActionBase FindBestMove(UnitData unit, GridData targetGrid = null)
{
if (unit == null) return null;
if (!_movesByUnit.TryGetValue(unit.Id, out var actions)) return null;
if (targetGrid == null) return actions.Count > 0 ? actions[0] : null;
AIActionBase best = null;
var bestDistance = int.MaxValue;
foreach (var action in actions)
{
var grid = action.Param.TargetGridData ?? action.Param.GridData;
var distance = AIDirectorMath.Distance(action.Param.MapData, grid, targetGrid);
if (distance > bestDistance) continue;
if (distance == bestDistance && StableActionKey(action).CompareTo(StableActionKey(best)) >= 0) continue;
bestDistance = distance;
best = action;
}
return best;
}
public AIActionBase FindAnyByType(CommonActionType actionType)
{
foreach (var action in AllActions)
{
if (action.ActionLogic.ActionId.ActionType == actionType) return action;
}
return null;
}
public AIActionBase FindBestFallback()
{
return FindFirstFallbackAction(AttackActions)
?? FindFirstFallbackAction(MoveActions)
?? FindFirstFallbackAction(CityActions)
?? FindFirstFallbackAction(GridActions)
?? FindFirstFallbackAction(PlayerActions)
?? FindFirstFallbackAction(UnitActions)
?? FindFirstFallbackAction(AllActions);
}
private void Add(AIActionBase action)
{
AllActions.Add(action);
var id = action.ActionLogic.ActionId;
switch (id.ActionType)
{
case CommonActionType.UnitAttack:
AttackActions.Add(action);
AddByUnit(_attacksByUnit, action);
break;
case CommonActionType.UnitAttackAlly:
AttackAllyActions.Add(action);
AddByUnit(_attackAlliesByUnit, action);
break;
case CommonActionType.UnitAttackGround:
AttackGroundActions.Add(action);
AddByUnit(_attackGroundByUnit, action);
break;
case CommonActionType.UnitMove:
MoveActions.Add(action);
AddByUnit(_movesByUnit, action);
break;
case CommonActionType.UnitAction:
case CommonActionType.UnitSkill:
UnitActions.Add(action);
AddByUnit(_unitActionsByUnit, action);
break;
case CommonActionType.CityAction:
case CommonActionType.CityLevelUpAction:
case CommonActionType.TrainUnit:
case CommonActionType.BuildWonder:
case CommonActionType.StartWonder:
CityActions.Add(action);
AddByCity(_cityActionsByCity, action);
break;
case CommonActionType.Build:
case CommonActionType.Gain:
case CommonActionType.GridMisc:
case CommonActionType.HakureiEinherjarCityDevelopment:
GridActions.Add(action);
AddByGrid(_gridActionsByGrid, action);
break;
case CommonActionType.PlayerAction:
if (id.PlayerActionType == PlayerActionType.SelectHero ||
id.PlayerActionType == PlayerActionType.FinishHeroTask)
{
HeroManagementActions.Add(action);
break;
}
PlayerActions.Add(action);
break;
case CommonActionType.LearnTech:
case CommonActionType.BuyCultureCard:
PlayerActions.Add(action);
break;
}
}
private static void AddByUnit(Dictionary<uint, List<AIActionBase>> dict, AIActionBase action)
{
var unit = action.Param.UnitData;
if (unit == null) return;
if (!dict.TryGetValue(unit.Id, out var list))
{
list = new List<AIActionBase>();
dict[unit.Id] = list;
}
list.Add(action);
}
private static void AddByCity(Dictionary<uint, List<AIActionBase>> dict, AIActionBase action)
{
var city = action.Param.CityData;
if (city == null) return;
if (!dict.TryGetValue(city.Id, out var list))
{
list = new List<AIActionBase>();
dict[city.Id] = list;
}
list.Add(action);
}
private static void AddByGrid(Dictionary<uint, List<AIActionBase>> dict, AIActionBase action)
{
var grid = action.Param.GridData ?? action.Param.TargetGridData;
if (grid == null) return;
if (!dict.TryGetValue(grid.Id, out var list))
{
list = new List<AIActionBase>();
dict[grid.Id] = list;
}
list.Add(action);
}
private static AIActionBase CopyAction(AIActionBase action)
{
if (action?.Param == null || action.ActionLogic == null) return null;
var param = action.Param.GetCopyParam();
param.MapData = action.Param.MapData;
param.RefreshParams();
return new AIActionBase(param, action.ActionLogic);
}
private static bool CanForceHeroTaskNow(PlayerData player, GiantType giantType, uint currentLevel)
{
if (player?.PlayerHeroData == null) return false;
if (player.Turn < 8 || player.Turn % 4 != 0) return false;
if (!player.PlayerHeroData.GetHeroTask(giantType, out var task) || task == null) return false;
if (task.IsForceFinished) return false;
if (currentLevel == 1) return player.Turn >= 15;
if (currentLevel == 2) return player.Turn >= 25;
return false;
}
private static float ScoreAttackTarget(UnitData target)
{
if (target == null) return 0f;
var score = AIDirectorMath.UnitPower(target);
score += (1f - AIDirectorMath.HealthRatio(target)) * 100f;
if (target.Health <= 1) score += 200f;
return score;
}
private void SortAll()
{
Sort(AllActions);
Sort(AttackActions);
Sort(AttackAllyActions);
Sort(AttackGroundActions);
Sort(MoveActions);
Sort(UnitActions);
Sort(CityActions);
Sort(GridActions);
Sort(PlayerActions);
Sort(HeroManagementActions);
foreach (var pair in _unitActionsByUnit) Sort(pair.Value);
foreach (var pair in _attacksByUnit) Sort(pair.Value);
foreach (var pair in _movesByUnit) Sort(pair.Value);
foreach (var pair in _attackAlliesByUnit) Sort(pair.Value);
foreach (var pair in _attackGroundByUnit) Sort(pair.Value);
foreach (var pair in _cityActionsByCity) Sort(pair.Value);
foreach (var pair in _gridActionsByGrid) Sort(pair.Value);
}
private static void Sort(List<AIActionBase> actions)
{
actions.Sort((a, b) => StableActionKey(a).CompareTo(StableActionKey(b)));
}
private static bool IsDangerousAction(AIActionBase action)
{
var id = action?.ActionLogic?.ActionId;
if (id == null) return true;
if (id.UnitActionType is UnitActionType.Disband or UnitActionType.ForceDisband or UnitActionType.Demolish or UnitActionType.Disperse or UnitActionType.ToggleShenlan) return true;
if (id.PlayerActionType == PlayerActionType.FinishHeroTask) return true;
if (IsDirectorExcludedPlayerAction(id.PlayerActionType)) return true;
if (id.GridMiscActionType == GridMiscActionType.Destroy) return true;
return false;
}
public static bool IsDirectorExcludedPlayerAction(PlayerActionType type)
{
return type is PlayerActionType.OfferAlly
or PlayerActionType.AcceptAlly
or PlayerActionType.RefuseAlly
or PlayerActionType.BreakAlly
or PlayerActionType.Embassy
or PlayerActionType.BreakEmbassy
or PlayerActionType.DanegeldDemand
or PlayerActionType.DanegeldPay
or PlayerActionType.DanegeldReject;
}
private static AIActionBase FindFirstFallbackAction(List<AIActionBase> actions)
{
foreach (var action in actions)
{
if (IsFallbackAction(action)) return action;
}
return null;
}
private static bool IsFallbackAction(AIActionBase action)
{
var id = action?.ActionLogic?.ActionId;
if (id == null) return false;
if (id.ActionType == CommonActionType.UnitAction
&& id.UnitActionType is UnitActionType.Examine
or UnitActionType.KANAKOSIT
or UnitActionType.KANAKOUNSIT)
{
return false;
}
if (id.ActionType == CommonActionType.PlayerAction
&& id.PlayerActionType is PlayerActionType.SelectHero
or PlayerActionType.FinishHeroTask)
{
return false;
}
return !IsDangerousAction(action);
}
public static string StableActionKey(AIActionBase action)
{
if (action == null) return string.Empty;
var id = action.ActionLogic?.ActionId;
var param = action.Param;
return string.Join(":",
id?.ActionType.ToString() ?? string.Empty,
((int)(id?.UnitActionType ?? UnitActionType.None)).ToString("D4"),
((int)(id?.PlayerActionType ?? PlayerActionType.None)).ToString("D4"),
((int)(id?.CityActionType ?? CityActionType.None)).ToString("D4"),
((int)(id?.CityLevelUpActionType ?? CityLevelUpActionType.None)).ToString("D4"),
((int)(id?.GridMiscActionType ?? GridMiscActionType.None)).ToString("D4"),
((int)(id?.TechType ?? TechType.None)).ToString("D4"),
((int)(id?.CultureCardType ?? CultureCardType.None)).ToString("D4"),
((int)(id?.ResourceType ?? ResourceType.None)).ToString("D4"),
((int)(id?.UnitType ?? UnitType.None)).ToString("D4"),
((int)(id?.GiantType ?? GiantType.None)).ToString("D4"),
(id?.UnitLevel ?? 0).ToString("D4"),
(param?.PlayerId ?? 0).ToString("D8"),
(param?.CityId ?? 0).ToString("D8"),
(param?.UnitId ?? 0).ToString("D8"),
(param?.GridId ?? 0).ToString("D8"),
(param?.TargetUnitId ?? 0).ToString("D8"),
(param?.TargetGridId ?? 0).ToString("D8"),
(param?.TargetPlayerId ?? 0).ToString("D8"));
}
}
}

View File

@ -1,5 +1,5 @@
fileFormatVersion: 2
guid: 12611efd81e4e39409aab374a47d8b98
guid: ed15bd0d18204bc7ad370bcd6ece3407
MonoImporter:
externalObjects: {}
serializedVersion: 2

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@ -0,0 +1,906 @@
using Logic.Action;
using RuntimeData;
using System.Diagnostics;
using TH1_Logic.Action;
using UnityEngine;
namespace Logic.AI.Director
{
public sealed class AIDirectorLogic
{
private readonly AIDirectorWorldCacheBuilder _cacheBuilder = new();
private readonly AIDirectorHeroRuleEvaluator _heroEvaluator = new();
public AIDirectorDecision Decide(MapData map, PlayerData player, AIDirectorConfig config = null)
{
var stopwatch = Stopwatch.StartNew();
var decision = new AIDirectorDecision();
if (map == null || player == null)
{
decision.AddTrace("AI Director skipped: map or player is null.", config?.MaxCandidateTraceCount ?? 16);
stopwatch.Stop();
decision.DecideMs = stopwatch.Elapsed.TotalMilliseconds;
return decision;
}
var ctx = new AIDirectorContext(map, player, config ?? AIDirectorConfig.CreateDefault());
ctx.Cache = _cacheBuilder.Build(ctx);
ctx.ActionIndex = AIDirectorActionIndex.Build(ctx);
_cacheBuilder.BuildUnitOpportunities(ctx);
decision.Cache = ctx.Cache;
decision.ActionIndex = ctx.ActionIndex;
decision.AddTrace(
$"Cache posture={ctx.Cache.StrategicPosture}, selfUnits={ctx.Cache.SelfUnits.Count}, enemyUnits={ctx.Cache.EnemyUnits.Count}, fronts={ctx.Cache.Fronts.Count}, opportunities={ctx.Cache.UnitOpportunities.Count}, actions={ctx.ActionIndex.AllActions.Count}.",
ctx.Config.MaxCandidateTraceCount);
if (ctx.ActionIndex.AllActions.Count == 0)
{
decision.AddTrace("No legal AI action generated.", ctx.Config.MaxCandidateTraceCount);
stopwatch.Stop();
decision.DecideMs = stopwatch.Elapsed.TotalMilliseconds;
return decision;
}
if (TryEmergencyLane(ctx, decision, out var candidate)
|| TryHeroManagementLane(ctx, decision, out candidate)
|| TryHeroPlaybookLane(ctx, decision, out candidate)
|| TryTacticLane(ctx, decision, out candidate)
|| TryUnitOpportunityLane(ctx, decision, out candidate)
|| TryFrontLane(ctx, decision, out candidate)
|| TryGrowthLane(ctx, decision, out candidate)
|| TryFallback(ctx, decision, out candidate))
{
decision.Candidate = candidate;
decision.RecordLaneResult(candidate.Lane, true, "Selected");
decision.AddTrace($"Picked {candidate.ActionId?.ActionType} lane={candidate.Lane}, priority={candidate.Priority}, reason={candidate.Reason}.", ctx.Config.MaxCandidateTraceCount);
}
else
{
decision.AddTrace("No legal AI Director action found.", ctx.Config.MaxCandidateTraceCount);
}
stopwatch.Stop();
decision.DecideMs = stopwatch.Elapsed.TotalMilliseconds;
return decision;
}
public bool TryDecide(MapData map, PlayerData player, out AIDirectorActionCandidate candidate, AIDirectorConfig config = null)
{
var decision = Decide(map, player, config);
candidate = decision.Candidate;
return decision.HasAction;
}
public bool TryExecuteDecision(AIDirectorDecision decision)
{
var candidate = decision?.Candidate;
if (candidate == null || !candidate.IsValid || !candidate.CheckCan()) return false;
return candidate.ActionLogic.CompleteExecute(candidate.Param);
}
private bool TryEmergencyLane(AIDirectorContext ctx, AIDirectorDecision decision, out AIDirectorActionCandidate candidate)
{
candidate = AIDirectorActionCandidate.None;
foreach (var threat in ctx.Cache.CityThreats)
{
if (threat == null) continue;
if (!threat.IsCritical && threat.EnemyUnits.Count < ctx.Config.CityDangerEnemyCount && threat.DangerScore <= 0f) continue;
var attack = TryEmergencyAttack(ctx, decision, threat);
if (attack.IsValid)
{
candidate = attack;
decision.AddTrace($"Emergency: attack city threat for city={threat.City?.Id}.", ctx.Config.MaxCandidateTraceCount);
return true;
}
var move = TryEmergencyMove(ctx, decision, threat);
if (move.IsValid)
{
candidate = move;
decision.AddTrace($"Emergency: move defender to city={threat.City?.Id}.", ctx.Config.MaxCandidateTraceCount);
return true;
}
var cityAction = TryEmergencyCityAction(ctx, decision, threat);
if (cityAction.IsValid)
{
candidate = cityAction;
decision.AddTrace($"Emergency: city action for city={threat.City?.Id}.", ctx.Config.MaxCandidateTraceCount);
return true;
}
}
return false;
}
private AIDirectorActionCandidate TryEmergencyAttack(AIDirectorContext ctx, AIDirectorDecision decision, AIDirectorCityThreat threat)
{
AIDirectorActionCandidate best = AIDirectorActionCandidate.None;
foreach (var defender in threat.Defenders)
{
var target = ChooseBestThreatTarget(ctx, threat);
var action = ctx.ActionIndex.FindBestAttack(defender, target) ?? ctx.ActionIndex.FindBestAttack(defender);
var targetValue = UnitTargetValue(ctx, target);
var score = 960f + threat.DangerScore * 20f + targetValue;
var candidate = ctx.ActionIndex.Candidate(action, AIDirectorLane.Emergency, "守军攻击城市威胁", score);
AddTerms(candidate, ("base", 960f), ("danger", threat.DangerScore * 20f), ("targetValue", targetValue));
candidate.Unit = candidate.Unit ?? defender;
candidate.TargetUnit = candidate.TargetUnit ?? target;
candidate.City = candidate.City ?? threat.City;
candidate.TargetGrid = candidate.TargetGrid ?? threat.CityGrid;
RecordCandidate(ctx, decision, "EmergencyAttack", candidate, action == null ? "NoAttackAction" : (candidate.IsValid ? null : "CheckCanFailed"));
best = MaxCandidate(best, candidate);
}
return best;
}
private AIDirectorActionCandidate TryEmergencyMove(AIDirectorContext ctx, AIDirectorDecision decision, AIDirectorCityThreat threat)
{
AIDirectorActionCandidate best = AIDirectorActionCandidate.None;
foreach (var unit in ctx.Cache.SelfUnits)
{
if (unit == null || unit.GetActionPoint(ActionPointType.Move) <= 0) continue;
if (threat.Defenders.Contains(unit) && threat.DefenderPower >= threat.EnemyPower) continue;
var action = ctx.ActionIndex.FindBestMove(unit, threat.CityGrid);
var unitGrid = unit.Grid(ctx.Map);
var startDistance = AIDirectorMath.Distance(ctx.Map, unitGrid, threat.CityGrid);
var unitPower = AIDirectorMath.UnitPower(unit);
var distancePenalty = -startDistance * 12f;
var heroBonus = unit.TreatedAsHero(ctx.Map, unit) ? 60f : 0f;
var score = 900f + threat.DangerScore * 20f + unitPower + distancePenalty + heroBonus;
var candidate = ctx.ActionIndex.Candidate(action, AIDirectorLane.Emergency, "回防城市", score);
AddTerms(candidate, ("base", 900f), ("danger", threat.DangerScore * 20f), ("unitPower", unitPower), ("distance", distancePenalty), ("hero", heroBonus));
candidate.Unit = candidate.Unit ?? unit;
candidate.City = candidate.City ?? threat.City;
candidate.TargetGrid = candidate.TargetGrid ?? threat.CityGrid;
RecordCandidate(ctx, decision, "EmergencyMove", candidate, action == null ? "NoMoveAction" : (candidate.IsValid ? null : "CheckCanFailed"));
best = MaxCandidate(best, candidate);
}
return best;
}
private AIDirectorActionCandidate TryEmergencyCityAction(AIDirectorContext ctx, AIDirectorDecision decision, AIDirectorCityThreat threat)
{
var plan = FindCityPlan(ctx, threat.City);
if (plan == null) return AIDirectorActionCandidate.None;
AIDirectorActionCandidate best = AIDirectorActionCandidate.None;
foreach (var action in ctx.ActionIndex.GetCityActions(threat.City))
{
var score = ScoreCityGrowth(ctx, action, plan);
if (score <= 0f) continue;
var candidate = ctx.ActionIndex.Candidate(action, AIDirectorLane.Emergency, "危险城市生产或防御", score + 250f);
AddTerms(candidate, ("cityGrowth", score), ("emergencyBonus", 250f));
candidate.City = candidate.City ?? threat.City;
candidate.TargetGrid = candidate.TargetGrid ?? threat.CityGrid;
RecordCandidate(ctx, decision, "EmergencyCityAction", candidate, candidate.IsValid ? null : "CheckCanFailed");
best = MaxCandidate(best, candidate);
}
return best;
}
private bool TryHeroManagementLane(AIDirectorContext ctx, AIDirectorDecision decision, out AIDirectorActionCandidate candidate)
{
candidate = AIDirectorActionCandidate.None;
var selectHero = ctx.ActionIndex.FindHeroManagementAction(PlayerActionType.SelectHero);
candidate = ctx.ActionIndex.Candidate(selectHero, AIDirectorLane.HeroManagement, "选择英雄", 890f);
AddTerms(candidate, ("base", 890f));
RecordCandidate(ctx, decision, "SelectHero", candidate, selectHero == null ? "NoAction" : null);
if (candidate.IsValid)
{
decision.AddTrace("HeroManagement: select hero.", ctx.Config.MaxCandidateTraceCount);
return true;
}
var finishTask = ctx.ActionIndex.FindBestHeroTaskFinish(ctx.Player);
candidate = ctx.ActionIndex.Candidate(finishTask, AIDirectorLane.HeroManagement, "推进最低等级英雄任务", 870f);
AddTerms(candidate, ("base", 870f));
RecordCandidate(ctx, decision, "FinishHeroTask", candidate, finishTask == null ? "NoAction" : null);
if (candidate.IsValid)
{
decision.AddTrace("HeroManagement: finish hero task.", ctx.Config.MaxCandidateTraceCount);
return true;
}
return false;
}
private bool TryHeroPlaybookLane(AIDirectorContext ctx, AIDirectorDecision decision, out AIDirectorActionCandidate candidate)
{
candidate = AIDirectorActionCandidate.None;
if (!ctx.Config.PreferHeroRules || ctx.Cache.HeroStates.Count == 0) return false;
AIDirectorActionCandidate best = AIDirectorActionCandidate.None;
foreach (var state in ctx.Cache.HeroStates)
{
var playbook = _heroEvaluator.Evaluate(ctx, state);
RecordCandidate(ctx, decision, "HeroRule", playbook, playbook.IsValid ? null : "NoHeroRuleAction");
best = MaxCandidate(best, playbook);
var generic = TryGenericHeroRule(ctx, state);
RecordCandidate(ctx, decision, "GenericHeroRule", generic, generic.IsValid ? null : "NoGenericHeroAction");
best = MaxCandidate(best, generic);
}
if (!best.IsValid) return false;
candidate = best;
decision.AddTrace($"HeroPlaybook: hero={candidate.Unit?.Id}.", ctx.Config.MaxCandidateTraceCount);
return true;
}
private AIDirectorActionCandidate TryGenericHeroRule(AIDirectorContext ctx, AIDirectorHeroState state)
{
if (state?.Hero == null) return AIDirectorActionCandidate.None;
if (state.HealthRatio <= ctx.Config.HeroLowHealthRatio)
{
var recover = ctx.ActionIndex.FindUnitAction(state.Hero, UnitActionType.Recover);
var recoverCandidate = ctx.ActionIndex.Candidate(recover, AIDirectorLane.HeroPlaybook, "英雄低血恢复", 840f);
AddTerms(recoverCandidate, ("base", 840f));
if (recoverCandidate.IsValid) return recoverCandidate;
var retreatTarget = FindSafestSelfCityGrid(ctx, state.Hero);
var move = ctx.ActionIndex.FindBestMove(state.Hero, retreatTarget);
var moveCandidate = ctx.ActionIndex.Candidate(move, AIDirectorLane.HeroPlaybook, "英雄低血撤退", 820f);
AddTerms(moveCandidate, ("base", 820f));
if (moveCandidate.IsValid) return moveCandidate;
}
var attack = ctx.ActionIndex.FindBestAttack(state.Hero);
var targetValue = UnitTargetValue(ctx, attack?.Param?.TargetUnitData);
var attackCandidate = ctx.ActionIndex.Candidate(attack, AIDirectorLane.HeroPlaybook, "英雄通用高价值攻击", 760f + targetValue);
AddTerms(attackCandidate, ("base", 760f), ("targetValue", targetValue));
if (attackCandidate.IsValid) return attackCandidate;
var target = state.Front?.TargetGrid ?? state.Front?.AnchorGrid;
var frontMove = ctx.ActionIndex.FindBestMove(state.Hero, target);
var frontCandidate = ctx.ActionIndex.Candidate(frontMove, AIDirectorLane.HeroPlaybook, "英雄站位到战线", 650f);
AddTerms(frontCandidate, ("base", 650f));
return frontCandidate;
}
private bool TryTacticLane(AIDirectorContext ctx, AIDirectorDecision decision, out AIDirectorActionCandidate candidate)
{
candidate = AIDirectorActionCandidate.None;
AIDirectorActionCandidate best = AIDirectorActionCandidate.None;
foreach (var battle in ctx.Cache.LocalBattles)
{
if (battle.SelfUnit == null || battle.EnemyUnit == null) continue;
var action = ctx.ActionIndex.FindBestAttack(battle.SelfUnit, battle.EnemyUnit);
var attackScore = ScoreAttackAction(ctx, action);
var score = 700f + battle.Value + attackScore * 0.1f;
var current = ctx.ActionIndex.Candidate(action, AIDirectorLane.Tactic, "局部战斗攻击", score);
AddTerms(current, ("base", 700f), ("localBattle", battle.Value), ("attackScore", attackScore * 0.1f));
RecordCandidate(ctx, decision, "LocalBattle", current, action == null ? "NoAttackAction" : null);
best = MaxCandidate(best, current);
}
foreach (var action in ctx.ActionIndex.AttackActions)
{
var score = ScoreAttackAction(ctx, action);
var current = ctx.ActionIndex.Candidate(action, AIDirectorLane.Tactic, "普通攻击收益", score);
AddTerms(current, ("attackScore", score));
RecordCandidate(ctx, decision, "AttackAction", current);
best = MaxCandidate(best, current);
}
if (!best.IsValid) return false;
candidate = best;
decision.AddTrace($"Tactic: attacker={candidate.Unit?.Id}, target={candidate.TargetUnit?.Id}.", ctx.Config.MaxCandidateTraceCount);
return true;
}
private bool TryUnitOpportunityLane(AIDirectorContext ctx, AIDirectorDecision decision, out AIDirectorActionCandidate candidate)
{
candidate = AIDirectorActionCandidate.None;
foreach (var opportunity in ctx.Cache.UnitOpportunities)
{
var current = ctx.ActionIndex.Candidate(opportunity.Action, AIDirectorLane.UnitOpportunity, opportunity.OpportunityType.ToString(), opportunity.Value);
AddTerms(current, ("opportunityValue", opportunity.Value));
RecordCandidate(ctx, decision, opportunity.OpportunityType.ToString(), current);
if (!current.IsValid) continue;
candidate = current;
decision.AddTrace($"UnitOpportunity: unit={candidate.Unit?.Id}, type={opportunity.OpportunityType}.", ctx.Config.MaxCandidateTraceCount);
return true;
}
return false;
}
private bool TryFrontLane(AIDirectorContext ctx, AIDirectorDecision decision, out AIDirectorActionCandidate candidate)
{
candidate = AIDirectorActionCandidate.None;
AIDirectorActionCandidate best = AIDirectorActionCandidate.None;
foreach (var front in ctx.Cache.Fronts)
{
var target = ResolveFrontTarget(front);
if (target == null) continue;
foreach (var unit in ctx.Cache.SelfUnits)
{
if (ShouldSkipFrontMove(ctx, unit, front)) continue;
var action = ctx.ActionIndex.FindBestMove(unit, target);
var score = ScoreFrontMove(ctx, unit, action, front);
var current = ctx.ActionIndex.Candidate(action, AIDirectorLane.Front, $"{front.FrontType} 战线移动", score);
AddTerms(current, ("frontMove", score));
RecordCandidate(ctx, decision, front.FrontType.ToString(), current, action == null ? "NoMoveAction" : null);
best = MaxCandidate(best, current);
}
}
if (!best.IsValid) return false;
candidate = best;
decision.AddTrace($"Front: unit={candidate.Unit?.Id}, grid={candidate.Grid?.Id}.", ctx.Config.MaxCandidateTraceCount);
return true;
}
private bool TryGrowthLane(AIDirectorContext ctx, AIDirectorDecision decision, out AIDirectorActionCandidate candidate)
{
candidate = AIDirectorActionCandidate.None;
var best = AIDirectorActionCandidate.None;
foreach (var action in ctx.ActionIndex.CityActions)
{
var plan = FindCityPlanByAction(ctx, action);
var score = ScoreCityGrowth(ctx, action, plan);
var current = ctx.ActionIndex.Candidate(action, AIDirectorLane.Growth, "城市发展", score);
AddTerms(current, ("cityGrowth", score));
RecordCandidate(ctx, decision, "CityGrowth", current);
best = MaxCandidate(best, current);
}
foreach (var action in ctx.ActionIndex.GridActions)
{
var score = ScoreGridGrowth(ctx, action);
var current = ctx.ActionIndex.Candidate(action, AIDirectorLane.Growth, "地块发展", score);
AddTerms(current, ("gridGrowth", score));
RecordCandidate(ctx, decision, "GridGrowth", current);
best = MaxCandidate(best, current);
}
foreach (var action in ctx.ActionIndex.PlayerActions)
{
var score = ScorePlayerGrowth(ctx, action);
var current = ctx.ActionIndex.Candidate(action, AIDirectorLane.Growth, "玩家发展", score);
AddTerms(current, ("playerGrowth", score));
RecordCandidate(ctx, decision, "PlayerGrowth", current);
best = MaxCandidate(best, current);
}
if (!best.IsValid || best.Priority <= 0f) return false;
candidate = best;
decision.AddTrace($"Growth: action={candidate.ActionId?.ActionType}.", ctx.Config.MaxCandidateTraceCount);
return true;
}
private bool TryFallback(AIDirectorContext ctx, AIDirectorDecision decision, out AIDirectorActionCandidate candidate)
{
var action = ctx.ActionIndex.FindBestFallback();
candidate = ctx.ActionIndex.Candidate(action, AIDirectorLane.Fallback, "兜底合法动作", 1f, true);
AddTerms(candidate, ("base", 1f));
RecordCandidate(ctx, decision, "Fallback", candidate, action == null ? "NoFallbackAction" : null);
if (!candidate.IsValid) return false;
decision.AddTrace("Fallback: picked first legal action from generated pool.", ctx.Config.MaxCandidateTraceCount);
return true;
}
private float ScoreAttackAction(AIDirectorContext ctx, AIActionBase action)
{
if (action?.Param?.UnitData == null || action.Param.TargetUnitData == null) return 0f;
var attacker = action.Param.UnitData;
var target = action.Param.TargetUnitData;
var damage = Table.Instance.CalcDamage(ctx.Map, attacker, target);
var score = 620f;
score += damage / Mathf.Max(1f, target.GetMaxHealth()) * UnitTargetValue(ctx, target) * 2f;
score += CounterBonus(attacker, target);
score -= CounterThreat(ctx, attacker, target);
if (damage >= target.Health) score += 160f;
if (target.TreatedAsHero(ctx.Map, target)) score += 120f;
if (IsThreateningAnyCity(ctx, target)) score += 120f;
if (TargetOwnerFeelingHighAndNotWar(ctx, target)) score -= 80f;
return score;
}
private float ScoreFrontMove(AIDirectorContext ctx, UnitData unit, AIActionBase action, AIDirectorFront front)
{
if (unit == null || action?.Param == null || front == null) return 0f;
var endGrid = action.Param.GridData ?? action.Param.TargetGridData;
var target = ResolveFrontTarget(front);
if (endGrid == null || target == null) return 0f;
var score = front.FrontType switch
{
AIDirectorFrontType.Defense => 620f + front.Pressure * 20f,
AIDirectorFrontType.Attack => 560f + front.Opportunity * 30f,
AIDirectorFrontType.Development => 500f + front.Opportunity * 15f,
AIDirectorFrontType.Hold => 420f,
_ => 0f
};
score -= AIDirectorMath.Distance(ctx.Map, endGrid, target) * 16f;
score -= GridThreat(ctx, endGrid) * 0.4f;
if (CanNextTurnPressureTarget(ctx, unit, endGrid, target)) score += 80f;
if (unit.GetActionPoint(ActionPointType.Move) >= 2 && front.FrontType != AIDirectorFrontType.Hold) score += 40f;
return score;
}
private float ScoreCityGrowth(AIDirectorContext ctx, AIActionBase action, AIDirectorCityPlan plan)
{
if (action?.ActionLogic?.ActionId == null) return 0f;
var id = action.ActionLogic.ActionId;
var city = action.Param.CityData;
var score = 360f + (plan?.Priority ?? 0f) * 0.2f;
var kind = plan?.Kind ?? AIDirectorCityPlanKind.BacklineGrowth;
if (kind == AIDirectorCityPlanKind.EmergencyDefense)
{
if (id.ActionType == CommonActionType.TrainUnit) score += TrainUnitDefenseValue(ctx, action, plan.Threat);
if (id.ActionType == CommonActionType.CityAction && id.CityActionType == CityActionType.BuildCityWall) score += 220f;
if (id.ActionType is CommonActionType.StartWonder or CommonActionType.BuildWonder) score -= 300f;
}
else if (kind is AIDirectorCityPlanKind.Mobilize or AIDirectorCityPlanKind.Frontline)
{
if (id.ActionType == CommonActionType.TrainUnit) score += TrainUnitWarValue(ctx, action);
if (id.ActionType == CommonActionType.CityAction && id.CityActionType == CityActionType.BuildCityWall) score += 100f;
if (id.ActionType == CommonActionType.CityLevelUpAction) score += 50f;
}
else if (kind == AIDirectorCityPlanKind.BacklineGrowth)
{
if (id.ActionType == CommonActionType.CityLevelUpAction) score += 140f;
if (id.ActionType == CommonActionType.TrainUnit) score += NeedStandingArmy(ctx, city) ? 80f : 20f;
if (id.ActionType == CommonActionType.CityAction && id.CityActionType == CityActionType.BuildCityWall) score += 20f;
}
else if (kind == AIDirectorCityPlanKind.Wonder)
{
if (id.ActionType is CommonActionType.StartWonder or CommonActionType.BuildWonder) score += 160f + WonderValue(action);
}
return score;
}
private float ScoreGridGrowth(AIDirectorContext ctx, AIActionBase action)
{
if (action?.ActionLogic?.ActionId == null) return 0f;
var grid = action.Param.GridData ?? action.Param.TargetGridData;
if (grid == null) return 0f;
var id = action.ActionLogic.ActionId;
var score = 330f;
if (GridIsNearDangerCity(ctx, grid) && id.ActionType is not CommonActionType.Build and not CommonActionType.Gain) score -= 100f;
if (id.ActionType == CommonActionType.Build) score += BuildResourceValue(grid);
if (id.ActionType == CommonActionType.Gain) score += ResourceValue(grid);
if (id.ActionType == CommonActionType.GridMisc) score += GridMiscValue(id);
if (FindDevelopmentTarget(ctx, grid) != null) score += 60f;
return score;
}
private float ScorePlayerGrowth(AIDirectorContext ctx, AIActionBase action)
{
if (action?.ActionLogic?.ActionId == null) return 0f;
var id = action.ActionLogic.ActionId;
if (id.ActionType == CommonActionType.LearnTech) return ScoreTech(ctx, id.TechType);
if (id.ActionType == CommonActionType.BuyCultureCard) return ScoreCultureCard(ctx, id.CultureCardType);
if (id.ActionType == CommonActionType.PlayerAction) return ScorePlayerAction(ctx, action);
return 0f;
}
private float ScoreTech(AIDirectorContext ctx, TechType tech)
{
var score = 300f;
switch (ctx.Cache.StrategicPosture)
{
case AIDirectorStrategicPosture.Defense:
if (TechLooksDefensive(tech)) score += 120f;
if (TechLooksMilitary(tech)) score += 70f;
break;
case AIDirectorStrategicPosture.Attack:
if (TechLooksMilitary(tech)) score += 130f;
if (TechLooksMobility(tech)) score += 80f;
break;
case AIDirectorStrategicPosture.Expansion:
if (TechLooksEconomic(tech)) score += 90f;
if (TechLooksMobility(tech)) score += 100f;
break;
default:
if (TechLooksEconomic(tech)) score += 120f;
if (tech == TechType.Philosophy || tech == TechType.Diplomacy) score += 70f;
break;
}
return score;
}
private float ScoreCultureCard(AIDirectorContext ctx, CultureCardType card)
{
var score = 300f;
if (ctx.Cache.StrategicPosture == AIDirectorStrategicPosture.Defense && card == CultureCardType.AdvancedMilitaryEnhance) score += 120f;
if (ctx.Cache.SelfHeroes.Count >= 1 && card is CultureCardType.SecondHero or CultureCardType.ThirdHero or CultureCardType.AdvancedHeroEnhance) score += 100f;
if (ctx.Cache.StrategicPosture == AIDirectorStrategicPosture.Development && card == CultureCardType.AdvancedEconomyEnhance) score += 80f;
return score;
}
private float ScorePlayerAction(AIDirectorContext ctx, AIActionBase action)
{
var id = action.ActionLogic.ActionId.PlayerActionType;
if (id is PlayerActionType.SelectTreasureOptionA or PlayerActionType.SelectTreasureOptionB or PlayerActionType.TreasureGainCoin or PlayerActionType.TreasureGainUnit or PlayerActionType.TreasureGainTech or PlayerActionType.TreasureGainCulture or PlayerActionType.TreasureGainCityExp)
return 520f;
if (AIDirectorActionIndex.IsDirectorExcludedPlayerAction(id))
return 0f;
var target = action.Param.TargetPlayerData;
var feeling = target != null && ctx.Player.GetCountryDiplomacyInfo(target.Id, out var info) && info != null ? info.FeelingValue : 50f;
if (id == PlayerActionType.Embassy) return feeling >= 70f ? 360f : 0f;
if (id == PlayerActionType.OfferAlly) return feeling >= 90f && !HasDirectExpansionConflict(ctx, target) ? 340f : 0f;
if (id == PlayerActionType.BreakAlly) return feeling <= 35f && target != null && !IsTeammate(ctx, target) ? 220f : 0f;
return 180f;
}
private AIDirectorActionCandidate MaxCandidate(AIDirectorActionCandidate a, AIDirectorActionCandidate b)
{
if (a == null || !a.IsValid) return b ?? AIDirectorActionCandidate.None;
if (b == null || !b.IsValid) return a;
if (b.Priority > a.Priority) return b;
if (Mathf.Approximately(b.Priority, a.Priority)
&& string.Compare(AIDirectorActionIndex.StableActionKey(b.AIAction), AIDirectorActionIndex.StableActionKey(a.AIAction), System.StringComparison.Ordinal) < 0)
return b;
return a;
}
private static void RecordCandidate(
AIDirectorContext ctx,
AIDirectorDecision decision,
string source,
AIDirectorActionCandidate candidate,
string rejectReason = null)
{
if (decision == null || candidate == null) return;
var max = ctx?.Config?.MaxDiagnosticCandidatePerLane ?? 8;
decision.RecordCandidate(candidate.Lane, source, candidate, max, rejectReason);
}
private static void AddTerms(AIDirectorActionCandidate candidate, params (string name, float value)[] terms)
{
if (candidate == null || terms == null) return;
foreach (var term in terms)
{
if (string.IsNullOrEmpty(term.name)) continue;
candidate.ScoreTerms.Add(new AIDirectorScoreTerm(term.name, term.value));
}
}
private UnitData ChooseBestThreatTarget(AIDirectorContext ctx, AIDirectorCityThreat threat)
{
UnitData best = null;
var bestScore = float.MinValue;
foreach (var enemy in threat.EnemyUnits)
{
var score = UnitTargetValue(ctx, enemy);
if (score <= bestScore) continue;
bestScore = score;
best = enemy;
}
return best;
}
private float UnitTargetValue(AIDirectorContext ctx, UnitData target)
{
if (target == null) return 0f;
var score = AIDirectorMath.UnitPower(target) + (1f - AIDirectorMath.HealthRatio(target)) * 80f;
if (target.TreatedAsHero(ctx.Map, target)) score += 160f;
return score;
}
private AIDirectorCityPlan FindCityPlan(AIDirectorContext ctx, CityData city)
{
if (city == null) return null;
foreach (var plan in ctx.Cache.CityPlans)
{
if (plan.City?.Id == city.Id) return plan;
}
return null;
}
private AIDirectorCityPlan FindCityPlanByAction(AIDirectorContext ctx, AIActionBase action)
{
return FindCityPlan(ctx, action?.Param?.CityData);
}
private GridData ResolveFrontTarget(AIDirectorFront front)
{
if (front == null) return null;
return front.FrontType == AIDirectorFrontType.Defense ? front.AnchorGrid : front.TargetGrid ?? front.AnchorGrid;
}
private bool ShouldSkipFrontMove(AIDirectorContext ctx, UnitData unit, AIDirectorFront front)
{
if (unit == null || unit.GetActionPoint(ActionPointType.Move) <= 0) return true;
if (unit.TreatedAsHero(ctx.Map, unit) && front.FrontType != AIDirectorFrontType.Defense) return true;
if (AIDirectorMath.HealthRatio(unit) <= ctx.Config.LowHealthRatio && front.FrontType == AIDirectorFrontType.Attack) return true;
if (IsStandingOnCriticalCityCenter(ctx, unit)) return true;
return false;
}
private bool IsStandingOnCriticalCityCenter(AIDirectorContext ctx, UnitData unit)
{
if (unit == null || !ctx.Map.GetGridDataByUnitId(unit.Id, out var grid)) return false;
foreach (var threat in ctx.Cache.CityThreats)
{
if (!threat.IsCritical) continue;
if (threat.CityGrid?.Id == grid.Id) return true;
}
return false;
}
private bool IsThreateningAnyCity(AIDirectorContext ctx, UnitData target)
{
if (target == null) return false;
foreach (var threat in ctx.Cache.CityThreats)
{
foreach (var enemy in threat.EnemyUnits)
{
if (enemy.Id == target.Id) return true;
}
}
return false;
}
private bool TargetOwnerFeelingHighAndNotWar(AIDirectorContext ctx, UnitData target)
{
if (target == null || !ctx.Map.GetPlayerDataByUnitId(target.Id, out var owner) || owner == null) return false;
if (!ctx.Player.GetCountryDiplomacyInfo(owner.Id, out var info) || info == null) return false;
return info.FeelingValue >= 80f && info.DiplomacyState != DiplomacyState.War;
}
private float CounterBonus(UnitData attacker, UnitData target)
{
if (attacker == null || target == null) return 0f;
return attacker.GetMilitary() - target.GetMilitary() > 0 ? 40f : 0f;
}
private float CounterThreat(AIDirectorContext ctx, UnitData attacker, UnitData target)
{
if (attacker == null || target == null) return 0f;
if (!ctx.Map.GetGridDataByUnitId(attacker.Id, out var attackerGrid)) return 0f;
if (!ctx.Map.GetGridDataByUnitId(target.Id, out var targetGrid)) return 0f;
var distance = AIDirectorMath.Distance(ctx.Map, attackerGrid, targetGrid);
if (distance > target.GetAttackRange(ctx.Map)) return 0f;
return AIDirectorMath.UnitPower(target) * 0.25f;
}
private GridData FindSafestSelfCityGrid(AIDirectorContext ctx, UnitData unit)
{
GridData best = null;
var bestScore = float.MinValue;
var unitGrid = unit?.Grid(ctx.Map);
foreach (var city in ctx.Cache.SelfCities)
{
if (!ctx.Map.GetGridDataByCityId(city.Id, out var cityGrid)) continue;
var score = -AIDirectorMath.Distance(ctx.Map, unitGrid, cityGrid) * 10f - GridThreat(ctx, cityGrid);
if (city.IsCapital) score += 20f;
if (score <= bestScore) continue;
bestScore = score;
best = cityGrid;
}
return best;
}
private bool CanNextTurnPressureTarget(AIDirectorContext ctx, UnitData unit, GridData endGrid, GridData target)
{
if (unit == null || endGrid == null || target == null) return false;
return AIDirectorMath.Distance(ctx.Map, endGrid, target) <= unit.GetAttackRange(ctx.Map) + unit.GetActionPoint(ActionPointType.Move);
}
private float GridThreat(AIDirectorContext ctx, GridData grid)
{
if (grid == null) return 0f;
var threat = 0f;
foreach (var enemy in ctx.Cache.EnemyUnits)
{
if (!ctx.Map.GetGridDataByUnitId(enemy.Id, out var enemyGrid)) continue;
if (AIDirectorMath.Distance(ctx.Map, enemyGrid, grid) <= enemy.GetAttackRange(ctx.Map) + enemy.GetActionPoint(ActionPointType.Move))
threat += AIDirectorMath.UnitPower(enemy);
}
return threat;
}
private float TrainUnitDefenseValue(AIDirectorContext ctx, AIActionBase action, AIDirectorCityThreat threat)
{
var id = action.ActionLogic.ActionId;
var score = UnitBaseMilitaryValue(id.UnitType);
if (threat != null)
{
foreach (var enemy in threat.EnemyUnits)
{
score += id.UnitType == UnitType.Defender ? 45f : 20f;
if (enemy.GetAttackRange(ctx.Map) > 1 && id.UnitType == UnitType.Rider) score += 30f;
}
}
return score;
}
private float TrainUnitWarValue(AIDirectorContext ctx, AIActionBase action)
{
var id = action.ActionLogic.ActionId;
var score = UnitBaseMilitaryValue(id.UnitType);
if (ctx.Cache.StrategicPosture == AIDirectorStrategicPosture.Attack) score += 60f;
if (id.UnitType is UnitType.Rider or UnitType.Knights or UnitType.Catapult) score += 40f;
return score;
}
private float UnitBaseMilitaryValue(UnitType unitType)
{
return unitType switch
{
UnitType.Defender => 90f,
UnitType.Warrior => 70f,
UnitType.Archer => 75f,
UnitType.Rider or UnitType.Knights => 85f,
UnitType.Catapult => 100f,
UnitType.Giant or UnitType.GiantJuggernaut => 140f,
_ => 50f
};
}
private bool NeedStandingArmy(AIDirectorContext ctx, CityData city)
{
if (city == null || !ctx.Map.GetGridDataByCityId(city.Id, out var cityGrid)) return true;
var defenders = 0;
foreach (var unit in ctx.Cache.SelfUnits)
{
if (!ctx.Map.GetGridDataByUnitId(unit.Id, out var unitGrid)) continue;
if (AIDirectorMath.Distance(ctx.Map, cityGrid, unitGrid) <= ctx.Config.EmergencyEnemySearchRange) defenders++;
}
return defenders < 2;
}
private float WonderValue(AIActionBase action)
{
return action?.ActionLogic?.ActionId?.WonderType == WonderTypeEnum.None ? 0f : 120f;
}
private bool GridIsNearDangerCity(AIDirectorContext ctx, GridData grid)
{
foreach (var threat in ctx.Cache.CityThreats)
{
if (!threat.IsCritical || threat.CityGrid == null) continue;
if (AIDirectorMath.Distance(ctx.Map, grid, threat.CityGrid) <= ctx.Config.EmergencyEnemySearchRange) return true;
}
return false;
}
private float BuildResourceValue(GridData grid)
{
return ResourceValue(grid) + (grid?.HasBuilding() == true ? 0f : 50f);
}
private float ResourceValue(GridData grid)
{
if (grid == null || grid.Resource == ResourceType.None) return 0f;
return grid.Resource switch
{
ResourceType.Metal => 130f,
ResourceType.Fruit => 100f,
ResourceType.Crop => 90f,
ResourceType.Animal => 80f,
ResourceType.Fish => 80f,
ResourceType.Starfish => 120f,
ResourceType.Treasure => 180f,
_ => 60f
};
}
private float GridMiscValue(CommonActionId id)
{
return id.GridMiscActionType switch
{
GridMiscActionType.GrowForest => 70f,
GridMiscActionType.UpgradeTemple => 110f,
GridMiscActionType.CreateMountain => 80f,
GridMiscActionType.SellMetal => 60f,
_ => 20f
};
}
private AIDirectorDevelopmentTarget FindDevelopmentTarget(AIDirectorContext ctx, GridData grid)
{
if (grid == null) return null;
foreach (var target in ctx.Cache.DevelopmentTargets)
{
if (target.Grid?.Id == grid.Id) return target;
}
return null;
}
private bool HasDirectExpansionConflict(AIDirectorContext ctx, PlayerData target)
{
if (target == null) return false;
foreach (var city in ctx.Cache.EnemyCities)
{
if (!ctx.Map.GetPlayerDataByCityId(city.Id, out var owner) || owner.Id != target.Id) continue;
if (!ctx.Map.GetGridDataByCityId(city.Id, out var grid)) continue;
foreach (var selfCity in ctx.Cache.SelfCities)
{
if (!ctx.Map.GetGridDataByCityId(selfCity.Id, out var selfGrid)) continue;
if (AIDirectorMath.Distance(ctx.Map, grid, selfGrid) <= ctx.Config.FrontSearchRange) return true;
}
}
return false;
}
private bool IsTeammate(AIDirectorContext ctx, PlayerData target)
{
if (target == null) return false;
if (ctx.Player.GetCountryDiplomacyInfo(target.Id, out var selfToTarget) && selfToTarget != null && selfToTarget.IsTeammate) return true;
return target.GetCountryDiplomacyInfo(ctx.Player.Id, out var targetToSelf) && targetToSelf != null && targetToSelf.IsTeammate;
}
private bool TechLooksDefensive(TechType tech)
{
return tech is TechType.Smithery or TechType.Strategy or TechType.Construction or TechType.Meditation or TechType.KanakoSmithery or TechType.HakureiStrategy;
}
private bool TechLooksMilitary(TechType tech)
{
return tech is TechType.Archery or TechType.Riding or TechType.Chivalry or TechType.Ramming or TechType.Hunting
or TechType.KaguyaArcher or TechType.KaguyaHunting or TechType.KanakoRiding or TechType.KanakoChivalry
or TechType.KomeijiIndianArchery or TechType.KomeijiIndianRiding or TechType.KomeijiIndianChivalry
or TechType.HakureiRamming or TechType.HakureiSmithery;
}
private bool TechLooksMobility(TechType tech)
{
return tech is TechType.Climbing or TechType.Roads or TechType.Sailing or TechType.Navigation or TechType.FreeSpirit
or TechType.KaguyaRoad or TechType.KanakoClimbing or TechType.KanakoRoads or TechType.KanakoNavigation
or TechType.KomeijiIndianSailing or TechType.KomeijiIndianNavigation or TechType.HakureiFishing;
}
private bool TechLooksEconomic(TechType tech)
{
return tech is TechType.Mining or TechType.Farming or TechType.Forestry or TechType.Trade or TechType.Fishing or TechType.Philosophy
or TechType.KaguyaForestry or TechType.KaguyaTrade or TechType.KaguyaConstruction
or TechType.RemiliaFarming or TechType.KanakoMining or TechType.KanakoTrade or TechType.HakureiTrade;
}
}
public static class AIDirectorIntegrationPort
{
private static readonly AIDirectorLogic Logic = new();
public static AIDirectorDecision PreviewNextAction(MapData map, PlayerData player, AIDirectorConfig config = null)
{
return Logic.Decide(map, player, config);
}
public static bool TryGetNextAction(MapData map, PlayerData player, out AIActionBase action, AIDirectorConfig config = null)
{
action = null;
var decision = Logic.Decide(map, player, config);
if (!decision.HasAction) return false;
action = decision.Candidate.AIAction;
return action != null;
}
public static bool TryGetNextCandidate(MapData map, PlayerData player, out AIDirectorActionCandidate candidate, AIDirectorConfig config = null)
{
return Logic.TryDecide(map, player, out candidate, config);
}
}
}

View File

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

View File

@ -0,0 +1,670 @@
using System;
using System.Collections.Generic;
using Logic.Action;
using RuntimeData;
using UnityEngine;
namespace Logic.AI.Director
{
public enum AIDirectorLane
{
None,
Emergency,
HeroManagement,
HeroPlaybook,
Tactic,
UnitOpportunity,
Front,
Growth,
Fallback
}
public enum AIDirectorFrontType
{
None,
Defense,
Attack,
Development,
Hold
}
public enum AIDirectorStrategicPosture
{
Development,
Defense,
Expansion,
Attack
}
public enum AIDirectorCityPlanKind
{
BacklineGrowth,
EmergencyDefense,
Mobilize,
Frontline,
Wonder
}
public enum AIDirectorDevelopmentTargetType
{
None,
Village,
EnemyEmptyCity,
Treasure,
Resource,
FogBoundary
}
public enum AIDirectorUnitOpportunityType
{
None,
Capture,
Examine,
Gather,
HeroUpgrade,
Upgrade,
CultureUnitUpgrade,
Recover
}
public enum AIDirectorHeroRole
{
None,
Vanguard,
Defender,
Support,
Caster,
Mobility,
Control,
Economy,
Summoner,
Assassin
}
public enum AIDirectorHeroContext
{
None,
Siege,
Defense,
FieldBattle,
Recovery,
Economy,
Mobility,
Control
}
public enum AIDirectorHeroActionKind
{
None,
UnitAction,
AttackEnemy,
AttackAlly,
AttackGround,
MoveTowardFront,
MoveAwayFromThreat,
Hold
}
public enum AIDirectorHeroTargetPolicy
{
None,
Self,
BestEnemy,
KillableEnemy,
BestEnemyHero,
BestAlly,
InjuredAlly,
AdjacentAlly,
FrontEnemyCity,
ThreateningEnemy,
BestGroundTarget,
BonePile,
CityCenterEnemy,
SafeForwardGrid,
RetreatGrid
}
public sealed class AIDirectorConfig
{
public int EmergencyEnemySearchRange = 3;
public int FrontSearchRange = 6;
public int LocalBattleRange = 2;
public int DevelopmentSearchRange = 6;
public int MaxGeneratedActions = 4096;
public int MaxFrontCount = 12;
public int MaxDevelopmentTargetCount = 20;
public float LowHealthRatio = 0.45f;
public float CriticalHealthRatio = 0.25f;
public float HeroLowHealthRatio = 0.6f;
public int CityDangerEnemyCount = 2;
public int CityCriticalDangerEnemyCount = 4;
public float CityThreatPowerRatio = 1.25f;
public int HeroTaskStartTurn = 8;
public int HeroTaskInterval = 4;
public int HeroLevel2MinTurn = 15;
public int HeroLevel3MinTurn = 25;
public int MaxCandidateTraceCount = 16;
public int MaxDiagnosticCandidatePerLane = 8;
public bool PreferHeroRules = true;
public bool AllowExecuteFromFacade = false;
public readonly List<AIDirectorHeroRule> HeroRules = new();
public static AIDirectorConfig CreateDefault()
{
var config = new AIDirectorConfig();
AIDirectorHeroRuleCatalog.FillDefault(config.HeroRules);
return config;
}
}
public sealed class AIDirectorContext
{
public readonly MapData Map;
public readonly PlayerData Player;
public readonly AIDirectorConfig Config;
public AIDirectorWorldCache Cache;
public AIDirectorActionIndex ActionIndex;
public AIDirectorContext(MapData map, PlayerData player, AIDirectorConfig config)
{
Map = map;
Player = player;
Config = config ?? AIDirectorConfig.CreateDefault();
}
}
public sealed class AIDirectorWorldCache
{
public readonly List<UnitData> SelfUnits = new();
public readonly List<UnitData> SelfHeroes = new();
public readonly List<UnitData> EnemyUnits = new();
public readonly List<UnitData> EnemyHeroes = new();
public readonly List<CityData> SelfCities = new();
public readonly List<CityData> EnemyCities = new();
public readonly List<PlayerData> EnemyPlayers = new();
public readonly List<PlayerData> AlliedPlayers = new();
public readonly List<PlayerData> WarTargetPlayers = new();
public readonly List<PlayerData> HighTrustPlayers = new();
public readonly List<PlayerData> LowTrustPlayers = new();
public readonly List<AIDirectorCityThreat> CityThreats = new();
public readonly List<AIDirectorCityPlan> CityPlans = new();
public readonly List<AIDirectorDevelopmentTarget> DevelopmentTargets = new();
public readonly List<AIDirectorFront> Fronts = new();
public readonly List<AIDirectorHeroState> HeroStates = new();
public readonly List<AIDirectorLocalBattle> LocalBattles = new();
public readonly List<AIDirectorUnitOpportunity> UnitOpportunities = new();
public readonly HashSet<uint> SelfTerritoryGridIds = new();
public AIDirectorFront PrimaryFront;
public AIDirectorStrategicPosture StrategicPosture;
public bool HasCriticalCityThreat;
public bool HasAnyEnemyContact;
public float SelfMilitary;
public float EnemyMilitary;
}
public sealed class AIDirectorCityThreat
{
public CityData City;
public GridData CityGrid;
public readonly List<UnitData> EnemyUnits = new();
public readonly List<UnitData> Defenders = new();
public float EnemyPower;
public float DefenderPower;
public float RescuePower;
public int NearestEnemyDistance = int.MaxValue;
public bool IsCritical;
public bool IsCapital;
public bool HasWall;
public bool HasEnemyOnTerritory;
public bool CanBeThreatenedNextTurn;
public float DangerScore;
}
public sealed class AIDirectorCityPlan
{
public CityData City;
public GridData CityGrid;
public AIDirectorCityThreat Threat;
public AIDirectorCityPlanKind Kind;
public bool NeedWall;
public bool NeedMilitary;
public bool NeedGrowth;
public bool NeedWonder;
public float Priority;
}
public sealed class AIDirectorDevelopmentTarget
{
public GridData Grid;
public AIDirectorDevelopmentTargetType TargetType;
public CityData NearestSelfCity;
public int Distance = int.MaxValue;
public float Value;
}
public sealed class AIDirectorFront
{
public AIDirectorFrontType FrontType;
public CityData SelfCity;
public CityData TargetCity;
public GridData AnchorGrid;
public GridData TargetGrid;
public float Pressure;
public float Opportunity;
public int Distance = int.MaxValue;
public readonly List<UnitData> SelfUnits = new();
public readonly List<UnitData> EnemyUnits = new();
}
public sealed class AIDirectorHeroState
{
public UnitData Hero;
public GiantType GiantType;
public uint Level;
public AIDirectorHeroRole Role;
public AIDirectorHeroContext Context;
public AIDirectorFront Front;
public float HealthRatio;
public bool IsThreatened;
public bool IsOnFront;
}
public sealed class AIDirectorLocalBattle
{
public UnitData SelfUnit;
public UnitData EnemyUnit;
public GridData SelfGrid;
public GridData EnemyGrid;
public int Distance;
public float Value;
}
public sealed class AIDirectorUnitOpportunity
{
public UnitData Unit;
public AIActionBase Action;
public AIDirectorUnitOpportunityType OpportunityType;
public float Value;
}
public sealed class AIDirectorHeroRule
{
public string RuleId;
public GiantType GiantType;
public UnitType UnitType;
public uint MinLevel;
public AIDirectorHeroRole Role;
public AIDirectorHeroContext Context;
public AIDirectorHeroActionKind ActionKind;
public UnitActionType UnitActionType;
public AIDirectorHeroTargetPolicy TargetPolicy;
public int Priority;
public string RequiredSkill;
public string AvoidTargetSkill;
public string Reason;
}
public sealed class AIDirectorActionCandidate
{
public static readonly AIDirectorActionCandidate None = new();
public AIActionBase AIAction;
public CommonActionParams Param;
public ActionLogicBase ActionLogic;
public CommonActionId ActionId;
public AIDirectorLane Lane;
public UnitData Unit;
public UnitData TargetUnit;
public CityData City;
public GridData Grid;
public GridData TargetGrid;
public string Reason;
public float Priority;
public bool IsFallback;
public readonly List<AIDirectorScoreTerm> ScoreTerms = new();
public bool IsValid => Param != null && ActionLogic != null && ActionId != null;
public bool CheckCan()
{
if (!IsValid) return false;
Param.RefreshParams();
return ActionLogic.CheckCan(Param);
}
private AIDirectorActionCandidate()
{
}
public static AIDirectorActionCandidate Invalid(
AIDirectorLane lane,
string reason,
float priority,
bool fallback = false)
{
return new AIDirectorActionCandidate
{
Lane = lane,
Reason = reason,
Priority = priority,
IsFallback = fallback
};
}
public AIDirectorActionCandidate(
AIActionBase action,
AIDirectorLane lane,
string reason,
float priority,
bool fallback = false)
{
AIAction = action;
Param = action?.Param;
ActionLogic = action?.ActionLogic;
ActionId = ActionLogic?.ActionId;
Lane = lane;
Reason = reason;
Priority = priority;
IsFallback = fallback;
Unit = Param?.UnitData;
TargetUnit = Param?.TargetUnitData;
City = Param?.CityData;
Grid = Param?.GridData;
TargetGrid = Param?.TargetGridData;
}
}
public sealed class AIDirectorDecision
{
public bool HasAction => Candidate != null && Candidate.IsValid;
public AIDirectorActionCandidate Candidate;
public AIDirectorWorldCache Cache;
public AIDirectorActionIndex ActionIndex;
public double DecideMs;
public readonly List<string> Trace = new();
public readonly List<AIDirectorLaneDiagnostic> LaneDiagnostics = new();
public void AddTrace(string message, int maxCount)
{
if (string.IsNullOrEmpty(message)) return;
if (maxCount > 0 && Trace.Count >= maxCount) return;
Trace.Add(message);
}
public void RecordCandidate(
AIDirectorLane lane,
string source,
AIDirectorActionCandidate candidate,
int maxPerLane,
string rejectReason = null)
{
var diagnostic = GetLaneDiagnostic(lane);
diagnostic.AddCandidate(source, candidate, maxPerLane, rejectReason);
}
public void RecordLaneResult(AIDirectorLane lane, bool selected, string note)
{
var diagnostic = GetLaneDiagnostic(lane);
diagnostic.selected = selected;
diagnostic.note = note ?? string.Empty;
}
private AIDirectorLaneDiagnostic GetLaneDiagnostic(AIDirectorLane lane)
{
foreach (var diagnostic in LaneDiagnostics)
{
if (diagnostic.lane == lane) return diagnostic;
}
var created = new AIDirectorLaneDiagnostic { lane = lane };
LaneDiagnostics.Add(created);
return created;
}
}
public sealed class AIDirectorLaneDiagnostic
{
public AIDirectorLane lane;
public bool selected;
public string note;
public readonly List<AIDirectorCandidateDiagnostic> candidates = new();
public void AddCandidate(
string source,
AIDirectorActionCandidate candidate,
int maxPerLane,
string rejectReason = null)
{
if (candidate == null) return;
var entry = new AIDirectorCandidateDiagnostic
{
source = source ?? string.Empty,
candidate = candidate,
priority = candidate.Priority,
isValid = candidate.IsValid,
rejectReason = rejectReason ?? (candidate.IsValid ? string.Empty : "InvalidCandidate")
};
if (maxPerLane <= 0)
{
candidates.Add(entry);
return;
}
var insertIndex = candidates.Count;
for (var i = 0; i < candidates.Count; i++)
{
if (Compare(entry, candidates[i]) < 0)
{
insertIndex = i;
break;
}
}
if (insertIndex < candidates.Count) candidates.Insert(insertIndex, entry);
else candidates.Add(entry);
while (candidates.Count > maxPerLane) candidates.RemoveAt(candidates.Count - 1);
}
private static int Compare(AIDirectorCandidateDiagnostic a, AIDirectorCandidateDiagnostic b)
{
if (a.isValid != b.isValid) return a.isValid ? -1 : 1;
var scoreCompare = b.priority.CompareTo(a.priority);
if (scoreCompare != 0) return scoreCompare;
return string.Compare(
AIDirectorActionIndex.StableActionKey(a.candidate?.AIAction),
AIDirectorActionIndex.StableActionKey(b.candidate?.AIAction),
StringComparison.Ordinal);
}
}
public sealed class AIDirectorCandidateDiagnostic
{
public string source;
public AIDirectorActionCandidate candidate;
public float priority;
public bool isValid;
public string rejectReason;
}
public sealed class AIDirectorScoreTerm
{
public string Name;
public float Value;
public AIDirectorScoreTerm(string name, float value)
{
Name = name;
Value = value;
}
}
public sealed class AIDirectorOutcomeProbe
{
public int NetActionCount;
public uint PlayerId;
public uint PlayerTurn;
public int PlayerCoin;
public int PlayerTechPoint;
public int PlayerCulture;
public int CultureCardCount;
public int PlayerScore;
public int CityCount;
public int UnitCount;
public int HeroCount;
public float SelfMilitary;
public float EnemyMilitary;
public int CriticalCityThreatCount;
public int CityThreatCount;
public float MaxCityDangerScore;
public string StrategicPosture;
public uint UnitId;
public bool UnitAlive;
public int UnitHealth;
public uint UnitGridId;
public uint TargetUnitId;
public bool TargetUnitAlive;
public int TargetUnitHealth;
public uint TargetUnitGridId;
public uint CityId;
public uint CityOwnerId;
public int CityLevel;
public uint TargetCityId;
public uint TargetCityOwnerId;
public int TargetCityLevel;
}
internal static class AIDirectorMath
{
public static float UnitPower(UnitData unit)
{
if (unit == null || !unit.IsAlive()) return 0f;
return Mathf.Max(1f, unit.GetMilitary());
}
public static float HealthRatio(UnitData unit)
{
if (unit == null || unit.GetMaxHealth() <= 0) return 0f;
return Mathf.Clamp01(unit.GetHealthRatio());
}
public static int Distance(MapData map, GridData a, GridData b)
{
if (map?.GridMap == null || a == null || b == null) return int.MaxValue;
return map.GridMap.CalcDistance(a, b);
}
public static bool SameSide(MapData map, UnitData a, UnitData b)
{
if (map == null || a == null || b == null) return false;
return map.SameUnionByUnitId(a.Id, b.Id);
}
public static bool IsEnemy(MapData map, PlayerData self, UnitData unit)
{
if (map == null || self == null || unit == null) return false;
return map.GetPlayerDataByUnitId(unit.Id, out var owner) && !map.SameUnion(self.Id, owner.Id);
}
public static bool TryGetSkillLevel(UnitData unit, string skillName, out int level)
{
level = 0;
if (unit == null || string.IsNullOrEmpty(skillName)) return false;
if (!Enum.TryParse(skillName, out SkillType skillType)) return false;
if (!unit.GetSkill(skillType, out var skill) || skill == null) return false;
level = skill.Level;
return level > 0;
}
public static bool HasSkill(UnitData unit, string skillName)
{
return TryGetSkillLevel(unit, skillName, out _);
}
public static int StableUnitId(UnitData unit)
{
return unit == null ? int.MaxValue : unchecked((int)unit.Id);
}
public static int StableCityId(CityData city)
{
return city == null ? int.MaxValue : unchecked((int)city.Id);
}
public static int StableGridId(GridData grid)
{
return grid == null ? int.MaxValue : unchecked((int)grid.Id);
}
}
public static class AIDirectorHeroRuleCatalog
{
public static void FillDefault(List<AIDirectorHeroRule> rules)
{
if (rules == null) return;
rules.Clear();
Add(rules, "Flandre_Kill", GiantType.EgyptianFlandre, AIDirectorHeroRole.Assassin, AIDirectorHeroContext.FieldBattle, AIDirectorHeroActionKind.AttackEnemy, UnitActionType.None, AIDirectorHeroTargetPolicy.KillableEnemy, 900, "低血量斩杀高价值目标");
Add(rules, "Remilia_Buff", GiantType.EgyptianRemilia, AIDirectorHeroRole.Support, AIDirectorHeroContext.FieldBattle, AIDirectorHeroActionKind.UnitAction, UnitActionType.REMILIABUFF, AIDirectorHeroTargetPolicy.Self, 820, "敌我接触时铺血雾增益");
Add(rules, "Remilia_Absorb", GiantType.EgyptianRemilia, AIDirectorHeroRole.Vanguard, AIDirectorHeroContext.Recovery, AIDirectorHeroActionKind.UnitAction, UnitActionType.REMILIAABSORB, AIDirectorHeroTargetPolicy.Self, 860, "可吸收血雾时先保证续航");
Add(rules, "Sakuya_GuardStrike", GiantType.EgyptianSakuya, AIDirectorHeroRole.Defender, AIDirectorHeroContext.Defense, AIDirectorHeroActionKind.AttackAlly, UnitActionType.None, AIDirectorHeroTargetPolicy.InjuredAlly, 850, "保护前线残血友军");
Add(rules, "Meiling_Recover", GiantType.EgyptianMeiling, AIDirectorHeroRole.Defender, AIDirectorHeroContext.Recovery, AIDirectorHeroActionKind.UnitAction, UnitActionType.Recover, AIDirectorHeroTargetPolicy.Self, 830, "低血量且不急于攻击时恢复");
Add(rules, "Patchouli_MoveSeal", GiantType.EgyptianPatchouli, AIDirectorHeroRole.Caster, AIDirectorHeroContext.Control, AIDirectorHeroActionKind.MoveAwayFromThreat, UnitActionType.None, AIDirectorHeroTargetPolicy.RetreatGrid, 810, "移动叠层后等待法术收益");
Add(rules, "Patchouli_Earth", GiantType.EgyptianPatchouli, AIDirectorHeroRole.Caster, AIDirectorHeroContext.Recovery, AIDirectorHeroActionKind.AttackGround, UnitActionType.None, AIDirectorHeroTargetPolicy.BestGroundTarget, 820, "用地块攻击触发土系回复");
Add(rules, "Kaguya_Synergy", GiantType.FrenchKaguya, AIDirectorHeroRole.Control, AIDirectorHeroContext.FieldBattle, AIDirectorHeroActionKind.AttackEnemy, UnitActionType.None, AIDirectorHeroTargetPolicy.BestEnemy, 820, "优先攻击可叠协同标记的敌人");
Add(rules, "Reisen_Boom", GiantType.FrenchReisen, AIDirectorHeroRole.Caster, AIDirectorHeroContext.FieldBattle, AIDirectorHeroActionKind.UnitAction, UnitActionType.ReisenFrenchBoom, AIDirectorHeroTargetPolicy.BestEnemy, 870, "敌方密集时触发幻象爆破");
Add(rules, "Tewi_Buff", GiantType.FrenchTewi, AIDirectorHeroRole.Support, AIDirectorHeroContext.FieldBattle, AIDirectorHeroActionKind.UnitAction, UnitActionType.TEWIFRENCHBUFF, AIDirectorHeroTargetPolicy.AdjacentAlly, 780, "给相邻作战友军增益");
Add(rules, "Eirin_HealHero", GiantType.FrenchEirin, AIDirectorHeroRole.Support, AIDirectorHeroContext.Recovery, AIDirectorHeroActionKind.AttackAlly, UnitActionType.None, AIDirectorHeroTargetPolicy.InjuredAlly, 860, "优先治疗低血英雄");
Add(rules, "Mokou_Boom", GiantType.FrenchMokou, AIDirectorHeroRole.Vanguard, AIDirectorHeroContext.FieldBattle, AIDirectorHeroActionKind.UnitAction, UnitActionType.MOKOUFRENCHBOOM, AIDirectorHeroTargetPolicy.Self, 900, "残血且敌人密集时自爆换收益");
Add(rules, "Sanae_WindAttack", GiantType.GermanySanae, AIDirectorHeroRole.Caster, AIDirectorHeroContext.FieldBattle, AIDirectorHeroActionKind.AttackGround, UnitActionType.None, AIDirectorHeroTargetPolicy.BestGroundTarget, 820, "用攻击地块扩大风系收益");
Add(rules, "Kanako_Unsit", GiantType.GermanyKanako, AIDirectorHeroRole.Defender, AIDirectorHeroContext.Defense, AIDirectorHeroActionKind.UnitAction, UnitActionType.KANAKOUNSIT, AIDirectorHeroTargetPolicy.Self, 840, "战线移动时取消坐镇");
Add(rules, "Kanako_Sit", GiantType.GermanyKanako, AIDirectorHeroRole.Economy, AIDirectorHeroContext.Economy, AIDirectorHeroActionKind.UnitAction, UnitActionType.KANAKOSIT, AIDirectorHeroTargetPolicy.Self, 720, "安全内政阶段坐镇城市");
Add(rules, "Suwako_Attack", GiantType.GermanySuwako, AIDirectorHeroRole.Vanguard, AIDirectorHeroContext.FieldBattle, AIDirectorHeroActionKind.AttackEnemy, UnitActionType.None, AIDirectorHeroTargetPolicy.BestEnemy, 800, "贴近前线稳定输出");
Add(rules, "Aya_MoveAgain", GiantType.GermanyAya, AIDirectorHeroRole.Mobility, AIDirectorHeroContext.Mobility, AIDirectorHeroActionKind.UnitAction, UnitActionType.AYAMOVEAGAIN, AIDirectorHeroTargetPolicy.Self, 860, "二动用于追击或回防");
Add(rules, "Momiji_Prey", GiantType.GermanyMomiji, AIDirectorHeroRole.Assassin, AIDirectorHeroContext.FieldBattle, AIDirectorHeroActionKind.AttackEnemy, UnitActionType.None, AIDirectorHeroTargetPolicy.BestEnemy, 810, "优先打猎物标记目标");
Add(rules, "Satori_Ban", GiantType.IndianSatori, AIDirectorHeroRole.Control, AIDirectorHeroContext.Control, AIDirectorHeroActionKind.AttackEnemy, UnitActionType.None, AIDirectorHeroTargetPolicy.BestEnemyHero, 870, "封禁敌方核心英雄");
Add(rules, "Koishi_Fear", GiantType.IndianKoishi, AIDirectorHeroRole.Control, AIDirectorHeroContext.Control, AIDirectorHeroActionKind.MoveTowardFront, UnitActionType.None, AIDirectorHeroTargetPolicy.SafeForwardGrid, 760, "走位制造恐惧光环");
Add(rules, "Rin_Corpse", GiantType.IndianRin, AIDirectorHeroRole.Caster, AIDirectorHeroContext.FieldBattle, AIDirectorHeroActionKind.UnitAction, UnitActionType.KomeijiRinFire, AIDirectorHeroTargetPolicy.Self, 830, "有尸火层数时转换输出");
Add(rules, "Utsuho_BoneBoom", GiantType.IndianUtsuho, AIDirectorHeroRole.Caster, AIDirectorHeroContext.FieldBattle, AIDirectorHeroActionKind.UnitAction, UnitActionType.KomeijiBonePileBoom, AIDirectorHeroTargetPolicy.BonePile, 880, "骨堆爆破命中多人时使用");
Add(rules, "Yuugi_Push", GiantType.IndianYuugi, AIDirectorHeroRole.Vanguard, AIDirectorHeroContext.FieldBattle, AIDirectorHeroActionKind.AttackEnemy, UnitActionType.None, AIDirectorHeroTargetPolicy.BestEnemy, 830, "利用近战推进压制");
Add(rules, "Reimu_Protect", GiantType.NorwayReimu, AIDirectorHeroRole.Support, AIDirectorHeroContext.Defense, AIDirectorHeroActionKind.AttackAlly, UnitActionType.None, AIDirectorHeroTargetPolicy.InjuredAlly, 880, "为关键友军挂保护");
Add(rules, "Reimu_ClearExtermination", GiantType.NorwayReimu, AIDirectorHeroRole.Support, AIDirectorHeroContext.Recovery, AIDirectorHeroActionKind.UnitAction, UnitActionType.ReimuPayClearExtermination, AIDirectorHeroTargetPolicy.Self, 780, "标记压力过高时清除退魔");
Add(rules, "Sumireko_Orb", GiantType.NorwaySumireko, AIDirectorHeroRole.Caster, AIDirectorHeroContext.Control, AIDirectorHeroActionKind.AttackGround, UnitActionType.None, AIDirectorHeroTargetPolicy.BestGroundTarget, 800, "用灵球控制战线");
Add(rules, "Kasen_Oni", GiantType.NorwayKasen, AIDirectorHeroRole.Vanguard, AIDirectorHeroContext.FieldBattle, AIDirectorHeroActionKind.UnitAction, UnitActionType.KasenToggleOniForm, AIDirectorHeroTargetPolicy.Self, 840, "进入鬼形态处理高压战斗");
Add(rules, "Aunn_Twin", GiantType.NorwayAunn, AIDirectorHeroRole.Defender, AIDirectorHeroContext.Defense, AIDirectorHeroActionKind.MoveTowardFront, UnitActionType.None, AIDirectorHeroTargetPolicy.SafeForwardGrid, 760, "双狛犬保持相互支援距离");
Add(rules, "Suika_CreateMini", GiantType.NorwaySuika, AIDirectorHeroRole.Summoner, AIDirectorHeroContext.FieldBattle, AIDirectorHeroActionKind.UnitAction, UnitActionType.SuikaCreateMiniByHp, AIDirectorHeroTargetPolicy.Self, 840, "安全时用血量换小萃香");
Add(rules, "Suika_ShakeOff", GiantType.NorwaySuika, AIDirectorHeroRole.Summoner, AIDirectorHeroContext.Defense, AIDirectorHeroActionKind.UnitAction, UnitActionType.SuikaShakeOffMinis, AIDirectorHeroTargetPolicy.Self, 860, "被围攻时甩出小萃香解围");
}
private static void Add(
List<AIDirectorHeroRule> rules,
string id,
GiantType giantType,
AIDirectorHeroRole role,
AIDirectorHeroContext context,
AIDirectorHeroActionKind actionKind,
UnitActionType unitActionType,
AIDirectorHeroTargetPolicy targetPolicy,
int priority,
string reason)
{
rules.Add(new AIDirectorHeroRule
{
RuleId = id,
GiantType = giantType,
Role = role,
Context = context,
ActionKind = actionKind,
UnitActionType = unitActionType,
TargetPolicy = targetPolicy,
Priority = priority,
Reason = reason
});
}
}
}

View File

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

View File

@ -0,0 +1,807 @@
using System.Collections.Generic;
using System.Linq;
using Logic.Action;
using RuntimeData;
using UnityEngine;
namespace Logic.AI.Director
{
public sealed class AIDirectorWorldCacheBuilder
{
private readonly List<GridData> _around = new();
public AIDirectorWorldCache Build(AIDirectorContext ctx)
{
var cache = new AIDirectorWorldCache();
if (ctx?.Map == null || ctx.Player == null) return cache;
CollectWorldObjects(ctx, cache);
BuildDiplomacyView(ctx, cache);
BuildMilitarySummary(ctx, cache);
BuildCityThreats(ctx, cache);
BuildDevelopmentTargets(ctx, cache);
BuildStrategicPosture(ctx, cache);
BuildCityPlans(ctx, cache);
BuildFronts(ctx, cache);
BuildLocalBattles(ctx, cache);
BuildHeroStates(ctx, cache);
cache.PrimaryFront = ChoosePrimaryFront(cache);
return cache;
}
public void BuildUnitOpportunities(AIDirectorContext ctx)
{
var cache = ctx?.Cache;
var actions = ctx?.ActionIndex;
if (cache == null || actions == null) return;
cache.UnitOpportunities.Clear();
foreach (var action in actions.UnitActions)
{
if (action?.Param?.UnitData == null || action.ActionLogic?.ActionId == null) continue;
var type = GetOpportunityType(action.ActionLogic.ActionId.UnitActionType);
if (type == AIDirectorUnitOpportunityType.None) continue;
var value = ScoreUnitOpportunity(ctx, action, type);
if (value <= 0f) continue;
cache.UnitOpportunities.Add(new AIDirectorUnitOpportunity
{
Unit = action.Param.UnitData,
Action = action,
OpportunityType = type,
Value = value
});
}
cache.UnitOpportunities.Sort((a, b) =>
{
var c = b.Value.CompareTo(a.Value);
if (c != 0) return c;
return AIDirectorMath.StableUnitId(a.Unit).CompareTo(AIDirectorMath.StableUnitId(b.Unit));
});
}
private void CollectWorldObjects(AIDirectorContext ctx, AIDirectorWorldCache cache)
{
ctx.Map.GetUnitDataListByPlayerId(ctx.Player.Id, cache.SelfUnits);
ctx.Map.GetCityDataListByPlayerId(ctx.Player.Id, cache.SelfCities);
ctx.Map.GetPlayerTerritoryGridIdSet(ctx.Player.Id, cache.SelfTerritoryGridIds);
for (var i = cache.SelfUnits.Count - 1; i >= 0; i--)
{
var unit = cache.SelfUnits[i];
if (unit == null || !unit.IsAlive())
{
cache.SelfUnits.RemoveAt(i);
continue;
}
if (unit.TreatedAsHero(ctx.Map, unit)) cache.SelfHeroes.Add(unit);
}
if (ctx.Map.PlayerMap?.PlayerDataList != null)
{
foreach (var player in ctx.Map.PlayerMap.PlayerDataList)
{
if (player == null || player.Id == ctx.Player.Id) continue;
if (ctx.Map.SameUnion(ctx.Player.Id, player.Id)) cache.AlliedPlayers.Add(player);
else cache.EnemyPlayers.Add(player);
}
}
if (ctx.Map.UnitMap?.UnitList != null)
{
foreach (var unit in ctx.Map.UnitMap.UnitList)
{
if (unit == null || !unit.IsAlive()) continue;
if (!AIDirectorMath.IsEnemy(ctx.Map, ctx.Player, unit)) continue;
cache.EnemyUnits.Add(unit);
if (unit.TreatedAsHero(ctx.Map, unit)) cache.EnemyHeroes.Add(unit);
}
}
if (ctx.Map.CityMap?.CityList != null)
{
foreach (var city in ctx.Map.CityMap.CityList)
{
if (city == null) continue;
if (!ctx.Map.GetPlayerDataByCityId(city.Id, out var owner) || owner == null) continue;
if (!ctx.Map.SameUnion(ctx.Player.Id, owner.Id)) cache.EnemyCities.Add(city);
}
}
}
private void BuildDiplomacyView(AIDirectorContext ctx, AIDirectorWorldCache cache)
{
foreach (var enemy in cache.EnemyPlayers)
{
if (!ctx.Player.GetCountryDiplomacyInfo(enemy.Id, out var info) || info == null) continue;
if (info.DiplomacyState == DiplomacyState.War) cache.WarTargetPlayers.Add(enemy);
if (info.FeelingValue >= 80f) cache.HighTrustPlayers.Add(enemy);
if (info.FeelingValue <= 30f) cache.LowTrustPlayers.Add(enemy);
}
}
private void BuildMilitarySummary(AIDirectorContext ctx, AIDirectorWorldCache cache)
{
foreach (var unit in cache.SelfUnits) cache.SelfMilitary += AIDirectorMath.UnitPower(unit);
foreach (var unit in cache.EnemyUnits) cache.EnemyMilitary += AIDirectorMath.UnitPower(unit);
}
private void BuildCityThreats(AIDirectorContext ctx, AIDirectorWorldCache cache)
{
foreach (var city in cache.SelfCities)
{
if (city == null || !ctx.Map.GetGridDataByCityId(city.Id, out var cityGrid)) continue;
var threat = BuildSingleCityThreat(ctx, cache, city, cityGrid);
cache.CityThreats.Add(threat);
cache.HasCriticalCityThreat |= threat.IsCritical;
cache.HasAnyEnemyContact |= threat.EnemyUnits.Count > 0;
}
cache.CityThreats.Sort((a, b) =>
{
var c = b.DangerScore.CompareTo(a.DangerScore);
if (c != 0) return c;
return AIDirectorMath.StableCityId(a.City).CompareTo(AIDirectorMath.StableCityId(b.City));
});
}
private AIDirectorCityThreat BuildSingleCityThreat(AIDirectorContext ctx, AIDirectorWorldCache cache, CityData city, GridData cityGrid)
{
var threat = new AIDirectorCityThreat
{
City = city,
CityGrid = cityGrid,
IsCapital = city.IsCapital,
HasWall = city.CityWall
};
foreach (var enemy in cache.EnemyUnits)
{
if (!ctx.Map.GetGridDataByUnitId(enemy.Id, out var enemyGrid)) continue;
var distance = AIDirectorMath.Distance(ctx.Map, cityGrid, enemyGrid);
var reach = ctx.Config.EmergencyEnemySearchRange + enemy.GetAttackRange(ctx.Map);
if (distance <= reach)
{
threat.EnemyUnits.Add(enemy);
threat.EnemyPower += AIDirectorMath.UnitPower(enemy) / Mathf.Max(1, distance);
threat.NearestEnemyDistance = Mathf.Min(threat.NearestEnemyDistance, distance);
}
if (cache.SelfTerritoryGridIds.Contains(enemyGrid.Id)) threat.HasEnemyOnTerritory = true;
if (CanThreatenCityNextTurn(ctx, enemy, cityGrid)) threat.CanBeThreatenedNextTurn = true;
}
foreach (var unit in cache.SelfUnits)
{
if (!ctx.Map.GetGridDataByUnitId(unit.Id, out var unitGrid)) continue;
var distance = AIDirectorMath.Distance(ctx.Map, cityGrid, unitGrid);
if (distance <= ctx.Config.EmergencyEnemySearchRange)
{
threat.Defenders.Add(unit);
threat.DefenderPower += AIDirectorMath.UnitPower(unit) / Mathf.Max(1, distance);
}
if (distance <= ctx.Config.FrontSearchRange)
{
threat.RescuePower += AIDirectorMath.UnitPower(unit) / Mathf.Max(1, distance);
}
}
threat.DangerScore = threat.EnemyPower
- threat.DefenderPower
+ (threat.HasEnemyOnTerritory ? 4f : 0f)
+ (threat.CanBeThreatenedNextTurn ? 6f : 0f)
+ (threat.IsCapital ? 3f : 0f)
- (threat.HasWall ? 2f : 0f);
threat.IsCritical = threat.CanBeThreatenedNextTurn
|| threat.EnemyUnits.Count >= ctx.Config.CityCriticalDangerEnemyCount
|| threat.HasEnemyOnTerritory
|| threat.EnemyPower > threat.DefenderPower * ctx.Config.CityThreatPowerRatio;
return threat;
}
private bool CanThreatenCityNextTurn(AIDirectorContext ctx, UnitData enemy, GridData cityGrid)
{
if (enemy == null || cityGrid == null) return false;
if (!ctx.Map.GetGridDataByUnitId(enemy.Id, out var enemyGrid)) return false;
var reach = enemy.GetActionPoint(ActionPointType.Move) + enemy.GetAttackRange(ctx.Map);
if (enemy.GetActionPoint(ActionPointType.Capture) > 0) reach += 1;
return AIDirectorMath.Distance(ctx.Map, enemyGrid, cityGrid) <= Mathf.Max(1, reach);
}
private void BuildStrategicPosture(AIDirectorContext ctx, AIDirectorWorldCache cache)
{
if (cache.HasCriticalCityThreat)
{
cache.StrategicPosture = AIDirectorStrategicPosture.Defense;
return;
}
if (ExistsHighValueDevelopmentTargetNearCity(ctx, cache))
{
cache.StrategicPosture = AIDirectorStrategicPosture.Expansion;
return;
}
BuildAttackTargetPlayers(ctx, cache);
if (cache.WarTargetPlayers.Count > 0)
{
cache.StrategicPosture = AIDirectorStrategicPosture.Attack;
return;
}
cache.StrategicPosture = AIDirectorStrategicPosture.Development;
}
private void BuildAttackTargetPlayers(AIDirectorContext ctx, AIDirectorWorldCache cache)
{
foreach (var enemy in cache.EnemyPlayers)
{
if (enemy == null || cache.WarTargetPlayers.Contains(enemy)) continue;
var nearest = NearestCityDistance(ctx, ctx.Player, enemy);
var militaryGap = cache.SelfMilitary - EstimatePlayerMilitary(ctx, enemy);
var feeling = GetFeeling(ctx.Player, enemy);
if (feeling <= 30f)
{
cache.WarTargetPlayers.Add(enemy);
continue;
}
if (nearest <= 5 && militaryGap >= 0f)
{
cache.WarTargetPlayers.Add(enemy);
continue;
}
if (nearest <= 7 && militaryGap >= -3f && PlayerThreatNearSelfCities(ctx, cache, enemy) >= 10f)
{
cache.WarTargetPlayers.Add(enemy);
}
}
}
private void BuildCityPlans(AIDirectorContext ctx, AIDirectorWorldCache cache)
{
foreach (var city in cache.SelfCities)
{
if (city == null || !ctx.Map.GetGridDataByCityId(city.Id, out var cityGrid)) continue;
var threat = cache.CityThreats.FirstOrDefault(t => t.City?.Id == city.Id);
var plan = new AIDirectorCityPlan
{
City = city,
CityGrid = cityGrid,
Threat = threat,
Kind = AIDirectorCityPlanKind.BacklineGrowth
};
if (threat != null && threat.IsCritical) plan.Kind = AIDirectorCityPlanKind.EmergencyDefense;
else if (threat != null && threat.DangerScore > 0f) plan.Kind = AIDirectorCityPlanKind.Mobilize;
else if (IsNearAttackFront(ctx, cache, cityGrid)) plan.Kind = AIDirectorCityPlanKind.Frontline;
else if (CanReasonablyPursueWonder(ctx, cache, city)) plan.Kind = AIDirectorCityPlanKind.Wonder;
plan.NeedWall = (plan.Kind == AIDirectorCityPlanKind.EmergencyDefense ||
plan.Kind == AIDirectorCityPlanKind.Mobilize ||
plan.Kind == AIDirectorCityPlanKind.Frontline) && !city.CityWall;
plan.NeedMilitary = plan.Kind == AIDirectorCityPlanKind.EmergencyDefense ||
plan.Kind == AIDirectorCityPlanKind.Mobilize ||
plan.Kind == AIDirectorCityPlanKind.Frontline;
plan.NeedGrowth = plan.Kind == AIDirectorCityPlanKind.BacklineGrowth ||
plan.Kind == AIDirectorCityPlanKind.Wonder;
plan.NeedWonder = plan.Kind == AIDirectorCityPlanKind.Wonder;
plan.Priority = ScoreCityPlan(plan);
cache.CityPlans.Add(plan);
}
cache.CityPlans.Sort((a, b) =>
{
var c = b.Priority.CompareTo(a.Priority);
if (c != 0) return c;
return AIDirectorMath.StableCityId(a.City).CompareTo(AIDirectorMath.StableCityId(b.City));
});
}
private void BuildDevelopmentTargets(AIDirectorContext ctx, AIDirectorWorldCache cache)
{
var targets = new List<AIDirectorDevelopmentTarget>();
foreach (var grid in ctx.Map.GridMap.GridList)
{
if (grid == null) continue;
var target = TryBuildDevelopmentTarget(ctx, cache, grid);
if (target == null) continue;
if (target.Distance > ctx.Config.DevelopmentSearchRange && target.TargetType != AIDirectorDevelopmentTargetType.EnemyEmptyCity) continue;
if (GridThreat(ctx, cache, grid) > 160f && target.TargetType != AIDirectorDevelopmentTargetType.EnemyEmptyCity) continue;
targets.Add(target);
}
targets.Sort((a, b) =>
{
var c = b.Value.CompareTo(a.Value);
if (c != 0) return c;
return AIDirectorMath.StableGridId(a.Grid).CompareTo(AIDirectorMath.StableGridId(b.Grid));
});
var limit = Mathf.Max(1, ctx.Config.MaxDevelopmentTargetCount);
for (var i = 0; i < targets.Count && i < limit; i++) cache.DevelopmentTargets.Add(targets[i]);
}
private void BuildFronts(AIDirectorContext ctx, AIDirectorWorldCache cache)
{
foreach (var threat in cache.CityThreats)
{
if (threat.DangerScore <= 0f && threat.EnemyUnits.Count == 0) continue;
var front = new AIDirectorFront
{
FrontType = AIDirectorFrontType.Defense,
SelfCity = threat.City,
AnchorGrid = threat.CityGrid,
Pressure = threat.DangerScore,
Opportunity = threat.RescuePower,
Distance = threat.NearestEnemyDistance,
};
front.SelfUnits.AddRange(threat.Defenders);
front.EnemyUnits.AddRange(threat.EnemyUnits);
cache.Fronts.Add(front);
}
foreach (var enemyCity in cache.EnemyCities)
{
if (enemyCity == null || !ctx.Map.GetGridDataByCityId(enemyCity.Id, out var enemyGrid)) continue;
if (!ctx.Map.GetPlayerDataByCityId(enemyCity.Id, out var owner) || owner == null) continue;
if (cache.StrategicPosture != AIDirectorStrategicPosture.Attack && !cache.WarTargetPlayers.Contains(owner)) continue;
var selfCity = FindNearestSelfCity(ctx, cache, enemyGrid, out var distance);
if (selfCity == null || !ctx.Map.GetGridDataByCityId(selfCity.Id, out var selfGrid)) continue;
cache.Fronts.Add(new AIDirectorFront
{
FrontType = AIDirectorFrontType.Attack,
SelfCity = selfCity,
TargetCity = enemyCity,
AnchorGrid = selfGrid,
TargetGrid = enemyGrid,
Distance = distance,
Opportunity = Mathf.Max(1f, ctx.Config.FrontSearchRange + 2 - distance) + (enemyCity.IsCapital ? 3f : 0f),
Pressure = 0f
});
}
foreach (var target in cache.DevelopmentTargets)
{
if (target.Grid == null) continue;
cache.Fronts.Add(new AIDirectorFront
{
FrontType = AIDirectorFrontType.Development,
SelfCity = target.NearestSelfCity,
AnchorGrid = target.NearestSelfCity?.Grid(ctx.Map),
TargetGrid = target.Grid,
Distance = target.Distance,
Opportunity = target.Value * 0.01f
});
}
foreach (var city in cache.SelfCities)
{
if (city == null || !ctx.Map.GetGridDataByCityId(city.Id, out var grid)) continue;
cache.Fronts.Add(new AIDirectorFront
{
FrontType = AIDirectorFrontType.Hold,
SelfCity = city,
AnchorGrid = grid,
TargetGrid = grid,
Distance = 0,
Opportunity = city.IsCapital ? 2f : 1f
});
}
cache.Fronts.Sort((a, b) =>
{
var c = ScoreFront(b).CompareTo(ScoreFront(a));
if (c != 0) return c;
c = a.Distance.CompareTo(b.Distance);
if (c != 0) return c;
return AIDirectorMath.StableGridId(a.TargetGrid ?? a.AnchorGrid)
.CompareTo(AIDirectorMath.StableGridId(b.TargetGrid ?? b.AnchorGrid));
});
while (cache.Fronts.Count > ctx.Config.MaxFrontCount) cache.Fronts.RemoveAt(cache.Fronts.Count - 1);
}
private void BuildLocalBattles(AIDirectorContext ctx, AIDirectorWorldCache cache)
{
foreach (var self in cache.SelfUnits)
{
if (self == null || !ctx.Map.GetGridDataByUnitId(self.Id, out var selfGrid)) continue;
foreach (var enemy in cache.EnemyUnits)
{
if (enemy == null || !ctx.Map.GetGridDataByUnitId(enemy.Id, out var enemyGrid)) continue;
var distance = AIDirectorMath.Distance(ctx.Map, selfGrid, enemyGrid);
if (distance > ctx.Config.LocalBattleRange + self.GetAttackRange(ctx.Map)) continue;
cache.LocalBattles.Add(new AIDirectorLocalBattle
{
SelfUnit = self,
EnemyUnit = enemy,
SelfGrid = selfGrid,
EnemyGrid = enemyGrid,
Distance = distance,
Value = AIDirectorMath.UnitPower(enemy) + (1f - AIDirectorMath.HealthRatio(enemy)) * 80f - distance * 8f
});
}
}
cache.LocalBattles.Sort((a, b) =>
{
var c = b.Value.CompareTo(a.Value);
if (c != 0) return c;
return AIDirectorMath.StableUnitId(a.SelfUnit).CompareTo(AIDirectorMath.StableUnitId(b.SelfUnit));
});
}
private void BuildHeroStates(AIDirectorContext ctx, AIDirectorWorldCache cache)
{
foreach (var hero in cache.SelfHeroes)
{
if (hero == null || !hero.TreatedAsHero(ctx.Map, hero, out var giantType)) continue;
var state = new AIDirectorHeroState
{
Hero = hero,
GiantType = giantType,
Level = ctx.Player.PlayerHeroData?.GetHeroLevel(giantType) ?? 0,
Role = GuessHeroRole(giantType),
HealthRatio = AIDirectorMath.HealthRatio(hero)
};
state.Front = FindNearestFront(ctx, cache, hero, out var distance);
state.IsOnFront = state.Front != null && distance <= ctx.Config.FrontSearchRange;
state.IsThreatened = state.HealthRatio <= ctx.Config.HeroLowHealthRatio || HasEnemyNear(ctx, cache, hero, ctx.Config.LocalBattleRange);
state.Context = GuessHeroContext(ctx, state);
cache.HeroStates.Add(state);
}
}
private AIDirectorDevelopmentTarget TryBuildDevelopmentTarget(AIDirectorContext ctx, AIDirectorWorldCache cache, GridData grid)
{
var nearestCity = FindNearestSelfCity(ctx, cache, grid, out var distance);
if (nearestCity == null) return null;
var type = AIDirectorDevelopmentTargetType.None;
var value = 0f;
if (grid.CityOnGrid(ctx.Map, out var city) && city != null)
{
if (!ctx.Map.GetPlayerDataByCityId(city.Id, out var owner) || owner == null || !ctx.Map.SameUnion(ctx.Player.Id, owner.Id))
{
type = AIDirectorDevelopmentTargetType.EnemyEmptyCity;
value = 950f + (city.IsCapital ? 120f : 0f);
}
}
else if (grid.Resource != ResourceType.None && !grid.HasBuilding())
{
type = AIDirectorDevelopmentTargetType.Resource;
value = 650f + ResourceValue(grid);
}
else if (IsFogBoundary(ctx, cache, grid))
{
type = AIDirectorDevelopmentTargetType.FogBoundary;
value = 420f;
}
if (type == AIDirectorDevelopmentTargetType.None) return null;
return new AIDirectorDevelopmentTarget
{
Grid = grid,
TargetType = type,
NearestSelfCity = nearestCity,
Distance = distance,
Value = value - distance * 12f
};
}
private AIDirectorUnitOpportunityType GetOpportunityType(UnitActionType type)
{
return type switch
{
UnitActionType.Capture => AIDirectorUnitOpportunityType.Capture,
UnitActionType.Examine => AIDirectorUnitOpportunityType.Examine,
UnitActionType.Gather => AIDirectorUnitOpportunityType.Gather,
UnitActionType.HeroUpgrade => AIDirectorUnitOpportunityType.HeroUpgrade,
UnitActionType.Upgrade => AIDirectorUnitOpportunityType.Upgrade,
UnitActionType.CultureUnitUpgrade => AIDirectorUnitOpportunityType.CultureUnitUpgrade,
UnitActionType.Recover => AIDirectorUnitOpportunityType.Recover,
_ => AIDirectorUnitOpportunityType.None
};
}
private float ScoreUnitOpportunity(AIDirectorContext ctx, AIActionBase action, AIDirectorUnitOpportunityType type)
{
var unit = action.Param.UnitData;
var grid = action.Param.GridData ?? unit?.Grid(ctx.Map);
var score = type switch
{
AIDirectorUnitOpportunityType.Capture => 820f,
AIDirectorUnitOpportunityType.Examine => 760f,
AIDirectorUnitOpportunityType.Gather => 680f + ResourceValue(grid),
AIDirectorUnitOpportunityType.HeroUpgrade => 760f,
AIDirectorUnitOpportunityType.Upgrade => 610f,
AIDirectorUnitOpportunityType.CultureUnitUpgrade => 600f,
AIDirectorUnitOpportunityType.Recover => ScoreRecover(ctx, unit),
_ => 0f
};
if (grid != null)
{
score += DevelopmentTargetValue(ctx.Cache, grid) * 0.2f;
score -= GridThreat(ctx, ctx.Cache, grid) * 0.25f;
}
if (unit != null && UnitIsCriticalCityDefender(ctx.Cache, unit)) score -= 180f;
return score;
}
private float ScoreRecover(AIDirectorContext ctx, UnitData unit)
{
if (unit == null) return 0f;
var hp = AIDirectorMath.HealthRatio(unit);
if (hp > ctx.Config.LowHealthRatio) return 0f;
var score = 560f + (1f - hp) * 240f;
if (unit.TreatedAsHero(ctx.Map, unit)) score += 120f;
return score;
}
private bool ExistsHighValueDevelopmentTargetNearCity(AIDirectorContext ctx, AIDirectorWorldCache cache)
{
foreach (var target in cache.DevelopmentTargets)
{
if (target is { Value: >= 700f }) return true;
}
return false;
}
private AIDirectorFront ChoosePrimaryFront(AIDirectorWorldCache cache)
{
return cache.Fronts.Count > 0 ? cache.Fronts[0] : null;
}
private AIDirectorFront FindNearestFront(AIDirectorContext ctx, AIDirectorWorldCache cache, UnitData unit, out int distance)
{
distance = int.MaxValue;
if (unit == null || !ctx.Map.GetGridDataByUnitId(unit.Id, out var unitGrid)) return null;
AIDirectorFront best = null;
foreach (var front in cache.Fronts)
{
var target = front.TargetGrid ?? front.AnchorGrid;
if (target == null) continue;
var d = AIDirectorMath.Distance(ctx.Map, unitGrid, target);
if (d >= distance) continue;
distance = d;
best = front;
}
return best;
}
private bool HasEnemyNear(AIDirectorContext ctx, AIDirectorWorldCache cache, UnitData unit, int range)
{
if (unit == null || !ctx.Map.GetGridDataByUnitId(unit.Id, out var grid)) return false;
foreach (var enemy in cache.EnemyUnits)
{
if (!ctx.Map.GetGridDataByUnitId(enemy.Id, out var enemyGrid)) continue;
if (AIDirectorMath.Distance(ctx.Map, grid, enemyGrid) <= range + enemy.GetAttackRange(ctx.Map)) return true;
}
return false;
}
private AIDirectorHeroContext GuessHeroContext(AIDirectorContext ctx, AIDirectorHeroState state)
{
if (state.HealthRatio <= ctx.Config.HeroLowHealthRatio) return AIDirectorHeroContext.Recovery;
if (state.Front?.FrontType == AIDirectorFrontType.Defense) return AIDirectorHeroContext.Defense;
if (state.IsOnFront) return AIDirectorHeroContext.FieldBattle;
return state.Role switch
{
AIDirectorHeroRole.Economy => AIDirectorHeroContext.Economy,
AIDirectorHeroRole.Control => AIDirectorHeroContext.Control,
AIDirectorHeroRole.Mobility => AIDirectorHeroContext.Mobility,
_ => AIDirectorHeroContext.FieldBattle
};
}
private AIDirectorHeroRole GuessHeroRole(GiantType giantType)
{
return giantType switch
{
GiantType.EgyptianFlandre or GiantType.GermanyMomiji => AIDirectorHeroRole.Assassin,
GiantType.EgyptianRemilia or GiantType.FrenchTewi or GiantType.FrenchEirin or GiantType.NorwayReimu => AIDirectorHeroRole.Support,
GiantType.EgyptianPatchouli or GiantType.FrenchReisen or GiantType.GermanySanae or GiantType.IndianUtsuho or GiantType.NorwaySumireko => AIDirectorHeroRole.Caster,
GiantType.GermanyAya => AIDirectorHeroRole.Mobility,
GiantType.IndianSatori or GiantType.IndianKoishi or GiantType.FrenchKaguya => AIDirectorHeroRole.Control,
GiantType.NorwaySuika => AIDirectorHeroRole.Summoner,
GiantType.GermanyKanako => AIDirectorHeroRole.Economy,
_ => AIDirectorHeroRole.Vanguard
};
}
private bool IsNearAttackFront(AIDirectorContext ctx, AIDirectorWorldCache cache, GridData cityGrid)
{
foreach (var enemyCity in cache.EnemyCities)
{
if (!ctx.Map.GetGridDataByCityId(enemyCity.Id, out var enemyGrid)) continue;
if (AIDirectorMath.Distance(ctx.Map, cityGrid, enemyGrid) <= ctx.Config.FrontSearchRange) return true;
}
return false;
}
private bool CanReasonablyPursueWonder(AIDirectorContext ctx, AIDirectorWorldCache cache, CityData city)
{
return city != null && ctx?.Player != null && cache != null && ctx.Player.PlayerCoin >= 20 && !cache.HasAnyEnemyContact;
}
private float ScoreCityPlan(AIDirectorCityPlan plan)
{
var score = plan.Kind switch
{
AIDirectorCityPlanKind.EmergencyDefense => 1000f,
AIDirectorCityPlanKind.Mobilize => 760f,
AIDirectorCityPlanKind.Frontline => 620f,
AIDirectorCityPlanKind.Wonder => 520f,
_ => 460f
};
if (plan.Threat != null) score += plan.Threat.DangerScore * 20f;
if (plan.City != null && plan.City.IsCapital) score += 50f;
return score;
}
private float ScoreFront(AIDirectorFront front)
{
if (front == null) return 0f;
return front.FrontType switch
{
AIDirectorFrontType.Defense => 1000f + front.Pressure * 20f,
AIDirectorFrontType.Attack => 700f + front.Opportunity * 30f - front.Distance * 10f,
AIDirectorFrontType.Development => 520f + front.Opportunity * 30f - front.Distance * 6f,
AIDirectorFrontType.Hold => 300f + front.Opportunity,
_ => 0f
};
}
private CityData FindNearestSelfCity(AIDirectorContext ctx, AIDirectorWorldCache cache, GridData target, out int distance)
{
distance = int.MaxValue;
CityData best = null;
if (target == null) return null;
foreach (var city in cache.SelfCities)
{
if (city == null || !ctx.Map.GetGridDataByCityId(city.Id, out var cityGrid)) continue;
var d = AIDirectorMath.Distance(ctx.Map, cityGrid, target);
if (d >= distance) continue;
distance = d;
best = city;
}
return best;
}
private int NearestCityDistance(AIDirectorContext ctx, PlayerData a, PlayerData b)
{
var aCities = new List<CityData>();
var bCities = new List<CityData>();
ctx.Map.GetCityDataListByPlayerId(a.Id, aCities);
ctx.Map.GetCityDataListByPlayerId(b.Id, bCities);
var best = int.MaxValue;
foreach (var ca in aCities)
{
if (!ctx.Map.GetGridDataByCityId(ca.Id, out var ga)) continue;
foreach (var cb in bCities)
{
if (!ctx.Map.GetGridDataByCityId(cb.Id, out var gb)) continue;
best = Mathf.Min(best, AIDirectorMath.Distance(ctx.Map, ga, gb));
}
}
return best;
}
private float EstimatePlayerMilitary(AIDirectorContext ctx, PlayerData player)
{
var units = new List<UnitData>();
ctx.Map.GetUnitDataListByPlayerId(player.Id, units);
var score = 0f;
foreach (var unit in units) score += AIDirectorMath.UnitPower(unit);
return score;
}
private float PlayerThreatNearSelfCities(AIDirectorContext ctx, AIDirectorWorldCache cache, PlayerData player)
{
var score = 0f;
foreach (var unit in cache.EnemyUnits)
{
if (!ctx.Map.GetPlayerDataByUnitId(unit.Id, out var owner) || owner.Id != player.Id) continue;
if (!ctx.Map.GetGridDataByUnitId(unit.Id, out var unitGrid)) continue;
foreach (var city in cache.SelfCities)
{
if (!ctx.Map.GetGridDataByCityId(city.Id, out var cityGrid)) continue;
var distance = AIDirectorMath.Distance(ctx.Map, unitGrid, cityGrid);
if (distance <= ctx.Config.FrontSearchRange) score += AIDirectorMath.UnitPower(unit) / Mathf.Max(1, distance);
}
}
return score;
}
private float GetFeeling(PlayerData self, PlayerData target)
{
return self.GetCountryDiplomacyInfo(target.Id, out var info) && info != null ? info.FeelingValue : 50f;
}
private float GridThreat(AIDirectorContext ctx, AIDirectorWorldCache cache, GridData grid)
{
if (grid == null) return 0f;
var threat = 0f;
foreach (var enemy in cache.EnemyUnits)
{
if (!ctx.Map.GetGridDataByUnitId(enemy.Id, out var enemyGrid)) continue;
if (AIDirectorMath.Distance(ctx.Map, enemyGrid, grid) <= enemy.GetActionPoint(ActionPointType.Move) + enemy.GetAttackRange(ctx.Map))
{
threat += AIDirectorMath.UnitPower(enemy);
}
}
return threat;
}
private float DevelopmentTargetValue(AIDirectorWorldCache cache, GridData grid)
{
if (grid == null) return 0f;
foreach (var target in cache.DevelopmentTargets)
{
if (target.Grid?.Id == grid.Id) return target.Value;
}
return 0f;
}
private bool UnitIsCriticalCityDefender(AIDirectorWorldCache cache, UnitData unit)
{
if (unit == null) return false;
foreach (var threat in cache.CityThreats)
{
if (!threat.IsCritical) continue;
foreach (var defender in threat.Defenders)
{
if (defender?.Id == unit.Id) return true;
}
}
return false;
}
private float ResourceValue(GridData grid)
{
if (grid == null || grid.Resource == ResourceType.None) return 0f;
return grid.Resource switch
{
ResourceType.Metal => 130f,
ResourceType.Fruit => 100f,
ResourceType.Crop => 90f,
ResourceType.Animal => 80f,
ResourceType.Fish => 80f,
ResourceType.Starfish => 120f,
_ => 60f
};
}
private bool IsFogBoundary(AIDirectorContext ctx, AIDirectorWorldCache cache, GridData grid)
{
if (grid == null || ctx.Player.Sight == null) return false;
if (ctx.Player.Sight.CheckIsInSight(grid.Id)) return false;
_around.Clear();
ctx.Map.GridMap.GetAroundGridData(1, 1, grid, _around);
foreach (var around in _around)
{
if (around != null && ctx.Player.Sight.CheckIsInSight(around.Id)) return true;
}
return false;
}
}
}

View File

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

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 7d15e9fe8d704a21bdcbdb2229323e8a
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,67 @@
using RuntimeData;
namespace Logic.AI
{
public enum AIKernelType
{
BehaviourTree,
Director
}
public enum AIKernelUpdateResult
{
None,
ActionReady,
Finished
}
public static class AIDirectorBatchRuntime
{
#if UNITY_EDITOR
public static bool ForceAllPlayersAi;
public static bool SkipPresentationWait;
public static bool CompactDiagnostics;
#else
public const bool ForceAllPlayersAi = false;
public const bool SkipPresentationWait = false;
public const bool CompactDiagnostics = false;
#endif
}
public readonly struct AIKernelUpdate
{
public readonly AIKernelUpdateResult Result;
public readonly AIActionBase Action;
private AIKernelUpdate(AIKernelUpdateResult result, AIActionBase action)
{
Result = result;
Action = action;
}
public static AIKernelUpdate None => new(AIKernelUpdateResult.None, null);
public static AIKernelUpdate Finished => new(AIKernelUpdateResult.Finished, null);
public static AIKernelUpdate ActionReady(AIActionBase action)
{
return action == null ? None : new AIKernelUpdate(AIKernelUpdateResult.ActionReady, action);
}
}
public interface IAIKernel
{
AIKernelType KernelType { get; }
void Initialize(AILogicContext context);
void StartTurn(MapData mapData, PlayerData playerData);
AIKernelUpdate Update();
void FinishTurn();
}
public sealed class AILogicContext
{
public AICalculatorData Data;
public AIActionGenerator Generator;
public AIActionScoreCalculator ScoreCalculator;
public AIConfigAsset Config;
}
}

View File

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

View File

@ -0,0 +1,44 @@
using System;
namespace Logic.AI
{
public static class AIKernelRegistry
{
private static Func<IAIKernel> _kernelFactory = () => new BehaviourTreeAIKernel();
public static AIKernelType CurrentKernelType { get; private set; } = AIKernelType.BehaviourTree;
public static int Version { get; private set; }
public static void Register(AIKernelType kernelType)
{
CurrentKernelType = kernelType;
_kernelFactory = kernelType switch
{
AIKernelType.Director => () => new DirectorAIKernel(),
_ => () => new BehaviourTreeAIKernel()
};
Version++;
}
public static void Register<TKernel>() where TKernel : IAIKernel, new()
{
CurrentKernelType = new TKernel().KernelType;
_kernelFactory = () => new TKernel();
Version++;
}
public static void Register(AIKernelType kernelType, Func<IAIKernel> factory)
{
CurrentKernelType = kernelType;
_kernelFactory = factory ?? throw new ArgumentNullException(nameof(factory));
Version++;
}
public static IAIKernel Create()
{
var kernel = _kernelFactory();
if (kernel == null) throw new InvalidOperationException("AI kernel factory returned null.");
return kernel;
}
}
}

View File

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

View File

@ -0,0 +1,114 @@
using System.Collections.Generic;
using Logic.CrashSight;
using NodeCanvas.BehaviourTrees;
using NodeCanvas.Framework;
using RuntimeData;
using TH1_Logic.Core;
using UnityEngine;
namespace Logic.AI
{
public sealed class BehaviourTreeAIKernel : IAIKernel
{
private AILogicContext _context;
private BehaviourTreeOwner _btOwner;
private readonly List<List<uint>> _nodeRecords = new();
public AIKernelType KernelType => AIKernelType.BehaviourTree;
public void Initialize(AILogicContext context)
{
_context = context;
var logicObject = GameObject.Find("AIBT");
if (logicObject == null)
{
LogSystem.LogError("AIBT object missing, BehaviourTreeAIKernel can not run.");
return;
}
_btOwner = logicObject.GetComponent<BehaviourTreeOwner>();
if (_btOwner == null)
{
LogSystem.LogError("AIBT BehaviourTreeOwner missing, BehaviourTreeAIKernel can not run.");
return;
}
var dataVariable = _btOwner.blackboard.GetVariable<AICalculatorData>("Data");
if (dataVariable == null)
{
LogSystem.LogError("AIBT blackboard variable Data missing, BehaviourTreeAIKernel can not run.");
_btOwner = null;
return;
}
if (dataVariable.value == null)
{
dataVariable.value = _context.Data;
}
else
{
_context.Data = dataVariable.value;
}
}
public void StartTurn(MapData mapData, PlayerData playerData)
{
if (_context == null || _btOwner == null) return;
_context.Generator.Init(mapData, playerData);
_context.Data.AiDiffInfo = _context.Config.GetAIDiffInfo(mapData.MapConfig.AIDiff);
_context.Data.Refresh(mapData, playerData);
_btOwner.StopBehaviour();
_btOwner.StartBehaviour();
MainEditor.Instance.Data = _context.Data;
}
public AIKernelUpdate Update()
{
if (_context == null || _btOwner == null) return AIKernelUpdate.Finished;
var index = 0;
for (var i = 0; i < _nodeRecords.Count; i++) _nodeRecords[i].Clear();
var nodeRecordsUsed = 0;
while (true)
{
if (MainEditor.Instance.IsEditor && !MainEditor.Instance.IsGo) return AIKernelUpdate.None;
index++;
if (index > nodeRecordsUsed)
{
nodeRecordsUsed = index;
if (index > _nodeRecords.Count) _nodeRecords.Add(new List<uint>());
}
_context.Data.ClearCache();
_nodeRecords[index - 1].Add(MainEditor.Instance.BTNodeId);
_btOwner.UpdateBehaviour();
MainEditor.Instance.IsGo = false;
_nodeRecords[index - 1].Add(MainEditor.Instance.BTNodeId);
if (_context.Data.MaxAiAction != null || _context.Data.IsFinish) break;
if (index > 150)
{
LogSystem.LogError($"AI 行为树疑似死循环,最终记录点为:{MainEditor.Instance.BTNodeId}");
return AIKernelUpdate.Finished;
}
}
if (_context.Data.MaxAiAction == null || index > 100) return AIKernelUpdate.Finished;
var action = _context.Data.MaxAiAction;
_context.Data.MaxAiAction = null;
return AIKernelUpdate.ActionReady(action);
}
public void FinishTurn()
{
if (_btOwner != null) _btOwner.StopBehaviour();
if (_context?.Data != null) _context.Data.MaxAiAction = null;
}
}
}

View File

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

View File

@ -0,0 +1,58 @@
using Logic.AI.Director;
using Logic.CrashSight;
using RuntimeData;
using TH1_Logic.Action;
namespace Logic.AI
{
public sealed class DirectorAIKernel : IAIKernel
{
private readonly AIDirectorLogic _director = new();
private MapData _mapData;
private PlayerData _playerData;
public AIKernelType KernelType => AIKernelType.Director;
public void Initialize(AILogicContext context)
{
}
public void StartTurn(MapData mapData, PlayerData playerData)
{
_mapData = mapData;
_playerData = playerData;
#if TH1_AI_DIRECTOR_DIAGNOSTICS || UNITY_EDITOR
AIDirectorDiagnostics.RecordTurnStart(_mapData, _playerData);
#endif
}
public AIKernelUpdate Update()
{
if (_mapData == null || _playerData == null) return AIKernelUpdate.Finished;
var decision = _director.Decide(_mapData, _playerData);
#if TH1_AI_DIRECTOR_DIAGNOSTICS || UNITY_EDITOR
AIDirectorDiagnostics.RecordDecision(_mapData, _playerData, decision);
#endif
if (!decision.HasAction) return AIKernelUpdate.Finished;
var candidate = decision.Candidate;
var action = candidate.AIAction;
if (action?.Param == null || action.ActionLogic == null) return AIKernelUpdate.Finished;
action.Param.MapData = _mapData;
action.Param.RefreshParams();
action.CheckIsActionInPlayerSight();
if (action.IsInSight) action.ActionLogic.CameraControl(action.Param);
if (action.ActionLogic.ActionId.PlayerActionType == PlayerActionType.OfferAlly)
LogSystem.LogInfo("AI 发起结盟");
return AIKernelUpdate.ActionReady(action);
}
public void FinishTurn()
{
#if TH1_AI_DIRECTOR_DIAGNOSTICS || UNITY_EDITOR
AIDirectorDiagnostics.RecordTurnEnd(_mapData, _playerData);
#endif
_mapData = null;
_playerData = null;
}
}
}

View File

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

View File

@ -1,3 +0,0 @@
fileFormatVersion: 2
guid: 8aadc7c4125b49b7af9d56af927fd52d
timeCreated: 1764056674

View File

@ -1,144 +0,0 @@
using Microsoft.ML.OnnxRuntime;
using Microsoft.ML.OnnxRuntime.Tensors;
using System.Collections.Generic;
using System.Linq;
using UnityEngine;
namespace TH1_Logic.AITrain
{
/// <summary>
/// ONNX模型推理类
/// 基于train.py的配置: STATE_DIM=802, ACTION_DIM=8
///
/// 依赖: Microsoft.ML.OnnxRuntime
/// 安装命令: 在Unity中通过NuGet安装 Microsoft.ML.OnnxRuntime 包
/// </summary>
public class ModelInference
{
public static ModelInference Instance = new ModelInference();
private const int STATE_DIM = 802;
private const int ACTION_DIM = 8;
private InferenceSession session;
private string inputName;
private string outputName;
/// <summary>
/// 加载ONNX模型
/// </summary>
/// <param name="modelPath">ONNX模型文件路径</param>
/// <param name="useGPU">是否使用GPU (默认false需要CUDA支持)</param>
public void LoadModel(string modelPath="AIModel/cql_model", bool useGPU = false)
{
UnloadModel();
// 配置会话选项
var options = new SessionOptions();
if (useGPU)
{
// 需要安装 Microsoft.ML.OnnxRuntime.Gpu 包
options.AppendExecutionProvider_CUDA(0);
}
// 从Resources加载模型文件不包含扩展名和Resources/前缀)
TextAsset modelAsset = TH1Resource.ResourceLoader.Load<TextAsset>(modelPath);
if (modelAsset == null)
{
throw new System.Exception($"无法从Resources加载模型: {modelPath}");
}
// 使用字节数组创建会话
session = new InferenceSession(modelAsset.bytes, options);
// 获取输入输出节点名称
inputName = session.InputMetadata.Keys.First();
outputName = session.OutputMetadata.Keys.First();
}
/// <summary>
/// 卸载模型
/// </summary>
public void UnloadModel()
{
session?.Dispose();
session = null;
}
/// <summary>
/// 推理 - 从合法actions中选择最佳action
/// </summary>
/// <param name="state">状态向量 (802维)</param>
/// <param name="legalActions">合法动作列表每个动作是8维float数组</param>
/// <returns>选择的最佳动作在列表中的索引,失败返回-1</returns>
public int Predict(float[] state, List<List<float>> legalActions)
{
if (session == null || state == null || state.Length != STATE_DIM)
return -1;
if (legalActions == null || legalActions.Count == 0)
return -1;
// 如果只有一个合法动作直接返回索引0
if (legalActions.Count == 1)
return 0;
// 获取模型预测的动作
// 创建输入Tensor (1, 802)
var inputTensor = new DenseTensor<float>(new[] { 1, STATE_DIM });
for (int i = 0; i < STATE_DIM; i++)
{
inputTensor[0, i] = state[i];
}
// 创建输入容器
var inputs = new List<NamedOnnxValue>
{
NamedOnnxValue.CreateFromTensor(inputName, inputTensor)
};
// 执行推理
float[] predictedAction;
using (var results = session.Run(inputs))
{
// 获取输出
var outputTensor = results.First().AsTensor<float>();
// 提取预测的动作向量
predictedAction = new float[ACTION_DIM];
for (int i = 0; i < ACTION_DIM; i++)
{
predictedAction[i] = outputTensor[0, i];
}
}
// 从合法actions中选择与预测最接近的action
float minDistance = float.MaxValue;
int bestIndex = 0;
for (int i = 0; i < legalActions.Count; i++)
{
if (legalActions[i] == null || legalActions[i].Count != ACTION_DIM)
continue;
// 计算欧几里得距离
float distance = 0f;
for (int j = 0; j < ACTION_DIM; j++)
{
float diff = predictedAction[j] - legalActions[i][j];
distance += diff * diff;
}
if (distance < minDistance)
{
minDistance = distance;
bestIndex = i;
}
}
return bestIndex;
}
}
}

View File

@ -1,3 +0,0 @@
{
"reference": "TH1.Hotfix"
}

View File

@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: d2ae4cde6abf41d5afd19d21acf5b44f
AssemblyDefinitionReferenceImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -1,105 +0,0 @@
/*
* @Author:
* @Description:
* @Date: 20251201 16:12:06
* @Modify:
*/
using System.Collections.Generic;
using System.IO;
using System.Linq;
using UnityEngine;
using System.Text;
namespace TH1_Logic.AITrain
{
[System.Serializable]
public class AllActions
{
public float[] data;
}
[System.Serializable]
public class TrainingData
{
public float[] State;
public AllActions[] Actions;
public float[] SelectedAction;
public float Reward;
public bool Done;
}
public class TrainingDataRecorder
{
public static TrainingDataRecorder Instance = new ();
private Dictionary<uint, List<TrainingData>> _episodeData = new ();
private string _outputDir;
private TrainingState _trainState;
public TrainingDataRecorder(string outputDir = "TrainingData")
{
_trainState = new TrainingState();
_outputDir = Path.Combine(@"F:\TrainData", outputDir);
Directory.CreateDirectory(_outputDir);
}
// 记录单步数据到内存
public void RecordStep(uint playerID, float[] state, float[][] validActions, float[] selectedAction, float reward)
{
if (!_episodeData.ContainsKey(playerID))
{
_episodeData[playerID] = new List<TrainingData>();
}
var data = new TrainingData
{
State = state,
Actions = validActions.Select(x => new AllActions { data = x }).ToArray(),
SelectedAction = selectedAction,
Reward = reward,
Done = false
};
_episodeData[playerID].Add(data);
}
// 游戏结束时一次性写入文件
public void SaveEpisode()
{
if (_episodeData.Count == 0) return;
string timestamp = System.DateTime.Now.ToString("yyyyMMdd_HHmmss");
string uniqueId = System.Guid.NewGuid().ToString("N").Substring(0, 8);
foreach (var kv in _episodeData)
{
kv.Value[^1].Done = true;
var fileName = $"episode_{timestamp}_{uniqueId}_{kv.Key}.jsonl";
string filePath = Path.Combine(_outputDir, fileName);
StringBuilder sb = new StringBuilder();
foreach (var data in kv.Value)
{
string json = JsonUtility.ToJson(data);
sb.AppendLine(json);
}
// 使用UTF8NoBOM避免BOM字节序标记
var utf8NoBom = new System.Text.UTF8Encoding(false);
File.WriteAllText(filePath, sb.ToString(), utf8NoBom);
Debug.Log($"训练数据已保存: {filePath}, 共 {kv.Value.Count} 步");
}
_episodeData.Clear();
}
// 清空当前回合数据(不保存)
public void ClearEpisode()
{
_episodeData.Clear();
}
}
}

View File

@ -1,3 +0,0 @@
fileFormatVersion: 2
guid: 7caddcfed4744645ae284218e3b4d1f5
timeCreated: 1764579480

View File

@ -1,543 +0,0 @@
/*
* @Author:
* @Description:
* @Date: 20251125 15:11:13
* @Modify:
*/
using System;
using System.Collections.Generic;
using Logic.Action;
using Logic.AI;
using Logic.CrashSight;
using Logic.Pool;
using MemoryPack;
using RuntimeData;
using TH1_Logic.Core;
using TH1_Logic.Tools;
using UnityEngine;
namespace TH1_Logic.AITrain
{
public class TrainingState
{
public static TrainingState Instance = new TrainingState();
private ActionLogicIdData _actionLogicIdData;
// 初始化环境
public void Initialize()
{
Main.Instance.StartMatch();
}
// State 获取
// Map State 维度只能向后拓展
// State 的数据选取非常重要,与 Action 的数据一起决定了所有 AI 的行为,对应导向的行为必须和 State 数据相关联
// 2 + 40 + 400 + 160 + 200 = 802 维度
public float[] GetMapState(MapData map, PlayerData selfPlayer)
{
using var pooledState = THCollectionPool.GetListHandle<float>(out var state);
// 1. 己方信息
using var pooledSelfUnits = THCollectionPool.GetHashSetHandle<UnitData>(out var selfUnits);
map.GetUnitDataListByPlayerId(selfPlayer.Id, selfUnits);
using var pooledSelfCities = THCollectionPool.GetHashSetHandle<CityData>(out var selfCities);
map.GetCityDataListByPlayerId(selfPlayer.Id, selfCities);
var maxScore = map.PlayerMap.GetMaxPlayerScore();
// 回合数进度
state.Add(Mathf.Min(selfPlayer.Turn / 100f, 1));
// 我的 Player Index
state.Add(GetPlayerIndex(map, selfPlayer.Id) / 10f);
using var pooledUnits = THCollectionPool.GetListHandle<UnitData>(out var units);
using var pooledCities = THCollectionPool.GetListHandle<CityData>(out var cities);
foreach (var id in selfPlayer.Sight.SightGidSet)
{
//TODO 白哉确认
if (map.GridMap.GetGridDataByGid(id,out var grid) && grid.RealUnit(map, out var unit)) units.Add(unit);
if (map.GetCityDataByGid(id, out var city)) cities.Add(city);
}
// Player state容量 10 * 4共 40 维度
for (int i = 0; i < 10; i++)
{
if (i >= map.PlayerMap.PlayerDataList.Count)
{
state.Add(0);
state.Add(0);
state.Add(0);
state.Add(0);
}
else
{
var player = map.PlayerMap.PlayerDataList[i];
var territory = map.GetPlayerTerritoryGridIdSet(player.Id);
// 领土总数比例
state.Add((float)territory.Count / map.MapConfig.Height / map.MapConfig.Width);
// 视野总数比例
state.Add((float)player.Sight.SightGidSet.Count / map.MapConfig.Height / map.MapConfig.Width);
// 钱
state.Add(Mathf.Min(player.PlayerCoin / 50f, 1));
// 积分
state.Add(maxScore > 0 ? player.PlayerScore / (float)maxScore : 0f);
}
}
// 小兵 state 容量 80 * 5 共 400 维度
for (int i = 0; i < 80; i++)
{
if (i >= units.Count)
{
state.Add(0);
state.Add(0);
state.Add(0);
state.Add(0);
state.Add(0);
}
else
{
var unit = units[i];
var grid = unit.Grid(map);
state.Add(GetUnitIndex(map, unit.Id) / 100f);
state.Add(selfUnits.Contains(unit) ? 1 : 0);
state.Add(GetGridIndex(map, grid.Id) / 500f);
state.Add(Table.Instance.UnitTypeDataAssets.GetUnitTypeInfoIndex(unit) / 100f);
state.Add(unit.GetHealthRatio());
}
}
// 城市 state 容量 40 * 4 共 160 维度
for (int i = 0; i < 40; i++)
{
if (i >= cities.Count)
{
state.Add(0);
state.Add(0);
state.Add(0);
state.Add(0);
}
else
{
var city = cities[i];
var grid = city.Grid(map);
state.Add(GetCityIndex(map, city.Id) / 100f);
state.Add(selfCities.Contains(city) ? 1 : 0);
state.Add(GetGridIndex(map, grid.Id) / 500f);
state.Add(Mathf.Min((city.Level + city.LevelExp / (float)city.Level / 2f) / 10f, 1f));
}
}
// 格子 state 容量 100 * 2 共 200 维度 (放最后方便拓展)
var gridStateCount = 0;
foreach (var id in selfPlayer.Sight.SightGidSet)
{
if (map.GridMap.GetGridDataByGid(id, out var grid)) continue;
if (grid.Resource != ResourceType.None)
{
state.Add(GetGridIndex(map, grid.Id) / 500f);
state.Add((int)grid.Resource / 100f);
gridStateCount++;
if (gridStateCount >= 100) break;
}
if (grid.SpTypeList.Count != 0)
{
foreach (var spType in grid.SpTypeList)
{
state.Add(GetGridIndex(map, grid.Id) / 500f);
state.Add((int)spType / 30f);
gridStateCount++;
if (gridStateCount >= 100) break;
}
}
if (gridStateCount >= 100) break;
}
for (int i = gridStateCount; i < 100; i++)
{
state.Add(0);
state.Add(0);
}
return state.ToArray();
}
// Action 获取
// Action 维度为固定 64 位整数 (可变长编码)
public bool GetActionBitCodec(CommonActionId actionId, CommonActionParams param, out List<float> packed)
{
LoadActionLogicIdData();
var bitCodec64Var = new AIActionPacker();
bitCodec64Var.ActionId = _actionLogicIdData.GetActionIdIndex(actionId);
if (param.PlayerId != 0) bitCodec64Var.PlayerIndex = GetPlayerIndex(param.MapData, param.PlayerId);
if (param.UnitId != 0) bitCodec64Var.UnitIndex = GetUnitIndex(param.MapData, param.UnitId);
if (param.CityId != 0) bitCodec64Var.CityIndex = GetCityIndex(param.MapData, param.CityId);
if (param.GridId != 0) bitCodec64Var.GridIndex = GetGridIndex(param.MapData, param.GridId);
if (param.TargetUnitId != 0) bitCodec64Var.TargetUnitIndex = GetUnitIndex(param.MapData, param.TargetUnitId);
if (param.TargetGridId != 0) bitCodec64Var.TargetGridIndex = GetGridIndex(param.MapData, param.TargetGridId);
if (param.TargetPlayerId != 0) bitCodec64Var.TargetPlayerIndex = GetPlayerIndex(param.MapData, param.TargetPlayerId);
return bitCodec64Var.TryPack(out packed);
}
public bool GetActionBitCodecWithoutLimit(CommonActionId actionId, CommonActionParams param, out List<float> packed)
{
LoadActionLogicIdData();
var bitCodec64Var = new AIActionPacker();
bitCodec64Var.ActionId = _actionLogicIdData.GetActionIdIndex(actionId);
if (param.PlayerId != 0) bitCodec64Var.PlayerIndex = GetPlayerIndex(param.MapData, param.PlayerId);
if (param.UnitId != 0) bitCodec64Var.UnitIndex = GetUnitIndex(param.MapData, param.UnitId);
if (param.CityId != 0) bitCodec64Var.CityIndex = GetCityIndex(param.MapData, param.CityId);
if (param.GridId != 0) bitCodec64Var.GridIndex = GetGridIndex(param.MapData, param.GridId);
if (param.TargetUnitId != 0) bitCodec64Var.TargetUnitIndex = GetUnitIndex(param.MapData, param.TargetUnitId);
if (param.TargetGridId != 0) bitCodec64Var.TargetGridIndex = GetGridIndex(param.MapData, param.TargetGridId);
if (param.TargetPlayerId != 0) bitCodec64Var.TargetPlayerIndex = GetPlayerIndex(param.MapData, param.TargetPlayerId);
return bitCodec64Var.TryPackWithOutLimit(out packed);
}
// Score 获取, Score 差值即为 Reward
public float GetMapScore(MapData mapData, PlayerData player)
{
var score = GetUnitsScore(mapData, player) + GetCityScore(mapData, player);
return score / 5f;
}
// 获取当前所有可以被执行的 Action 的 BitCodec 列表
public List<List<float>> GetAllActionBitCodecForUse(MapData mapData, PlayerData selfPlayer, out List<AIActionBase> actions)
{
var packedList = new List<List<float>>();
var actionList = AIActionGenerator.GeneratorAllActionIdsForUse(mapData, selfPlayer);
foreach (var action in actionList)
{
if (!GetActionBitCodec(action.ActionLogic.ActionId, action.Param, out var packed)) continue;
packedList.Add(packed);
}
actions = actionList;
return packedList;
}
// 获取当前所有可以被执行的 Action 的 BitCodec 列表
public List<List<float>> GetAllActionBitCodec(MapData mapData, PlayerData selfPlayer, out List<AIActionBase> actions)
{
var packedList = new List<List<float>>();
var actionList = AIActionGenerator.GeneratorAllActionIds(mapData, selfPlayer);
foreach (var action in actionList)
{
if (!GetActionBitCodec(action.ActionLogic.ActionId, action.Param, out var packed)) continue;
packedList.Add(packed);
}
actions = actionList;
return packedList;
}
// Action 反编码
public bool GetActionFromBitCodec(List<float> packed, MapData mapData, out CommonActionId actionId,
out CommonActionParams param)
{
actionId = default;
param = null;
LoadActionLogicIdData();
var packer = new AIActionPacker();
if (!packer.TryUnpack(packed)) return false;
// 将 ActionId 索引映射回 CommonActionId
if (packer.ActionId < 0 || packer.ActionId >= _actionLogicIdData.ActionIdList.Count) return false;
actionId = _actionLogicIdData.ActionIdList[packer.ActionId];
var p = new CommonActionParams
{
MapData = mapData
};
// 按索引回填对象(存在即取,不存在即返回 false
if (packer.PlayerIndex != -1)
{
if (packer.PlayerIndex < 0 || packer.PlayerIndex >= mapData.PlayerMap.PlayerDataList.Count) return false;
p.PlayerData = mapData.PlayerMap.PlayerDataList[packer.PlayerIndex];
}
if (packer.UnitIndex != -1)
{
if (packer.UnitIndex < 0 || packer.UnitIndex >= mapData.UnitMap.UnitList.Count) return false;
p.UnitData = mapData.UnitMap.UnitList[packer.UnitIndex];
}
if (packer.CityIndex != -1)
{
if (packer.CityIndex < 0 || packer.CityIndex >= mapData.CityMap.CityList.Count) return false;
p.CityData = mapData.CityMap.CityList[packer.CityIndex];
}
if (packer.GridIndex != -1)
{
if (packer.GridIndex < 0 || packer.GridIndex >= mapData.GridMap.GridList.Count) return false;
p.GridData = mapData.GridMap.GridList[packer.GridIndex];
}
if (packer.TargetUnitIndex != -1)
{
if (packer.TargetUnitIndex < 0 || packer.TargetUnitIndex >= mapData.UnitMap.UnitList.Count) return false;
p.TargetUnitData = mapData.UnitMap.UnitList[packer.TargetUnitIndex];
}
if (packer.TargetGridIndex != -1)
{
if (packer.GridIndex < 0 || packer.TargetGridIndex >= mapData.GridMap.GridList.Count) return false;
p.TargetGridData = mapData.GridMap.GridList[packer.TargetGridIndex];
}
if (packer.TargetPlayerIndex != -1)
{
if (packer.TargetPlayerIndex < 0 || packer.TargetPlayerIndex >= mapData.PlayerMap.PlayerDataList.Count)
return false;
p.TargetPlayerData = mapData.PlayerMap.PlayerDataList[packer.TargetPlayerIndex];
}
param = p;
param.MainObjectType = ActionLogicFactory.GetMainObjectType(actionId.ActionType);
param.OnParamChanged();
return true;
}
// 获取 Player 的 Index
private int GetPlayerIndex(MapData map, uint id)
{
int index = 0;
foreach (var p in map.PlayerMap.PlayerDataList)
{
if (p.Id == id) return index;
index++;
}
return index;
}
// 获取 Unit 的 Index
private int GetUnitIndex(MapData map, uint id)
{
int index = 0;
foreach (var u in map.UnitMap.UnitList)
{
if (u.Id == id) return index;
index++;
}
return index;
}
// 获取 City 的 Index
private int GetCityIndex(MapData map, uint id)
{
int index = 0;
foreach (var c in map.CityMap.CityList)
{
if (c.Id == id) return index;
index++;
}
return index;
}
// 获取 Grid 的 Index
private int GetGridIndex(MapData map, uint id)
{
return map.GridMap.GetGridIndexByGid(id);
}
// 按照 Player 的 PlayerScore 排名得分
public float GetPlayerScore(MapData mapData, PlayerData playerData)
{
var playerList = mapData.PlayerMap.PlayerDataList;
if (playerList.Count == 0) return 0f;
if (playerList.Count == 1) return 10f;
// 按照 PlayerScore 降序排序获取排名
var sortedPlayers = new List<PlayerData>(playerList);
sortedPlayers.Sort((a, b) => b.PlayerScore.CompareTo(a.PlayerScore));
int rank = 0;
for (int i = 0; i < sortedPlayers.Count; i++)
{
if (sortedPlayers[i].Id == playerData.Id)
{
rank = i;
break;
}
}
// 将排名转换为 0-10 分数,第一名 10 分,最后一名 0 分
// 使用线性插值
float score = 100f * (1f - (float)rank / (playerList.Count - 1));
score += playerData.Sight.SightGidSet.Count / 10f;
return score;
}
private float GetUnitsScore(MapData mapData, PlayerData playerData)
{
float score = 0f;
using var pooledUnits = THCollectionPool.GetHashSetHandle<UnitData>(out var units);
mapData.GetUnitDataListByPlayerId(playerData.Id, units);
foreach (var unit in mapData.UnitMap.UnitList)
{
if (!unit.IsAlive()) continue;
var unitScore = unit.GetAttackRange(mapData) + unit.GetMoveRange(mapData) +
unit.GetAllAttackValue(mapData) + unit.GetAllDefenseValue(mapData);
if (units.Contains(unit)) score += unitScore;
else score -= unitScore;
}
return score;
}
private float GetCityScore(MapData mapData, PlayerData playerData)
{
float score = 0f;
using var pooledCities = THCollectionPool.GetHashSetHandle<CityData>(out var cities);
mapData.GetCityDataListByPlayerId(playerData.Id, cities);
foreach (var city in mapData.CityMap.CityList)
{
var cityScore = city.Level * 10f + city.Territory.TerritoryArea.Count * 2;
if (cities.Contains(city)) score += cityScore;
else score -= cityScore;
}
return score;
}
private void LoadActionLogicIdData()
{
if (_actionLogicIdData != null) return;
TextAsset asset = TH1Resource.ResourceLoader.Load<TextAsset>($"CommonIdData/CommonIdData");
var data = asset?.bytes ?? Array.Empty<byte>();
_actionLogicIdData = TH1Serialization.Deserialize<ActionLogicIdData>(data) ?? new ActionLogicIdData();
}
}
[MemoryPackable]
public partial class ActionLogicIdData
{
public List<CommonActionId> ActionIdList = new();
private Dictionary<CommonActionId, int> _idIndex = new();
public void Initialize()
{
if (_idIndex.Count == ActionIdList.Count) return;
_idIndex.Clear();
for (int i = 0; i < ActionIdList.Count; i++)
{
_idIndex[ActionIdList[i]] = i;
}
}
public int GetActionIdIndex(CommonActionId actionId)
{
Initialize();
return _idIndex.GetValueOrDefault(actionId, -1);
}
public void AddActionId(CommonActionId actionId)
{
Initialize();
if (_idIndex.ContainsKey(actionId)) return;
ActionIdList.Add(actionId);
_idIndex[actionId] = ActionIdList.Count - 1;
}
}
public class AIActionPacker
{
public int ActionId;
public int PlayerIndex;
public int UnitIndex;
public int CityIndex;
public int GridIndex;
public int TargetUnitIndex;
public int TargetGridIndex;
public int TargetPlayerIndex;
public int MaxActionID = 500;
public int MaxPlayerIndex = 10;
public int MaxUnitIndex = 80;
public int MaxCityIndex = 40;
public int MaxGridIndex = 500;
public int MaxTargetUnitIndex = 80;
public int MaxTargetGridIndex = 500;
public int MaxTargetPlayerIndex = 10;
public AIActionPacker()
{
PlayerIndex = -1;
UnitIndex = -1;
CityIndex = -1;
GridIndex = -1;
TargetUnitIndex = -1;
TargetGridIndex = -1;
TargetPlayerIndex = -1;
}
// 紧凑序列化(前置存在位 + 变长位段)
public bool TryPack(out List<float> packed)
{
packed = new List<float>();
packed.Add((float)(ActionId + 1) / MaxActionID);
packed.Add((float)(PlayerIndex + 1) / MaxPlayerIndex);
packed.Add((float)(UnitIndex + 1) / MaxUnitIndex);
packed.Add((float)(CityIndex + 1) / MaxCityIndex);
packed.Add((float)(GridIndex + 1) / MaxGridIndex);
packed.Add((float)(TargetUnitIndex + 1) / MaxTargetUnitIndex);
packed.Add((float)(TargetGridIndex + 1) / MaxTargetGridIndex);
packed.Add((float)(TargetPlayerIndex + 1) / MaxTargetPlayerIndex);
foreach (var value in packed)
{
if (value > 1 || value < 0)
{
LogSystem.LogError($"数据越界!!! {ActionId} {PlayerIndex} {UnitIndex} {CityIndex} {GridIndex} {TargetUnitIndex} {TargetGridIndex} {TargetPlayerIndex}");
return false;
}
}
return true;
}
public bool TryPackWithOutLimit(out List<float> packed)
{
packed = new List<float>();
packed.Add((float)(ActionId + 1) / MaxActionID);
packed.Add((float)(PlayerIndex + 1) / MaxPlayerIndex);
packed.Add((float)(UnitIndex + 1) / MaxUnitIndex);
packed.Add((float)(CityIndex + 1) / MaxCityIndex);
packed.Add((float)(GridIndex + 1) / MaxGridIndex);
packed.Add((float)(TargetUnitIndex + 1) / MaxTargetUnitIndex);
packed.Add((float)(TargetGridIndex + 1) / MaxTargetGridIndex);
packed.Add((float)(TargetPlayerIndex + 1) / MaxTargetPlayerIndex);
return true;
}
// 反序列化(读取存在位,再按位宽依序提取)
// 对应解包,顺序必须与 TryPack 完全一致
public bool TryUnpack(List<float> packed)
{
ActionId = (int)Math.Round(packed[0] * MaxActionID) - 1;
PlayerIndex = (int)Math.Round(packed[1] * MaxPlayerIndex) - 1;
UnitIndex = (int)Math.Round(packed[2] * MaxUnitIndex) - 1;
CityIndex = (int)Math.Round(packed[3] * MaxCityIndex) - 1;
GridIndex = (int)Math.Round(packed[4] * MaxGridIndex) - 1;
TargetUnitIndex = (int)Math.Round(packed[5] * MaxTargetUnitIndex) - 1;
TargetGridIndex = (int)Math.Round(packed[6] * MaxTargetGridIndex) - 1;
TargetPlayerIndex = (int)Math.Round(packed[7] * MaxTargetPlayerIndex) - 1;
foreach (var value in packed)
{
if (value > 1 || value < 0)
{
LogSystem.LogError($"数据越界!!! {ActionId} {PlayerIndex} {UnitIndex} {CityIndex} {GridIndex} {TargetUnitIndex} {TargetGridIndex} {TargetPlayerIndex}");
return false;
}
}
return true;
}
}
}

View File

@ -1,3 +0,0 @@
fileFormatVersion: 2
guid: 199f5199fd66439f87cec581d0b58eba
timeCreated: 1764056709

View File

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

View File

@ -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是否已经出了英雄且拥有空的槽位

View File

@ -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
}

View File

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

View File

@ -0,0 +1,815 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
using Logic;
using Logic.AI;
using Logic.CrashSight;
using RuntimeData;
using TH1_Core.Managers;
using TH1_Logic.Core;
using TH1_Logic.MatchConfig;
using TH1_Logic.Net;
using TH1Renderer;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
namespace TH1_Logic.Editor
{
[InitializeOnLoad]
public static class AIDirectorBatchRunner
{
private const string PendingKey = "TH1.AIDirectorBatch.Pending";
private const string OptionsKey = "TH1.AIDirectorBatch.Options";
private const string GameIndexKey = "TH1.AIDirectorBatch.GameIndex";
private const string ResultsKey = "TH1.AIDirectorBatch.Results";
static AIDirectorBatchRunner()
{
if (SessionState.GetBool(PendingKey, false))
{
EditorApplication.playModeStateChanged -= OnPlayModeStateChanged;
EditorApplication.playModeStateChanged += OnPlayModeStateChanged;
}
}
[MenuItem("Tools/TH1/AI Director/Run Batch")]
public static void RunFromMenu()
{
StartBatch(ParseOptionsFromCommandLine());
}
public static void Run()
{
StartBatch(ParseOptionsFromCommandLine());
}
private static void StartBatch(BatchOptions options)
{
try
{
options.Normalize();
Directory.CreateDirectory(options.OutputDirectory);
SessionState.SetString(OptionsKey, JsonUtility.ToJson(options));
SessionState.SetInt(GameIndexKey, 0);
SessionState.SetString(ResultsKey, JsonUtility.ToJson(new BatchResultList()));
SessionState.SetBool(PendingKey, true);
EditorApplication.playModeStateChanged -= OnPlayModeStateChanged;
EditorApplication.playModeStateChanged += OnPlayModeStateChanged;
if (!EditorApplication.isPlaying)
{
OpenStartupScene(options.ScenePath);
EditorApplication.EnterPlaymode();
return;
}
StartPlayModeRunner();
}
catch (Exception e)
{
Debug.LogError($"[AI.Batch] Start failed:\n{e}");
ExitEditor(1);
}
}
private static void OnPlayModeStateChanged(PlayModeStateChange state)
{
if (!SessionState.GetBool(PendingKey, false)) return;
if (state != PlayModeStateChange.EnteredPlayMode) return;
EditorApplication.playModeStateChanged -= OnPlayModeStateChanged;
StartPlayModeRunner();
}
private static void StartPlayModeRunner()
{
EditorApplication.update -= WaitForMainAndStartCoroutine;
EditorApplication.update += WaitForMainAndStartCoroutine;
}
private static void WaitForMainAndStartCoroutine()
{
if (!EditorApplication.isPlaying) return;
if (Main.Instance == null) return;
EditorApplication.update -= WaitForMainAndStartCoroutine;
Main.Instance.StartCoroutine(RunChecked(RunBatchCoroutine(), HandleFatalBatchException));
}
private static IEnumerator RunBatchCoroutine()
{
var options = ReadOptions();
var gameIndex = SessionState.GetInt(GameIndexKey, 0);
var results = ReadResults();
while (gameIndex < options.Games)
{
var result = new BatchGameResult
{
gameIndex = gameIndex,
startedAt = DateTime.UtcNow.ToString("o", CultureInfo.InvariantCulture)
};
var gameDirectory = Path.Combine(options.OutputDirectory, $"game_{gameIndex + 1:000}");
Directory.CreateDirectory(gameDirectory);
Exception gameException = null;
yield return RunChecked(RunGameCoroutine(options, result, gameIndex), e => gameException = e);
if (gameException != null)
{
result.success = false;
result.reason = $"Exception: {gameException.GetType().Name}: {gameException.Message}";
result.stackTrace = gameException.ToString();
Debug.LogError($"[AI.Batch] Game {gameIndex + 1} failed:\n{gameException}");
}
#if UNITY_EDITOR
AIDirectorBatchRuntime.ForceAllPlayersAi = false;
AIDirectorBatchRuntime.SkipPresentationWait = false;
AIDirectorBatchRuntime.CompactDiagnostics = false;
#endif
CompleteResult(result, gameDirectory);
results.Add(result);
WriteGameSummary(gameDirectory, result);
WriteBatchSummary(options, results);
SessionState.SetString(ResultsKey, JsonUtility.ToJson(new BatchResultList { results = results }));
gameIndex++;
SessionState.SetInt(GameIndexKey, gameIndex);
if (!result.success && options.FailFast) break;
if (gameIndex < options.Games)
{
yield return ResetForNextGame();
}
}
var hasFailure = results.Any(item => !item.success);
CleanupSessionState();
Debug.Log($"[AI.Batch] Finished. games={results.Count}, failed={results.Count(item => !item.success)}, output={options.OutputDirectory}");
ExitEditor(hasFailure ? 1 : 0);
}
private static IEnumerator RunGameCoroutine(BatchOptions options, BatchGameResult result, int gameIndex)
{
yield return WaitForRuntimeReady(options);
ConfigureDebugRuntime(options);
AILogic.UseDirectorKernel();
AIDirectorBatchRuntime.ForceAllPlayersAi = true;
AIDirectorBatchRuntime.SkipPresentationWait = true;
AIDirectorBatchRuntime.CompactDiagnostics = true;
var main = Main.Instance;
main.MapConfig = BuildMapConfig(options, gameIndex);
result.playerCount = (int)main.MapConfig.PlayerCount;
result.width = (int)main.MapConfig.Width;
result.height = (int)main.MapConfig.Height;
result.aiKernel = AIKernelRegistry.CurrentKernelType.ToString();
Debug.Log($"[AI.Batch] Start game {gameIndex + 1}/{options.Games}: players={result.playerCount}, size={result.width}x{result.height}");
main.StartMatch();
yield return null;
yield return RunOneGame(options, result);
}
private static IEnumerator RunChecked(IEnumerator root, Action<Exception> onException)
{
var stack = new Stack<IEnumerator>();
stack.Push(root);
while (stack.Count > 0)
{
object current;
try
{
var top = stack.Peek();
if (!top.MoveNext())
{
stack.Pop();
continue;
}
current = top.Current;
}
catch (Exception e)
{
onException?.Invoke(e);
yield break;
}
if (current is IEnumerator nested)
{
stack.Push(nested);
continue;
}
yield return current;
}
}
private static void HandleFatalBatchException(Exception exception)
{
Debug.LogError($"[AI.Batch] Fatal failure:\n{exception}");
#if UNITY_EDITOR
AIDirectorBatchRuntime.ForceAllPlayersAi = false;
AIDirectorBatchRuntime.SkipPresentationWait = false;
AIDirectorBatchRuntime.CompactDiagnostics = false;
#endif
try
{
var options = ReadOptionsSafe();
var results = ReadResultsSafe();
var gameIndex = SessionState.GetInt(GameIndexKey, 0);
var gameDirectory = Path.Combine(options.OutputDirectory, $"game_{gameIndex + 1:000}");
Directory.CreateDirectory(gameDirectory);
var result = new BatchGameResult
{
gameIndex = gameIndex,
success = false,
reason = $"FatalException:{exception.GetType().Name}:{exception.Message}",
stackTrace = exception.ToString(),
startedAt = DateTime.UtcNow.ToString("o", CultureInfo.InvariantCulture)
};
CompleteResult(result, gameDirectory);
results.Add(result);
WriteGameSummary(gameDirectory, result);
WriteBatchSummary(options, results);
}
catch (Exception writeException)
{
Debug.LogError($"[AI.Batch] Failed to write fatal summary:\n{writeException}");
}
finally
{
CleanupSessionState();
ExitEditor(1);
}
}
private static IEnumerator WaitForRuntimeReady(BatchOptions options)
{
var deadline = EditorApplication.timeSinceStartup + options.StartupTimeoutSeconds;
while (EditorApplication.timeSinceStartup <= deadline)
{
var main = Main.Instance;
if (main != null
&& main.GameLogic != null
&& Main.PlayerLogic != null
&& Main.CityLogic != null
&& Main.UnitLogic != null
&& UIManager.Instance != null)
{
yield break;
}
yield return null;
}
throw new TimeoutException($"Runtime startup timeout after {options.StartupTimeoutSeconds} seconds.");
}
private static IEnumerator ResetForNextGame()
{
var main = Main.Instance;
if (main != null)
{
main.Clear();
yield return null;
while (PresentationManager.Busy) yield return null;
}
}
private static IEnumerator RunOneGame(BatchOptions options, BatchGameResult result)
{
var deadline = EditorApplication.timeSinceStartup + options.TimeoutSeconds;
var lastNetActionCount = GetNetActionCount();
var lastTurnKey = string.Empty;
var actionsThisPlayerTurn = 0;
var stagnantFrames = 0;
while (EditorApplication.timeSinceStartup <= deadline)
{
yield return null;
result.frames++;
var map = Main.MapData;
if (map == null)
{
result.success = false;
result.reason = "Main.MapData is null.";
yield break;
}
result.netActions = GetNetActionCount();
result.maxPlayerTurn = GetMaxPlayerTurn(map);
result.curPlayerId = map.CurPlayer?.Id ?? 0;
result.curPlayerTurn = map.CurPlayer?.Turn ?? 0;
result.survivingPlayers = CountSurvivingPlayers(map);
result.gameState = Main.Instance.GameLogic?.GetCurState().ToString() ?? string.Empty;
if (map.CheckIfGameEnd(out var isWin)
|| Main.Instance.GameLogic?.GetCurState() == GameState.Finished)
{
result.success = true;
result.reason = isWin ? "GameEnd:SelfOrTeamWin" : "GameEnd";
yield break;
}
if (options.MaxTurns > 0 && result.maxPlayerTurn >= options.MaxTurns)
{
result.success = true;
result.reason = $"ReachedMaxTurns:{options.MaxTurns}";
yield break;
}
if (options.MaxActions > 0 && result.netActions >= options.MaxActions)
{
result.success = false;
result.reason = $"ReachedMaxActions:{options.MaxActions}";
yield break;
}
var turnKey = $"{result.curPlayerId}:{result.curPlayerTurn}";
if (turnKey != lastTurnKey)
{
lastTurnKey = turnKey;
actionsThisPlayerTurn = 0;
}
if (result.netActions > lastNetActionCount)
{
actionsThisPlayerTurn += result.netActions - lastNetActionCount;
lastNetActionCount = result.netActions;
stagnantFrames = 0;
}
else
{
stagnantFrames++;
}
if (options.MaxActionsPerPlayerTurn > 0 && actionsThisPlayerTurn > options.MaxActionsPerPlayerTurn)
{
result.success = false;
result.reason = $"ActionsPerPlayerTurnGuard:{actionsThisPlayerTurn}>{options.MaxActionsPerPlayerTurn}";
yield break;
}
if (options.StagnantFrameLimit > 0 && stagnantFrames > options.StagnantFrameLimit)
{
result.success = false;
result.reason = $"StagnantFrameGuard:{stagnantFrames}";
yield break;
}
}
result.success = false;
result.reason = $"Timeout:{options.TimeoutSeconds}s";
}
private static MapConfig BuildMapConfig(BatchOptions options, int gameIndex)
{
var config = new MapConfig((uint)options.Width, (uint)options.Height, (uint)options.Players, 0, 0, options.Difficulty)
{
GameMode = GameMode.DOMINATION,
MatchSettlement = MatchSettlementType.Normal,
IsLimitTime = false,
DisableNearbySpawnPoints = options.DisableNearbySpawnPoints,
WaterType = options.WaterType
};
config.SetPlayerCount((uint)options.Players, NetMode.Single);
var used = new HashSet<uint>();
for (var i = 0; i < options.Players; i++)
{
var civId = PickCivId(i + gameIndex, used);
used.Add(civId);
if (!config.SetSinglePlayerSlotCiv(i, civId, civId))
{
config.SetPlayerSlotRandomCiv(i, NetMode.Single);
}
}
config.EnsurePlayerSlots(NetMode.Single);
return config;
}
private static uint PickCivId(int preferredIndex, HashSet<uint> used)
{
const int defaultCivCount = 17;
uint fallback = 0;
var hasFallback = false;
for (var offset = 0; offset < defaultCivCount; offset++)
{
var civId = (uint)((preferredIndex + offset) % defaultCivCount);
if (!ContentGate.CanUseEmpire(civId, civId)) continue;
if (!hasFallback)
{
fallback = civId;
hasFallback = true;
}
if (!used.Contains(civId)) return civId;
}
return hasFallback ? fallback : 0;
}
private static void ConfigureDebugRuntime(BatchOptions options)
{
var main = Main.Instance;
if (main != null)
{
main.NoAI = false;
main.FullSight = true;
main.AIActionTime = 0f;
main.AnimationSpeed = Mathf.Max(1f, options.AnimationSpeed);
main.DebugMode = true;
main.DebugHideCenterMessage = true;
}
if (DebugCenter.Instance == null) return;
DebugCenter.Instance.DebugNoAI = false;
DebugCenter.Instance.DebugSelfPlayerAllSight = true;
DebugCenter.Instance.DebugAIActionTime = 0f;
DebugCenter.Instance.AnimationSpeed = Mathf.Max(1f, options.AnimationSpeed);
DebugCenter.Instance.DebugMode = true;
DebugCenter.Instance.DebugHideCenterMessage = true;
}
private static BatchOptions ParseOptionsFromCommandLine()
{
var options = new BatchOptions();
options.Games = GetIntArg("-aiBatchGames", options.Games);
options.Players = GetIntArg("-aiBatchPlayers", options.Players);
options.Width = GetIntArg("-aiBatchWidth", options.Width);
options.Height = GetIntArg("-aiBatchHeight", options.Height);
options.MaxTurns = GetIntArg("-aiBatchTurns", options.MaxTurns);
options.TimeoutSeconds = GetIntArg("-aiBatchTimeoutSeconds", options.TimeoutSeconds);
options.StartupTimeoutSeconds = GetIntArg("-aiBatchStartupTimeoutSeconds", options.StartupTimeoutSeconds);
options.MaxActions = GetIntArg("-aiBatchMaxActions", options.MaxActions);
options.MaxActionsPerPlayerTurn = GetIntArg("-aiBatchMaxActionsPerPlayerTurn", options.MaxActionsPerPlayerTurn);
options.StagnantFrameLimit = GetIntArg("-aiBatchStagnantFrames", options.StagnantFrameLimit);
options.AnimationSpeed = GetFloatArg("-aiBatchAnimationSpeed", options.AnimationSpeed);
options.FailFast = GetBoolArg("-aiBatchFailFast", options.FailFast);
options.DisableNearbySpawnPoints = GetBoolArg("-aiBatchDisableNearbySpawnPoints", options.DisableNearbySpawnPoints);
var outputDirectory = GetStringArg("-aiBatchOut");
if (!string.IsNullOrWhiteSpace(outputDirectory)) options.OutputDirectory = outputDirectory;
var scenePath = GetStringArg("-aiBatchScene");
if (!string.IsNullOrWhiteSpace(scenePath)) options.ScenePath = scenePath;
var difficulty = GetStringArg("-aiBatchDifficulty");
if (!string.IsNullOrWhiteSpace(difficulty) && Enum.TryParse(difficulty, true, out AIDifficult parsedDifficulty))
options.Difficulty = parsedDifficulty;
var waterType = GetStringArg("-aiBatchWaterType");
if (!string.IsNullOrWhiteSpace(waterType) && Enum.TryParse(waterType, true, out MapWaterType parsedWaterType))
options.WaterType = parsedWaterType;
return options;
}
private static BatchOptions ReadOptions()
{
var json = SessionState.GetString(OptionsKey, string.Empty);
var options = string.IsNullOrWhiteSpace(json)
? new BatchOptions()
: JsonUtility.FromJson<BatchOptions>(json);
options.Normalize();
return options;
}
private static List<BatchGameResult> ReadResults()
{
var json = SessionState.GetString(ResultsKey, string.Empty);
if (string.IsNullOrWhiteSpace(json)) return new List<BatchGameResult>();
if (json.TrimStart().StartsWith("[", StringComparison.Ordinal)) return new List<BatchGameResult>();
var list = JsonUtility.FromJson<BatchResultList>(json);
return list?.results ?? new List<BatchGameResult>();
}
private static BatchOptions ReadOptionsSafe()
{
try
{
return ReadOptions();
}
catch
{
var options = ParseOptionsFromCommandLine();
options.Normalize();
return options;
}
}
private static List<BatchGameResult> ReadResultsSafe()
{
try
{
return ReadResults();
}
catch
{
return new List<BatchGameResult>();
}
}
private static void CompleteResult(BatchGameResult result, string gameDirectory)
{
result.endedAt = DateTime.UtcNow.ToString("o", CultureInfo.InvariantCulture);
result.summaryPath = Path.Combine(gameDirectory, "batch_game_summary.json").Replace('\\', '/');
var map = Main.MapData;
if (map == null) return;
result.mapId = map.MapID;
result.netActions = GetNetActionCount();
result.maxPlayerTurn = GetMaxPlayerTurn(map);
result.curPlayerId = map.CurPlayer?.Id ?? 0;
result.curPlayerTurn = map.CurPlayer?.Turn ?? 0;
result.survivingPlayers = CountSurvivingPlayers(map);
result.players = BuildPlayerResults(map);
}
private static List<BatchPlayerResult> BuildPlayerResults(MapData map)
{
var results = new List<BatchPlayerResult>();
var players = map.PlayerMap?.PlayerDataList;
if (players == null) return results;
foreach (var player in players)
{
if (player == null) continue;
results.Add(new BatchPlayerResult
{
id = player.Id,
civId = player.PlayerCivId,
forceId = player.PlayerForceId,
turn = player.Turn,
alive = player.Alive,
score = player.PlayerScore,
coin = player.PlayerCoin,
techPoint = player.PlayerTechPoint,
cityCount = CountPlayerCities(map, player.Id),
unitCount = CountPlayerUnits(map, player.Id),
isWin = map.MatchSettlement?.IsWin(player.Id) ?? false
});
}
return results;
}
private static int CountPlayerCities(MapData map, uint playerId)
{
var cities = map.CityMap?.CityList;
if (cities == null) return 0;
var count = 0;
foreach (var city in cities)
{
if (city != null && map.CheckCityIdBelongPlayerId(city.Id, playerId)) count++;
}
return count;
}
private static int CountPlayerUnits(MapData map, uint playerId)
{
var units = map.UnitMap?.UnitList;
if (units == null) return 0;
var count = 0;
foreach (var unit in units)
{
if (unit != null && map.GetPlayerIdByUnitId(unit.Id, out var ownerId) && ownerId == playerId && unit.IsAlive()) count++;
}
return count;
}
private static int CountSurvivingPlayers(MapData map)
{
var players = map.PlayerMap?.PlayerDataList;
if (players == null) return 0;
var count = 0;
foreach (var player in players)
{
if (player != null && player.IsSurvival) count++;
}
return count;
}
private static uint GetMaxPlayerTurn(MapData map)
{
var players = map.PlayerMap?.PlayerDataList;
if (players == null || players.Count == 0) return 0;
var maxTurn = 0u;
foreach (var player in players)
{
if (player != null && player.Turn > maxTurn) maxTurn = player.Turn;
}
return maxTurn;
}
private static int GetNetActionCount()
{
return Main.MapData?.Net?.Actions?.Count ?? 0;
}
private static void WriteGameSummary(string gameDirectory, BatchGameResult result)
{
var path = Path.Combine(gameDirectory, "batch_game_summary.json");
File.WriteAllText(path, JsonUtility.ToJson(result, true), Encoding.UTF8);
}
private static void WriteBatchSummary(BatchOptions options, List<BatchGameResult> results)
{
var summary = new BatchSummary
{
options = options,
results = results,
generatedAt = DateTime.UtcNow.ToString("o", CultureInfo.InvariantCulture),
totalGames = results.Count,
failedGames = results.Count(item => !item.success)
};
var path = Path.Combine(options.OutputDirectory, "batch_summary.json");
File.WriteAllText(path, JsonUtility.ToJson(summary, true), Encoding.UTF8);
}
private static void OpenStartupScene(string scenePath)
{
if (string.IsNullOrWhiteSpace(scenePath))
{
var scene = EditorBuildSettings.scenes.FirstOrDefault(item => item.enabled);
scenePath = scene?.path;
}
if (string.IsNullOrWhiteSpace(scenePath)) return;
if (!File.Exists(Path.Combine(ProjectRoot, scenePath)))
{
throw new FileNotFoundException($"Startup scene not found: {scenePath}");
}
EditorSceneManager.OpenScene(scenePath);
}
private static string GetStringArg(string name)
{
var args = Environment.GetCommandLineArgs();
for (var i = 0; i < args.Length - 1; i++)
{
if (!string.Equals(args[i], name, StringComparison.OrdinalIgnoreCase)) continue;
return args[i + 1];
}
return null;
}
private static int GetIntArg(string name, int fallback)
{
var value = GetStringArg(name);
return int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) ? parsed : fallback;
}
private static float GetFloatArg(string name, float fallback)
{
var value = GetStringArg(name);
return float.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed) ? parsed : fallback;
}
private static bool GetBoolArg(string name, bool fallback)
{
var value = GetStringArg(name);
if (string.IsNullOrWhiteSpace(value)) return fallback;
if (bool.TryParse(value, out var parsed)) return parsed;
return value == "1" || value.Equals("yes", StringComparison.OrdinalIgnoreCase);
}
private static void CleanupSessionState()
{
SessionState.EraseBool(PendingKey);
SessionState.EraseString(OptionsKey);
SessionState.EraseInt(GameIndexKey);
SessionState.EraseString(ResultsKey);
}
private static void ExitEditor(int code)
{
if (Application.isBatchMode)
{
EditorApplication.Exit(code);
return;
}
if (EditorApplication.isPlaying)
{
EditorApplication.ExitPlaymode();
}
}
private static string ProjectRoot => Directory.GetParent(Application.dataPath).FullName;
}
[Serializable]
public class BatchOptions
{
public int Games = 1;
public int Players = 17;
public int Width = 30;
public int Height = 30;
public int MaxTurns = 100;
public int TimeoutSeconds = 1800;
public int StartupTimeoutSeconds = 180;
public int MaxActions = 20000;
public int MaxActionsPerPlayerTurn = 260;
public int StagnantFrameLimit = 3600;
public float AnimationSpeed = 100f;
public bool FailFast = true;
public bool DisableNearbySpawnPoints = false;
public string OutputDirectory;
public string ScenePath;
public AIDifficult Difficulty = AIDifficult.LUNATIC;
public MapWaterType WaterType = MapWaterType.Pangea;
public void Normalize()
{
Games = Mathf.Clamp(Games, 1, 1000);
Players = Mathf.Clamp(Players, 2, 17);
Width = Mathf.Clamp(Width, 8, 80);
Height = Mathf.Clamp(Height, 8, 80);
MaxTurns = Mathf.Max(0, MaxTurns);
TimeoutSeconds = Mathf.Max(30, TimeoutSeconds);
StartupTimeoutSeconds = Mathf.Max(30, StartupTimeoutSeconds);
MaxActions = Mathf.Max(0, MaxActions);
MaxActionsPerPlayerTurn = Mathf.Max(0, MaxActionsPerPlayerTurn);
StagnantFrameLimit = Mathf.Max(0, StagnantFrameLimit);
AnimationSpeed = Mathf.Max(1f, AnimationSpeed);
if (string.IsNullOrWhiteSpace(OutputDirectory))
{
var root = Directory.GetParent(Application.dataPath).FullName;
OutputDirectory = Path.Combine(root, "Logs", "AI_Batch", DateTime.Now.ToString("yyyyMMdd_HHmmss", CultureInfo.InvariantCulture));
}
OutputDirectory = Path.GetFullPath(OutputDirectory);
}
}
[Serializable]
public class BatchSummary
{
public string generatedAt;
public int totalGames;
public int failedGames;
public BatchOptions options;
public List<BatchGameResult> results;
}
[Serializable]
public class BatchResultList
{
public List<BatchGameResult> results = new();
}
[Serializable]
public class BatchGameResult
{
public int gameIndex;
public bool success;
public string reason;
public string startedAt;
public string endedAt;
public string summaryPath;
public string stackTrace;
public string aiKernel;
public string gameState;
public uint mapId;
public int playerCount;
public int width;
public int height;
public int frames;
public int netActions;
public uint maxPlayerTurn;
public uint curPlayerId;
public uint curPlayerTurn;
public int survivingPlayers;
public List<BatchPlayerResult> players = new();
}
[Serializable]
public class BatchPlayerResult
{
public uint id;
public uint civId;
public uint forceId;
public uint turn;
public bool alive;
public bool isWin;
public int score;
public int coin;
public int techPoint;
public int cityCount;
public int unitCount;
}
}

View File

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

View File

@ -1,99 +0,0 @@
/*
* @Author:
* @Description:
* @Date: 20250725 14:07:55
* @Modify:
*/
using System;
using System.Linq;
using Logic.Action;
using Logic.Config;
using TH1_Logic.AITrain;
using Unity.VisualScripting;
using UnityEditor;
using UnityEngine;
namespace Logic.Editor
{
public class AITrainEditorWindow : EditorWindow
{
// 背景
private GUIStyle _redBoxStyle;
private GUIStyle _whiteBoxStyle;
[MenuItem("Tools/AI训练窗口")]
private static void ShowWindow()
{
var window = GetWindow<AITrainEditorWindow>();
window.titleContent = new GUIContent("AI训练窗口");
window.Show();
}
private void OnEnable()
{
}
private void OnGUI()
{
if (_redBoxStyle == null)
{
_redBoxStyle = InspectorUtils.GetHelpBoxStyle();
InspectorUtils.AddBorder(_redBoxStyle, new Color(0.5f, 0.4f, 0.4f, 0.6f));
}
if (_whiteBoxStyle == null)
{
_whiteBoxStyle = InspectorUtils.GetHelpBoxStyle();
InspectorUtils.AddBorder(_whiteBoxStyle, new Color(1f, 1f, 1f, 0.2f));
}
if (InspectorUtils.InspectorButtonWithTextWidth($"构建 AI 行为集"))
{
ActionLogicIdData data = null;
var bytes = LoadActionLogicIdData();
if (bytes.Length > 0) data = MemoryPack.MemoryPackSerializer.Deserialize<ActionLogicIdData>(bytes);
if (data == null) data = new ActionLogicIdData();
var dict = ActionLogicFactory.GetActionLogicDict();
foreach (var actionId in dict.Keys) data.AddActionId(actionId);
SaveActionLogicIdData(MemoryPack.MemoryPackSerializer.Serialize(data));
Debug.LogError($"Action数量 {data.ActionIdList.Count}");
}
}
private byte[] LoadActionLogicIdData()
{
TextAsset asset = TH1Resource.ResourceLoader.Load<TextAsset>($"CommonIdData/CommonIdData");
return asset?.bytes ?? Array.Empty<byte>();
}
private void SaveActionLogicIdData(byte[] data)
{
// 构建完整路径Resources 子目录)
string directory = "Assets/BundleResources/CommonIdData";
string filePath = $"{directory}/CommonIdData.bytes";
// 检查目录,不存在则创建
if (!System.IO.Directory.Exists(directory))
{
System.IO.Directory.CreateDirectory(directory);
}
// 写入文件(存在则覆盖,不存在则创建)
try
{
System.IO.File.WriteAllBytes(filePath, data);
// 刷新 Unity 资源数据库
AssetDatabase.Refresh();
}
catch (Exception e)
{
Debug.LogError($"写入文件失败: {e.Message}");
}
}
}
}

View File

@ -1,3 +0,0 @@
fileFormatVersion: 2
guid: 5b0319a8d0354ad2a6899d5f6ddaf928
timeCreated: 1764405047

View File

@ -18,8 +18,6 @@ namespace Logic.Editor
{
// 定义宏名称
public const string ENABLE_SPEEDUP = "ENABLE_SPEEDUP";
public const string ENABLE_TRAIN = "ENABLE_TRAIN";
public const string ENABLE_AIMODEL = "ENABLE_AIMODEL";
public const string GAME_AUTO_DEBUG = "GAME_AUTO_DEBUG";
public const string CHECK_ACTIONDEFFERENCE = "CHECK_ACTIONDEFFERENCE";
public const string STEAM_TEST = "STEAM_TEST";
@ -132,28 +130,6 @@ namespace Logic.Editor
EditorGUILayout.EndVertical();
EditorGUILayout.BeginHorizontal();
#if ENABLE_AIMODEL
InspectorUtils.InspectorTextWidthRich($"<b><color=red>AI模型已开启</color></b>");
if (InspectorUtils.InspectorButtonWithTextWidth($"关闭AI模型")) RemoveDefine(ENABLE_AIMODEL);
#else
InspectorUtils.InspectorTextWidthRich($"<b>AI模型已关闭</b>");
if (InspectorUtils.InspectorButtonWithTextWidth($"开启AI模型")) AddDefine(ENABLE_AIMODEL);
#endif
EditorGUILayout.EndHorizontal();
EditorGUILayout.BeginHorizontal();
#if ENABLE_TRAIN
InspectorUtils.InspectorTextWidthRich($"<b><color=red>训练模式已开启:</color></b>");
if (InspectorUtils.InspectorButtonWithTextWidth($"关闭训练模式")) RemoveDefine(ENABLE_TRAIN);
#else
InspectorUtils.InspectorTextWidthRich($"<b>训练模式已关闭:</b>");
if (InspectorUtils.InspectorButtonWithTextWidth($"开启训练模式")) AddDefine(ENABLE_TRAIN);
#endif
EditorGUILayout.EndHorizontal();
EditorGUILayout.BeginHorizontal();
#if ENABLE_SPEEDUP
InspectorUtils.InspectorTextWidthRich($"<b><color=red>加速模式已开启:</color></b>");
@ -224,8 +200,6 @@ namespace Logic.Editor
{
AddDefine(STEAM_CHANNEL);
RemoveDefine(ENABLE_SPEEDUP);
RemoveDefine(ENABLE_TRAIN);
RemoveDefine(ENABLE_AIMODEL);
RemoveDefine(GAME_AUTO_DEBUG);
RemoveDefine(CHECK_ACTIONDEFFERENCE);
RemoveDefine(STEAM_TEST);
@ -373,4 +347,4 @@ namespace Logic.Editor
}
}
}
}
}

View File

@ -8,6 +8,7 @@
using System;
using System.Text;
using Logic.CrashSight;
using Logic.Multilingual;
using Steamworks;
using TH1_Logic.Core;
@ -208,6 +209,9 @@ namespace Logic.Editor
private string _multilingualSelectedText = "测试多语言选中文本";
private string _multilingualDescription = "测试多语言问题自述";
private MultilingualType _multilingualLanguage = MultilingualType.EN;
private string _questionnaireId = "upload-flow-test";
private string _questionnaireSingleChoiceId = "test-choice-a";
private string _questionnaireOpenText = "测试问卷开放题回答";
private string _status = "就绪";
private string _lastObjectKey = "";
@ -304,6 +308,10 @@ namespace Logic.Editor
_multilingualSelectedText = EditorGUILayout.TextArea(_multilingualSelectedText, GUILayout.MinHeight(55));
EditorGUILayout.LabelField("多语言玩家自述");
_multilingualDescription = EditorGUILayout.TextArea(_multilingualDescription, GUILayout.MinHeight(55));
_questionnaireId = EditorGUILayout.TextField("问卷 ID", _questionnaireId);
_questionnaireSingleChoiceId = EditorGUILayout.TextField("问卷单选选项 ID", _questionnaireSingleChoiceId);
EditorGUILayout.LabelField("问卷开放题回答");
_questionnaireOpenText = EditorGUILayout.TextArea(_questionnaireOpenText, GUILayout.MinHeight(45));
EditorGUILayout.EndVertical();
}
@ -323,7 +331,9 @@ namespace Logic.Editor
TestBugReportUpload();
if (GUILayout.Button("4. 多语言汇报上传 type=multilingualreport", GUILayout.Height(28)))
TestMultilingualReportUpload();
if (GUILayout.Button("顺序测试 1-4", GUILayout.Height(32)))
if (GUILayout.Button("5. 问卷上传 type=questionnaire", GUILayout.Height(28)))
TestQuestionnaireUpload();
if (GUILayout.Button("顺序测试 1-5", GUILayout.Height(32)))
TestAll();
}
@ -412,9 +422,29 @@ namespace Logic.Editor
});
}
private async void TestQuestionnaireUpload()
{
await RunTest("问卷上传", async () =>
{
var steamId = GetSteamId();
var authTicket = GetAuthTicket();
var questionnaireBytes = BuildQuestionnairePayloadBytes(steamId);
var credentials = await _stsService.RequestStsTokenAsync(steamId, authTicket, "questionnaire",
_version);
if (!IsQuestionnaireObjectKey(credentials?.objectKey))
throw new Exception($"服务端返回了非问卷 objectKey: {credentials?.objectKey}");
var success = await _uploadService.UploadFileAsync(credentials, questionnaireBytes,
"application/json", MaxQuestionnaireUploadBytes);
_lastObjectKey = credentials.objectKey ?? "";
return success
? $"问卷上传成功: {credentials.objectKey}"
: $"问卷上传失败: {credentials.objectKey}";
});
}
private async void TestAll()
{
await RunTest("顺序测试 1-4", async () =>
await RunTest("顺序测试 1-5", async () =>
{
var steamId = GetSteamId();
var authTicket = GetAuthTicket();
@ -438,8 +468,16 @@ namespace Logic.Editor
var multilingualOk = await OssManager.Instance.UploadPlayerMultilingualReportAsync(steamId,
multilingualPackage.Data, multilingualPackage.Manifest.version);
_lastObjectKey = multilingualOk.objectKey ?? "";
return $"顺序测试完成: steamauth cached={warmup.cached}; ossdata={(normalOk ? "" : "")} {credentials.objectKey}; bugreport={(bugOk.success ? "" : "")} {bugOk.objectKey}; multilingualreport={(multilingualOk.success ? "" : "")} {multilingualOk.objectKey}";
var questionnaireBytes = BuildQuestionnairePayloadBytes(steamId);
var questionnaireCredentials = await _stsService.RequestStsTokenAsync(steamId, authTicket,
"questionnaire", _version);
if (!IsQuestionnaireObjectKey(questionnaireCredentials?.objectKey))
throw new Exception($"服务端返回了非问卷 objectKey: {questionnaireCredentials?.objectKey}");
var questionnaireOk = await _uploadService.UploadFileAsync(questionnaireCredentials,
questionnaireBytes, "application/json", MaxQuestionnaireUploadBytes);
_lastObjectKey = questionnaireCredentials.objectKey ?? "";
return $"顺序测试完成: steamauth cached={warmup.cached}; ossdata={(normalOk ? "" : "")} {credentials.objectKey}; bugreport={(bugOk.success ? "" : "")} {bugOk.objectKey}; multilingualreport={(multilingualOk.success ? "" : "")} {multilingualOk.objectKey}; questionnaire={(questionnaireOk ? "" : "")} {questionnaireCredentials.objectKey}";
});
}
@ -511,5 +549,145 @@ namespace Logic.Editor
return "(Steam 未初始化)";
}
}
private const int MaxQuestionnaireUploadBytes = 512 * 1024;
private static bool IsQuestionnaireObjectKey(string objectKey)
{
return !string.IsNullOrEmpty(objectKey)
&& objectKey.StartsWith("questionnaire/", StringComparison.Ordinal)
&& objectKey.EndsWith(".json", StringComparison.OrdinalIgnoreCase);
}
[Serializable]
private class QuestionnaireTestPayload
{
public string schema = "th1.questionnaire-answer.v1";
public string responseId;
public string questionnaireId;
public long submittedAtUnix;
public string submittedAtUtc;
public string createdAtUtc;
public string createdAtLocal;
public string timezone;
public string steamId;
public string version;
public string unityVersion;
public string platform;
public string crashSightDeviceId;
public string deviceModel;
public string deviceName;
public string operatingSystem;
public string processorType;
public int processorCount;
public int systemMemorySizeMb;
public string graphicsDeviceName;
public int graphicsMemorySizeMb;
public QuestionnaireTestAnswerSheet answerSheet;
}
[Serializable]
private class QuestionnaireTestAnswerSheet
{
public string QuestionnaireId;
public long SubmittedAtUnix;
public string SubmittedAtUtc;
public string LastUploadObjectKey = "";
public string LastUploadedAtUtc = "";
public string LastUploadError = "";
public QuestionnaireTestAnswer[] Answers;
}
[Serializable]
private class QuestionnaireTestAnswer
{
public string QuestionId;
public int QuestionType;
public string[] SelectedOptionIds;
public string OpenText;
}
private byte[] BuildQuestionnairePayloadBytes(string steamId)
{
var now = DateTimeOffset.UtcNow;
var questionnaireId = string.IsNullOrWhiteSpace(_questionnaireId)
? "upload-flow-test"
: _questionnaireId.Trim();
var selectedOptionId = string.IsNullOrWhiteSpace(_questionnaireSingleChoiceId)
? "test-choice-a"
: _questionnaireSingleChoiceId.Trim();
var openText = string.IsNullOrWhiteSpace(_questionnaireOpenText)
? "测试问卷开放题回答"
: _questionnaireOpenText.Trim();
var submittedAtUtc = now.UtcDateTime.ToString("O");
var answerSheet = new QuestionnaireTestAnswerSheet
{
QuestionnaireId = questionnaireId,
SubmittedAtUnix = now.ToUnixTimeSeconds(),
SubmittedAtUtc = submittedAtUtc,
Answers = new[]
{
new QuestionnaireTestAnswer
{
QuestionId = "upload-flow-single-choice",
QuestionType = 1,
SelectedOptionIds = new[] { selectedOptionId },
OpenText = ""
},
new QuestionnaireTestAnswer
{
QuestionId = "upload-flow-open",
QuestionType = 0,
SelectedOptionIds = Array.Empty<string>(),
OpenText = openText
}
}
};
var payload = new QuestionnaireTestPayload
{
responseId = Guid.NewGuid().ToString("N"),
questionnaireId = questionnaireId,
submittedAtUnix = answerSheet.SubmittedAtUnix,
submittedAtUtc = submittedAtUtc,
createdAtUtc = DateTime.UtcNow.ToString("O"),
createdAtLocal = DateTime.Now.ToString("O"),
timezone = GetLocalTimezone(),
steamId = steamId ?? "",
version = string.IsNullOrWhiteSpace(_version) ? PlayerBugReportService.GetCurrentVersion() : _version.Trim(),
unityVersion = Application.unityVersion,
platform = Application.platform.ToString(),
crashSightDeviceId = CrashSightManager.GetCrashSightDeviceId(),
deviceModel = SystemInfo.deviceModel,
deviceName = SystemInfo.deviceName,
operatingSystem = SystemInfo.operatingSystem,
processorType = SystemInfo.processorType,
processorCount = SystemInfo.processorCount,
systemMemorySizeMb = SystemInfo.systemMemorySize,
graphicsDeviceName = SystemInfo.graphicsDeviceName,
graphicsMemorySizeMb = SystemInfo.graphicsMemorySize,
answerSheet = answerSheet
};
var bytes = new UTF8Encoding(false).GetBytes(JsonUtility.ToJson(payload, true));
if (bytes.Length > MaxQuestionnaireUploadBytes)
throw new Exception($"问卷测试 payload 大小 {bytes.Length} 超过 {MaxQuestionnaireUploadBytes} 字节限制");
return bytes;
}
private static string GetLocalTimezone()
{
try
{
var offset = DateTimeOffset.Now.Offset;
var sign = offset < TimeSpan.Zero ? "-" : "+";
return $"{TimeZoneInfo.Local.Id} (UTC{sign}{offset.Duration():hh\\:mm})";
}
catch
{
return DateTimeOffset.Now.Offset.ToString();
}
}
}
}

View File

@ -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",

View File

@ -42,8 +42,6 @@ namespace TH1_Logic.Editor
"STEAM_CHANNEL",
"USE_INPUT",
"ENABLE_SPEEDUP",
"ENABLE_TRAIN",
"ENABLE_AIMODEL",
"GAME_AUTO_DEBUG",
"CHECK_ACTIONDEFFERENCE",
"STEAM_TEST",
@ -67,8 +65,6 @@ namespace TH1_Logic.Editor
private bool _buildYooAssetAb = true;
private bool _showAdvanced;
private bool _enableSpeedup;
private bool _enableTrain;
private bool _enableAiModel;
private bool _gameAutoDebug;
private bool _checkActionDifference;
private bool _steamTest;
@ -217,8 +213,6 @@ namespace TH1_Logic.Editor
using (new EditorGUI.DisabledScope(_package == PackageProfile.Release))
{
_enableSpeedup = EditorGUILayout.ToggleLeft("加速模式 ENABLE_SPEEDUP", _enableSpeedup);
_enableTrain = EditorGUILayout.ToggleLeft("训练模式 ENABLE_TRAIN", _enableTrain);
_enableAiModel = EditorGUILayout.ToggleLeft("AI 模型 ENABLE_AIMODEL", _enableAiModel);
_gameAutoDebug = EditorGUILayout.ToggleLeft("自动战斗 GAME_AUTO_DEBUG", _gameAutoDebug);
_checkActionDifference = EditorGUILayout.ToggleLeft("MapData 变化检查 CHECK_ACTIONDEFFERENCE", _checkActionDifference);
_steamTest = EditorGUILayout.ToggleLeft("Steam 测试窗口 STEAM_TEST", _platform == BuildPlatformProfile.PC && _steamTest);
@ -720,8 +714,6 @@ namespace TH1_Logic.Editor
private void AddSpecialDefines(HashSet<string> defines)
{
if (_enableSpeedup) defines.Add("ENABLE_SPEEDUP");
if (_enableTrain) defines.Add("ENABLE_TRAIN");
if (_enableAiModel) defines.Add("ENABLE_AIMODEL");
if (_gameAutoDebug) defines.Add("GAME_AUTO_DEBUG");
if (_checkActionDifference) defines.Add("CHECK_ACTIONDEFFERENCE");
if (_platform == BuildPlatformProfile.PC && _steamTest) defines.Add("STEAM_TEST");
@ -1027,8 +1019,6 @@ namespace TH1_Logic.Editor
{
if (_package != PackageProfile.Release) return;
_enableSpeedup = false;
_enableTrain = false;
_enableAiModel = false;
_gameAutoDebug = false;
_checkActionDifference = false;
_steamTest = false;

View File

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