Improve AI Director expansion and action pruning

This commit is contained in:
wuwenbo 2026-06-30 18:50:46 +08:00
parent 694df5ac4c
commit 05db312da7
9 changed files with 559 additions and 60 deletions

View File

@ -55,6 +55,7 @@ AI 不是一次性规划完整回合,而是重复执行“读局势,选一
```text
如果城市危险,它去救城。
否则如果眼前能打有价值目标,它攻击。
否则如果还没有稳定二城,且附近有可占城中心,它优先去扩张。
否则如果脚下或附近有占领、遗迹、资源、恢复、升级机会,它做机会动作。
否则如果它空闲,它向防守、进攻或发展战线移动。
否则它交给内政或兜底逻辑。
@ -87,17 +88,18 @@ Director 使用固定优先级车道。车道之间不做统一评分,车道
|---|---|---|
| 1 | Emergency | 阻止立即丢城、被偷家、关键单位暴毙 |
| 2 | HeroManagement | 处理选英雄、英雄任务、出场和复活节奏 |
| 3 | HeroPlaybook | 发挥英雄机制和阵营特色 |
| 4 | Tactic | 处理当前可攻击或近距离交战 |
| 5 | UnitOpportunity | 抢占领、遗迹、采集、恢复、升级等确定收益 |
| 6 | Front | 把空闲单位送往正确战略方向 |
| 7 | Growth | 推进城市、地块、科技、文化、外交 |
| 8 | Fallback | 防止规则缺口导致 AI 停摆 |
| 3 | Expansion | 二城前和早期高价值扩张优先抢可占城中心 |
| 4 | HeroPlaybook | 发挥英雄机制和阵营特色 |
| 5 | Tactic | 处理当前可攻击或近距离交战 |
| 6 | UnitOpportunity | 抢占领、遗迹、采集、恢复、升级等确定收益 |
| 7 | Front | 把空闲单位送往正确战略方向 |
| 8 | Growth | 推进城市、地块、科技、文化、外交 |
| 9 | Fallback | 防止规则缺口导致 AI 停摆 |
车道顺序的核心含义:
```text
活命 > 英雄体系 > 英雄特色 > 当前战斗 > 短期机会 > 战略移动 > 长期发展 > 兜底
活命 > 英雄体系 > 早期扩张 > 英雄特色 > 当前战斗 > 短期机会 > 战略移动 > 长期发展 > 兜底
```
---
@ -144,6 +146,7 @@ Director 使用固定优先级车道。车道之间不做统一评分,车道
- 不保存跨多回合的大型黑板式复杂状态。
- 不建立永久军团状态机。
- 大范围搜索只保留 TopN 目标。
- 同一玩家同一回合内,已经执行过的同一个 action stableKey 不再进入 Director 候选,避免单位移动、城市训练等动作反复被选中。
---
@ -186,7 +189,12 @@ Defense 的行为倾向:
Expansion 的行为倾向:
- UnitOpportunity 优先占领和探索。
- 二城前,非严重城市威胁下,可占城中心优先于普通战线移动。
- 可占城中心包括 `ResourceType.CityCenter` 村庄、无归属城市,也包括当前地图数据中表现为非同盟 owner 的空城/村点。
- 早期扩张只追可在有限回合内转化的目标,避免全地图远距离追城导致移动量很大但二城率不变。
- Expansion 只扫描已排序的少量高价值扩张目标;扩大城市数不能让每次决策退化成“所有目标 × 所有单位”的全量搜索。
- 单位移动优先满足“本回合能占”或“下回合能站到可占点旁边”,远距离目标只保留作低优先级战线。
- Expansion 车道优先处理占领和向目标靠近UnitOpportunity 负责脚下的占领、遗迹和采集补漏。
- Development Front 指向村庄、遗迹、资源和边界。
- 城市优先增长和基础建设。
- 科技优先资源开发、移动能力和道路。
@ -462,7 +470,7 @@ Director 不直接推演行为结果,而是从合法 Action 池中选择。
| 单位行为 | Capture、Examine、Gather、Recover、Upgrade、HeroUpgrade、CultureUnitUpgrade、英雄主动 |
| 城市行为 | TrainUnit、CityLevelUpAction、CityAction、StartWonder、BuildWonder |
| 地块行为 | Gain、Build、GridMisc |
| 玩家行为 | LearnTech、BuyCultureCard、PlayerAction |
| 玩家行为 | LearnTech、PlayerActionBuyCultureCard 暂不进入 AI 候选,等文化卡策略单独回归 |
| 英雄管理 | SelectHero、FinishHeroTask、出场、复活 |
危险动作默认不进入普通 AI 选择:
@ -510,7 +518,10 @@ AI 可以重新实现,但必须遵守 TH1 的游戏架构:
- 局部战斗只看可接触范围。
- Front 只保留少量高价值目标。
- DevelopmentTarget 只保留 TopN。
- Expansion 从 DevelopmentTarget 中只扫描前 N 个扩张目标。
- UnitMove 不生成整张地图的所有可走格;先从城市威胁、扩张目标、战线、自城、局部战斗收集移动锚点,每个单位只保留最靠近锚点的少量移动候选。
- 行动候选只生成一次。
- 同一玩家回合内已经执行过的 stableKey 不再参与下一次候选选择。
- 车道只查缓存和动作池。
如果卡顿,优先削减:

View File

@ -74,6 +74,7 @@ SkillBase
Cache: WorldCache
Actions: ActionPool
Trace: List<string>
BlockedActionKeysThisTurn: Set<string>
```
### 2.2 AIConfig
@ -87,6 +88,8 @@ SkillBase
MaxActionCount = 4096
MaxFrontCount = 12
MaxDevelopmentTargetCount = 20
MaxExpansionTargetScanCount = 8
MaxMoveActionsPerUnit = 8
LowHpRatio = 0.45
CriticalHpRatio = 0.25
HeroLowHpRatio = 0.60
@ -107,6 +110,7 @@ SkillBase
枚举 Lane:
Emergency
HeroManagement
Expansion
HeroPlaybook
Tactic
UnitOpportunity
@ -208,8 +212,9 @@ SkillBase
### 3.1 Decide
```text
函数 Decide(map, player, config):
函数 Decide(map, player, config, blockedActionKeysThisTurn):
ctx = new AIContext(map, player, config)
ctx.BlockedActionKeysThisTurn = blockedActionKeysThisTurn
如果 map == null 或 player == null 或 player.Alive == false:
返回 NoDecision
@ -223,6 +228,7 @@ SkillBase
lanes = [
TryEmergency,
TryHeroManagement,
TryExpansion,
TryHeroPlaybook,
TryTactic,
TryUnitOpportunity,
@ -264,7 +270,8 @@ SkillBase
```text
函数 BuildActionPool(ctx):
pool = new ActionPool
rawActions = GenerateAllLegalLikeActions(ctx.Map, ctx.Player)
moveAnchors = BuildMoveAnchorGridIds(ctx)
rawActions = GenerateDirectorActions(ctx.Map, ctx.Player, moveAnchors, Config.MaxMoveActionsPerUnit)
对每个 rawAction in rawActions 按稳定顺序:
如果 pool.All.Count >= Config.MaxActionCount:
@ -274,6 +281,9 @@ SkillBase
如果 action == null:
继续
如果 StableActionKey(action) in ctx.BlockedActionKeysThisTurn:
继续
action.Param.RefreshParams()
如果 !action.ActionLogic.CheckCan(action.Param):
继续
@ -286,6 +296,48 @@ SkillBase
返回 pool
```
```text
函数 BuildMoveAnchorGridIds(ctx):
anchors = []
对每个 cityThreat in ctx.Cache.CityThreats:
anchors.Add(cityThreat.CityGrid)
anchors.Add(每个威胁单位所在格)
对每个 developmentTarget in ctx.Cache.DevelopmentTargets:
anchors.Add(developmentTarget.Grid)
对每个 front in ctx.Cache.Fronts:
anchors.Add(front.TargetGrid)
anchors.Add(front.AnchorGrid)
对每个 selfCity in ctx.Cache.SelfCities:
anchors.Add(selfCity.Grid)
对每个 localBattle in ctx.Cache.LocalBattles:
anchors.Add(localBattle.EnemyGrid)
anchors.Add(localBattle.SelfGrid)
返回去重后的 anchors
```
```text
函数 GenerateDirectorActions(map, player, moveAnchors, maxMoveActionsPerUnit):
普通非移动动作按 CheckCan 生成。
对每个可移动单位:
先计算 UnitMoveInfo。
收集该单位本回合所有可移动候选格。
按“距离 moveAnchors 最近、城市格优先、已占格降权、稳定 GridId”排序。
只保留前 maxMoveActionsPerUnit 个 UnitMove 动作。
对每个可攻击单位:
只在 moveAnchors 中检查可见敌方单位格是否可攻击。
如果可攻击则生成 UnitAttack 动作。
返回动作列表
```
### 4.2 动作分类
```text
@ -321,9 +373,12 @@ SkillBase
pool.GridActions.Add(action)
pool.ByGrid[action.GridId].Add(action)
如果 id.ActionType == LearnTech 或 BuyCultureCard:
如果 id.ActionType == LearnTech:
pool.PlayerActions.Add(action)
如果 id.ActionType == BuyCultureCard:
继续
如果 id.ActionType == PlayerAction:
如果 id.PlayerActionType in [SelectHero, FinishHeroTask]:
pool.HeroManagementActions.Add(action)
@ -756,6 +811,8 @@ SkillBase
target = TryBuildDevelopmentTarget(ctx, cache, grid)
如果 target == null:
继续
如果 target.Distance > Config.DevelopmentSearchRange:
继续
如果 GridThreatToAnySelfUnit(ctx, grid) 太高 且 target.Type 不是 EnemyEmptyCity:
继续
targets.Add(target)
@ -766,7 +823,7 @@ SkillBase
```text
函数 TryBuildDevelopmentTarget(ctx, cache, grid):
如果 grid 是可占村庄:
如果 grid.Resource == CityCenter 且 grid 上没有己方城市:
返回 DevelopmentTarget(grid, Village, 900)
如果 grid 是敌方空城中心:
@ -784,6 +841,27 @@ SkillBase
返回 null
```
可占城中心的识别规则:
```text
函数 ResolveExpansionCityTarget(ctx, city):
owner = GetPlayerDataByCityId(city.Id)
如果 owner == null:
返回 Village
如果 owner 与 ctx.Player 同盟:
返回 None
如果 city 中心可被 Capture 行为占领:
返回 EnemyEmptyCity
返回 None
```
说明TH1 的地图数据里,有些策划意义上的“村庄/空城”已经存在 `CityData` 和 owner 映射。
所以 Expansion 不能只认 owner 为空的城;二城前也必须把可占的非同盟城中心当作扩张目标处理。
### 5.9 BuildFronts
```text
@ -862,6 +940,8 @@ SkillBase
```text
函数 BuildDevelopmentFronts(ctx, cache):
对每个 target in cache.DevelopmentTargets:
如果 己方城市数 < Config.ExpansionUrgentCityThreshold target.Type in [Village, EnemyEmptyCity]:
继续
selfCity = target.NearestSelfCity
yield Front(
Type = Development,
@ -1232,9 +1312,169 @@ SkillBase
---
## 8. HeroPlaybook Lane
## 8. Expansion Lane
### 8.1 TryHeroPlaybook
Expansion Lane 只处理早期扩张的刚性目标,不处理普通资源开发。
它的职责是让 AI 在没有严重城防危机时,优先拿到第二座城市或明显高价值的可占城中心。
### 8.1 TryExpansion
```text
函数 TryExpansion(ctx):
如果 !ShouldPushExpansion(ctx):
返回 None
best = None
scannedTargetCount = 0
对每个 target in ctx.Cache.DevelopmentTargets:
如果 !IsExpansionTarget(ctx, target):
继续
如果 Config.MaxExpansionTargetScanCount > 0 且 scannedTargetCount >= Config.MaxExpansionTargetScanCount:
break
scannedTargetCount += 1
capture = TryExpansionCapture(ctx, target)
best = MaxCandidate(best, capture)
move = TryExpansionMove(ctx, target)
best = MaxCandidate(best, move)
返回 best
```
### 8.2 ShouldPushExpansion
```text
函数 ShouldPushExpansion(ctx):
如果 有严重城市威胁:
返回 false
如果 ctx.Cache.DevelopmentTargets 为空:
返回 false
如果 己方城市数 < Config.ExpansionUrgentCityThreshold:
返回 true
如果 当前回合 <= Config.ExpansionHardPressureTurn 且存在扩张目标:
返回 true
返回 false
```
### 8.3 IsExpansionTarget
```text
函数 IsExpansionTarget(ctx, target):
如果 target == null 或 target.Grid == null:
返回 false
如果 target.Type == Village:
返回 true
如果 target.Type == EnemyEmptyCity:
如果 target.Distance > Config.DevelopmentSearchRange:
返回 false
如果 己方城市数 < Config.ExpansionUrgentCityThreshold:
返回 true
如果 当前回合 > Config.ExpansionHardPressureTurn:
返回 true
返回 false
```
### 8.4 TryExpansionCapture
```text
函数 TryExpansionCapture(ctx, target):
best = None
对每个 action in ctx.Actions.UnitActions:
如果 action.UnitActionType != Capture:
继续
unit = action.Unit
unitGrid = unit.Grid
如果 unitGrid != target.Grid:
继续
score = ScoreExpansionTarget(ctx, target) + 420
candidate = Candidate(action, Expansion, score, "扩张占领")
best = MaxCandidate(best, candidate)
返回 best
```
### 8.5 TryExpansionMove
```text
函数 TryExpansionMove(ctx, target):
best = None
对每个 unit in ctx.Cache.SelfUnits:
如果 unit 没有移动行动点:
继续
如果 unit 是严重城市威胁中的关键守军:
继续
如果 unit 低血且脚下有威胁:
继续
startGrid = unit.Grid
如果 startGrid == target.Grid:
继续
action = FindBestMove(unit, target.Grid)
endGrid = ActionEndGrid(action)
如果 endGrid == null:
继续
如果 endGrid 没有更接近 target 且 startGrid 距离 target > 1:
继续
score = ScoreExpansionTarget(ctx, target)
score += 接近距离 * 90
如果 endGrid == target.Grid: score += 360
如果 endGrid 距离 target == 1: score += 160
如果 unit 是高机动单位: score += 80
如果 startGrid 距离 target >= 5 且 本次只推进 1 格:
score -= 120
score -= endGrid 到 target 的距离 * 28
score -= GridThreat(ctx, endGrid) * ThreatFactor(target)
candidate = Candidate(action, Expansion, score, "扩张移动")
best = MaxCandidate(best, candidate)
返回 best
```
### 8.6 ScoreExpansionTarget
```text
函数 ScoreExpansionTarget(ctx, target):
score = 740 + target.Value
如果 target.Type == Village:
score += 520
如果 target.Type == EnemyEmptyCity:
score += 180
如果 己方城市数 < Config.ExpansionUrgentCityThreshold:
score += 360
如果 当前回合 <= Config.ExpansionHardPressureTurn:
score += 180
score -= target.Distance * 20
返回 score
```
---
## 9. HeroPlaybook Lane
### 9.1 TryHeroPlaybook
```text
函数 TryHeroPlaybook(ctx):
@ -1247,7 +1487,7 @@ SkillBase
返回 best
```
### 8.2 HeroRule
### 9.2 HeroRule
```text
结构 HeroRule:
@ -1269,7 +1509,7 @@ SkillBase
Reason
```
### 8.3 EvaluateHeroPlaybook
### 9.3 EvaluateHeroPlaybook
```text
函数 EvaluateHeroPlaybook(ctx, state):
@ -1294,7 +1534,7 @@ SkillBase
返回 EvaluateGenericHeroRule(ctx, state)
```
### 8.4 BuildHeroAction
### 9.4 BuildHeroAction
```text
函数 BuildHeroAction(ctx, state, rule):
@ -1324,7 +1564,7 @@ SkillBase
返回 null
```
### 8.5 通用英雄保命
### 9.5 通用英雄保命
```text
函数 EvaluateGenericHeroRule(ctx, state):
@ -1347,7 +1587,7 @@ SkillBase
返回 None
```
### 8.6 默认英雄规则
### 9.6 默认英雄规则
```text
Flandre:
@ -1469,7 +1709,7 @@ Byakuren / Miko / Zanmu:
---
## 9. Tactic Lane
## 10. Tactic Lane
```text
函数 TryTactic(ctx):
@ -1518,7 +1758,7 @@ Byakuren / Miko / Zanmu:
---
## 10. UnitOpportunity Lane
## 11. UnitOpportunity Lane
```text
函数 TryUnitOpportunity(ctx):
@ -1629,7 +1869,7 @@ Byakuren / Miko / Zanmu:
---
## 11. Front Lane
## 12. Front Lane
```text
函数 TryFront(ctx):
@ -1703,7 +1943,7 @@ Byakuren / Miko / Zanmu:
---
## 12. Growth Lane
## 13. Growth Lane
```text
函数 TryGrowth(ctx):
@ -1716,7 +1956,7 @@ Byakuren / Miko / Zanmu:
返回 best
```
### 12.1 CityGrowth
### 13.1 CityGrowth
```text
函数 TryCityGrowth(ctx):
@ -1765,7 +2005,7 @@ Byakuren / Miko / Zanmu:
返回 score
```
### 12.2 GridGrowth
### 13.2 GridGrowth
```text
函数 TryGridGrowth(ctx):
@ -1802,7 +2042,7 @@ Byakuren / Miko / Zanmu:
返回 score
```
### 12.3 PlayerGrowth
### 13.3 PlayerGrowth
```text
函数 TryPlayerGrowth(ctx):
@ -1824,7 +2064,7 @@ Byakuren / Miko / Zanmu:
返回 ScoreTech(ctx, action)
如果 id.ActionType == BuyCultureCard:
返回 ScoreCultureCard(ctx, action)
返回 0
如果 id.ActionType == PlayerAction:
返回 ScorePlayerAction(ctx, action)
@ -1858,6 +2098,9 @@ Byakuren / Miko / Zanmu:
```text
函数 ScoreCultureCard(ctx, action):
当前 AI 不生成 BuyCultureCard 动作。
此函数只作为未来恢复文化卡策略时的评分入口。
score = 300
如果 前线吃紧 且文化卡提供即时战力:
@ -1899,7 +2142,7 @@ Byakuren / Miko / Zanmu:
---
## 13. Fallback
## 14. Fallback
```text
函数 TryFallback(ctx):
@ -1927,9 +2170,9 @@ Fallback 的目标是不断回合,不负责聪明。大量进入 Fallback 说
---
## 14. 评分辅助函数
## 15. 评分辅助函数
### 14.1 稳定排序
### 15.1 稳定排序
```text
函数 MaxCandidate(a, b):
@ -1958,7 +2201,7 @@ Fallback 的目标是不断回合,不负责聪明。大量进入 Fallback 说
)
```
### 14.2 伤害和威胁
### 15.2 伤害和威胁
```text
函数 EstimateDamageValue(ctx, attacker, target):
@ -1983,7 +2226,7 @@ Fallback 的目标是不断回合,不负责聪明。大量进入 Fallback 说
return EstimateDamage(ctx, attacker, target) >= target.Health
```
### 14.3 资源和建设价值
### 15.3 资源和建设价值
```text
函数 ResourceValue(ctx, grid):
@ -2018,7 +2261,7 @@ Fallback 的目标是不断回合,不负责聪明。大量进入 Fallback 说
返回 value
```
### 14.4 训练单位价值
### 15.4 训练单位价值
```text
函数 TrainUnitDefenseValue(ctx, action, threat):
@ -2045,9 +2288,9 @@ Fallback 的目标是不断回合,不负责聪明。大量进入 Fallback 说
---
## 15. 正确性约束
## 16. 正确性约束
### 15.1 权威行为
### 16.1 权威行为
```text
所有最终动作:
@ -2068,7 +2311,7 @@ AI 不复制:
回放记录
```
### 15.2 确定性
### 16.2 确定性
禁止在权威选择路径使用:
@ -2082,7 +2325,7 @@ UI/Renderer 状态
同分必须用稳定 key 解决。
### 15.3 技能安全
### 16.3 技能安全
英雄和单位技能只通过 Action 入口触发:
@ -2098,7 +2341,7 @@ AI 不直接调用 `SkillBase` 的效果方法。
---
## 16. 性能约束
## 17. 性能约束
```text
每次 Decide:
@ -2130,9 +2373,9 @@ Growth: 只遍历合法动作
---
## 17. 测试脚本标准
## 18. 测试脚本标准
### 17.1 城市防守
### 18.1 城市防守
```text
给 AI 城市附近放敌军。
@ -2140,7 +2383,7 @@ Growth: 只遍历合法动作
Emergency 返回攻击威胁、回防、或危险城市生产。
```
### 17.2 占领扩张
### 18.2 占领扩张
```text
给 AI 单位脚下放村庄、敌空城、遗迹。
@ -2148,7 +2391,7 @@ Growth: 只遍历合法动作
无更高车道时 UnitOpportunity 执行占领或探索。
```
### 17.3 英雄机制
### 18.3 英雄机制
```text
给 AI 放残血友军、敌方英雄、地面攻击目标、自爆窗口。
@ -2156,7 +2399,7 @@ Growth: 只遍历合法动作
HeroPlaybook 选择对应英雄动作。
```
### 17.4 战线移动
### 18.4 战线移动
```text
无局部战斗、无机会动作。
@ -2166,7 +2409,7 @@ Growth: 只遍历合法动作
DevelopmentFront 优先于 HoldFront。
```
### 17.5 内政发展
### 18.5 内政发展
```text
安全局面。
@ -2175,7 +2418,7 @@ Growth: 只遍历合法动作
前线城市生产军力。
```
### 17.6 性能
### 18.6 性能
```text
中大型地图 AI 连续执行。

View File

@ -231,6 +231,8 @@ namespace Logic.AI
public CommonActionParams TargetParam;
public Strategy TargetStrategy;
public List<uint> TargetList;
public bool UseTargetListAsMoveAnchors;
public int MaxMoveActionsPerUnit;
public HashSet<string> Marks;
@ -328,6 +330,8 @@ namespace Logic.AI
ForeachLegion = new List<uint>();
ForeachCity = new List<CityData>();
TargetList = new List<uint>();
UseTargetListAsMoveAnchors = false;
MaxMoveActionsPerUnit = 0;
AroundGridBuffer = new List<GridData>();
TmpUnitSetBuffer = new HashSet<UnitData>();
TmpCityListBuffer = new List<CityData>();
@ -446,6 +450,8 @@ namespace Logic.AI
ForeachLegion.Clear();
ForeachCity.Clear();
TargetList.Clear();
UseTargetListAsMoveAnchors = false;
MaxMoveActionsPerUnit = 0;
CityStrategy.Clear();
FreeUnitStrategy.Clear();

View File

@ -346,6 +346,70 @@ namespace Logic.AI
return data.AIActions;
}
public static List<AIActionBase> GeneratorDirectorActionIdsForUse(
MapData map,
PlayerData selfPlayer,
IEnumerable<uint> moveAnchorGridIds,
int maxMoveActionsPerUnit)
{
var data = new AICalculatorData();
data.Map = map;
data.Player = selfPlayer;
data.UseTargetListAsMoveAnchors = true;
data.MaxMoveActionsPerUnit = maxMoveActionsPerUnit;
if (moveAnchorGridIds != null)
{
foreach (var gridId in moveAnchorGridIds)
{
if (!data.TargetList.Contains(gridId)) data.TargetList.Add(gridId);
}
}
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);
data.TargetParam.MapData = map;
data.TargetParam.PlayerData = selfPlayer;
GeneratorActionIds(data, CommonActionType.LearnTech);
GeneratorActionIds(data, CommonActionType.StartWonder);
GeneratorActionIds(data, CommonActionType.PlayerAction);
foreach (var city in selfCities)
{
data.TargetParam.CityData = city;
GeneratorActionIds(data, CommonActionType.Gain);
GeneratorActionIds(data, CommonActionType.Build);
GeneratorActionIds(data, CommonActionType.BuildWonder);
GeneratorActionIds(data, CommonActionType.GridMisc);
GeneratorActionIds(data, CommonActionType.TrainUnit);
GeneratorActionIds(data, CommonActionType.CityLevelUpAction);
}
foreach (var unit in selfUnits)
{
data.TargetParam.UnitData = unit;
GeneratorActionIds(data, CommonActionType.UnitAction);
GeneratorActionIds(data, CommonActionType.UnitSkill);
GeneratorActionIds(data, CommonActionType.UnitMove);
GeneratorActionIds(data, CommonActionType.UnitAttack);
GeneratorActionIds(data, CommonActionType.UnitAttackAlly);
GeneratorActionIds(data, CommonActionType.UnitAttackGround);
}
for (int i = data.AIActions.Count - 1; i >= 0; i--)
{
var id = data.AIActions[i].ActionLogic.ActionId;
if (id.UnitActionType is UnitActionType.Disband or UnitActionType.ForceDisband or UnitActionType.Demolish or UnitActionType.Disperse
|| id.GridMiscActionType == GridMiscActionType.Destroy)
{
data.AIActions.RemoveAt(i);
}
}
return data.AIActions;
}
public static List<AIActionBase> GeneratorAllActionIds(MapData map, PlayerData selfPlayer)
{
@ -505,6 +569,11 @@ namespace Logic.AI
data.TargetParam.MainObjectType = ActionLogicFactory.GetMainObjectType(type);
Main.UnitLogic.CalcUnitMoveInfo(data.Map, data.TargetParam.UnitData.Id);
if (data.UseTargetListAsMoveAnchors)
{
GenerateMoveActionsTowardAnchors(data, actions, unitGrid);
return;
}
if (data.TargetList.Count > 0)
{
@ -572,6 +641,34 @@ namespace Logic.AI
data.TargetParam.MainObjectType = ActionLogicFactory.GetMainObjectType(type);
Main.UnitLogic.CalcUnitMoveInfo(data.Map, data.TargetParam.UnitData.Id);
if (data.UseTargetListAsMoveAnchors && data.TargetList.Count > 0)
{
foreach (var gridId in data.TargetList)
{
if (!data.Map.GridMap.GetGridDataByGid(gridId, out var grid)) continue;
if (!grid.VisibleUnit(data.Map, data.TargetParam.PlayerData, out var targetUnit)) continue;
var result = Main.UnitLogic.CheckUnitCanMoveOrAttack(data.Map, data.TargetParam.UnitData, grid);
if (result != MoveAttackType.Attack) continue;
data.TargetParam.GridData = grid;
data.TargetParam.TargetUnitData = targetUnit;
data.TargetParam.OnParamChanged();
foreach (var action in actions)
{
if (!action.CheckCan(data.TargetParam)) continue;
var param = data.TargetParam.GetCopyParam();
param.CityData = null;
param.TargetGridData = null;
param.TargetPlayerData = null;
param.OnParamChanged();
data.AIActions.Add(new AIActionBase(param, action));
}
}
return;
}
foreach (var grid in data.Map.GridMap.GridList)
{
//TODO check playerData right
@ -748,5 +845,70 @@ namespace Logic.AI
}
}
}
private static void GenerateMoveActionsTowardAnchors(
AICalculatorData data,
List<ActionLogicBase> actions,
GridData unitGrid)
{
if (data == null || actions == null || unitGrid == null) return;
data.AroundGridBuffer.Clear();
Main.UnitLogic.CollectMoveAttackCandidateGrids(data.Map, data.TargetParam.UnitData, data.AroundGridBuffer);
if (data.AroundGridBuffer.Count == 0) return;
data.AroundGridBuffer.Sort((a, b) =>
{
var scoreCompare = ScoreDirectorMoveGrid(data, unitGrid, b)
.CompareTo(ScoreDirectorMoveGrid(data, unitGrid, a));
if (scoreCompare != 0) return scoreCompare;
return a.Id.CompareTo(b.Id);
});
var generated = 0;
var limit = data.MaxMoveActionsPerUnit <= 0 ? int.MaxValue : data.MaxMoveActionsPerUnit;
foreach (var grid in data.AroundGridBuffer)
{
var result = Main.UnitLogic.CheckUnitCanMoveOrAttack(data.Map, data.TargetParam.UnitData, grid);
if (result != MoveAttackType.Move && result != MoveAttackType.MoveToPort && result != MoveAttackType.MoveAshore && result != MoveAttackType.MoveTeleport) continue;
data.TargetParam.GridData = grid;
data.TargetParam.OnParamChanged();
foreach (var action in actions)
{
if (!action.CheckCan(data.TargetParam)) continue;
var param = data.TargetParam.GetCopyParam();
param.CityData = null;
param.TargetUnitData = null;
param.TargetGridData = null;
param.TargetPlayerData = null;
param.OnParamChanged();
data.AIActions.Add(new AIActionBase(param, action));
generated++;
break;
}
if (generated >= limit) break;
}
}
private static int ScoreDirectorMoveGrid(AICalculatorData data, GridData unitGrid, GridData moveGrid)
{
if (data == null || moveGrid == null) return int.MinValue;
var bestDistance = int.MaxValue;
foreach (var targetGridId in data.TargetList)
{
if (!data.Map.GridMap.GetGridDataByGid(targetGridId, out var targetGrid)) continue;
var distance = data.Map.GridMap.CalcDistance(moveGrid, targetGrid);
if (distance < bestDistance) bestDistance = distance;
}
if (bestDistance == int.MaxValue) bestDistance = data.Map.GridMap.CalcDistance(unitGrid, moveGrid);
var score = -bestDistance * 100;
if (moveGrid.CityOnGrid(data.Map, out _)) score += 80;
if (moveGrid.VisibleUnit(data.Map, data.Player, out _)) score -= 120;
if (data.Player?.Sight != null && data.Player.Sight.CheckIsInSight(moveGrid.Id)) score += 10;
return score;
}
}
}

View File

@ -2,7 +2,6 @@ using System.Collections.Generic;
using Logic.Action;
using RuntimeData;
using TH1_Logic.Action;
using UnityEngine;
namespace Logic.AI.Director
{
@ -32,7 +31,11 @@ namespace Logic.AI.Director
var index = new AIDirectorActionIndex();
if (ctx?.Map == null || ctx.Player == null) return index;
var generated = AIActionGenerator.GeneratorAllActionIdsForUse(ctx.Map, ctx.Player);
var generated = AIActionGenerator.GeneratorDirectorActionIdsForUse(
ctx.Map,
ctx.Player,
BuildMoveAnchorGridIds(ctx),
ctx.Config.MaxMoveActionsPerUnit);
if (generated == null) return index;
var limit = ctx.Config.MaxGeneratedActions <= 0 ? int.MaxValue : ctx.Config.MaxGeneratedActions;
@ -42,6 +45,7 @@ namespace Logic.AI.Director
if (index.AllActions.Count >= limit) break;
var copied = CopyAction(action);
if (copied == null) continue;
if (ctx.BlockedActionKeys != null && ctx.BlockedActionKeys.Contains(StableActionKey(copied))) continue;
if (!copied.ActionLogic.CheckCan(copied.Param)) continue;
if (IsDangerousAction(copied)) continue;
index.Add(copied);
@ -52,6 +56,49 @@ namespace Logic.AI.Director
return index;
}
private static List<uint> BuildMoveAnchorGridIds(AIDirectorContext ctx)
{
var result = new List<uint>();
if (ctx?.Cache == null) return result;
void Add(GridData grid)
{
if (grid == null || result.Contains(grid.Id)) return;
result.Add(grid.Id);
}
foreach (var threat in ctx.Cache.CityThreats)
{
Add(threat?.CityGrid);
if (threat?.EnemyUnits == null) continue;
foreach (var enemy in threat.EnemyUnits)
{
if (enemy != null && ctx.Map.GetGridDataByUnitId(enemy.Id, out var enemyGrid)) Add(enemyGrid);
}
}
foreach (var target in ctx.Cache.DevelopmentTargets) Add(target?.Grid);
foreach (var front in ctx.Cache.Fronts)
{
Add(front?.TargetGrid);
Add(front?.AnchorGrid);
}
foreach (var city in ctx.Cache.SelfCities)
{
if (city != null && ctx.Map.GetGridDataByCityId(city.Id, out var cityGrid)) Add(cityGrid);
}
foreach (var battle in ctx.Cache.LocalBattles)
{
Add(battle?.EnemyGrid);
Add(battle?.SelfGrid);
}
return result;
}
public AIDirectorActionCandidate Candidate(AIActionBase action, AIDirectorLane lane, string reason, float priority, bool fallback = false)
{
if (action == null) return AIDirectorActionCandidate.Invalid(lane, reason, priority, fallback);

View File

@ -1,5 +1,6 @@
using Logic.Action;
using RuntimeData;
using System.Collections.Generic;
using System.Diagnostics;
using TH1_Logic.Action;
using UnityEngine;
@ -11,7 +12,7 @@ namespace Logic.AI.Director
private readonly AIDirectorWorldCacheBuilder _cacheBuilder = new();
private readonly AIDirectorHeroRuleEvaluator _heroEvaluator = new();
public AIDirectorDecision Decide(MapData map, PlayerData player, AIDirectorConfig config = null)
public AIDirectorDecision Decide(MapData map, PlayerData player, AIDirectorConfig config = null, HashSet<string> blockedActionKeys = null)
{
var stopwatch = Stopwatch.StartNew();
var decision = new AIDirectorDecision();
@ -23,7 +24,7 @@ namespace Logic.AI.Director
return decision;
}
var ctx = new AIDirectorContext(map, player, config ?? AIDirectorConfig.CreateDefault());
var ctx = new AIDirectorContext(map, player, config ?? AIDirectorConfig.CreateDefault(), blockedActionKeys);
ctx.Cache = _cacheBuilder.Build(ctx);
ctx.ActionIndex = AIDirectorActionIndex.Build(ctx);
_cacheBuilder.BuildUnitOpportunities(ctx);
@ -66,9 +67,9 @@ namespace Logic.AI.Director
return decision;
}
public bool TryDecide(MapData map, PlayerData player, out AIDirectorActionCandidate candidate, AIDirectorConfig config = null)
public bool TryDecide(MapData map, PlayerData player, out AIDirectorActionCandidate candidate, AIDirectorConfig config = null, HashSet<string> blockedActionKeys = null)
{
var decision = Decide(map, player, config);
var decision = Decide(map, player, config, blockedActionKeys);
candidate = decision.Candidate;
return decision.HasAction;
}
@ -123,9 +124,12 @@ namespace Logic.AI.Director
if (!ShouldPushExpansion(ctx)) return false;
var best = AIDirectorActionCandidate.None;
var scannedTargets = 0;
foreach (var target in ctx.Cache.DevelopmentTargets)
{
if (!IsExpansionTarget(ctx, target)) continue;
if (ctx.Config.MaxExpansionTargetScanCount > 0 && scannedTargets >= ctx.Config.MaxExpansionTargetScanCount) break;
scannedTargets++;
var capture = TryExpansionCapture(ctx, decision, target);
best = MaxCandidate(best, capture);
@ -190,9 +194,11 @@ namespace Logic.AI.Director
var targetScore = ScoreExpansionTarget(ctx, target);
var progressScore = Mathf.Max(0, startDistance - endDistance) * 90f;
var reachBonus = endDistance == 0 ? 360f : endDistance == 1 ? 160f : 0f;
var mobilityBonus = unit.GetActionPoint(ActionPointType.Move) >= 2 ? 80f : 0f;
var nearConversionBonus = startDistance <= 2 ? 180f : startDistance <= 3 ? 80f : 0f;
var mobilityBonus = unit.GetActionPoint(ActionPointType.Move) >= 2 ? 120f : 0f;
var longDragPenalty = startDistance >= 5 && startDistance - endDistance <= 1 ? 140f : 0f;
var safetyPenalty = GridThreat(ctx, endGrid) * (target.TargetType == AIDirectorDevelopmentTargetType.Village ? 0.25f : 0.5f);
var score = targetScore + progressScore + reachBonus + mobilityBonus - endDistance * 28f - safetyPenalty;
var score = targetScore + progressScore + reachBonus + nearConversionBonus + mobilityBonus - longDragPenalty - endDistance * 28f - safetyPenalty;
var current = ctx.ActionIndex.Candidate(action, AIDirectorLane.Expansion, $"Expansion.Move.{target.TargetType}", score);
AddTerms(
@ -200,7 +206,9 @@ namespace Logic.AI.Director
("target", targetScore),
("progress", progressScore),
("reach", reachBonus),
("nearConversion", nearConversionBonus),
("mobility", mobilityBonus),
("longDrag", -longDragPenalty),
("distance", -endDistance * 28f),
("threat", -safetyPenalty));
current.Unit = current.Unit ?? unit;
@ -784,6 +792,8 @@ namespace Logic.AI.Director
if (target?.Grid == null) return false;
if (target.TargetType == AIDirectorDevelopmentTargetType.Village) return true;
if (target.TargetType != AIDirectorDevelopmentTargetType.EnemyEmptyCity) return false;
if (target.Distance > ctx.Config.DevelopmentSearchRange) return false;
if (ctx.Cache.SelfCities.Count < ctx.Config.ExpansionUrgentCityThreshold) return true;
return ctx.Cache.SelfCities.Count >= ctx.Config.ExpansionUrgentCityThreshold
|| ctx.Player.Turn > ctx.Config.ExpansionHardPressureTurn;
}
@ -1054,9 +1064,9 @@ namespace Logic.AI.Director
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;
return tech is TechType.Climbing or TechType.Riding or TechType.Roads or TechType.Sailing or TechType.Navigation or TechType.FreeSpirit
or TechType.KaguyaRoad or TechType.KanakoClimbing or TechType.KanakoRiding or TechType.KanakoRoads or TechType.KanakoNavigation
or TechType.KomeijiIndianRiding or TechType.KomeijiIndianSailing or TechType.KomeijiIndianNavigation or TechType.HakureiFishing;
}
private bool TechLooksEconomic(TechType tech)

View File

@ -137,6 +137,8 @@ namespace Logic.AI.Director
public int MaxGeneratedActions = 4096;
public int MaxFrontCount = 12;
public int MaxDevelopmentTargetCount = 20;
public int MaxExpansionTargetScanCount = 8;
public int MaxMoveActionsPerUnit = 8;
public float LowHealthRatio = 0.45f;
public float CriticalHealthRatio = 0.25f;
public float HeroLowHealthRatio = 0.6f;
@ -168,12 +170,14 @@ namespace Logic.AI.Director
public readonly AIDirectorConfig Config;
public AIDirectorWorldCache Cache;
public AIDirectorActionIndex ActionIndex;
public readonly HashSet<string> BlockedActionKeys;
public AIDirectorContext(MapData map, PlayerData player, AIDirectorConfig config)
public AIDirectorContext(MapData map, PlayerData player, AIDirectorConfig config, HashSet<string> blockedActionKeys = null)
{
Map = map;
Player = player;
Config = config ?? AIDirectorConfig.CreateDefault();
BlockedActionKeys = blockedActionKeys;
}
}

View File

@ -310,7 +310,7 @@ namespace Logic.AI.Director
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 (target.Distance > ctx.Config.DevelopmentSearchRange) continue;
if (GridThreat(ctx, cache, grid) > 160f && target.TargetType != AIDirectorDevelopmentTargetType.EnemyEmptyCity) continue;
targets.Add(target);
}
@ -368,6 +368,12 @@ namespace Logic.AI.Director
foreach (var target in cache.DevelopmentTargets)
{
if (target.Grid == null) continue;
if (cache.SelfCities.Count < ctx.Config.ExpansionUrgentCityThreshold
&& target.TargetType is AIDirectorDevelopmentTargetType.Village or AIDirectorDevelopmentTargetType.EnemyEmptyCity)
{
continue;
}
cache.Fronts.Add(new AIDirectorFront
{
FrontType = AIDirectorFrontType.Development,
@ -477,6 +483,11 @@ namespace Logic.AI.Director
value = 900f + (city.IsCapital ? 160f : 0f);
}
}
else if (grid.Resource == ResourceType.CityCenter)
{
type = AIDirectorDevelopmentTargetType.Village;
value = 1220f + (ctx.Player.Turn <= ctx.Config.ExpansionHardPressureTurn ? 220f : 0f);
}
else if (grid.Resource != ResourceType.None && !grid.HasBuilding())
{
type = AIDirectorDevelopmentTargetType.Resource;

View File

@ -1,6 +1,7 @@
using Logic.AI.Director;
using Logic.CrashSight;
using RuntimeData;
using System.Collections.Generic;
using TH1_Logic.Action;
namespace Logic.AI
@ -8,6 +9,7 @@ namespace Logic.AI
public sealed class DirectorAIKernel : IAIKernel
{
private readonly AIDirectorLogic _director = new();
private readonly HashSet<string> _executedActionKeysThisTurn = new();
private MapData _mapData;
private PlayerData _playerData;
@ -21,6 +23,7 @@ namespace Logic.AI
{
_mapData = mapData;
_playerData = playerData;
_executedActionKeysThisTurn.Clear();
#if TH1_AI_DIRECTOR_DIAGNOSTICS || UNITY_EDITOR
AIDirectorDiagnostics.RecordTurnStart(_mapData, _playerData);
#endif
@ -29,7 +32,7 @@ namespace Logic.AI
public AIKernelUpdate Update()
{
if (_mapData == null || _playerData == null) return AIKernelUpdate.Finished;
var decision = _director.Decide(_mapData, _playerData);
var decision = _director.Decide(_mapData, _playerData, null, _executedActionKeysThisTurn);
#if TH1_AI_DIRECTOR_DIAGNOSTICS || UNITY_EDITOR
AIDirectorDiagnostics.RecordDecision(_mapData, _playerData, decision);
#endif
@ -43,6 +46,7 @@ namespace Logic.AI
if (action.IsInSight) action.ActionLogic.CameraControl(action.Param);
if (action.ActionLogic.ActionId.PlayerActionType == PlayerActionType.OfferAlly)
LogSystem.LogInfo("AI 发起结盟");
_executedActionKeysThisTurn.Add(AIDirectorActionIndex.StableActionKey(action));
return AIKernelUpdate.ActionReady(action);
}
@ -53,6 +57,7 @@ namespace Logic.AI
#endif
_mapData = null;
_playerData = null;
_executedActionKeysThisTurn.Clear();
}
}
}