Improve AI Director combat and threat logic

This commit is contained in:
wuwenbo 2026-07-01 11:56:14 +08:00
parent 05db312da7
commit 7eca426175
8 changed files with 499 additions and 43 deletions

View File

@ -88,18 +88,19 @@ Director 使用固定优先级车道。车道之间不做统一评分,车道
|---|---|---|
| 1 | Emergency | 阻止立即丢城、被偷家、关键单位暴毙 |
| 2 | HeroManagement | 处理选英雄、英雄任务、出场和复活节奏 |
| 3 | Expansion | 二城前和早期高价值扩张优先抢可占城中心 |
| 4 | HeroPlaybook | 发挥英雄机制和阵营特色 |
| 5 | Tactic | 处理当前可攻击或近距离交战 |
| 6 | UnitOpportunity | 抢占领、遗迹、采集、恢复、升级等确定收益 |
| 7 | Front | 把空闲单位送往正确战略方向 |
| 8 | Growth | 推进城市、地块、科技、文化、外交 |
| 9 | Fallback | 防止规则缺口导致 AI 停摆 |
| 3 | PriorityTactic | 只让击杀、打英雄、打城市威胁等确定战果抢在扩张前 |
| 4 | Expansion | 二城前和早期高价值扩张优先抢可占城中心 |
| 5 | HeroPlaybook | 发挥英雄机制和阵营特色 |
| 6 | Tactic | 处理当前可攻击或近距离交战 |
| 7 | UnitOpportunity | 抢占领、遗迹、采集、恢复、升级等确定收益 |
| 8 | Front | 把空闲单位送往正确战略方向 |
| 9 | Growth | 推进城市、地块、科技、文化、外交 |
| 10 | Fallback | 防止规则缺口导致 AI 停摆 |
车道顺序的核心含义:
```text
活命 > 英雄体系 > 早期扩张 > 英雄特色 > 当前战斗 > 短期机会 > 战略移动 > 长期发展 > 兜底
活命 > 英雄体系 > 确定战果 > 早期扩张 > 英雄特色 > 当前战斗 > 短期机会 > 战略移动 > 长期发展 > 兜底
```
---
@ -147,6 +148,7 @@ Director 使用固定优先级车道。车道之间不做统一评分,车道
- 不建立永久军团状态机。
- 大范围搜索只保留 TopN 目标。
- 同一玩家同一回合内,已经执行过的同一个 action stableKey 不再进入 Director 候选,避免单位移动、城市训练等动作反复被选中。
- 同一玩家同一回合内,扩张、回防、前线移动会记录目标意图;同类目标意图达到预算后不再重复派兵,避免所有单位反复奔向同一个目标。
---
@ -166,7 +168,7 @@ Development
进入 Defense 的典型条件:
- 有城市 `CityThreat` 达到危险。
- 敌方单位进入己方领土。
- 敌方单位进入某座己方城市自己的领土,或短期可威胁该城市
- 敌军压力显著高于附近守军。
- 敌军下回合可能威胁城市中心。
- 多个敌国同时压境。
@ -178,6 +180,8 @@ Defense 的行为倾向:
- 科技优先防御、基础兵种、移动和克制。
- 英雄优先治疗、保护、控场、守城。
城市威胁必须绑定到具体城市。远处敌人踩到我方另一座城市的领土,不能让所有城市都进入 Emergency否则 AI 会过度防守,扩张和成长动作会被挤掉。
### 5.2 Expansion
进入 Expansion 的典型条件:
@ -212,6 +216,7 @@ Attack 的行为倾向:
- Front 指向进攻目标城市。
- Tactic 优先击杀高价值目标和守城单位。
- PriorityTactic 只处理确定战果:击杀、攻击英雄、攻击正在威胁城市的单位,或极低反击风险的高分攻击。
- 城市优先训练克制兵种和攻城相关单位。
- 科技优先军事克制、攻击、移动、海战和攻城。
@ -515,6 +520,7 @@ AI 可以重新实现,但必须遵守 TH1 的游戏架构:
性能原则:
- 城市威胁只看城市周围有限范围。
- 城市领土威胁只看当前城市自己的 `Territory`,不使用全玩家领土做全局标记。
- 局部战斗只看可接触范围。
- Front 只保留少量高价值目标。
- DevelopmentTarget 只保留 TopN。
@ -522,6 +528,7 @@ AI 可以重新实现,但必须遵守 TH1 的游戏架构:
- UnitMove 不生成整张地图的所有可走格;先从城市威胁、扩张目标、战线、自城、局部战斗收集移动锚点,每个单位只保留最靠近锚点的少量移动候选。
- 行动候选只生成一次。
- 同一玩家回合内已经执行过的 stableKey 不再参与下一次候选选择。
- 扩张移动、回防移动、前线移动有每回合意图预算;预算耗尽后交给其他车道,避免单一目标吞掉整回合。
- 车道只查缓存和动作池。
如果卡顿,优先削减:
@ -545,6 +552,7 @@ AI 可以重新实现,但必须遵守 TH1 的游戏架构:
| 城市防守 | 城市危险时优先攻击威胁或回防 |
| 占领扩张 | 能占村、占城、开遗迹时不会长期无视 |
| 局部战斗 | 能打高价值目标、残血目标和威胁城市目标 |
| 高价值战术 | 击杀、打英雄、解除城市威胁时能插队,但普通蹭血不压过早期扩张 |
| 英雄表现 | 英雄会治疗、保护、地面攻击、自爆、坐镇或控场 |
| 战线移动 | 空闲单位能向防守、进攻、发展目标移动 |
| 城市发展 | 安全城市能生产、升级、建设、科技文化 |
@ -559,6 +567,8 @@ AI 可以重新实现,但必须遵守 TH1 的游戏架构:
| 能占不占 | UnitOpportunity、DevelopmentTarget |
| 英雄不放技能 | HeroState、HeroPlaybook、ActionPool |
| 单位乱走 | Front、GridThreat、MoveTarget |
| 过度防守 | CityThreat 是否把全玩家领土误算到每座城市 |
| Fallback 偏高 | Recover、UnitAttackAlly、特殊 UnitAction 是否应并入 UnitOpportunity 或 HeroPlaybook |
| 城市不发展 | CityPlan、Growth、ActionPool |
| 科技乱学 | StrategicPosture、TechScore |
| 回合慢 | ActionPool、移动候选、局部搜索半径 |

View File

@ -75,6 +75,7 @@ SkillBase
Actions: ActionPool
Trace: List<string>
BlockedActionKeysThisTurn: Set<string>
BlockedIntentKeysThisTurn: Set<string>
```
### 2.2 AIConfig
@ -88,8 +89,14 @@ SkillBase
MaxActionCount = 4096
MaxFrontCount = 12
MaxDevelopmentTargetCount = 20
MaxExpansionTargetScanCount = 8
MaxMoveActionsPerUnit = 8
MaxExpansionTargetScanCount = 6
MaxMoveActionsPerUnit = 5
MaxExpansionMoveIntentsPerTurn = 2
MaxEmergencyMoveIntentsPerTurn = 2
MaxFrontMoveIntentsPerTurn = 1
MaxEmergencyRescueDistance = 6
MaxFrontMoveDistance = 8
PriorityTacticScore = 780
LowHpRatio = 0.45
CriticalHpRatio = 0.25
HeroLowHpRatio = 0.60
@ -212,9 +219,10 @@ SkillBase
### 3.1 Decide
```text
函数 Decide(map, player, config, blockedActionKeysThisTurn):
函数 Decide(map, player, config, blockedActionKeysThisTurn, blockedIntentKeysThisTurn):
ctx = new AIContext(map, player, config)
ctx.BlockedActionKeysThisTurn = blockedActionKeysThisTurn
ctx.BlockedIntentKeysThisTurn = blockedIntentKeysThisTurn
如果 map == null 或 player == null 或 player.Alive == false:
返回 NoDecision
@ -228,6 +236,7 @@ SkillBase
lanes = [
TryEmergency,
TryHeroManagement,
TryPriorityTactic,
TryExpansion,
TryHeroPlaybook,
TryTactic,
@ -613,13 +622,14 @@ SkillBase
对每个 enemy in cache.EnemyUnits:
enemyGrid = enemy.Grid(ctx.Map)
d = Distance(cityGrid, enemyGrid)
isInCityTerritory = city.CheckIsInTerritory(enemyGrid.Id)
如果 d <= Config.EmergencyRange + AttackRange(enemy):
如果 d <= Config.EmergencyRange + AttackRange(enemy) 或 isInCityTerritory:
threat.EnemyUnits.Add(enemy)
threat.EnemyPower += UnitMilitaryValue(ctx, enemy) / max(1, d)
threat.NearestEnemyDistance = min(threat.NearestEnemyDistance, d)
如果 enemyGrid.Id in cache.SelfTerritoryGridIds:
如果 isInCityTerritory:
threat.HasEnemyOnTerritory = true
如果 CanThreatenCityNextTurn(ctx, enemy, city):
@ -1106,7 +1116,7 @@ SkillBase
```text
函数 TryEmergency(ctx):
对每个 threat in ctx.Cache.CityThreats:
如果 !threat.IsCritical:
如果 !ShouldUseEmergency(threat):
继续
candidate = TryEmergencyAttack(ctx, threat)
@ -1124,6 +1134,24 @@ SkillBase
返回 None
```
```text
函数 ShouldUseEmergency(threat):
如果 threat == null:
返回 false
如果 threat.HasEnemyOnTerritory:
返回 true
如果 threat.EnemyUnits.Count >= Config.CityCriticalEnemyCount:
返回 true
如果 threat.EnemyPower > threat.DefenderPower * Config.CityThreatPowerRatio
且 threat.EnemyUnits.Count >= Config.CityDangerEnemyCount:
返回 true
如果 threat.DangerScore >= Config.EmergencyDangerScore:
返回 true
如果 threat.IsCritical 且 threat.CanBeThreatenedNextTurn 且 threat.DefenderPower <= 0:
返回 true
返回 false
```
### 6.2 EmergencyAttack
```text
@ -1155,13 +1183,27 @@ SkillBase
```text
函数 TryEmergencyMoveToCity(ctx, threat):
best = None
intentKey = BuildIntentKey(Emergency, "MoveToCity", threat.City.Id)
如果 IsIntentBlocked(ctx, intentKey):
返回 None
如果 IsIntentBudgetReached(ctx, Emergency, Config.MaxEmergencyMoveIntentsPerTurn):
返回 None
对每个 unit in ctx.Cache.SelfUnits:
如果 !CanUseAsDefender(ctx, unit, threat):
继续
startGrid = unit.Grid(ctx.Map)
如果 Distance(startGrid, threat.CityGrid) > Config.MaxEmergencyRescueDistance 且 !threat.IsCritical:
继续
action = FindBestMoveToward(ctx, unit, threat.CityGrid)
endGrid = GetActionEndGrid(action)
如果 !IsUsefulMoveToward(ctx, startGrid, endGrid, threat.CityGrid):
继续
candidate = Candidate(action, Emergency, ScoreEmergencyMove(ctx, unit, action, threat), "回防城市")
candidate.IntentKey = intentKey
best = MaxCandidate(best, candidate)
返回 best
@ -1169,13 +1211,15 @@ SkillBase
```text
函数 ScoreEmergencyMove(ctx, unit, action, threat):
startGrid = unit.Grid(ctx.Map)
endGrid = GetActionEndGrid(action)
score = 820
score = 900
score += threat.DangerScore * 20
score += UnitMilitaryValue(ctx, unit)
score -= Distance(endGrid, threat.CityGrid) * 20
score -= GridThreat(ctx, unit, endGrid) * 0.5
score += max(0, Distance(startGrid, threat.CityGrid) - Distance(endGrid, threat.CityGrid)) * 55
score -= Distance(endGrid, threat.CityGrid) * 14
如果 unit 是英雄:
score += 40
score += 60
返回 score
```
@ -1412,6 +1456,11 @@ Expansion Lane 只处理早期扩张的刚性目标,不处理普通资源开
```text
函数 TryExpansionMove(ctx, target):
best = None
intentKey = BuildIntentKey(Expansion, "Move." + target.Type, target.Grid.Id)
如果 IsIntentBlocked(ctx, intentKey):
返回 None
如果 IsIntentBudgetReached(ctx, Expansion, Config.MaxExpansionMoveIntentsPerTurn):
返回 None
对每个 unit in ctx.Cache.SelfUnits:
如果 unit 没有移动行动点:
@ -1444,6 +1493,7 @@ Expansion Lane 只处理早期扩张的刚性目标,不处理普通资源开
score -= GridThreat(ctx, endGrid) * ThreatFactor(target)
candidate = Candidate(action, Expansion, score, "扩张移动")
candidate.IntentKey = intentKey
best = MaxCandidate(best, candidate)
返回 best
@ -1712,22 +1762,54 @@ Byakuren / Miko / Zanmu:
## 10. Tactic Lane
```text
函数 TryTactic(ctx):
函数 TryPriorityTactic(ctx):
返回 TryTactic(ctx, priorityOnly = true)
```
```text
函数 TryTactic(ctx, priorityOnly = false):
best = None
对每个 battle in ctx.Cache.LocalBattles:
action = FindBestAttack(ctx, battle.SelfUnit, battle.EnemyUnit)
candidate = Candidate(action, Tactic, 700 + battle.Value, "局部战斗攻击")
attackScore = ScoreAttackAction(ctx, action)
score = 700 + battle.Value + attackScore * 0.1
如果 priorityOnly 且 (score < Config.PriorityTacticScore !IsPriorityTacticAction(ctx, action, attackScore)):
继续
candidate = Candidate(action, Tactic, score, priorityOnly ? "高价值局部战斗攻击" : "局部战斗攻击")
best = MaxCandidate(best, candidate)
对每个 action in ctx.Actions.Attacks:
score = ScoreAttackAction(ctx, action)
candidate = Candidate(action, Tactic, score, "普通攻击收益")
如果 priorityOnly 且 (score < Config.PriorityTacticScore !IsPriorityTacticAction(ctx, action, score)):
继续
candidate = Candidate(action, Tactic, score, priorityOnly ? "高价值攻击收益" : "普通攻击收益")
best = MaxCandidate(best, candidate)
返回 best
```
```text
函数 IsPriorityTacticAction(ctx, action, attackScore):
attacker = action.UnitData
target = action.TargetUnitData
damage = CalcDamage(ctx.Map, attacker, target)
如果 damage >= target.Health:
返回 true
如果 target 是英雄:
返回 true
如果 target 正在威胁城市:
返回 true
targetValue = UnitTargetValue(ctx, target)
counterThreat = EstimateCounterDamageValue(ctx, target, attacker)
如果 attackScore >= Config.PriorityTacticScore + 60 且 counterThreat <= targetValue * 0.15:
返回 true
返回 false
```
```text
函数 ScoreAttackAction(ctx, action):
attacker = action.UnitData
@ -2201,6 +2283,31 @@ Fallback 的目标是不断回合,不负责聪明。大量进入 Fallback 说
)
```
```text
函数 BuildIntentKey(lane, kind, targetId):
如果 targetId == 0:
返回 ""
返回 lane + ":" + kind + ":" + targetId
函数 IsIntentBlocked(ctx, intentKey):
如果 intentKey 为空:
返回 false
返回 intentKey in ctx.BlockedIntentKeysThisTurn
函数 IsIntentBudgetReached(ctx, lane, maxCount):
如果 maxCount <= 0:
返回 false
count = ctx.BlockedIntentKeysThisTurn 中以 lane + ":" 开头的数量
返回 count >= maxCount
函数 IsUsefulMoveToward(ctx, startGrid, endGrid, targetGrid):
如果 startGrid == null 或 endGrid == null 或 targetGrid == null:
返回 false
startDistance = Distance(startGrid, targetGrid)
endDistance = Distance(endGrid, targetGrid)
返回 endDistance < startDistance endDistance <= 1
```
### 15.2 伤害和威胁
```text

View File

@ -77,10 +77,18 @@ namespace Logic.AI.Director
}
}
foreach (var target in ctx.Cache.DevelopmentTargets) Add(target?.Grid);
var expansionAnchorCount = 0;
foreach (var target in ctx.Cache.DevelopmentTargets)
{
if (target?.TargetType is not (AIDirectorDevelopmentTargetType.Village or AIDirectorDevelopmentTargetType.EnemyEmptyCity)) continue;
if (ctx.Config.MaxExpansionTargetScanCount > 0 && expansionAnchorCount >= ctx.Config.MaxExpansionTargetScanCount) break;
Add(target.Grid);
expansionAnchorCount++;
}
foreach (var front in ctx.Cache.Fronts)
{
if (front?.FrontType == AIDirectorFrontType.Development) continue;
Add(front?.TargetGrid);
Add(front?.AnchorGrid);
}
@ -249,12 +257,12 @@ namespace Logic.AI.Director
public AIActionBase FindBestFallback()
{
return FindFirstFallbackAction(AttackActions)
?? FindFirstFallbackAction(MoveActions)
?? FindFirstFallbackAction(AttackAllyActions)
?? FindFirstFallbackAction(AttackGroundActions)
?? FindFirstFallbackAction(CityActions)
?? FindFirstFallbackAction(GridActions)
?? FindFirstFallbackAction(PlayerActions)
?? FindFirstFallbackAction(UnitActions)
?? FindFirstFallbackAction(AllActions);
?? FindFirstFallbackAction(UnitActions);
}
private void Add(AIActionBase action)

View File

@ -12,7 +12,12 @@ namespace Logic.AI.Director
private readonly AIDirectorWorldCacheBuilder _cacheBuilder = new();
private readonly AIDirectorHeroRuleEvaluator _heroEvaluator = new();
public AIDirectorDecision Decide(MapData map, PlayerData player, AIDirectorConfig config = null, HashSet<string> blockedActionKeys = null)
public AIDirectorDecision Decide(
MapData map,
PlayerData player,
AIDirectorConfig config = null,
HashSet<string> blockedActionKeys = null,
HashSet<string> blockedIntentKeys = null)
{
var stopwatch = Stopwatch.StartNew();
var decision = new AIDirectorDecision();
@ -24,7 +29,7 @@ namespace Logic.AI.Director
return decision;
}
var ctx = new AIDirectorContext(map, player, config ?? AIDirectorConfig.CreateDefault(), blockedActionKeys);
var ctx = new AIDirectorContext(map, player, config ?? AIDirectorConfig.CreateDefault(), blockedActionKeys, blockedIntentKeys);
ctx.Cache = _cacheBuilder.Build(ctx);
ctx.ActionIndex = AIDirectorActionIndex.Build(ctx);
_cacheBuilder.BuildUnitOpportunities(ctx);
@ -45,9 +50,10 @@ namespace Logic.AI.Director
if (TryEmergencyLane(ctx, decision, out var candidate)
|| TryHeroManagementLane(ctx, decision, out candidate)
|| TryTacticLane(ctx, decision, out candidate, true)
|| TryExpansionLane(ctx, decision, out candidate)
|| TryHeroPlaybookLane(ctx, decision, out candidate)
|| TryTacticLane(ctx, decision, out candidate)
|| TryTacticLane(ctx, decision, out candidate, false)
|| TryUnitOpportunityLane(ctx, decision, out candidate)
|| TryFrontLane(ctx, decision, out candidate)
|| TryGrowthLane(ctx, decision, out candidate)
@ -67,9 +73,15 @@ namespace Logic.AI.Director
return decision;
}
public bool TryDecide(MapData map, PlayerData player, out AIDirectorActionCandidate candidate, AIDirectorConfig config = null, HashSet<string> blockedActionKeys = null)
public bool TryDecide(
MapData map,
PlayerData player,
out AIDirectorActionCandidate candidate,
AIDirectorConfig config = null,
HashSet<string> blockedActionKeys = null,
HashSet<string> blockedIntentKeys = null)
{
var decision = Decide(map, player, config, blockedActionKeys);
var decision = Decide(map, player, config, blockedActionKeys, blockedIntentKeys);
candidate = decision.Candidate;
return decision.HasAction;
}
@ -175,6 +187,9 @@ namespace Logic.AI.Director
AIDirectorDevelopmentTarget target)
{
AIDirectorActionCandidate best = AIDirectorActionCandidate.None;
if (IsIntentBudgetReached(ctx, AIDirectorLane.Expansion, ctx.Config.MaxExpansionMoveIntentsPerTurn)) return best;
var intentKey = BuildIntentKey(AIDirectorLane.Expansion, $"Move.{target.TargetType}", target?.Grid?.Id ?? 0);
if (IsIntentBlocked(ctx, intentKey)) return best;
foreach (var unit in ctx.Cache.SelfUnits)
{
if (unit == null || unit.GetActionPoint(ActionPointType.Move) <= 0) continue;
@ -213,6 +228,7 @@ namespace Logic.AI.Director
("threat", -safetyPenalty));
current.Unit = current.Unit ?? unit;
current.TargetGrid = current.TargetGrid ?? target.Grid;
current.IntentKey = intentKey;
RecordCandidate(ctx, decision, "ExpansionMove", current, action == null ? "NoMoveAction" : (current.IsValid ? null : "CheckCanFailed"));
best = MaxCandidate(best, current);
}
@ -245,6 +261,9 @@ namespace Logic.AI.Director
private AIDirectorActionCandidate TryEmergencyMove(AIDirectorContext ctx, AIDirectorDecision decision, AIDirectorCityThreat threat)
{
AIDirectorActionCandidate best = AIDirectorActionCandidate.None;
if (IsIntentBudgetReached(ctx, AIDirectorLane.Emergency, ctx.Config.MaxEmergencyMoveIntentsPerTurn)) return best;
var intentKey = BuildIntentKey(AIDirectorLane.Emergency, "MoveToCity", threat?.City?.Id ?? threat?.CityGrid?.Id ?? 0);
if (IsIntentBlocked(ctx, intentKey)) return best;
foreach (var unit in ctx.Cache.SelfUnits)
{
if (unit == null || unit.GetActionPoint(ActionPointType.Move) <= 0) continue;
@ -252,15 +271,21 @@ namespace Logic.AI.Director
var action = ctx.ActionIndex.FindBestMove(unit, threat.CityGrid);
var unitGrid = unit.Grid(ctx.Map);
var startDistance = AIDirectorMath.Distance(ctx.Map, unitGrid, threat.CityGrid);
if (startDistance > ctx.Config.MaxEmergencyRescueDistance && !threat.IsCritical) continue;
var endGrid = action?.Param?.TargetGridData ?? action?.Param?.GridData;
if (!IsUsefulMoveToward(ctx, unitGrid, endGrid, threat.CityGrid)) continue;
var endDistance = AIDirectorMath.Distance(ctx.Map, endGrid, threat.CityGrid);
var unitPower = AIDirectorMath.UnitPower(unit);
var distancePenalty = -startDistance * 12f;
var progressBonus = Mathf.Max(0, startDistance - endDistance) * 55f;
var distancePenalty = -endDistance * 14f;
var heroBonus = unit.TreatedAsHero(ctx.Map, unit) ? 60f : 0f;
var score = 900f + threat.DangerScore * 20f + unitPower + distancePenalty + heroBonus;
var score = 900f + threat.DangerScore * 20f + unitPower + progressBonus + distancePenalty + heroBonus;
var candidate = ctx.ActionIndex.Candidate(action, AIDirectorLane.Emergency, "Emergency.MoveToCity", score);
AddTerms(candidate, ("base", 900f), ("danger", threat.DangerScore * 20f), ("unitPower", unitPower), ("distance", distancePenalty), ("hero", heroBonus));
AddTerms(candidate, ("base", 900f), ("danger", threat.DangerScore * 20f), ("unitPower", unitPower), ("progress", progressBonus), ("distance", distancePenalty), ("hero", heroBonus));
candidate.Unit = candidate.Unit ?? unit;
candidate.City = candidate.City ?? threat.City;
candidate.TargetGrid = candidate.TargetGrid ?? threat.CityGrid;
candidate.IntentKey = intentKey;
RecordCandidate(ctx, decision, "EmergencyMove", candidate, action == null ? "NoMoveAction" : (candidate.IsValid ? null : "CheckCanFailed"));
best = MaxCandidate(best, candidate);
}
@ -369,10 +394,12 @@ namespace Logic.AI.Director
return frontCandidate;
}
private bool TryTacticLane(AIDirectorContext ctx, AIDirectorDecision decision, out AIDirectorActionCandidate candidate)
private bool TryTacticLane(AIDirectorContext ctx, AIDirectorDecision decision, out AIDirectorActionCandidate candidate, bool priorityOnly)
{
candidate = AIDirectorActionCandidate.None;
AIDirectorActionCandidate best = AIDirectorActionCandidate.None;
var minScore = priorityOnly ? ctx.Config.PriorityTacticScore : 0f;
var reasonPrefix = priorityOnly ? "PriorityTactic" : "Tactic";
foreach (var battle in ctx.Cache.LocalBattles)
{
@ -380,8 +407,9 @@ namespace Logic.AI.Director
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, "Tactic.LocalBattleAttack", score);
var current = ctx.ActionIndex.Candidate(action, AIDirectorLane.Tactic, $"{reasonPrefix}.LocalBattleAttack", score);
AddTerms(current, ("base", 700f), ("localBattle", battle.Value), ("attackScore", attackScore * 0.1f));
if (priorityOnly && (score < minScore || !IsPriorityTacticAction(ctx, action, attackScore))) continue;
RecordCandidate(ctx, decision, "LocalBattle", current, action == null ? "NoAttackAction" : null);
best = MaxCandidate(best, current);
}
@ -389,8 +417,9 @@ namespace Logic.AI.Director
foreach (var action in ctx.ActionIndex.AttackActions)
{
var score = ScoreAttackAction(ctx, action);
var current = ctx.ActionIndex.Candidate(action, AIDirectorLane.Tactic, "Tactic.AttackValue", score);
var current = ctx.ActionIndex.Candidate(action, AIDirectorLane.Tactic, $"{reasonPrefix}.AttackValue", score);
AddTerms(current, ("attackScore", score));
if (priorityOnly && (score < minScore || !IsPriorityTacticAction(ctx, action, score))) continue;
RecordCandidate(ctx, decision, "AttackAction", current);
best = MaxCandidate(best, current);
}
@ -421,19 +450,28 @@ namespace Logic.AI.Director
private bool TryFrontLane(AIDirectorContext ctx, AIDirectorDecision decision, out AIDirectorActionCandidate candidate)
{
candidate = AIDirectorActionCandidate.None;
if (IsIntentBudgetReached(ctx, AIDirectorLane.Front, ctx.Config.MaxFrontMoveIntentsPerTurn)) return false;
AIDirectorActionCandidate best = AIDirectorActionCandidate.None;
foreach (var front in ctx.Cache.Fronts)
{
if (front.FrontType == AIDirectorFrontType.Development) continue;
var target = ResolveFrontTarget(front);
if (target == null) continue;
var intentKey = BuildIntentKey(AIDirectorLane.Front, front.FrontType.ToString(), target.Id);
if (IsIntentBlocked(ctx, intentKey)) continue;
foreach (var unit in ctx.Cache.SelfUnits)
{
if (ShouldSkipFrontMove(ctx, unit, front)) continue;
var action = ctx.ActionIndex.FindBestMove(unit, target);
var startGrid = unit.Grid(ctx.Map);
var endGrid = action?.Param?.TargetGridData ?? action?.Param?.GridData;
if (!IsUsefulMoveToward(ctx, startGrid, endGrid, target)) continue;
var score = ScoreFrontMove(ctx, unit, action, front);
var current = ctx.ActionIndex.Candidate(action, AIDirectorLane.Front, $"Front.{front.FrontType}.Move", score);
AddTerms(current, ("frontMove", score));
current.IntentKey = intentKey;
RecordCandidate(ctx, decision, front.FrontType.ToString(), current, action == null ? "NoMoveAction" : null);
best = MaxCandidate(best, current);
}
@ -734,6 +772,14 @@ namespace Logic.AI.Director
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;
var target = ResolveFrontTarget(front);
var startGrid = unit.Grid(ctx.Map);
if (target != null
&& startGrid != null
&& ctx.Config.MaxFrontMoveDistance > 0
&& AIDirectorMath.Distance(ctx.Map, startGrid, target) > ctx.Config.MaxFrontMoveDistance
&& front.FrontType != AIDirectorFrontType.Defense)
return true;
return false;
}
@ -839,6 +885,21 @@ namespace Logic.AI.Director
return false;
}
private bool IsPriorityTacticAction(AIDirectorContext ctx, AIActionBase action, float attackScore)
{
if (action?.Param?.UnitData == null || action.Param.TargetUnitData == null) return false;
var attacker = action.Param.UnitData;
var target = action.Param.TargetUnitData;
var damage = Table.Instance.CalcDamage(ctx.Map, attacker, target);
if (damage >= target.Health) return true;
if (target.TreatedAsHero(ctx.Map, target)) return true;
if (IsThreateningAnyCity(ctx, target)) return true;
var targetValue = UnitTargetValue(ctx, target);
var counterThreat = CounterThreat(ctx, attacker, target);
return attackScore >= ctx.Config.PriorityTacticScore + 60f && counterThreat <= targetValue * 0.15f;
}
private bool TargetOwnerFeelingHighAndNotWar(AIDirectorContext ctx, UnitData target)
{
if (target == null || !ctx.Map.GetPlayerDataByUnitId(target.Id, out var owner) || owner == null) return false;
@ -886,6 +947,40 @@ namespace Logic.AI.Director
return AIDirectorMath.Distance(ctx.Map, endGrid, target) <= unit.GetAttackRange(ctx.Map) + unit.GetActionPoint(ActionPointType.Move);
}
private static bool IsUsefulMoveToward(AIDirectorContext ctx, GridData startGrid, GridData endGrid, GridData targetGrid)
{
if (ctx?.Map == null || startGrid == null || endGrid == null || targetGrid == null) return false;
var startDistance = AIDirectorMath.Distance(ctx.Map, startGrid, targetGrid);
var endDistance = AIDirectorMath.Distance(ctx.Map, endGrid, targetGrid);
return endDistance < startDistance || endDistance <= 1;
}
private static string BuildIntentKey(AIDirectorLane lane, string kind, uint targetId)
{
if (targetId == 0) return string.Empty;
return $"{lane}:{kind}:{targetId}";
}
private static bool IsIntentBlocked(AIDirectorContext ctx, string intentKey)
{
return !string.IsNullOrEmpty(intentKey)
&& ctx?.BlockedIntentKeys != null
&& ctx.BlockedIntentKeys.Contains(intentKey);
}
private static bool IsIntentBudgetReached(AIDirectorContext ctx, AIDirectorLane lane, int maxCount)
{
if (maxCount <= 0 || ctx?.BlockedIntentKeys == null) return false;
var prefix = $"{lane}:";
var count = 0;
foreach (var key in ctx.BlockedIntentKeys)
{
if (key != null && key.StartsWith(prefix, System.StringComparison.Ordinal)) count++;
}
return count >= maxCount;
}
private float GridThreat(AIDirectorContext ctx, GridData grid)
{
if (grid == null) return 0f;

View File

@ -134,11 +134,17 @@ namespace Logic.AI.Director
public int ExpansionUrgentCityThreshold = 2;
public int ExpansionHardPressureTurn = 24;
public float EmergencyDangerScore = 8f;
public float PriorityTacticScore = 780f;
public int MaxGeneratedActions = 4096;
public int MaxFrontCount = 12;
public int MaxDevelopmentTargetCount = 20;
public int MaxExpansionTargetScanCount = 8;
public int MaxMoveActionsPerUnit = 8;
public int MaxExpansionTargetScanCount = 6;
public int MaxMoveActionsPerUnit = 5;
public int MaxExpansionMoveIntentsPerTurn = 2;
public int MaxEmergencyMoveIntentsPerTurn = 2;
public int MaxFrontMoveIntentsPerTurn = 1;
public int MaxEmergencyRescueDistance = 6;
public int MaxFrontMoveDistance = 8;
public float LowHealthRatio = 0.45f;
public float CriticalHealthRatio = 0.25f;
public float HeroLowHealthRatio = 0.6f;
@ -171,13 +177,20 @@ namespace Logic.AI.Director
public AIDirectorWorldCache Cache;
public AIDirectorActionIndex ActionIndex;
public readonly HashSet<string> BlockedActionKeys;
public readonly HashSet<string> BlockedIntentKeys;
public AIDirectorContext(MapData map, PlayerData player, AIDirectorConfig config, HashSet<string> blockedActionKeys = null)
public AIDirectorContext(
MapData map,
PlayerData player,
AIDirectorConfig config,
HashSet<string> blockedActionKeys = null,
HashSet<string> blockedIntentKeys = null)
{
Map = map;
Player = player;
Config = config ?? AIDirectorConfig.CreateDefault();
BlockedActionKeys = blockedActionKeys;
BlockedIntentKeys = blockedIntentKeys;
}
}
@ -327,6 +340,7 @@ namespace Logic.AI.Director
public GridData Grid;
public GridData TargetGrid;
public string Reason;
public string IntentKey;
public float Priority;
public bool IsFallback;
public readonly List<AIDirectorScoreTerm> ScoreTerms = new();

View File

@ -161,14 +161,15 @@ namespace Logic.AI.Director
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)
var isInCityTerritory = city.CheckIsInTerritory(enemyGrid.Id);
if (distance <= reach || isInCityTerritory)
{
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 (isInCityTerritory) threat.HasEnemyOnTerritory = true;
if (CanThreatenCityNextTurn(ctx, enemy, cityGrid)) threat.CanBeThreatenedNextTurn = true;
}

View File

@ -10,6 +10,7 @@ namespace Logic.AI
{
private readonly AIDirectorLogic _director = new();
private readonly HashSet<string> _executedActionKeysThisTurn = new();
private readonly HashSet<string> _executedIntentKeysThisTurn = new();
private MapData _mapData;
private PlayerData _playerData;
@ -24,6 +25,7 @@ namespace Logic.AI
_mapData = mapData;
_playerData = playerData;
_executedActionKeysThisTurn.Clear();
_executedIntentKeysThisTurn.Clear();
#if TH1_AI_DIRECTOR_DIAGNOSTICS || UNITY_EDITOR
AIDirectorDiagnostics.RecordTurnStart(_mapData, _playerData);
#endif
@ -32,7 +34,7 @@ namespace Logic.AI
public AIKernelUpdate Update()
{
if (_mapData == null || _playerData == null) return AIKernelUpdate.Finished;
var decision = _director.Decide(_mapData, _playerData, null, _executedActionKeysThisTurn);
var decision = _director.Decide(_mapData, _playerData, null, _executedActionKeysThisTurn, _executedIntentKeysThisTurn);
#if TH1_AI_DIRECTOR_DIAGNOSTICS || UNITY_EDITOR
AIDirectorDiagnostics.RecordDecision(_mapData, _playerData, decision);
#endif
@ -47,6 +49,7 @@ namespace Logic.AI
if (action.ActionLogic.ActionId.PlayerActionType == PlayerActionType.OfferAlly)
LogSystem.LogInfo("AI 发起结盟");
_executedActionKeysThisTurn.Add(AIDirectorActionIndex.StableActionKey(action));
if (!string.IsNullOrEmpty(candidate.IntentKey)) _executedIntentKeysThisTurn.Add(candidate.IntentKey);
return AIKernelUpdate.ActionReady(action);
}
@ -58,6 +61,7 @@ namespace Logic.AI
_mapData = null;
_playerData = null;
_executedActionKeysThisTurn.Clear();
_executedIntentKeysThisTurn.Clear();
}
}
}

View File

@ -7,7 +7,9 @@ using System.Linq;
using System.Text;
using Logic;
using Logic.AI;
using Logic.AI.Director;
using Logic.CrashSight;
using Newtonsoft.Json.Linq;
using RuntimeData;
using TH1_Core.Managers;
using TH1_Logic.Core;
@ -164,6 +166,10 @@ namespace TH1_Logic.Editor
AIDirectorBatchRuntime.ForceAllPlayersAi = true;
AIDirectorBatchRuntime.SkipPresentationWait = true;
AIDirectorBatchRuntime.CompactDiagnostics = options.CompactDiagnostics;
#if TH1_AI_DIRECTOR_DIAGNOSTICS || UNITY_EDITOR
AIDirectorDiagnostics.BeginNewSession();
result.diagnosticsLogPath = AIDirectorDiagnostics.CurrentLogPath;
#endif
var main = Main.Instance;
main.MapConfig = BuildMapConfig(options, gameIndex);
@ -538,6 +544,7 @@ namespace TH1_Logic.Editor
result.curPlayerTurn = map.CurPlayer?.Turn ?? 0;
result.survivingPlayers = CountSurvivingPlayers(map);
result.players = BuildPlayerResults(map);
result.diagnostics = BuildDiagnosticsSummary(result.diagnosticsLogPath);
}
private static List<BatchPlayerResult> BuildPlayerResults(MapData map)
@ -641,6 +648,176 @@ namespace TH1_Logic.Editor
File.WriteAllText(path, JsonUtility.ToJson(summary, true), Encoding.UTF8);
}
private static BatchDiagnosticSummary BuildDiagnosticsSummary(string diagnosticsLogPath)
{
var summary = new BatchDiagnosticSummary();
if (string.IsNullOrWhiteSpace(diagnosticsLogPath) || !File.Exists(diagnosticsLogPath)) return summary;
summary.logPath = diagnosticsLogPath.Replace('\\', '/');
var decisionMs = new List<float>();
var actionPoolAll = new List<int>();
var actionPoolMoves = new List<int>();
var actionCountByPlayerTurn = new Dictionary<string, int>();
var executedStableKeysByPlayerTurn = new HashSet<string>();
var lanes = new Dictionary<string, int>();
var reasons = new Dictionary<string, int>();
var selectedActionTypes = new Dictionary<string, int>();
var executedActionTypes = new Dictionary<string, int>();
var laneActionTypes = new Dictionary<string, int>();
var noEffectActionTypes = new Dictionary<string, int>();
try
{
foreach (var line in File.ReadLines(diagnosticsLogPath))
{
if (string.IsNullOrWhiteSpace(line)) continue;
var record = JObject.Parse(line);
var eventType = record.Value<string>("eventType");
if (eventType == "Decision")
{
summary.decisions++;
var decision = record["decision"];
if (decision == null) continue;
decisionMs.Add(decision.Value<float?>("decideMs") ?? 0f);
var pool = record["actionPool"];
if (pool != null)
{
actionPoolAll.Add(pool.Value<int?>("all") ?? 0);
actionPoolMoves.Add(pool.Value<int?>("moves") ?? 0);
}
if (!(decision.Value<bool?>("hasAction") ?? false))
{
summary.noActionDecisions++;
continue;
}
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;
Increment(lanes, lane);
Increment(reasons, reason);
Increment(selectedActionTypes, actionType);
Increment(laneActionTypes, $"{lane}:{actionType}");
if (decision.Value<bool?>("isFallback") ?? false) summary.fallbackDecisions++;
}
else if (eventType == "Execution")
{
summary.executions++;
var execution = record["execution"];
var action = execution?["action"];
var actionType = action?.Value<string>("actionType") ?? string.Empty;
Increment(executedActionTypes, actionType);
var playerTurnKey = $"{record.Value<uint?>("playerId") ?? 0}:{record.Value<uint?>("playerTurn") ?? 0}";
actionCountByPlayerTurn.TryGetValue(playerTurnKey, out var actionCount);
actionCountByPlayerTurn[playerTurnKey] = actionCount + 1;
var stableKey = action?.Value<string>("stableKey");
if (!string.IsNullOrEmpty(stableKey))
{
var stableKeyInTurn = $"{playerTurnKey}:{stableKey}";
if (!executedStableKeysByPlayerTurn.Add(stableKeyInTurn)) summary.repeatedExecutions++;
}
if ((execution?.Value<bool?>("executed") ?? false) && !HasMeaningfulDelta(execution["delta"]))
{
summary.noEffectExecutions++;
Increment(noEffectActionTypes, actionType);
}
}
}
}
catch (Exception e)
{
summary.error = e.Message;
}
FillFloatStats(decisionMs, out summary.avgDecideMs, out summary.p95DecideMs, out summary.maxDecideMs);
FillIntStats(actionPoolAll, out summary.avgActionPoolAll, out summary.p95ActionPoolAll, out summary.maxActionPoolAll);
FillIntStats(actionPoolMoves, out summary.avgActionPoolMoves, out summary.p95ActionPoolMoves, out summary.maxActionPoolMoves);
FillIntStats(actionCountByPlayerTurn.Values.ToList(), out summary.avgActionsPerPlayerTurn, out summary.p95ActionsPerPlayerTurn, out summary.maxActionsPerPlayerTurn);
summary.topLanes = TopCounts(lanes, 12);
summary.topReasons = TopCounts(reasons, 20);
summary.topSelectedActionTypes = TopCounts(selectedActionTypes, 12);
summary.topExecutedActionTypes = TopCounts(executedActionTypes, 12);
summary.topLaneActionTypes = TopCounts(laneActionTypes, 20);
summary.noEffectActionTypes = TopCounts(noEffectActionTypes, 12);
return summary;
}
private static bool HasMeaningfulDelta(JToken delta)
{
if (delta == null) return false;
foreach (var property in delta.Children<JProperty>())
{
if (property.Name == "netActionDelta") continue;
var value = property.Value;
switch (value.Type)
{
case JTokenType.Boolean:
if (value.Value<bool>()) return true;
break;
case JTokenType.Integer:
case JTokenType.Float:
if (Math.Abs(value.Value<float>()) > 0.0001f) return true;
break;
}
}
return false;
}
private static void Increment(Dictionary<string, int> values, string key)
{
if (string.IsNullOrEmpty(key)) key = "(empty)";
values.TryGetValue(key, out var count);
values[key] = count + 1;
}
private static List<BatchCountMetric> TopCounts(Dictionary<string, int> values, int maxCount)
{
return values
.OrderByDescending(item => item.Value)
.ThenBy(item => item.Key, StringComparer.Ordinal)
.Take(maxCount)
.Select(item => new BatchCountMetric { key = item.Key, count = item.Value })
.ToList();
}
private static void FillFloatStats(List<float> values, out float average, out float p95, out float max)
{
if (values == null || values.Count == 0)
{
average = 0f;
p95 = 0f;
max = 0f;
return;
}
values.Sort();
average = values.Average();
p95 = values[Mathf.Clamp(Mathf.CeilToInt(values.Count * 0.95f) - 1, 0, values.Count - 1)];
max = values[^1];
}
private static void FillIntStats(List<int> values, out float average, out int p95, out int max)
{
if (values == null || values.Count == 0)
{
average = 0f;
p95 = 0;
max = 0;
return;
}
values.Sort();
average = (float)values.Average();
p95 = values[Mathf.Clamp(Mathf.CeilToInt(values.Count * 0.95f) - 1, 0, values.Count - 1)];
max = values[^1];
}
private static void OpenStartupScene(string scenePath)
{
if (string.IsNullOrWhiteSpace(scenePath))
@ -798,6 +975,8 @@ namespace TH1_Logic.Editor
public uint curPlayerId;
public uint curPlayerTurn;
public int survivingPlayers;
public string diagnosticsLogPath;
public BatchDiagnosticSummary diagnostics;
public List<BatchPlayerResult> players = new();
}
@ -816,4 +995,42 @@ namespace TH1_Logic.Editor
public int cityCount;
public int unitCount;
}
[Serializable]
public class BatchDiagnosticSummary
{
public string logPath;
public string error;
public int decisions;
public int noActionDecisions;
public int fallbackDecisions;
public int executions;
public int noEffectExecutions;
public int repeatedExecutions;
public float avgDecideMs;
public float p95DecideMs;
public float maxDecideMs;
public float avgActionPoolAll;
public int p95ActionPoolAll;
public int maxActionPoolAll;
public float avgActionPoolMoves;
public int p95ActionPoolMoves;
public int maxActionPoolMoves;
public float avgActionsPerPlayerTurn;
public int p95ActionsPerPlayerTurn;
public int maxActionsPerPlayerTurn;
public List<BatchCountMetric> topLanes = new();
public List<BatchCountMetric> topReasons = new();
public List<BatchCountMetric> topSelectedActionTypes = new();
public List<BatchCountMetric> topExecutedActionTypes = new();
public List<BatchCountMetric> topLaneActionTypes = new();
public List<BatchCountMetric> noEffectActionTypes = new();
}
[Serializable]
public class BatchCountMetric
{
public string key;
public int count;
}
}