Improve AI Director combat and threat logic
This commit is contained in:
parent
05db312da7
commit
7eca426175
@ -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、移动候选、局部搜索半径 |
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user