Improve AI Director hero strategy metrics

This commit is contained in:
wuwenbo 2026-07-01 15:51:16 +08:00
parent 7eca426175
commit d6fec5029e
13 changed files with 520 additions and 51 deletions

View File

@ -130,6 +130,19 @@ def find_matching_logs(root: Path, batch_path: Path, batch, explicit_logs):
result.append(path)
return result
direct_logs = []
for game in batch.get("results", []) or []:
value = game.get("diagnosticsLogPath") or ""
if not value:
continue
path = Path(value)
if not path.is_absolute():
path = root / path
if path.exists():
direct_logs.append(path)
if direct_logs:
return direct_logs
start, end = batch_local_window(batch_path, batch)
log_dir = root / "Unity" / "Logs" / "AI_Director_Diagnostics"
logs = sorted(log_dir.glob("ai_director_*.jsonl"), key=lambda p: p.stat().st_mtime)
@ -170,6 +183,20 @@ def analyze_logs(log_paths):
"decide_ms": [],
"top_no_effect": Counter(),
"top_repeated": Counter(),
"hero_reasons": Counter(),
"hero_actions": Counter(),
"hero_executed_actions": Counter(),
"hero_lane_counts": Counter(),
"fallback_reasons": Counter(),
"fallback_actions": Counter(),
"fallback_executed_actions": Counter(),
"fallback_no_effect_actions": Counter(),
"hero_delta": 0,
"selected_hero_delta": 0,
"hero_task_delta": 0,
"ready_hero_task_delta": 0,
"forced_hero_task_delta": 0,
"hero_task_progress_delta": 0,
}
for path in log_paths:
@ -195,16 +222,33 @@ def analyze_logs(log_paths):
stable_rows_by_turn = defaultdict(list)
turn_rows = defaultdict(list)
decision_lane_by_turn_action = {}
for row in rows:
event_type = row.get("eventType", "")
if event_type == "Decision":
decision = row.get("decision") or {}
lane = decision.get("lane") or ""
reason = decision.get("reason") or ""
action = decision.get("action") or {}
if lane and lane != "None":
aggregate["lane_counts"][lane] += 1
decide_ms = decision.get("decideMs")
if isinstance(decide_ms, (int, float)):
aggregate["decide_ms"].append(float(decide_ms))
if decision.get("hasAction"):
turn_action_key = (
row.get("playerId", 0),
row.get("playerTurn", 0),
stable_key(action),
)
decision_lane_by_turn_action[turn_action_key] = lane
if lane in ("HeroManagement", "HeroPlaybook"):
aggregate["hero_lane_counts"][lane] += 1
aggregate["hero_reasons"][reason] += 1
aggregate["hero_actions"][action_key(action)] += 1
if decision.get("isFallback"):
aggregate["fallback_reasons"][reason] += 1
aggregate["fallback_actions"][action_key(action)] += 1
continue
if event_type != "Execution":
@ -217,7 +261,22 @@ def analyze_logs(log_paths):
delta = execution.get("delta") or {}
player_id = row.get("playerId", 0)
key = action_key(action)
turn_action_key = (player_id, row.get("playerTurn", 0), stable_key(action))
decision_lane = decision_lane_by_turn_action.get(turn_action_key, "")
aggregate["player_actions"][player_id] += 1
if decision_lane in ("HeroManagement", "HeroPlaybook"):
aggregate["hero_executed_actions"][key] += 1
if decision_lane == "Fallback":
aggregate["fallback_executed_actions"][key] += 1
if has_no_effect_delta(delta):
aggregate["fallback_no_effect_actions"][key] += 1
aggregate["hero_delta"] += int(delta.get("heroDelta") or 0)
aggregate["selected_hero_delta"] += int(delta.get("selectedHeroDelta") or 0)
aggregate["hero_task_delta"] += int(delta.get("heroTaskDelta") or 0)
aggregate["ready_hero_task_delta"] += int(delta.get("readyHeroTaskDelta") or 0)
aggregate["forced_hero_task_delta"] += int(delta.get("forcedHeroTaskDelta") or 0)
aggregate["hero_task_progress_delta"] += int(delta.get("heroTaskProgressDelta") or 0)
if delta.get("targetUnitDied"):
aggregate["kills"] += 1
@ -372,6 +431,24 @@ def compact_log_metrics(log_metrics, top):
},
"top_actions": log_metrics["action_counts"].most_common(top),
"top_lanes": log_metrics["lane_counts"].most_common(top),
"hero": {
"lane_counts": log_metrics["hero_lane_counts"].most_common(top),
"top_reasons": log_metrics["hero_reasons"].most_common(top),
"top_actions": log_metrics["hero_actions"].most_common(top),
"top_executed_actions": log_metrics["hero_executed_actions"].most_common(top),
"hero_delta": log_metrics["hero_delta"],
"selected_hero_delta": log_metrics["selected_hero_delta"],
"hero_task_delta": log_metrics["hero_task_delta"],
"ready_hero_task_delta": log_metrics["ready_hero_task_delta"],
"forced_hero_task_delta": log_metrics["forced_hero_task_delta"],
"hero_task_progress_delta": log_metrics["hero_task_progress_delta"],
},
"fallback": {
"top_reasons": log_metrics["fallback_reasons"].most_common(top),
"top_actions": log_metrics["fallback_actions"].most_common(top),
"top_executed_actions": log_metrics["fallback_executed_actions"].most_common(top),
"top_no_effect_actions": log_metrics["fallback_no_effect_actions"].most_common(top),
},
}
@ -447,6 +524,43 @@ def print_report(batch_path, batch, metrics, log_metrics, warnings, top):
for key, count in log_metrics["lane_counts"].most_common(top):
print(f" {count:>5} {key}")
print("Hero:")
print(
" deltas: "
f"selected={log_metrics['selected_hero_delta']} "
f"spawned={log_metrics['hero_delta']} "
f"task={log_metrics['hero_task_delta']} "
f"readyTask={log_metrics['ready_hero_task_delta']} "
f"forcedTask={log_metrics['forced_hero_task_delta']} "
f"taskProgress={log_metrics['hero_task_progress_delta']}"
)
if log_metrics["hero_reasons"]:
print(" top reasons:")
for key, count in log_metrics["hero_reasons"].most_common(top):
print(f" {count:>5} {key}")
else:
print(" top reasons: <none>")
if log_metrics["hero_executed_actions"]:
print(" executed actions:")
for key, count in log_metrics["hero_executed_actions"].most_common(top):
print(f" {count:>5} {key}")
else:
print(" executed actions: <none>")
print("Fallback:")
if log_metrics["fallback_actions"]:
print(" selected actions:")
for key, count in log_metrics["fallback_actions"].most_common(top):
print(f" {count:>5} {key}")
else:
print(" selected actions: <none>")
if log_metrics["fallback_no_effect_actions"]:
print(" no-effect actions:")
for key, count in log_metrics["fallback_no_effect_actions"].most_common(top):
print(f" {count:>5} {key}")
else:
print(" no-effect actions: <none>")
print("Warnings:")
if warnings:
for warning in warnings:

View File

@ -340,7 +340,21 @@ Development 的行为倾向:
## 9. 英雄策略
英雄不走普通单位公式。英雄由 HeroPlaybook 驱动。
英雄不走普通单位公式。英雄分两层处理:
- HeroManagement 负责英雄体系节奏:选英雄、让已选择英雄尽快出场、再推进任务。
- HeroPlaybook 负责场上英雄怎么发挥机制:治疗、保护、控场、地面攻击、主动技能、收割、站位。
HeroManagement 的节奏:
```text
没有选择英雄
→ 先选择最适合当前阵营和局势的英雄
→ 已选择但未上场时,优先在合法城市训练/复活英雄
→ 英雄已经能参与战局后,再推进可强制完成的英雄任务
```
这个顺序保证 AI 不会只完成任务却长期没有英雄在棋盘上,也不会把英雄当作普通兵随缘训练。
HeroPlaybook 的判断顺序:
@ -472,11 +486,13 @@ Director 不直接推演行为结果,而是从合法 Action 池中选择。
|---|---|
| 单位战斗 | UnitAttack、UnitAttackAlly、UnitAttackGround |
| 单位移动 | UnitMove |
| 单位行为 | Capture、Examine、Gather、Recover、Upgrade、HeroUpgrade、CultureUnitUpgrade、英雄主动 |
| 单位行为 | Capture、Examine、Gather、Recover、Upgrade、HeroUpgrade、CultureUnitUpgrade、ShipUpgrade、AbsorbMarker、英雄主动 |
| 城市行为 | TrainUnit、CityLevelUpAction、CityAction、StartWonder、BuildWonder |
| 地块行为 | Gain、Build、GridMisc |
| 玩家行为 | LearnTech、PlayerActionBuyCultureCard 暂不进入 AI 候选,等文化卡策略单独回归 |
| 英雄管理 | SelectHero、FinishHeroTask、出场、复活 |
| 英雄管理 | SelectHero、TrainUnit:Giant 出场/复活、FinishHeroTask |
UnitAttackAlly 只由 HeroPlaybook、支援战术或明确的友军互动规则使用不进入 Fallback。友军目标动作如果落到兜底通常说明它缺少英雄或支援规则。
危险动作默认不进入普通 AI 选择:
@ -559,6 +575,17 @@ AI 可以重新实现,但必须遵守 TH1 的游戏架构:
| 外交行为 | 高好感不乱开战,必要时建立使馆或结盟 |
| 性能 | 单个 AI 动作决策无明显卡顿 |
当前批跑调优采用以下基线指标,不用单局主观感觉判断 AI 是否变聪明:
| 指标组 | 观察项 | 目标 |
|---|---|---|
| 正确性 | failedGames、noEffect、repeated、maxActions/playerTurn | 必须为 0 或远低于强制停止线 |
| 扩张 | aliveAvgCities、alive>=2、alive>=3、maxCities | 二城率和三城率稳定提升,不能靠单个高滚玩家掩盖整体弱扩张 |
| 战斗 | UnitAttack 数、kills、actingUnitDeaths、PriorityTactic/Tactic 占比 | 有战果且不过度白送,城市威胁能被处理 |
| 英雄 | selected、spawned、HeroManagement、HeroPlaybook、英雄专属 reason/action | 已选择英雄应尽快上场,英雄动作应体现个人机制 |
| Fallback | Fallback 总数、Fallback actionType、Fallback noEffect | 越接近 0 越好;非 0 时优先把有意义动作归入正式车道 |
| 性能 | actions/sec、avgGame、decision avg/p95/max | 先保证聪明,再把明显尖峰纳入下一轮优化 |
问题定位:
| 现象 | 优先检查 |
@ -568,7 +595,7 @@ AI 可以重新实现,但必须遵守 TH1 的游戏架构:
| 英雄不放技能 | HeroState、HeroPlaybook、ActionPool |
| 单位乱走 | Front、GridThreat、MoveTarget |
| 过度防守 | CityThreat 是否把全玩家领土误算到每座城市 |
| Fallback 偏高 | Recover、UnitAttackAlly、特殊 UnitAction 是否应并入 UnitOpportunity 或 HeroPlaybook |
| Fallback 偏高 | Recover、特殊 UnitAction 是否应并入 UnitOpportunity 或 HeroPlaybookUnitAttackAlly 不应出现在 Fallback |
| 城市不发展 | CityPlan、Growth、ActionPool |
| 科技乱学 | StrategicPosture、TechScore |
| 回合慢 | ActionPool、移动候选、局部搜索半径 |

View File

@ -1271,11 +1271,11 @@ SkillBase
如果 candidate.IsValid:
返回 candidate
candidate = TryForceFinishHeroTask(ctx)
candidate = TrySpawnSelectedHero(ctx)
如果 candidate.IsValid:
返回 candidate
candidate = TryDeployOrReviveHero(ctx)
candidate = TryForceFinishHeroTask(ctx)
如果 candidate.IsValid:
返回 candidate
@ -1290,7 +1290,58 @@ SkillBase
返回 Candidate(action, HeroManagement, 890, "选择英雄")
```
### 7.3 FinishHeroTask
### 7.3 SpawnSelectedHero
```text
函数 TrySpawnSelectedHero(ctx):
如果 ctx.Player 没有已选择英雄:
返回 None
best = None
对每个 cityPlan in ctx.Cache.CityPlans:
对每个 action in ctx.Actions.ByCity[cityPlan.City.Id]:
如果 !IsSelectedHeroSpawnAction(ctx.Player, action):
继续
score = 880
score += max(cityPlan.Priority, 0) * 0.08
如果 cityPlan.Kind == EmergencyDefense:
score += 40
如果 cityPlan.Kind == Mobilize:
score += 30
如果 cityPlan.Kind == Frontline:
score += 25
candidate = Candidate(action, HeroManagement, score, "英雄出场")
candidate.City = cityPlan.City
candidate.TargetGrid = cityPlan.CityGrid
best = MaxCandidate(best, candidate)
如果 best.IsValid:
返回 best
对每个 action in ctx.Actions.CityActions:
如果 IsSelectedHeroSpawnAction(ctx.Player, action):
candidate = Candidate(action, HeroManagement, 880, "英雄出场")
best = MaxCandidate(best, candidate)
返回 best
```
```text
函数 IsSelectedHeroSpawnAction(player, action):
id = action.ActionId
返回 id.ActionType == TrainUnit
且 id.UnitType == Giant
且 id.GiantType != None
且 player.PlayerHeroData.HasHero(id.GiantType)
```
英雄出场仍然走 TrainUnitAction 的 CheckCan 和 Execute。AI 只负责更早选择这个合法动作,不直接改变英雄状态。
### 7.4 FinishHeroTask
```text
函数 TryForceFinishHeroTask(ctx):
@ -1335,25 +1386,6 @@ SkillBase
返回 false
```
### 7.4 DeployOrReviveHero
```text
函数 TryDeployOrReviveHero(ctx):
best = None
对每个 cityPlan in ctx.Cache.CityPlans:
如果 cityPlan.Kind == EmergencyDefense 且 城市无法安全出英雄:
继续
对每个 action in ctx.Actions.ByCity[cityPlan.City.Id]:
如果 action 是英雄出场或复活动作:
score = 780 + ScoreHeroDeployValue(ctx, action, cityPlan)
candidate = Candidate(action, HeroManagement, score, "英雄出场或复活")
best = MaxCandidate(best, candidate)
返回 best
```
---
## 8. Expansion Lane
@ -1870,6 +1902,10 @@ Byakuren / Miko / Zanmu:
返回 ScoreCultureUpgrade(ctx, unit, action)
如果 type == Recover:
返回 ScoreRecover(ctx, unit, action)
如果 type == ShipUpgrade:
返回 ScoreShipUpgrade(ctx, unit, action)
如果 type == AbsorbMarker:
返回 ScoreAbsorbMarker(ctx, unit, action)
返回 0
```
@ -1915,9 +1951,12 @@ Byakuren / Miko / Zanmu:
```text
函数 ScoreRecover(ctx, unit, action):
hp = HealthRatio(unit)
如果 hp > Config.LowHpRatio:
hasNegativeState = unit 有 KomeijiFear 或 SAKUYATIRED
如果 hp > Config.LowHpRatio 且 !hasNegativeState:
返回 0
score = 560 + (1 - hp) * 240
score = 520 + (1 - hp) * 220
如果 hasNegativeState:
score += 160
如果 unit 是英雄:
score += 120
如果 unit 当前能击杀高价值目标:
@ -1925,6 +1964,36 @@ Byakuren / Miko / Zanmu:
返回 score
```
```text
函数 ScoreShipUpgrade(ctx, unit, action):
如果 action 不是合法船升级:
返回 0
cost = GetShipUpgradeCost(unit, action.ActionId.UnitType)
如果 ctx.Player.Coin < cost:
返回 0
score = 560 + UnitPower(unit) * 0.2
如果 cost == 0:
score += 120
否则:
score -= cost * 18
如果 ctx.Cache.StrategicPosture 是 Expansion 或 Attack:
score += 90
返回 score
```
```text
函数 ScoreAbsorbMarker(ctx, unit, action):
missing = 1 - HealthRatio(unit)
如果 missing <= 0.05:
返回 0
score = 500 + missing * 260
如果 unit 是英雄:
score += 100
返回 score
```
```text
函数 ScoreUpgrade(ctx, unit, action):
如果 unit 当前承担 Emergency 或可击杀高价值目标:
@ -2230,16 +2299,17 @@ Byakuren / Miko / Zanmu:
函数 TryFallback(ctx):
order = [
ctx.Actions.Attacks,
ctx.Actions.UnitActions,
ctx.Actions.Moves,
ctx.Actions.AttackGrounds,
ctx.Actions.CityActions,
ctx.Actions.GridActions,
ctx.Actions.PlayerActions,
ctx.Actions.All
ctx.Actions.UnitActions
]
对每个 list in order:
对每个 action in list:
如果 action.ActionType == UnitAttackAlly:
继续
candidate = Candidate(action, Fallback, 1, "兜底合法动作")
如果 candidate.IsValid:
candidate.IsFallback = true

View File

@ -26,6 +26,17 @@ AI 测试必须回答四个问题:
| 评分错误 | 车道对了但目标很蠢 | 修同类动作排序 |
| 执行失败 | `executed=false` | 查 `CompleteExecute`、参数刷新、网络状态 |
本轮自循环统一使用一组可比较指标,避免只凭单局观感判断聪明程度:
| 指标组 | 字段 | 判断方式 |
|---|---|---|
| 正确性 | `failedGames``noEffect``repeated``maxActions/playerTurn` | 先保证没有错误、死循环和无收益动作 |
| 扩张 | `aliveAvgCities``alive>=2``alive>=3``maxCities` | 看整体城市数和二城/三城率是否提升 |
| 战斗 | `UnitAttack``kills``actingUnitDeaths``PriorityTactic/Tactic` | 看是否有战果,是否过度送兵 |
| 英雄 | `selected``spawned``HeroManagement``HeroPlaybook`、英雄 reason/action | 看英雄是否上场、是否用个人机制 |
| Fallback | Fallback 总数、actionType、no-effect | 用来发现规则缺口 |
| 性能 | `actions/sec``avgGame``decision avg/p95/max` | 只处理明显尖峰,不牺牲聪明度做极端优化 |
---
## 2. 诊断开关
@ -613,16 +624,24 @@ decideMs 尖峰且 actionPool.all 高
自循环的固定流程:
```text
1. 固定 10 个 seed、地图、阵营组合
2. 跑一批 JSONL。
3. 聚合上面的指标。
1. 固定 Seed、地图尺寸、玩家数、回合数、难度、动作预算
2. 用 Editor batch runner 跑一批 JSONL 和 batch_summary.json
3. 先用 analyze_ai_batch_quality.py 聚合指标,不直接打开大日志
4. 找异常最高的 3 类问题。
5. 对每类问题抽 3-5 条 eventSequence 做人工复核。
6. 判断改 18、19、代码还是 Action 生成。
7. 修改后用同一批 seed 回归。
7. 修改后用同一批 Seed 和同一批参数回归。
8. 对比指标是否改善。
```
固定 Seed 的含义:
```text
Tools/RunAIDirectorBatch.ps1 -Seed 424242 ...
```
BatchRunner 会在 Editor/batch 环境把 `MapData.Net.RandomSeed`、Unity 随机种子和地图高度噪声种子固定住。多局批跑时使用 `Seed + gameIndex``batch_summary.json` 中的 `diagnosticsLogPath` 是分析器匹配 JSONL 的权威路径,不用再按时间戳猜日志。
---
## 10. 问题回写流程
@ -682,6 +701,13 @@ delta证据
开始调整 18/19 的策略思路
```
推荐命令:
```powershell
Tools/RunAIDirectorBatch.ps1 -Games 1 -Players 17 -Width 30 -Height 30 -Turns 12 -Seed 424242 -TimeoutSeconds 420 -MaxActions 9000 -MaxActionsPerPlayerTurn 120 -Difficulty LUNATIC -KeepGoing
python .codex/skills/th1-ai-director/scripts/analyze_ai_batch_quality.py --batch Unity/Logs/AI_Batch/<timestamp>/batch_summary.json --top 12
```
第四轮:
```text

View File

@ -9,6 +9,7 @@ param(
[int]$TimeoutSeconds = 1800,
[int]$MaxActions = 20000,
[int]$MaxActionsPerPlayerTurn = 260,
[int]$Seed = 0,
[string]$OutDir,
[string]$Difficulty = "LUNATIC",
[switch]$KeepGoing,
@ -113,6 +114,7 @@ $args = @(
"-aiBatchTimeoutSeconds", $TimeoutSeconds,
"-aiBatchMaxActions", $MaxActions,
"-aiBatchMaxActionsPerPlayerTurn", $MaxActionsPerPlayerTurn,
"-aiBatchSeed", $Seed,
"-aiBatchDifficulty", $Difficulty,
"-aiBatchFailFast", $failFast,
"-aiBatchCompactDiagnostics", $compactDiagnostics,

View File

@ -257,7 +257,6 @@ namespace Logic.AI.Director
public AIActionBase FindBestFallback()
{
return FindFirstFallbackAction(AttackActions)
?? FindFirstFallbackAction(AttackAllyActions)
?? FindFirstFallbackAction(AttackGroundActions)
?? FindFirstFallbackAction(CityActions)
?? FindFirstFallbackAction(GridActions)
@ -457,6 +456,11 @@ namespace Logic.AI.Director
var id = action?.ActionLogic?.ActionId;
if (id == null) return false;
if (id.ActionType == CommonActionType.UnitAttackAlly)
{
return false;
}
if (id.ActionType == CommonActionType.UnitAction
&& id.UnitActionType is UnitActionType.Examine
or UnitActionType.KANAKOSIT

View File

@ -327,6 +327,14 @@ namespace Logic.AI.Director
return true;
}
var heroSpawn = FindBestHeroSpawn(ctx, decision);
if (heroSpawn.IsValid)
{
candidate = heroSpawn;
decision.AddTrace($"HeroManagement: spawn hero {candidate.AIAction?.ActionLogic?.ActionId?.GiantType}.", ctx.Config.MaxCandidateTraceCount);
return true;
}
var finishTask = ctx.ActionIndex.FindBestHeroTaskFinish(ctx.Player);
candidate = ctx.ActionIndex.Candidate(finishTask, AIDirectorLane.HeroManagement, "HeroManagement.FinishLowestTask", 870f);
AddTerms(candidate, ("base", 870f));
@ -340,6 +348,59 @@ namespace Logic.AI.Director
return false;
}
private AIDirectorActionCandidate FindBestHeroSpawn(AIDirectorContext ctx, AIDirectorDecision decision)
{
AIDirectorActionCandidate best = AIDirectorActionCandidate.None;
foreach (var plan in ctx.Cache.CityPlans)
{
if (plan?.City == null) continue;
foreach (var action in ctx.ActionIndex.GetCityActions(plan.City))
{
if (!IsSelectedHeroSpawnAction(ctx.Player, action)) continue;
var cityPlanBonus = (plan.Priority > 0f ? plan.Priority : 0f) * 0.08f;
var roleBonus = plan.Kind switch
{
AIDirectorCityPlanKind.EmergencyDefense => 40f,
AIDirectorCityPlanKind.Mobilize => 30f,
AIDirectorCityPlanKind.Frontline => 25f,
_ => 0f
};
var priority = 880f + cityPlanBonus + roleBonus;
var current = ctx.ActionIndex.Candidate(action, AIDirectorLane.HeroManagement, "HeroManagement.SpawnHero", priority);
AddTerms(current, ("base", 880f), ("cityPlan", cityPlanBonus), ("role", roleBonus));
current.City = current.City ?? plan.City;
current.TargetGrid = current.TargetGrid ?? plan.CityGrid;
RecordCandidate(ctx, decision, "SpawnHero", current, current.IsValid ? null : "CheckCanFailed");
best = MaxCandidate(best, current);
}
}
if (best.IsValid) return best;
foreach (var action in ctx.ActionIndex.CityActions)
{
if (!IsSelectedHeroSpawnAction(ctx.Player, action)) continue;
var current = ctx.ActionIndex.Candidate(action, AIDirectorLane.HeroManagement, "HeroManagement.SpawnHero", 880f);
AddTerms(current, ("base", 880f));
RecordCandidate(ctx, decision, "SpawnHero", current, current.IsValid ? null : "CheckCanFailed");
best = MaxCandidate(best, current);
}
return best;
}
private static bool IsSelectedHeroSpawnAction(PlayerData player, AIActionBase action)
{
var id = action?.ActionLogic?.ActionId;
if (id == null) return false;
return id.ActionType == CommonActionType.TrainUnit
&& id.UnitType == UnitType.Giant
&& id.GiantType != GiantType.None
&& player?.PlayerHeroData != null
&& player.PlayerHeroData.HasHero(id.GiantType);
}
private bool TryHeroPlaybookLane(AIDirectorContext ctx, AIDirectorDecision decision, out AIDirectorActionCandidate candidate)
{
candidate = AIDirectorActionCandidate.None;

View File

@ -65,7 +65,9 @@ namespace Logic.AI.Director
HeroUpgrade,
Upgrade,
CultureUnitUpgrade,
Recover
Recover,
ShipUpgrade,
AbsorbMarker
}
public enum AIDirectorHeroRole

View File

@ -39,7 +39,7 @@ namespace Logic.AI.Director
foreach (var action in actions.UnitActions)
{
if (action?.Param?.UnitData == null || action.ActionLogic?.ActionId == null) continue;
var type = GetOpportunityType(action.ActionLogic.ActionId.UnitActionType);
var type = GetOpportunityType(action);
if (type == AIDirectorUnitOpportunityType.None) continue;
var value = ScoreUnitOpportunity(ctx, action, type);
if (value <= 0f) continue;
@ -511,9 +511,13 @@ namespace Logic.AI.Director
};
}
private AIDirectorUnitOpportunityType GetOpportunityType(UnitActionType type)
private AIDirectorUnitOpportunityType GetOpportunityType(AIActionBase action)
{
return type switch
var id = action?.ActionLogic?.ActionId;
if (id == null) return AIDirectorUnitOpportunityType.None;
if (UnitActionUpgradeHelper.IsShipUpgradeTarget(id.UnitType)) return AIDirectorUnitOpportunityType.ShipUpgrade;
return id.UnitActionType switch
{
UnitActionType.Capture => AIDirectorUnitOpportunityType.Capture,
UnitActionType.Examine => AIDirectorUnitOpportunityType.Examine,
@ -522,6 +526,7 @@ namespace Logic.AI.Director
UnitActionType.Upgrade => AIDirectorUnitOpportunityType.Upgrade,
UnitActionType.CultureUnitUpgrade => AIDirectorUnitOpportunityType.CultureUnitUpgrade,
UnitActionType.Recover => AIDirectorUnitOpportunityType.Recover,
UnitActionType.AbsorbRedMist or UnitActionType.HakureiAbsorbRune => AIDirectorUnitOpportunityType.AbsorbMarker,
_ => AIDirectorUnitOpportunityType.None
};
}
@ -539,6 +544,8 @@ namespace Logic.AI.Director
AIDirectorUnitOpportunityType.Upgrade => 610f,
AIDirectorUnitOpportunityType.CultureUnitUpgrade => 600f,
AIDirectorUnitOpportunityType.Recover => ScoreRecover(ctx, unit),
AIDirectorUnitOpportunityType.ShipUpgrade => ScoreShipUpgrade(ctx, action, unit),
AIDirectorUnitOpportunityType.AbsorbMarker => ScoreAbsorbMarker(ctx, unit),
_ => 0f
};
@ -562,12 +569,37 @@ namespace Logic.AI.Director
{
if (unit == null) return 0f;
var hp = AIDirectorMath.HealthRatio(unit);
if (hp > ctx.Config.LowHealthRatio) return 0f;
var score = 560f + (1f - hp) * 240f;
var hasNegativeState = unit.GetSkill(SkillType.KomeijiFear, out _) || unit.GetSkill(SkillType.SAKUYATIRED, out _);
if (hp > ctx.Config.LowHealthRatio && !hasNegativeState) return 0f;
var score = 520f + (1f - hp) * 220f;
if (hasNegativeState) score += 160f;
if (unit.TreatedAsHero(ctx.Map, unit)) score += 120f;
return score;
}
private float ScoreShipUpgrade(AIDirectorContext ctx, AIActionBase action, UnitData unit)
{
if (unit == null || action?.ActionLogic?.ActionId == null) return 0f;
if (!UnitActionUpgradeHelper.TryGetShipUpgradeCost(unit, action.ActionLogic.ActionId.UnitType, out var cost)) return 0f;
if (ctx.Player.PlayerCoin < cost) return 0f;
var score = 560f + AIDirectorMath.UnitPower(unit) * 0.2f;
if (cost == 0) score += 120f;
else score -= cost * 18f;
if (ctx.Cache.StrategicPosture is AIDirectorStrategicPosture.Expansion or AIDirectorStrategicPosture.Attack) score += 90f;
return score;
}
private float ScoreAbsorbMarker(AIDirectorContext ctx, UnitData unit)
{
if (unit == null) return 0f;
var missing = 1f - AIDirectorMath.HealthRatio(unit);
if (missing <= 0.05f) return 0f;
var score = 500f + missing * 260f;
if (unit.TreatedAsHero(ctx.Map, unit)) score += 100f;
return score;
}
private bool ExistsHighValueDevelopmentTargetNearCity(AIDirectorContext ctx, AIDirectorWorldCache cache)
{
foreach (var target in cache.DevelopmentTargets)

View File

@ -21,10 +21,12 @@ namespace Logic.AI
public static bool ForceAllPlayersAi;
public static bool SkipPresentationWait;
public static bool CompactDiagnostics;
public static int RandomSeedOverride;
#else
public const bool ForceAllPlayersAi = false;
public const bool SkipPresentationWait = false;
public const bool CompactDiagnostics = false;
public const int RandomSeedOverride = 0;
#endif
}

View File

@ -242,6 +242,13 @@ namespace TH1_Logic.Core
{
MapData = new MapData(MapConfig, NetMode.Single);
MapData.Net.Mode = NetMode.Single;
#if UNITY_EDITOR
if (AIDirectorBatchRuntime.RandomSeedOverride != 0)
{
MapData.Net.RandomSeed = AIDirectorBatchRuntime.RandomSeedOverride;
UnityEngine.Random.InitState(AIDirectorBatchRuntime.RandomSeedOverride);
}
#endif
//step #1 初始化Audio
InitGameAudio();

View File

@ -136,6 +136,7 @@ namespace TH1_Logic.Editor
AIDirectorBatchRuntime.ForceAllPlayersAi = false;
AIDirectorBatchRuntime.SkipPresentationWait = false;
AIDirectorBatchRuntime.CompactDiagnostics = false;
AIDirectorBatchRuntime.RandomSeedOverride = 0;
#endif
CompleteResult(result, gameDirectory);
results.Add(result);
@ -166,6 +167,7 @@ namespace TH1_Logic.Editor
AIDirectorBatchRuntime.ForceAllPlayersAi = true;
AIDirectorBatchRuntime.SkipPresentationWait = true;
AIDirectorBatchRuntime.CompactDiagnostics = options.CompactDiagnostics;
AIDirectorBatchRuntime.RandomSeedOverride = options.Seed == 0 ? 0 : options.Seed + gameIndex;
#if TH1_AI_DIRECTOR_DIAGNOSTICS || UNITY_EDITOR
AIDirectorDiagnostics.BeginNewSession();
result.diagnosticsLogPath = AIDirectorDiagnostics.CurrentLogPath;
@ -176,6 +178,7 @@ namespace TH1_Logic.Editor
result.playerCount = (int)main.MapConfig.PlayerCount;
result.width = (int)main.MapConfig.Width;
result.height = (int)main.MapConfig.Height;
result.randomSeed = AIDirectorBatchRuntime.RandomSeedOverride;
result.aiKernel = AIKernelRegistry.CurrentKernelType.ToString();
Debug.Log($"[AI.Batch] Start game {gameIndex + 1}/{options.Games}: players={result.playerCount}, size={result.width}x{result.height}");
@ -226,6 +229,7 @@ namespace TH1_Logic.Editor
AIDirectorBatchRuntime.ForceAllPlayersAi = false;
AIDirectorBatchRuntime.SkipPresentationWait = false;
AIDirectorBatchRuntime.CompactDiagnostics = false;
AIDirectorBatchRuntime.RandomSeedOverride = 0;
#endif
try
{
@ -397,10 +401,7 @@ namespace TH1_Logic.Editor
{
var civId = PickCivId(i + gameIndex, used);
used.Add(civId);
if (!config.SetSinglePlayerSlotCiv(i, civId, civId))
{
config.SetPlayerSlotRandomCiv(i, NetMode.Single);
}
config.SetSinglePlayerSlotCiv(i, civId, civId);
}
config.EnsurePlayerSlots(NetMode.Single);
@ -463,6 +464,7 @@ namespace TH1_Logic.Editor
options.MaxActionsPerPlayerTurn = GetIntArg("-aiBatchMaxActionsPerPlayerTurn", options.MaxActionsPerPlayerTurn);
options.StagnantFrameLimit = GetIntArg("-aiBatchStagnantFrames", options.StagnantFrameLimit);
options.AnimationSpeed = GetFloatArg("-aiBatchAnimationSpeed", options.AnimationSpeed);
options.Seed = GetIntArg("-aiBatchSeed", options.Seed);
options.FailFast = GetBoolArg("-aiBatchFailFast", options.FailFast);
options.DisableNearbySpawnPoints = GetBoolArg("-aiBatchDisableNearbySpawnPoints", options.DisableNearbySpawnPoints);
options.CompactDiagnostics = GetBoolArg("-aiBatchCompactDiagnostics", options.CompactDiagnostics);
@ -665,6 +667,14 @@ namespace TH1_Logic.Editor
var executedActionTypes = new Dictionary<string, int>();
var laneActionTypes = new Dictionary<string, int>();
var noEffectActionTypes = new Dictionary<string, int>();
var heroReasons = new Dictionary<string, int>();
var heroActionTypes = new Dictionary<string, int>();
var heroExecutedActionTypes = new Dictionary<string, int>();
var fallbackActionTypes = new Dictionary<string, int>();
var fallbackReasons = new Dictionary<string, int>();
var fallbackExecutedActionTypes = new Dictionary<string, int>();
var fallbackNoEffectActionTypes = new Dictionary<string, int>();
var decisionLaneByAction = new Dictionary<string, string>();
try
{
@ -695,12 +705,37 @@ namespace TH1_Logic.Editor
var lane = decision.Value<string>("lane") ?? string.Empty;
var reason = decision.Value<string>("reason") ?? string.Empty;
var actionType = decision["action"]?.Value<string>("actionType") ?? string.Empty;
var action = decision["action"];
var actionType = action?.Value<string>("actionType") ?? string.Empty;
var actionKey = BuildActionMetricKey(action);
var turnActionKey = BuildTurnActionKey(record, action);
Increment(lanes, lane);
Increment(reasons, reason);
Increment(selectedActionTypes, actionType);
Increment(laneActionTypes, $"{lane}:{actionType}");
if (decision.Value<bool?>("isFallback") ?? false) summary.fallbackDecisions++;
if (!string.IsNullOrEmpty(turnActionKey)) decisionLaneByAction[turnActionKey] = lane;
if (lane == "HeroManagement")
{
summary.heroManagementDecisions++;
Increment(heroReasons, reason);
Increment(heroActionTypes, actionKey);
}
else if (lane == "HeroPlaybook")
{
summary.heroPlaybookDecisions++;
Increment(heroReasons, reason);
Increment(heroActionTypes, actionKey);
if (reason.StartsWith("HeroPlaybook.", StringComparison.Ordinal)) summary.genericHeroDecisions++;
else summary.heroRuleDecisions++;
}
if (decision.Value<bool?>("isFallback") ?? false)
{
summary.fallbackDecisions++;
Increment(fallbackReasons, reason);
Increment(fallbackActionTypes, actionKey);
}
}
else if (eventType == "Execution")
{
@ -708,6 +743,7 @@ namespace TH1_Logic.Editor
var execution = record["execution"];
var action = execution?["action"];
var actionType = action?.Value<string>("actionType") ?? string.Empty;
var actionKey = BuildActionMetricKey(action);
Increment(executedActionTypes, actionType);
var playerTurnKey = $"{record.Value<uint?>("playerId") ?? 0}:{record.Value<uint?>("playerTurn") ?? 0}";
@ -721,10 +757,33 @@ namespace TH1_Logic.Editor
if (!executedStableKeysByPlayerTurn.Add(stableKeyInTurn)) summary.repeatedExecutions++;
}
if ((execution?.Value<bool?>("executed") ?? false) && !HasMeaningfulDelta(execution["delta"]))
var turnActionKey = BuildTurnActionKey(record, action);
decisionLaneByAction.TryGetValue(turnActionKey, out var executedLane);
if (executedLane == "HeroManagement" || executedLane == "HeroPlaybook")
{
Increment(heroExecutedActionTypes, actionKey);
}
else if (executedLane == "Fallback")
{
Increment(fallbackExecutedActionTypes, actionKey);
}
var delta = execution?["delta"];
if (execution?.Value<bool?>("executed") ?? false)
{
summary.heroDelta += delta?.Value<int?>("heroDelta") ?? 0;
summary.selectedHeroDelta += delta?.Value<int?>("selectedHeroDelta") ?? 0;
summary.heroTaskDelta += delta?.Value<int?>("heroTaskDelta") ?? 0;
summary.readyHeroTaskDelta += delta?.Value<int?>("readyHeroTaskDelta") ?? 0;
summary.forcedHeroTaskDelta += delta?.Value<int?>("forcedHeroTaskDelta") ?? 0;
summary.heroTaskProgressDelta += delta?.Value<int?>("heroTaskProgressDelta") ?? 0;
}
if ((execution?.Value<bool?>("executed") ?? false) && !HasMeaningfulDelta(delta))
{
summary.noEffectExecutions++;
Increment(noEffectActionTypes, actionType);
if (executedLane == "Fallback") Increment(fallbackNoEffectActionTypes, actionKey);
}
}
}
@ -744,9 +803,46 @@ namespace TH1_Logic.Editor
summary.topExecutedActionTypes = TopCounts(executedActionTypes, 12);
summary.topLaneActionTypes = TopCounts(laneActionTypes, 20);
summary.noEffectActionTypes = TopCounts(noEffectActionTypes, 12);
summary.topHeroReasons = TopCounts(heroReasons, 16);
summary.topHeroActionTypes = TopCounts(heroActionTypes, 12);
summary.topHeroExecutedActionTypes = TopCounts(heroExecutedActionTypes, 12);
summary.topFallbackReasons = TopCounts(fallbackReasons, 12);
summary.topFallbackActionTypes = TopCounts(fallbackActionTypes, 12);
summary.topFallbackExecutedActionTypes = TopCounts(fallbackExecutedActionTypes, 12);
summary.fallbackNoEffectActionTypes = TopCounts(fallbackNoEffectActionTypes, 12);
return summary;
}
private static string BuildTurnActionKey(JToken record, JToken action)
{
var stableKey = action?.Value<string>("stableKey");
if (string.IsNullOrEmpty(stableKey)) return string.Empty;
return $"{record?.Value<uint?>("playerId") ?? 0}:{record?.Value<uint?>("playerTurn") ?? 0}:{stableKey}";
}
private static string BuildActionMetricKey(JToken action)
{
if (action == null) return string.Empty;
var actionType = action.Value<string>("actionType") ?? string.Empty;
var subType = actionType switch
{
"UnitAction" => action.Value<string>("unitActionType") ?? string.Empty,
"PlayerAction" => action.Value<string>("playerActionType") ?? string.Empty,
"CityAction" => action.Value<string>("cityActionType") ?? string.Empty,
"CityLevelUpAction" => action.Value<string>("cityLevelUpActionType") ?? string.Empty,
"GridMisc" => action.Value<string>("gridMiscActionType") ?? string.Empty,
"LearnTech" => action.Value<string>("techType") ?? string.Empty,
"BuyCultureCard" => action.Value<string>("cultureCardType") ?? string.Empty,
"Gain" => action.Value<string>("resourceType") ?? string.Empty,
"TrainUnit" => action.Value<string>("unitType") ?? string.Empty,
_ => string.Empty
};
return string.IsNullOrEmpty(subType) || subType == "None"
? actionType
: $"{actionType}:{subType}";
}
private static bool HasMeaningfulDelta(JToken delta)
{
if (delta == null) return false;
@ -910,6 +1006,7 @@ namespace TH1_Logic.Editor
public bool DisableNearbySpawnPoints = false;
public bool CompactDiagnostics = true;
public bool AllSight = false;
public int Seed = 0;
public string OutputDirectory;
public string ScenePath;
public AIDifficult Difficulty = AIDifficult.LUNATIC;
@ -928,6 +1025,7 @@ namespace TH1_Logic.Editor
MaxActionsPerPlayerTurn = Mathf.Max(0, MaxActionsPerPlayerTurn);
StagnantFrameLimit = Mathf.Max(0, StagnantFrameLimit);
AnimationSpeed = Mathf.Max(1f, AnimationSpeed);
Seed = Mathf.Max(0, Seed);
if (string.IsNullOrWhiteSpace(OutputDirectory))
{
var root = Directory.GetParent(Application.dataPath).FullName;
@ -969,6 +1067,7 @@ namespace TH1_Logic.Editor
public int playerCount;
public int width;
public int height;
public int randomSeed;
public int frames;
public int netActions;
public uint maxPlayerTurn;
@ -1004,9 +1103,19 @@ namespace TH1_Logic.Editor
public int decisions;
public int noActionDecisions;
public int fallbackDecisions;
public int heroManagementDecisions;
public int heroPlaybookDecisions;
public int heroRuleDecisions;
public int genericHeroDecisions;
public int executions;
public int noEffectExecutions;
public int repeatedExecutions;
public int heroDelta;
public int selectedHeroDelta;
public int heroTaskDelta;
public int readyHeroTaskDelta;
public int forcedHeroTaskDelta;
public int heroTaskProgressDelta;
public float avgDecideMs;
public float p95DecideMs;
public float maxDecideMs;
@ -1025,6 +1134,13 @@ namespace TH1_Logic.Editor
public List<BatchCountMetric> topExecutedActionTypes = new();
public List<BatchCountMetric> topLaneActionTypes = new();
public List<BatchCountMetric> noEffectActionTypes = new();
public List<BatchCountMetric> topHeroReasons = new();
public List<BatchCountMetric> topHeroActionTypes = new();
public List<BatchCountMetric> topHeroExecutedActionTypes = new();
public List<BatchCountMetric> topFallbackReasons = new();
public List<BatchCountMetric> topFallbackActionTypes = new();
public List<BatchCountMetric> topFallbackExecutedActionTypes = new();
public List<BatchCountMetric> fallbackNoEffectActionTypes = new();
}
[Serializable]

View File

@ -11,6 +11,7 @@ using System;
using System.Collections.Generic;
using System.Linq;
using Logic.Pool;
using Logic.AI;
using RuntimeData;
using TH1_Logic.Core;
using Unity.VisualScripting;
@ -156,12 +157,17 @@ namespace Logic
//海陆层使用的地形高度数据
private void GenerateHeightMap()
{
System.Random batchRandom = null;
#if UNITY_EDITOR
if (AIDirectorBatchRuntime.RandomSeedOverride != 0)
batchRandom = new System.Random(AIDirectorBatchRuntime.RandomSeedOverride ^ 0x5F3759DF);
#endif
for (var x = 0; x < _width; x++)
{
for (var y = 0; y < _height; y++)
{
float seda = new System.Random().Next(1, 30);
float sedb = new System.Random().Next(2, 40);
float seda = batchRandom?.Next(1, 30) ?? new System.Random().Next(1, 30);
float sedb = batchRandom?.Next(2, 40) ?? new System.Random().Next(2, 40);
// 使用种子值来改变噪声的生成,保证每次生成的噪声不同
var xCoord = (float)x / _width * Scale / seda;
var yCoord = (float)y / _height * Scale / sedb;