Tune AI director defense diagnostics loop
This commit is contained in:
parent
6c1de79a8f
commit
2cfeb25a1f
@ -179,6 +179,7 @@ Defense 的行为倾向:
|
||||
- 城市优先训练防守单位、建城墙、保留占城格单位。
|
||||
- 如果危险城市中心为空,城市生产要优先补可站城/守城单位;扩张变强后不能让空城威胁长期升高。
|
||||
- 如果危险城市中心为空且敌方下回合可占,Emergency 改为“补兵/回城心优先”;只有能直接击杀最后一个威胁单位的攻击才能压过驻守,普通攻击不能继续把城心暴露给占领。
|
||||
- 如果空城心威胁发生在首都或 critical 城,且本次 Emergency 没有解除,后续非 Emergency 车道停手;必要时同一城市允许第二个回防单位继续补位,不能只派一个单位后转去建设或移动英雄离线。
|
||||
- 正在站危险城市中心的守军不能被调离城心去救另一座城;其他周边守军仍可参与回防,避免防线互相抽空。
|
||||
- 科技优先防御、基础兵种、移动和克制。
|
||||
- 英雄优先治疗、保护、控场、守城。
|
||||
@ -356,8 +357,8 @@ HeroManagement 的节奏:
|
||||
```text
|
||||
没有选择英雄
|
||||
→ 先选择最适合当前阵营和局势的英雄
|
||||
→ 如果已选择英雄数量已经占满当前英雄槽位,先买下一英雄槽位
|
||||
→ 已选择但未上场时,优先在合法城市训练/复活英雄
|
||||
→ 如果已选择英雄都已经上场,且已选择英雄数量占满当前英雄槽位,再买下一英雄槽位
|
||||
→ 英雄已经能参与战局后,再推进可强制完成的英雄任务
|
||||
```
|
||||
|
||||
@ -444,6 +445,7 @@ HeroPlaybook 的判断顺序:
|
||||
- 治疗和保护类动作优先给残血英雄和高价值友军。
|
||||
- 地面攻击类动作优先选敌军密集、敌城中心或可触发召唤的格子。
|
||||
- 刺客、先锋、法师、控制英雄在有合法有效攻击或地面控制目标时,动作优先级高于普通 MoveToFront;否则已上场英雄容易只移动、不形成个人威胁。
|
||||
- MoveToFront 只作为低优先级补位:必须真实缩短到目标的距离,并且不能让法师、控制、支援、经济英雄踩进明显高威胁格。
|
||||
- 自身主动类动作只在满足局部价值时使用。
|
||||
- 没有专属规则时退回通用英雄攻击、恢复和 Front 站位。
|
||||
|
||||
@ -551,11 +553,16 @@ AI 可以重新实现,但必须遵守 TH1 的游戏架构:
|
||||
- Front 只保留少量高价值目标。
|
||||
- DevelopmentTarget 只保留 TopN。
|
||||
- Expansion 从 DevelopmentTarget 中只扫描前 N 个扩张目标。
|
||||
- UnitMove 不生成整张地图的所有可走格;先从城市威胁、扩张目标、战线、自城、局部战斗收集移动锚点,每个单位只保留最靠近锚点的少量移动候选。
|
||||
- UnitMove 不生成整张地图的所有可走格;先从城市威胁、扩张目标、战线、TopN 自城、局部战斗收集移动锚点,每个单位默认只保留 2 个最靠近锚点的移动候选。
|
||||
- 同一次决策内,单位到目标格的最佳移动结果可以缓存,避免 Expansion、Emergency、Front、HeroPlaybook 重复扫描同一单位的移动候选。
|
||||
- 行动候选只生成一次。
|
||||
- 同一次决策内,格子威胁值按 GridId 缓存;Expansion、Front、Recover、风险判断不能重复遍历全体敌军。
|
||||
- 行动候选只生成、复制、CheckCan 一次;车道评分只包装已验证候选,最终执行前再做一次 CheckCan 兜底。
|
||||
- 同一玩家回合内已经执行过的 stableKey 不再参与下一次候选选择。
|
||||
- 扩张移动、回防移动、前线移动有每回合意图预算;预算耗尽后交给其他车道,避免单一目标吞掉整回合。
|
||||
- Emergency 回防只扫描按距离和用途筛过的少量 responder,默认 8 个;强国后期不能为了每个危险城市全军重算移动。
|
||||
- Growth 按 CityPlan 顺序和动作类型预算扫描城市、地块、玩家动作;软预算后只做高价值发展,硬预算后不再用 Growth/Fallback 把回合填满。
|
||||
- 软预算后的 Fallback 只保留攻击、地面攻击、占领、恢复、升级、宝藏选择等明确收益动作;普通建设、清理地块、学习低价值科技不靠 Fallback 继续填动作。
|
||||
- 批量 compact 诊断只写分析所需字段;Decision 保留 lane/reason/action/actionPool,Execution 保留 action、关键 delta、轻量 before/after,不写完整 cache 和冗余执行快照;完整候选明细仅在非 compact 调试时打开。
|
||||
- 车道只查缓存和动作池。
|
||||
|
||||
如果卡顿,优先削减:
|
||||
|
||||
@ -90,8 +90,16 @@ SkillBase
|
||||
MaxFrontCount = 12
|
||||
MaxDevelopmentTargetCount = 20
|
||||
MaxExpansionTargetScanCount = 6
|
||||
MaxMoveActionsPerUnit = 5
|
||||
MaxExpansionMoveIntentsPerTurn = 2
|
||||
MaxMoveActionsPerUnit = 2
|
||||
MaxSelfCityMoveAnchorCount = 3
|
||||
MaxEmergencyResponderScanCount = 8
|
||||
SoftGrowthActionBudget = 10
|
||||
MaxNonCriticalActionsPerPlayerTurn = 18
|
||||
MinGrowthPriorityAfterSoftBudget = 430
|
||||
MaxGrowthCityActionsPerDecision = 18
|
||||
MaxGrowthGridActionsPerDecision = 24
|
||||
MaxGrowthPlayerActionsPerDecision = 10
|
||||
MaxExpansionMoveIntentsPerTurn = 3
|
||||
MaxEmergencyMoveIntentsPerTurn = 2
|
||||
MaxFrontMoveIntentsPerTurn = 1
|
||||
MaxEmergencyRescueDistance = 6
|
||||
@ -233,8 +241,14 @@ SkillBase
|
||||
如果 ctx.Actions.All.Count == 0:
|
||||
返回 NoDecision
|
||||
|
||||
candidate = TryEmergency(ctx)
|
||||
如果 candidate.IsValid:
|
||||
返回 candidate
|
||||
|
||||
如果 HasUnresolvedEmptyCityCoreThreat(ctx):
|
||||
返回 NoDecision
|
||||
|
||||
lanes = [
|
||||
TryEmergency,
|
||||
TryHeroManagement,
|
||||
TryPriorityTactic,
|
||||
TryExpansion,
|
||||
@ -320,8 +334,19 @@ SkillBase
|
||||
anchors.Add(front.TargetGrid)
|
||||
anchors.Add(front.AnchorGrid)
|
||||
|
||||
对每个 selfCity in ctx.Cache.SelfCities:
|
||||
anchors.Add(selfCity.Grid)
|
||||
selfCityAnchors = 0
|
||||
对每个 cityPlan in ctx.Cache.CityPlans:
|
||||
如果 selfCityAnchors >= Config.MaxSelfCityMoveAnchorCount:
|
||||
break
|
||||
anchors.Add(cityPlan.CityGrid)
|
||||
selfCityAnchors++
|
||||
|
||||
如果 selfCityAnchors == 0:
|
||||
对每个 selfCity in ctx.Cache.SelfCities:
|
||||
如果 selfCityAnchors >= Config.MaxSelfCityMoveAnchorCount:
|
||||
break
|
||||
anchors.Add(selfCity.Grid)
|
||||
selfCityAnchors++
|
||||
|
||||
对每个 localBattle in ctx.Cache.LocalBattles:
|
||||
anchors.Add(localBattle.EnemyGrid)
|
||||
@ -1244,19 +1269,25 @@ SkillBase
|
||||
```text
|
||||
函数 TryEmergencyMoveToCity(ctx, threat):
|
||||
best = None
|
||||
intentKey = BuildIntentKey(Emergency, "MoveToCity", threat.City.Id)
|
||||
如果 IsIntentBlocked(ctx, intentKey):
|
||||
返回 None
|
||||
如果 IsIntentBudgetReached(ctx, Emergency, Config.MaxEmergencyMoveIntentsPerTurn):
|
||||
返回 None
|
||||
allowStackedCityGuard = IsUnresolvedEmptyCityCoreThreat(threat)
|
||||
如果 !allowStackedCityGuard:
|
||||
intentKey = BuildIntentKey(Emergency, "MoveToCity", threat.City.Id)
|
||||
如果 IsIntentBlocked(ctx, intentKey):
|
||||
返回 None
|
||||
如果 IsIntentBudgetReached(ctx, Emergency, Config.MaxEmergencyMoveIntentsPerTurn):
|
||||
返回 None
|
||||
否则:
|
||||
intentKey = ""
|
||||
|
||||
对每个 unit in ctx.Cache.SelfUnits:
|
||||
如果 !CanUseAsDefender(ctx, unit, threat):
|
||||
继续
|
||||
|
||||
startGrid = unit.Grid(ctx.Map)
|
||||
如果 Distance(startGrid, threat.CityGrid) > Config.MaxEmergencyRescueDistance 且 !threat.IsCritical:
|
||||
startDistance = Distance(startGrid, threat.CityGrid)
|
||||
如果 !ShouldScanEmergencyResponder(ctx, threat, unit, startGrid, startDistance, scannedResponders):
|
||||
继续
|
||||
scannedResponders += 1
|
||||
|
||||
action = FindBestMoveToward(ctx, unit, threat.CityGrid)
|
||||
endGrid = GetActionEndGrid(action)
|
||||
@ -1272,6 +1303,25 @@ SkillBase
|
||||
返回 best
|
||||
```
|
||||
|
||||
```text
|
||||
函数 ShouldScanEmergencyResponder(ctx, threat, unit, startGrid, startDistance, scannedResponders):
|
||||
如果 startGrid 为空:
|
||||
返回 false
|
||||
如果 startDistance > Config.MaxEmergencyRescueDistance 且 !threat.IsCritical:
|
||||
返回 false
|
||||
如果 startDistance > Config.MaxEmergencyRescueDistance + 2 且 unit 不是英雄:
|
||||
返回 false
|
||||
如果 Config.MaxEmergencyResponderScanCount <= 0:
|
||||
返回 true
|
||||
如果 scannedResponders < Config.MaxEmergencyResponderScanCount:
|
||||
返回 true
|
||||
如果 unit 是英雄 且 startDistance <= Config.MaxEmergencyRescueDistance + 1:
|
||||
返回 true
|
||||
如果 unit 是 Defender 且 startDistance <= 2:
|
||||
返回 true
|
||||
返回 false
|
||||
```
|
||||
|
||||
```text
|
||||
函数 ScoreEmergencyMove(ctx, unit, action, threat):
|
||||
startGrid = unit.Grid(ctx.Map)
|
||||
@ -1448,6 +1498,8 @@ SkillBase
|
||||
返回 false
|
||||
如果 heroData.MaxHeroCount >= 3:
|
||||
返回 false
|
||||
如果 ctx.Cache.SelfHeroes.Count < heroData.HeroCount:
|
||||
返回 false
|
||||
如果 heroData.HeroCount > 0 且 heroData.HeroCount >= heroData.MaxHeroCount:
|
||||
返回 true
|
||||
返回 false
|
||||
@ -1867,9 +1919,50 @@ Expansion Lane 只处理早期扩张的刚性目标,不处理普通资源开
|
||||
score += 60
|
||||
返回 Candidate(ground, HeroPlaybook, score, "英雄通用地面控制")
|
||||
|
||||
target = state.Front.TargetGrid 或 state.Front.AnchorGrid
|
||||
move = FindBestMoveToward(ctx, hero, target)
|
||||
score = ScoreHeroFrontMove(ctx, state, move, target)
|
||||
如果 move 可用 且 score > 0:
|
||||
返回 Candidate(move, HeroPlaybook, score, "英雄前线补位")
|
||||
|
||||
返回 None
|
||||
```
|
||||
|
||||
```text
|
||||
函数 ScoreHeroFrontMove(ctx, state, move, target):
|
||||
如果 move 不存在 或 target 不存在:
|
||||
返回 0
|
||||
start = hero 当前格
|
||||
end = move 目标格
|
||||
如果 end 没有缩短到 target 的距离:
|
||||
返回 0
|
||||
|
||||
如果 hero 已在前线 且 end 距离 target 仍大于 1:
|
||||
返回 0
|
||||
如果 hero 是 Support / Economy 且当前不是 Defense front:
|
||||
返回 0
|
||||
如果 hero 是 Caster / Control 且当前没有敌我接触 且 end 距离 target 大于 2:
|
||||
返回 0
|
||||
|
||||
score = 500
|
||||
score += 缩短距离 * 30
|
||||
score -= end 到 target 距离 * 18
|
||||
|
||||
如果 front 是 Defense:
|
||||
score += 70
|
||||
如果 hero 是 Mobility:
|
||||
score += 45
|
||||
否则如果 hero 是 Vanguard / Assassin:
|
||||
score += 20
|
||||
如果 hero 是 Caster / Control:
|
||||
score -= 45
|
||||
如果 hero 已受威胁:
|
||||
如果低血: score -= 170
|
||||
否则: score -= 60
|
||||
|
||||
返回 score
|
||||
```
|
||||
|
||||
### 9.6 默认英雄规则
|
||||
|
||||
```text
|
||||
@ -2350,6 +2443,8 @@ Byakuren / Miko / Zanmu:
|
||||
```text
|
||||
函数 TryGrowth(ctx):
|
||||
best = None
|
||||
如果 ShouldStopNonCriticalLane(ctx):
|
||||
返回 None
|
||||
|
||||
best = MaxCandidate(best, TryCityGrowth(ctx))
|
||||
best = MaxCandidate(best, TryGridGrowth(ctx))
|
||||
@ -2363,12 +2458,18 @@ Byakuren / Miko / Zanmu:
|
||||
```text
|
||||
函数 TryCityGrowth(ctx):
|
||||
best = None
|
||||
scanned = 0
|
||||
|
||||
对每个 action in ctx.Actions.CityActions:
|
||||
plan = FindCityPlanByAction(ctx, action)
|
||||
score = ScoreCityGrowth(ctx, action, plan)
|
||||
candidate = Candidate(action, Growth, score, "城市发展")
|
||||
best = MaxCandidate(best, candidate)
|
||||
对每个 plan in ctx.Cache.CityPlans:
|
||||
对每个 action in ctx.Actions.GetCityActions(plan.City):
|
||||
如果 scanned >= Config.MaxGrowthCityActionsPerDecision:
|
||||
跳出
|
||||
scanned += 1
|
||||
score = ScoreCityGrowth(ctx, action, plan)
|
||||
如果 ShouldSkipLowValueGrowth(ctx, score):
|
||||
继续
|
||||
candidate = Candidate(action, Growth, score, "城市发展")
|
||||
best = MaxCandidate(best, candidate)
|
||||
|
||||
返回 best
|
||||
```
|
||||
@ -2420,9 +2521,15 @@ Byakuren / Miko / Zanmu:
|
||||
```text
|
||||
函数 TryGridGrowth(ctx):
|
||||
best = None
|
||||
scanned = 0
|
||||
|
||||
对每个 action in ctx.Actions.GridActions:
|
||||
如果 scanned >= Config.MaxGrowthGridActionsPerDecision:
|
||||
跳出
|
||||
scanned += 1
|
||||
score = ScoreGridGrowth(ctx, action)
|
||||
如果 ShouldSkipLowValueGrowth(ctx, score):
|
||||
继续
|
||||
candidate = Candidate(action, Growth, score, "地块发展")
|
||||
best = MaxCandidate(best, candidate)
|
||||
|
||||
@ -2457,15 +2564,51 @@ Byakuren / Miko / Zanmu:
|
||||
```text
|
||||
函数 TryPlayerGrowth(ctx):
|
||||
best = None
|
||||
scanned = 0
|
||||
|
||||
对每个 action in ctx.Actions.PlayerActions:
|
||||
如果 scanned >= Config.MaxGrowthPlayerActionsPerDecision:
|
||||
跳出
|
||||
scanned += 1
|
||||
score = ScorePlayerGrowth(ctx, action)
|
||||
如果 ShouldSkipLowValueGrowth(ctx, score):
|
||||
继续
|
||||
candidate = Candidate(action, Growth, score, "玩家发展")
|
||||
best = MaxCandidate(best, candidate)
|
||||
|
||||
返回 best
|
||||
```
|
||||
|
||||
```text
|
||||
函数 ShouldStopNonCriticalLane(ctx):
|
||||
如果 HasUnresolvedEmptyCityCoreThreat(ctx):
|
||||
返回 true
|
||||
如果 Config.MaxNonCriticalActionsPerPlayerTurn <= 0:
|
||||
返回 false
|
||||
如果 ctx.Cache.SelfCities.Count < Config.ExpansionUrgentCityThreshold:
|
||||
返回 false
|
||||
返回 ctx.ExecutedActionCountThisTurn >= Config.MaxNonCriticalActionsPerPlayerTurn
|
||||
|
||||
函数 HasUnresolvedEmptyCityCoreThreat(ctx):
|
||||
对每个 threat in ctx.Cache.CityThreats:
|
||||
如果 IsUnresolvedEmptyCityCoreThreat(threat):
|
||||
返回 true
|
||||
返回 false
|
||||
|
||||
函数 IsUnresolvedEmptyCityCoreThreat(threat):
|
||||
返回 threat 非空
|
||||
且 !threat.HasCityCenterDefender
|
||||
且 threat.CanBeThreatenedNextTurn
|
||||
且 (threat.IsCapital 或 threat.IsCritical)
|
||||
|
||||
函数 ShouldSkipLowValueGrowth(ctx, score):
|
||||
如果 Config.SoftGrowthActionBudget <= 0:
|
||||
返回 false
|
||||
如果 ctx.ExecutedActionCountThisTurn < Config.SoftGrowthActionBudget:
|
||||
返回 false
|
||||
返回 score < Config.MinGrowthPriorityAfterSoftBudget
|
||||
```
|
||||
|
||||
```text
|
||||
函数 ScorePlayerGrowth(ctx, action):
|
||||
id = action.ActionId
|
||||
@ -2556,6 +2699,26 @@ Byakuren / Miko / Zanmu:
|
||||
|
||||
```text
|
||||
函数 TryFallback(ctx):
|
||||
如果 ShouldStopNonCriticalLane(ctx):
|
||||
返回 None
|
||||
|
||||
如果 IsPastSoftGrowthBudget(ctx):
|
||||
action = FindStrictFallbackAction(ctx)
|
||||
reason = "软预算后严格兜底"
|
||||
否则:
|
||||
action = FindBestFallbackAction(ctx)
|
||||
reason = "兜底合法动作"
|
||||
|
||||
candidate = Candidate(action, Fallback, 1, reason)
|
||||
如果 candidate.IsValid:
|
||||
candidate.IsFallback = true
|
||||
返回 candidate
|
||||
|
||||
返回 None
|
||||
```
|
||||
|
||||
```text
|
||||
函数 FindBestFallbackAction(ctx):
|
||||
order = [
|
||||
ctx.Actions.Attacks,
|
||||
ctx.Actions.AttackGrounds,
|
||||
@ -2567,17 +2730,31 @@ Byakuren / Miko / Zanmu:
|
||||
|
||||
对每个 list in order:
|
||||
对每个 action in list:
|
||||
如果 action.ActionType == UnitAttackAlly:
|
||||
继续
|
||||
candidate = Candidate(action, Fallback, 1, "兜底合法动作")
|
||||
如果 candidate.IsValid:
|
||||
candidate.IsFallback = true
|
||||
返回 candidate
|
||||
如果 IsFallbackAction(action):
|
||||
返回 action
|
||||
|
||||
返回 None
|
||||
|
||||
函数 FindStrictFallbackAction(ctx):
|
||||
对每个 action in ctx.Actions.Attacks:
|
||||
如果 ScoreAttackAction(ctx, action) > 0:
|
||||
返回 action
|
||||
|
||||
对每个 action in ctx.Actions.AttackGrounds:
|
||||
返回 action
|
||||
|
||||
对每个 action in ctx.Actions.UnitActions:
|
||||
如果 action.UnitActionType in [Capture, Recover, Upgrade, CultureUnitUpgrade, HeroUpgrade, AbsorbRedMist, HakureiAbsorbRune, WarCry]:
|
||||
返回 action
|
||||
|
||||
对每个 action in ctx.Actions.PlayerActions:
|
||||
如果 action.PlayerActionType 是宝藏选择或宝藏收益:
|
||||
返回 action
|
||||
|
||||
返回 None
|
||||
```
|
||||
|
||||
Fallback 的目标是不断回合,不负责聪明。大量进入 Fallback 说明前面车道缺规则。
|
||||
Fallback 的目标是不断回合,不负责聪明。大量进入 Fallback 说明前面车道缺规则。软预算之后 Fallback 只能执行明确收益动作,避免强国后期用低价值建设或清理动作把回合拖长。
|
||||
|
||||
---
|
||||
|
||||
@ -2647,6 +2824,9 @@ Fallback 的目标是不断回合,不负责聪明。大量进入 Fallback 说
|
||||
|
||||
```text
|
||||
函数 GridThreat(ctx, unit, grid):
|
||||
如果 ctx.GridThreatCache 包含 grid.Id:
|
||||
返回 ctx.GridThreatCache[grid.Id]
|
||||
|
||||
threat = 0
|
||||
|
||||
对每个 enemy in ctx.Cache.EnemyUnits:
|
||||
@ -2654,6 +2834,7 @@ Fallback 的目标是不断回合,不负责聪明。大量进入 Fallback 说
|
||||
如果 Distance(enemyGrid, grid) <= MoveRange(enemy) + AttackRange(enemy):
|
||||
threat += EstimateDamageValue(ctx, enemy, unit)
|
||||
|
||||
ctx.GridThreatCache[grid.Id] = threat
|
||||
返回 threat
|
||||
```
|
||||
|
||||
@ -2888,7 +3069,7 @@ CityThreat: SelfCities * nearby EnemyUnits
|
||||
LocalBattle: SelfUnits * nearby EnemyUnits
|
||||
Front: SelfCities * EnemyCities + DevelopmentTargets
|
||||
ActionPool: MaxActionCount 上限
|
||||
Growth: 只遍历合法动作
|
||||
Growth: 按 CityPlan 顺序扫描 TopN 城市动作、TopN 地块动作、TopN 玩家动作
|
||||
```
|
||||
|
||||
性能保护:
|
||||
@ -2897,8 +3078,17 @@ Growth: 只遍历合法动作
|
||||
限制移动候选数量
|
||||
限制 DevelopmentTarget TopN
|
||||
限制 Front TopN
|
||||
限制自城移动锚点 TopN
|
||||
限制 Emergency responder 扫描数量
|
||||
限制 Growth 每次决策扫描数量
|
||||
软预算后过滤低价值 Growth,硬预算后 Growth/Fallback 停手
|
||||
限制 LocalBattle 搜索半径
|
||||
一次决策内缓存 FindBestMoveToward(unit, targetGrid)
|
||||
一次决策内缓存 GridThreat(grid)
|
||||
ActionPool 构建阶段完成 CopyAction + CheckCan
|
||||
各车道 Candidate 只引用已验证 action,不重复 CopyAction + CheckCan
|
||||
最终 Execute 前再 CheckCan 一次兜底
|
||||
compact batch 诊断只写分析所需字段;Decision 保留 lane/reason/action/actionPool,Execution 保留 action、关键 delta、轻量 before/after,完整候选明细只在非 compact 调试时打开
|
||||
科技只看当前可学和一层后继预览
|
||||
```
|
||||
|
||||
|
||||
@ -94,9 +94,27 @@ namespace Logic.AI.Director
|
||||
Add(front?.AnchorGrid);
|
||||
}
|
||||
|
||||
foreach (var city in ctx.Cache.SelfCities)
|
||||
var cityAnchorLimit = ctx.Config.MaxSelfCityMoveAnchorCount <= 0 ? int.MaxValue : ctx.Config.MaxSelfCityMoveAnchorCount;
|
||||
var cityAnchorCount = 0;
|
||||
foreach (var plan in ctx.Cache.CityPlans)
|
||||
{
|
||||
if (city != null && ctx.Map.GetGridDataByCityId(city.Id, out var cityGrid)) Add(cityGrid);
|
||||
if (cityAnchorCount >= cityAnchorLimit) break;
|
||||
if (plan?.CityGrid == null) continue;
|
||||
Add(plan.CityGrid);
|
||||
cityAnchorCount++;
|
||||
}
|
||||
|
||||
if (cityAnchorCount == 0)
|
||||
{
|
||||
foreach (var city in ctx.Cache.SelfCities)
|
||||
{
|
||||
if (cityAnchorCount >= cityAnchorLimit) break;
|
||||
if (city != null && ctx.Map.GetGridDataByCityId(city.Id, out var cityGrid))
|
||||
{
|
||||
Add(cityGrid);
|
||||
cityAnchorCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var battle in ctx.Cache.LocalBattles)
|
||||
@ -111,9 +129,7 @@ namespace Logic.AI.Director
|
||||
public AIDirectorActionCandidate Candidate(AIActionBase action, AIDirectorLane lane, string reason, float priority, bool fallback = false)
|
||||
{
|
||||
if (action == null) return AIDirectorActionCandidate.Invalid(lane, reason, priority, fallback);
|
||||
var copied = CopyAction(action);
|
||||
if (copied == null || !copied.ActionLogic.CheckCan(copied.Param)) return AIDirectorActionCandidate.Invalid(lane, reason, priority, fallback);
|
||||
return new AIDirectorActionCandidate(copied, lane, reason, priority, fallback);
|
||||
return new AIDirectorActionCandidate(action, lane, reason, priority, fallback);
|
||||
}
|
||||
|
||||
public AIActionBase FindUnitAction(UnitData unit, UnitActionType type)
|
||||
|
||||
@ -26,6 +26,8 @@ namespace Logic.AI.Director
|
||||
private static int _eventSequence;
|
||||
private static int _decisionSequence;
|
||||
private static int _sessionMapObjectHash;
|
||||
private static StreamWriter _writer;
|
||||
private static string _writerPath;
|
||||
|
||||
public static bool Enabled => _enabled;
|
||||
public static string CurrentLogPath => EnsureSession();
|
||||
@ -52,8 +54,14 @@ namespace Logic.AI.Director
|
||||
ResetSession();
|
||||
}
|
||||
|
||||
public static void FlushAndClose()
|
||||
{
|
||||
CloseWriter();
|
||||
}
|
||||
|
||||
private static void ResetSession()
|
||||
{
|
||||
CloseWriter();
|
||||
_sessionId = null;
|
||||
_currentLogPath = null;
|
||||
_eventSequence = 0;
|
||||
@ -66,6 +74,11 @@ namespace Logic.AI.Director
|
||||
if (!_enabled || map == null || player == null) return;
|
||||
var record = CreateBaseRecord("TurnStart", map, player);
|
||||
record.turnSummary = BuildOutcomeSnapshot(CaptureOutcomeProbe(map, player, null));
|
||||
if (Logic.AI.AIDirectorBatchRuntime.CompactDiagnostics)
|
||||
{
|
||||
WriteCompactRecord(BuildCompactTurnRecord(record));
|
||||
return;
|
||||
}
|
||||
WriteRecord(record);
|
||||
}
|
||||
|
||||
@ -74,6 +87,11 @@ namespace Logic.AI.Director
|
||||
if (!_enabled || map == null || player == null) return;
|
||||
var record = CreateBaseRecord("TurnEnd", map, player);
|
||||
record.turnSummary = BuildOutcomeSnapshot(CaptureOutcomeProbe(map, player, null));
|
||||
if (Logic.AI.AIDirectorBatchRuntime.CompactDiagnostics)
|
||||
{
|
||||
WriteCompactRecord(BuildCompactTurnRecord(record));
|
||||
return;
|
||||
}
|
||||
WriteRecord(record);
|
||||
}
|
||||
|
||||
@ -85,6 +103,13 @@ namespace Logic.AI.Director
|
||||
record.decisionSequence = ++_decisionSequence;
|
||||
record.decision = BuildDecisionSnapshot(decision);
|
||||
record.actionPool = BuildActionPoolSnapshot(decision.ActionIndex);
|
||||
if (Logic.AI.AIDirectorBatchRuntime.CompactDiagnostics)
|
||||
{
|
||||
var compactRecord = BuildCompactDecisionRecord(record);
|
||||
if (!decision.HasAction || decision.Candidate?.IsFallback == true) compactRecord.trace = CopyTrace(decision.Trace);
|
||||
WriteCompactRecord(compactRecord);
|
||||
return;
|
||||
}
|
||||
if (!Logic.AI.AIDirectorBatchRuntime.CompactDiagnostics)
|
||||
{
|
||||
record.cache = BuildCacheSnapshot(map, player, decision.Cache);
|
||||
@ -129,6 +154,11 @@ namespace Logic.AI.Director
|
||||
isInSight = action.IsInSight,
|
||||
duration = action.Duration
|
||||
};
|
||||
if (Logic.AI.AIDirectorBatchRuntime.CompactDiagnostics)
|
||||
{
|
||||
WriteCompactRecord(BuildCompactExecutionRecord(record));
|
||||
return;
|
||||
}
|
||||
WriteRecord(record);
|
||||
}
|
||||
|
||||
@ -568,7 +598,7 @@ namespace Logic.AI.Director
|
||||
var directory = GetLogDirectory();
|
||||
Directory.CreateDirectory(directory);
|
||||
_currentLogPath = Path.Combine(directory, $"ai_director_{_sessionId}.jsonl");
|
||||
WriteRecord(new DiagnosticRecord
|
||||
WriteCompactRecord(new CompactSessionRecord
|
||||
{
|
||||
schemaVersion = SchemaVersion,
|
||||
sessionId = _sessionId,
|
||||
@ -1090,8 +1120,7 @@ namespace Logic.AI.Director
|
||||
{
|
||||
try
|
||||
{
|
||||
var path = EnsureSession();
|
||||
File.AppendAllText(path, JsonUtility.ToJson(record, false) + Environment.NewLine);
|
||||
WriteJsonLine(record);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
@ -1099,6 +1128,293 @@ namespace Logic.AI.Director
|
||||
}
|
||||
}
|
||||
|
||||
private static void WriteCompactRecord(object record)
|
||||
{
|
||||
try
|
||||
{
|
||||
WriteJsonLine(record);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
LogSystem.LogWarning($"AI Director diagnostics write failed: {e.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static void WriteJsonLine(object record)
|
||||
{
|
||||
if (record == null) return;
|
||||
var path = EnsureSession();
|
||||
var writer = EnsureWriter(path);
|
||||
writer.WriteLine(JsonUtility.ToJson(record, false));
|
||||
writer.Flush();
|
||||
}
|
||||
|
||||
private static StreamWriter EnsureWriter(string path)
|
||||
{
|
||||
if (_writer != null && _writerPath == path) return _writer;
|
||||
CloseWriter();
|
||||
_writerPath = path;
|
||||
var stream = new FileStream(path, FileMode.Append, FileAccess.Write, FileShare.ReadWrite);
|
||||
_writer = new StreamWriter(stream);
|
||||
return _writer;
|
||||
}
|
||||
|
||||
private static void CloseWriter()
|
||||
{
|
||||
if (_writer == null) return;
|
||||
try
|
||||
{
|
||||
_writer.Flush();
|
||||
_writer.Dispose();
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Best effort cleanup for editor/batch diagnostics.
|
||||
}
|
||||
finally
|
||||
{
|
||||
_writer = null;
|
||||
_writerPath = null;
|
||||
}
|
||||
}
|
||||
|
||||
private static CompactTurnRecord BuildCompactTurnRecord(DiagnosticRecord record)
|
||||
{
|
||||
var compact = CopyCompactBase<CompactTurnRecord>(record);
|
||||
compact.turnSummary = BuildCompactOutcomeSnapshot(record.turnSummary);
|
||||
return compact;
|
||||
}
|
||||
|
||||
private static CompactDecisionRecord BuildCompactDecisionRecord(DiagnosticRecord record)
|
||||
{
|
||||
var compact = CopyCompactBase<CompactDecisionRecord>(record);
|
||||
compact.decisionSequence = record.decisionSequence;
|
||||
compact.decision = record.decision;
|
||||
compact.actionPool = record.actionPool;
|
||||
return compact;
|
||||
}
|
||||
|
||||
private static CompactExecutionRecord BuildCompactExecutionRecord(DiagnosticRecord record)
|
||||
{
|
||||
var compact = CopyCompactBase<CompactExecutionRecord>(record);
|
||||
compact.actionIndexInTurn = record.actionIndexInTurn;
|
||||
compact.execution = BuildCompactExecutionSnapshot(record.execution);
|
||||
return compact;
|
||||
}
|
||||
|
||||
private static CompactExecutionSnapshot BuildCompactExecutionSnapshot(ExecutionSnapshot snapshot)
|
||||
{
|
||||
if (snapshot == null) return null;
|
||||
return new CompactExecutionSnapshot
|
||||
{
|
||||
executed = snapshot.executed,
|
||||
action = snapshot.action,
|
||||
before = BuildCompactOutcomeSnapshot(snapshot.before),
|
||||
after = BuildCompactOutcomeSnapshot(snapshot.after),
|
||||
delta = snapshot.delta,
|
||||
isInSight = snapshot.isInSight,
|
||||
duration = snapshot.duration
|
||||
};
|
||||
}
|
||||
|
||||
private static CompactOutcomeSnapshot BuildCompactOutcomeSnapshot(OutcomeSnapshot snapshot)
|
||||
{
|
||||
if (snapshot == null) return null;
|
||||
return new CompactOutcomeSnapshot
|
||||
{
|
||||
netActionCount = snapshot.netActionCount,
|
||||
playerId = snapshot.playerId,
|
||||
playerTurn = snapshot.playerTurn,
|
||||
playerCoin = snapshot.playerCoin,
|
||||
playerTechPoint = snapshot.playerTechPoint,
|
||||
playerCulture = snapshot.playerCulture,
|
||||
cultureCardCount = snapshot.cultureCardCount,
|
||||
playerScore = snapshot.playerScore,
|
||||
sightGridCount = snapshot.sightGridCount,
|
||||
cityCount = snapshot.cityCount,
|
||||
ownedCityIdsSignature = snapshot.ownedCityIdsSignature,
|
||||
capitalCityIdsSignature = snapshot.capitalCityIdsSignature,
|
||||
unitCount = snapshot.unitCount,
|
||||
heroCount = snapshot.heroCount,
|
||||
selectedHeroCount = snapshot.selectedHeroCount,
|
||||
maxHeroCount = snapshot.maxHeroCount,
|
||||
heroTaskCount = snapshot.heroTaskCount,
|
||||
readyHeroTaskCount = snapshot.readyHeroTaskCount,
|
||||
forcedHeroTaskCount = snapshot.forcedHeroTaskCount,
|
||||
heroTaskProgress = snapshot.heroTaskProgress,
|
||||
selfMilitary = snapshot.selfMilitary,
|
||||
enemyMilitary = snapshot.enemyMilitary,
|
||||
criticalCityThreatCount = snapshot.criticalCityThreatCount,
|
||||
cityThreatCount = snapshot.cityThreatCount,
|
||||
capitalThreatCount = snapshot.capitalThreatCount,
|
||||
criticalCapitalThreatCount = snapshot.criticalCapitalThreatCount,
|
||||
emptyThreatenedCityCount = snapshot.emptyThreatenedCityCount,
|
||||
threatenedCityIdsSignature = snapshot.threatenedCityIdsSignature,
|
||||
criticalThreatenedCityIdsSignature = snapshot.criticalThreatenedCityIdsSignature,
|
||||
maxCityDangerScore = snapshot.maxCityDangerScore,
|
||||
strategicPosture = snapshot.strategicPosture,
|
||||
unitId = snapshot.unitId,
|
||||
unitAlive = snapshot.unitAlive,
|
||||
unitHealth = snapshot.unitHealth,
|
||||
unitGridId = snapshot.unitGridId,
|
||||
unitSkillCount = snapshot.unitSkillCount,
|
||||
unitSkillSignature = snapshot.unitSkillSignature,
|
||||
targetUnitId = snapshot.targetUnitId,
|
||||
targetUnitAlive = snapshot.targetUnitAlive,
|
||||
targetUnitHealth = snapshot.targetUnitHealth,
|
||||
targetUnitGridId = snapshot.targetUnitGridId,
|
||||
targetUnitSkillCount = snapshot.targetUnitSkillCount,
|
||||
targetUnitSkillSignature = snapshot.targetUnitSkillSignature,
|
||||
cityId = snapshot.cityId,
|
||||
cityOwnerId = snapshot.cityOwnerId,
|
||||
targetCityId = snapshot.targetCityId,
|
||||
targetCityOwnerId = snapshot.targetCityOwnerId
|
||||
};
|
||||
}
|
||||
|
||||
private static T CopyCompactBase<T>(DiagnosticRecord record) where T : CompactBaseRecord, new()
|
||||
{
|
||||
return new T
|
||||
{
|
||||
schemaVersion = record.schemaVersion,
|
||||
sessionId = record.sessionId,
|
||||
eventSequence = record.eventSequence,
|
||||
eventType = record.eventType,
|
||||
timeLocal = record.timeLocal,
|
||||
frame = record.frame,
|
||||
mapId = record.mapId,
|
||||
curPlayerId = record.curPlayerId,
|
||||
netActionCount = record.netActionCount,
|
||||
playerId = record.playerId,
|
||||
playerTurn = record.playerTurn,
|
||||
playerCoin = record.playerCoin,
|
||||
playerCulture = record.playerCulture,
|
||||
selfCities = record.selfCities,
|
||||
selfUnits = record.selfUnits,
|
||||
kernel = record.kernel
|
||||
};
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
private class CompactBaseRecord
|
||||
{
|
||||
public string schemaVersion;
|
||||
public string sessionId;
|
||||
public int eventSequence;
|
||||
public string eventType;
|
||||
public string timeLocal;
|
||||
public int frame;
|
||||
public uint mapId;
|
||||
public uint curPlayerId;
|
||||
public int netActionCount;
|
||||
public uint playerId;
|
||||
public uint playerTurn;
|
||||
public int playerCoin;
|
||||
public int playerCulture;
|
||||
public int selfCities;
|
||||
public int selfUnits;
|
||||
public string kernel;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
private sealed class CompactSessionRecord
|
||||
{
|
||||
public string schemaVersion;
|
||||
public string sessionId;
|
||||
public int eventSequence;
|
||||
public string eventType;
|
||||
public string timeLocal;
|
||||
public int frame;
|
||||
public string kernel;
|
||||
public string logPath;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
private sealed class CompactTurnRecord : CompactBaseRecord
|
||||
{
|
||||
public CompactOutcomeSnapshot turnSummary;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
private sealed class CompactDecisionRecord : CompactBaseRecord
|
||||
{
|
||||
public int decisionSequence;
|
||||
public DecisionSnapshot decision;
|
||||
public ActionPoolSnapshot actionPool;
|
||||
public List<string> trace;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
private sealed class CompactExecutionRecord : CompactBaseRecord
|
||||
{
|
||||
public int actionIndexInTurn;
|
||||
public CompactExecutionSnapshot execution;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
private sealed class CompactExecutionSnapshot
|
||||
{
|
||||
public bool executed;
|
||||
public ActionSnapshot action;
|
||||
public CompactOutcomeSnapshot before;
|
||||
public CompactOutcomeSnapshot after;
|
||||
public OutcomeDeltaSnapshot delta;
|
||||
public bool isInSight;
|
||||
public float duration;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
private sealed class CompactOutcomeSnapshot
|
||||
{
|
||||
public int netActionCount;
|
||||
public uint playerId;
|
||||
public uint playerTurn;
|
||||
public int playerCoin;
|
||||
public int playerTechPoint;
|
||||
public int playerCulture;
|
||||
public int cultureCardCount;
|
||||
public int playerScore;
|
||||
public int sightGridCount;
|
||||
public int cityCount;
|
||||
public string ownedCityIdsSignature;
|
||||
public string capitalCityIdsSignature;
|
||||
public int unitCount;
|
||||
public int heroCount;
|
||||
public int selectedHeroCount;
|
||||
public int maxHeroCount;
|
||||
public int heroTaskCount;
|
||||
public int readyHeroTaskCount;
|
||||
public int forcedHeroTaskCount;
|
||||
public int heroTaskProgress;
|
||||
public float selfMilitary;
|
||||
public float enemyMilitary;
|
||||
public int criticalCityThreatCount;
|
||||
public int cityThreatCount;
|
||||
public int capitalThreatCount;
|
||||
public int criticalCapitalThreatCount;
|
||||
public int emptyThreatenedCityCount;
|
||||
public string threatenedCityIdsSignature;
|
||||
public string criticalThreatenedCityIdsSignature;
|
||||
public float maxCityDangerScore;
|
||||
public string strategicPosture;
|
||||
public uint unitId;
|
||||
public bool unitAlive;
|
||||
public int unitHealth;
|
||||
public uint unitGridId;
|
||||
public int unitSkillCount;
|
||||
public string unitSkillSignature;
|
||||
public uint targetUnitId;
|
||||
public bool targetUnitAlive;
|
||||
public int targetUnitHealth;
|
||||
public uint targetUnitGridId;
|
||||
public int targetUnitSkillCount;
|
||||
public string targetUnitSkillSignature;
|
||||
public uint cityId;
|
||||
public uint cityOwnerId;
|
||||
public uint targetCityId;
|
||||
public uint targetCityOwnerId;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
private sealed class DiagnosticRecord
|
||||
{
|
||||
@ -1537,6 +1853,7 @@ namespace Logic.AI.Director
|
||||
public static void Enable() { }
|
||||
public static void Disable() { }
|
||||
public static void BeginNewSession() { }
|
||||
public static void FlushAndClose() { }
|
||||
public static void RecordTurnStart(MapData map, PlayerData player) { }
|
||||
public static void RecordTurnEnd(MapData map, PlayerData player) { }
|
||||
public static void RecordDecision(MapData map, PlayerData player, AIDirectorDecision decision) { }
|
||||
|
||||
@ -17,7 +17,8 @@ namespace Logic.AI.Director
|
||||
PlayerData player,
|
||||
AIDirectorConfig config = null,
|
||||
HashSet<string> blockedActionKeys = null,
|
||||
HashSet<string> blockedIntentKeys = null)
|
||||
HashSet<string> blockedIntentKeys = null,
|
||||
int executedActionCountThisTurn = 0)
|
||||
{
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
var decision = new AIDirectorDecision();
|
||||
@ -29,7 +30,7 @@ namespace Logic.AI.Director
|
||||
return decision;
|
||||
}
|
||||
|
||||
var ctx = new AIDirectorContext(map, player, config ?? AIDirectorConfig.CreateDefault(), blockedActionKeys, blockedIntentKeys);
|
||||
var ctx = new AIDirectorContext(map, player, config ?? AIDirectorConfig.CreateDefault(), blockedActionKeys, blockedIntentKeys, executedActionCountThisTurn);
|
||||
ctx.Cache = _cacheBuilder.Build(ctx);
|
||||
ctx.ActionIndex = AIDirectorActionIndex.Build(ctx);
|
||||
_cacheBuilder.BuildUnitOpportunities(ctx);
|
||||
@ -48,16 +49,25 @@ namespace Logic.AI.Director
|
||||
return decision;
|
||||
}
|
||||
|
||||
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, false)
|
||||
|| TryUnitOpportunityLane(ctx, decision, out candidate)
|
||||
|| TryFrontLane(ctx, decision, out candidate)
|
||||
|| TryGrowthLane(ctx, decision, out candidate)
|
||||
|| TryFallback(ctx, decision, out candidate))
|
||||
var hasCandidate = TryEmergencyLane(ctx, decision, out var candidate);
|
||||
if (!hasCandidate && HasUnresolvedEmptyCityCoreThreat(ctx))
|
||||
{
|
||||
decision.AddTrace("Emergency lock: unresolved empty capital/critical city threat; skip non-emergency lanes.", ctx.Config.MaxCandidateTraceCount);
|
||||
}
|
||||
else if (!hasCandidate)
|
||||
{
|
||||
hasCandidate = 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, false)
|
||||
|| TryUnitOpportunityLane(ctx, decision, out candidate)
|
||||
|| TryFrontLane(ctx, decision, out candidate)
|
||||
|| TryGrowthLane(ctx, decision, out candidate)
|
||||
|| TryFallback(ctx, decision, out candidate);
|
||||
}
|
||||
|
||||
if (hasCandidate)
|
||||
{
|
||||
decision.Candidate = candidate;
|
||||
decision.RecordLaneResult(candidate.Lane, true, "Selected");
|
||||
@ -79,9 +89,10 @@ namespace Logic.AI.Director
|
||||
out AIDirectorActionCandidate candidate,
|
||||
AIDirectorConfig config = null,
|
||||
HashSet<string> blockedActionKeys = null,
|
||||
HashSet<string> blockedIntentKeys = null)
|
||||
HashSet<string> blockedIntentKeys = null,
|
||||
int executedActionCountThisTurn = 0)
|
||||
{
|
||||
var decision = Decide(map, player, config, blockedActionKeys, blockedIntentKeys);
|
||||
var decision = Decide(map, player, config, blockedActionKeys, blockedIntentKeys, executedActionCountThisTurn);
|
||||
candidate = decision.Candidate;
|
||||
return decision.HasAction;
|
||||
}
|
||||
@ -312,17 +323,22 @@ 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;
|
||||
var allowStackedCityGuard = IsUnresolvedEmptyCityCoreThreat(threat);
|
||||
if (!allowStackedCityGuard && IsIntentBudgetReached(ctx, AIDirectorLane.Emergency, ctx.Config.MaxEmergencyMoveIntentsPerTurn)) return best;
|
||||
var intentKey = allowStackedCityGuard
|
||||
? string.Empty
|
||||
: BuildIntentKey(AIDirectorLane.Emergency, "MoveToCity", threat?.City?.Id ?? threat?.CityGrid?.Id ?? 0);
|
||||
if (!allowStackedCityGuard && IsIntentBlocked(ctx, intentKey)) return best;
|
||||
var scannedResponders = 0;
|
||||
foreach (var unit in ctx.Cache.SelfUnits)
|
||||
{
|
||||
if (unit == null || unit.GetActionPoint(ActionPointType.Move) <= 0) continue;
|
||||
if (threat.Defenders.Contains(unit) && threat.DefenderPower >= threat.EnemyPower) continue;
|
||||
var action = ctx.ActionIndex.FindBestMove(unit, threat.CityGrid);
|
||||
var unitGrid = unit.Grid(ctx.Map);
|
||||
var startDistance = AIDirectorMath.Distance(ctx.Map, unitGrid, threat.CityGrid);
|
||||
if (startDistance > ctx.Config.MaxEmergencyRescueDistance && !threat.IsCritical) continue;
|
||||
if (!ShouldScanEmergencyResponder(ctx, threat, unit, unitGrid, startDistance, scannedResponders)) continue;
|
||||
scannedResponders++;
|
||||
var action = ctx.ActionIndex.FindBestMove(unit, threat.CityGrid);
|
||||
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);
|
||||
@ -438,6 +454,7 @@ namespace Logic.AI.Director
|
||||
var heroData = ctx?.Player?.PlayerHeroData;
|
||||
if (heroData == null) return false;
|
||||
if (heroData.MaxHeroCount >= 3) return false;
|
||||
if (ctx.Cache.SelfHeroes.Count < heroData.HeroCount) return false;
|
||||
return heroData.HeroCount > 0 && heroData.HeroCount >= heroData.MaxHeroCount;
|
||||
}
|
||||
|
||||
@ -561,9 +578,11 @@ namespace Logic.AI.Director
|
||||
|
||||
var target = state.Front?.TargetGrid ?? state.Front?.AnchorGrid;
|
||||
var frontMove = ctx.ActionIndex.FindBestMove(state.Hero, target);
|
||||
var frontCandidate = ctx.ActionIndex.Candidate(frontMove, AIDirectorLane.HeroPlaybook, "HeroPlaybook.MoveToFront", 650f);
|
||||
AddTerms(frontCandidate, ("base", 650f));
|
||||
return frontCandidate;
|
||||
var frontScore = ScoreHeroFrontMove(ctx, state, frontMove, target);
|
||||
var frontCandidate = ctx.ActionIndex.Candidate(frontMove, AIDirectorLane.HeroPlaybook, "HeroPlaybook.MoveToFront", frontScore);
|
||||
AddTerms(frontCandidate, ("frontMove", frontScore));
|
||||
if (frontCandidate.IsValid && frontScore > 0f) return frontCandidate;
|
||||
return AIDirectorActionCandidate.None;
|
||||
}
|
||||
|
||||
private bool TryTacticLane(AIDirectorContext ctx, AIDirectorDecision decision, out AIDirectorActionCandidate candidate, bool priorityOnly)
|
||||
@ -660,30 +679,56 @@ namespace Logic.AI.Director
|
||||
private bool TryGrowthLane(AIDirectorContext ctx, AIDirectorDecision decision, out AIDirectorActionCandidate candidate)
|
||||
{
|
||||
candidate = AIDirectorActionCandidate.None;
|
||||
if (ShouldStopNonCriticalLane(ctx)) return false;
|
||||
var best = AIDirectorActionCandidate.None;
|
||||
|
||||
foreach (var action in ctx.ActionIndex.CityActions)
|
||||
var scannedCityActions = 0;
|
||||
foreach (var plan in ctx.Cache.CityPlans)
|
||||
{
|
||||
var plan = FindCityPlanByAction(ctx, action);
|
||||
var score = ScoreCityGrowth(ctx, action, plan);
|
||||
var current = ctx.ActionIndex.Candidate(action, AIDirectorLane.Growth, "Growth.City", score);
|
||||
AddTerms(current, ("cityGrowth", score));
|
||||
RecordCandidate(ctx, decision, "CityGrowth", current);
|
||||
best = MaxCandidate(best, current);
|
||||
if (plan?.City == null) continue;
|
||||
foreach (var action in ctx.ActionIndex.GetCityActions(plan.City))
|
||||
{
|
||||
if (ctx.Config.MaxGrowthCityActionsPerDecision > 0
|
||||
&& scannedCityActions >= ctx.Config.MaxGrowthCityActionsPerDecision)
|
||||
break;
|
||||
scannedCityActions++;
|
||||
var score = ScoreCityGrowth(ctx, action, plan);
|
||||
if (ShouldSkipLowValueGrowth(ctx, score)) continue;
|
||||
var current = ctx.ActionIndex.Candidate(action, AIDirectorLane.Growth, "Growth.City", score);
|
||||
AddTerms(current, ("cityGrowth", score));
|
||||
RecordCandidate(ctx, decision, "CityGrowth", current);
|
||||
best = MaxCandidate(best, current);
|
||||
}
|
||||
|
||||
if (ctx.Config.MaxGrowthCityActionsPerDecision > 0
|
||||
&& scannedCityActions >= ctx.Config.MaxGrowthCityActionsPerDecision)
|
||||
break;
|
||||
}
|
||||
|
||||
var scannedGridActions = 0;
|
||||
foreach (var action in ctx.ActionIndex.GridActions)
|
||||
{
|
||||
if (ctx.Config.MaxGrowthGridActionsPerDecision > 0
|
||||
&& scannedGridActions >= ctx.Config.MaxGrowthGridActionsPerDecision)
|
||||
break;
|
||||
scannedGridActions++;
|
||||
var score = ScoreGridGrowth(ctx, action);
|
||||
if (ShouldSkipLowValueGrowth(ctx, score)) continue;
|
||||
var current = ctx.ActionIndex.Candidate(action, AIDirectorLane.Growth, "Growth.Grid", score);
|
||||
AddTerms(current, ("gridGrowth", score));
|
||||
RecordCandidate(ctx, decision, "GridGrowth", current);
|
||||
best = MaxCandidate(best, current);
|
||||
}
|
||||
|
||||
var scannedPlayerActions = 0;
|
||||
foreach (var action in ctx.ActionIndex.PlayerActions)
|
||||
{
|
||||
if (ctx.Config.MaxGrowthPlayerActionsPerDecision > 0
|
||||
&& scannedPlayerActions >= ctx.Config.MaxGrowthPlayerActionsPerDecision)
|
||||
break;
|
||||
scannedPlayerActions++;
|
||||
var score = ScorePlayerGrowth(ctx, action);
|
||||
if (ShouldSkipLowValueGrowth(ctx, score)) continue;
|
||||
var current = ctx.ActionIndex.Candidate(action, AIDirectorLane.Growth, "Growth.Player", score);
|
||||
AddTerms(current, ("playerGrowth", score));
|
||||
RecordCandidate(ctx, decision, "PlayerGrowth", current);
|
||||
@ -698,15 +743,47 @@ namespace Logic.AI.Director
|
||||
|
||||
private bool TryFallback(AIDirectorContext ctx, AIDirectorDecision decision, out AIDirectorActionCandidate candidate)
|
||||
{
|
||||
var action = ctx.ActionIndex.FindBestFallback();
|
||||
candidate = ctx.ActionIndex.Candidate(action, AIDirectorLane.Fallback, "Fallback.LegalAction", 1f, true);
|
||||
if (ShouldStopNonCriticalLane(ctx))
|
||||
{
|
||||
candidate = AIDirectorActionCandidate.None;
|
||||
return false;
|
||||
}
|
||||
|
||||
var strictFallback = IsPastSoftGrowthBudget(ctx);
|
||||
var action = strictFallback ? FindStrictFallbackAction(ctx) : ctx.ActionIndex.FindBestFallback();
|
||||
candidate = ctx.ActionIndex.Candidate(action, AIDirectorLane.Fallback, strictFallback ? "Fallback.StrictLegalAction" : "Fallback.LegalAction", 1f, true);
|
||||
AddTerms(candidate, ("base", 1f));
|
||||
RecordCandidate(ctx, decision, "Fallback", candidate, action == null ? "NoFallbackAction" : null);
|
||||
if (!candidate.IsValid) return false;
|
||||
decision.AddTrace("Fallback: picked first legal action from generated pool.", ctx.Config.MaxCandidateTraceCount);
|
||||
decision.AddTrace(strictFallback ? "Fallback: picked strict legal action after soft budget." : "Fallback: picked first legal action from generated pool.", ctx.Config.MaxCandidateTraceCount);
|
||||
return true;
|
||||
}
|
||||
|
||||
private AIActionBase FindStrictFallbackAction(AIDirectorContext ctx)
|
||||
{
|
||||
foreach (var action in ctx.ActionIndex.AttackActions)
|
||||
{
|
||||
if (ScoreAttackAction(ctx, action) > 0f) return action;
|
||||
}
|
||||
|
||||
foreach (var action in ctx.ActionIndex.AttackGroundActions)
|
||||
{
|
||||
return action;
|
||||
}
|
||||
|
||||
foreach (var action in ctx.ActionIndex.UnitActions)
|
||||
{
|
||||
if (IsStrictFallbackUnitAction(action)) return action;
|
||||
}
|
||||
|
||||
foreach (var action in ctx.ActionIndex.PlayerActions)
|
||||
{
|
||||
if (IsStrictFallbackPlayerAction(action)) return action;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private float ScoreAttackAction(AIDirectorContext ctx, AIActionBase action)
|
||||
{
|
||||
if (action?.Param?.UnitData == null || action.Param.TargetUnitData == null) return 0f;
|
||||
@ -728,10 +805,18 @@ namespace Logic.AI.Director
|
||||
{
|
||||
score += counterThreat <= 0f ? 95f : 35f;
|
||||
}
|
||||
else if (IsFragileSpecialUnit(attacker) && damage < target.Health && counterThreat > 0f)
|
||||
if (IsFragileSpecialUnit(attacker) && damage < target.Health)
|
||||
{
|
||||
score -= 120f;
|
||||
score -= counterThreat > 0f ? 260f : 80f;
|
||||
if (AIDirectorMath.HealthRatio(attacker) <= ctx.Config.LowHealthRatio) score -= 180f;
|
||||
}
|
||||
|
||||
if (IsDefenderUnit(attacker.UnitType) && damage < target.Health)
|
||||
{
|
||||
if (counterThreat > 0f) score -= 120f;
|
||||
if (!IsThreateningAnyCity(ctx, target)) score -= 80f;
|
||||
}
|
||||
|
||||
if (damage >= target.Health) score += 160f;
|
||||
if (target.TreatedAsHero(ctx.Map, target)) score += 120f;
|
||||
if (IsThreateningAnyCity(ctx, target)) score += 120f;
|
||||
@ -756,6 +841,30 @@ namespace Logic.AI.Director
|
||||
return best;
|
||||
}
|
||||
|
||||
private float ScoreHeroFrontMove(AIDirectorContext ctx, AIDirectorHeroState state, AIActionBase action, GridData target)
|
||||
{
|
||||
if (ctx?.Map == null || state?.Hero == null || action == null || target == null) return 0f;
|
||||
var startGrid = state.Hero.Grid(ctx.Map);
|
||||
var endGrid = action.Param?.TargetGridData ?? action.Param?.GridData;
|
||||
if (!IsUsefulMoveToward(ctx, startGrid, endGrid, target)) return 0f;
|
||||
|
||||
var startDistance = AIDirectorMath.Distance(ctx.Map, startGrid, target);
|
||||
var endDistance = AIDirectorMath.Distance(ctx.Map, endGrid, target);
|
||||
var distanceGain = Mathf.Max(0, startDistance - endDistance);
|
||||
|
||||
if (state.IsOnFront && endDistance > 1) return 0f;
|
||||
if (state.Role is AIDirectorHeroRole.Support or AIDirectorHeroRole.Economy && state.Front?.FrontType != AIDirectorFrontType.Defense) return 0f;
|
||||
if (state.Role is AIDirectorHeroRole.Caster or AIDirectorHeroRole.Control && !ctx.Cache.HasAnyEnemyContact && endDistance > 2) return 0f;
|
||||
|
||||
var score = 500f + distanceGain * 30f - endDistance * 18f;
|
||||
if (state.Front?.FrontType == AIDirectorFrontType.Defense) score += 70f;
|
||||
if (state.Role is AIDirectorHeroRole.Mobility) score += 45f;
|
||||
else if (state.Role is AIDirectorHeroRole.Vanguard or AIDirectorHeroRole.Assassin) score += 20f;
|
||||
if (state.Role is AIDirectorHeroRole.Caster or AIDirectorHeroRole.Control) score -= 45f;
|
||||
if (state.IsThreatened) score -= state.HealthRatio <= ctx.Config.LowHealthRatio ? 170f : 60f;
|
||||
return score;
|
||||
}
|
||||
|
||||
private static bool CanTreatAttackAsUseful(UnitData attacker, UnitData target, int damage)
|
||||
{
|
||||
if (attacker == null || target == null) return false;
|
||||
@ -994,6 +1103,7 @@ namespace Logic.AI.Director
|
||||
string rejectReason = null)
|
||||
{
|
||||
if (decision == null || candidate == null) return;
|
||||
if (Logic.AI.AIDirectorBatchRuntime.CompactDiagnostics) return;
|
||||
var max = ctx?.Config?.MaxDiagnosticCandidatePerLane ?? 8;
|
||||
decision.RecordCandidate(candidate.Lane, source, candidate, max, rejectReason);
|
||||
}
|
||||
@ -1166,6 +1276,92 @@ namespace Logic.AI.Director
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool ShouldStopNonCriticalLane(AIDirectorContext ctx)
|
||||
{
|
||||
if (ctx?.Config == null) return false;
|
||||
if (HasUnresolvedEmptyCityCoreThreat(ctx)) return true;
|
||||
if (ctx.Config.MaxNonCriticalActionsPerPlayerTurn <= 0) return false;
|
||||
if (ctx.Cache?.SelfCities.Count < ctx.Config.ExpansionUrgentCityThreshold) return false;
|
||||
return ctx.ExecutedActionCountThisTurn >= ctx.Config.MaxNonCriticalActionsPerPlayerTurn;
|
||||
}
|
||||
|
||||
private static bool HasUnresolvedEmptyCityCoreThreat(AIDirectorContext ctx)
|
||||
{
|
||||
if (ctx?.Cache?.CityThreats == null) return false;
|
||||
foreach (var threat in ctx.Cache.CityThreats)
|
||||
{
|
||||
if (IsUnresolvedEmptyCityCoreThreat(threat)) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool IsUnresolvedEmptyCityCoreThreat(AIDirectorCityThreat threat)
|
||||
{
|
||||
return threat != null
|
||||
&& !threat.HasCityCenterDefender
|
||||
&& threat.CanBeThreatenedNextTurn
|
||||
&& (threat.IsCapital || threat.IsCritical);
|
||||
}
|
||||
|
||||
private static bool ShouldSkipLowValueGrowth(AIDirectorContext ctx, float score)
|
||||
{
|
||||
if (ctx?.Config == null) return false;
|
||||
if (!IsPastSoftGrowthBudget(ctx)) return false;
|
||||
return score < ctx.Config.MinGrowthPriorityAfterSoftBudget;
|
||||
}
|
||||
|
||||
private static bool IsPastSoftGrowthBudget(AIDirectorContext ctx)
|
||||
{
|
||||
if (ctx?.Config == null) return false;
|
||||
if (ctx.Config.SoftGrowthActionBudget <= 0) return false;
|
||||
return ctx.ExecutedActionCountThisTurn >= ctx.Config.SoftGrowthActionBudget;
|
||||
}
|
||||
|
||||
private static bool IsStrictFallbackUnitAction(AIActionBase action)
|
||||
{
|
||||
var id = action?.ActionLogic?.ActionId;
|
||||
if (id == null || id.ActionType != CommonActionType.UnitAction) return false;
|
||||
return id.UnitActionType is UnitActionType.Capture
|
||||
or UnitActionType.Recover
|
||||
or UnitActionType.Upgrade
|
||||
or UnitActionType.CultureUnitUpgrade
|
||||
or UnitActionType.HeroUpgrade
|
||||
or UnitActionType.AbsorbRedMist
|
||||
or UnitActionType.HakureiAbsorbRune
|
||||
or UnitActionType.WarCry;
|
||||
}
|
||||
|
||||
private static bool IsStrictFallbackPlayerAction(AIActionBase action)
|
||||
{
|
||||
var id = action?.ActionLogic?.ActionId;
|
||||
if (id == null || id.ActionType != CommonActionType.PlayerAction) return false;
|
||||
return id.PlayerActionType is PlayerActionType.SelectTreasureOptionA
|
||||
or PlayerActionType.SelectTreasureOptionB
|
||||
or PlayerActionType.TreasureGainCoin
|
||||
or PlayerActionType.TreasureGainUnit
|
||||
or PlayerActionType.TreasureGainTech
|
||||
or PlayerActionType.TreasureGainCulture
|
||||
or PlayerActionType.TreasureGainCityExp;
|
||||
}
|
||||
|
||||
private bool ShouldScanEmergencyResponder(
|
||||
AIDirectorContext ctx,
|
||||
AIDirectorCityThreat threat,
|
||||
UnitData unit,
|
||||
GridData unitGrid,
|
||||
int startDistance,
|
||||
int scannedResponders)
|
||||
{
|
||||
if (ctx == null || threat?.CityGrid == null || unit == null || unitGrid == null) return false;
|
||||
if (startDistance > ctx.Config.MaxEmergencyRescueDistance && !threat.IsCritical) return false;
|
||||
if (startDistance > ctx.Config.MaxEmergencyRescueDistance + 2 && !unit.TreatedAsHero(ctx.Map, unit)) return false;
|
||||
if (ctx.Config.MaxEmergencyResponderScanCount <= 0) return true;
|
||||
if (scannedResponders < ctx.Config.MaxEmergencyResponderScanCount) return true;
|
||||
if (unit.TreatedAsHero(ctx.Map, unit) && startDistance <= ctx.Config.MaxEmergencyRescueDistance + 1) return true;
|
||||
return IsDefenderUnit(unit.UnitType) && startDistance <= 2;
|
||||
}
|
||||
|
||||
private bool HasSafeUrgentExpansionTarget(AIDirectorContext ctx)
|
||||
{
|
||||
foreach (var target in ctx.Cache.DevelopmentTargets)
|
||||
@ -1405,6 +1601,7 @@ namespace Logic.AI.Director
|
||||
private float GridThreat(AIDirectorContext ctx, GridData grid)
|
||||
{
|
||||
if (grid == null) return 0f;
|
||||
if (ctx?.GridThreatCache != null && ctx.GridThreatCache.TryGetValue(grid.Id, out var cached)) return cached;
|
||||
var threat = 0f;
|
||||
foreach (var enemy in ctx.Cache.EnemyUnits)
|
||||
{
|
||||
@ -1413,6 +1610,7 @@ namespace Logic.AI.Director
|
||||
threat += AIDirectorMath.UnitPower(enemy);
|
||||
}
|
||||
|
||||
if (ctx?.GridThreatCache != null) ctx.GridThreatCache[grid.Id] = threat;
|
||||
return threat;
|
||||
}
|
||||
|
||||
|
||||
@ -141,7 +141,15 @@ namespace Logic.AI.Director
|
||||
public int MaxFrontCount = 12;
|
||||
public int MaxDevelopmentTargetCount = 20;
|
||||
public int MaxExpansionTargetScanCount = 6;
|
||||
public int MaxMoveActionsPerUnit = 5;
|
||||
public int MaxMoveActionsPerUnit = 2;
|
||||
public int MaxSelfCityMoveAnchorCount = 3;
|
||||
public int MaxEmergencyResponderScanCount = 8;
|
||||
public int SoftGrowthActionBudget = 10;
|
||||
public int MaxNonCriticalActionsPerPlayerTurn = 18;
|
||||
public float MinGrowthPriorityAfterSoftBudget = 430f;
|
||||
public int MaxGrowthCityActionsPerDecision = 18;
|
||||
public int MaxGrowthGridActionsPerDecision = 24;
|
||||
public int MaxGrowthPlayerActionsPerDecision = 10;
|
||||
public int MaxExpansionMoveIntentsPerTurn = 3;
|
||||
public int MaxEmergencyMoveIntentsPerTurn = 2;
|
||||
public int MaxFrontMoveIntentsPerTurn = 1;
|
||||
@ -180,19 +188,23 @@ namespace Logic.AI.Director
|
||||
public AIDirectorActionIndex ActionIndex;
|
||||
public readonly HashSet<string> BlockedActionKeys;
|
||||
public readonly HashSet<string> BlockedIntentKeys;
|
||||
public readonly int ExecutedActionCountThisTurn;
|
||||
public readonly Dictionary<uint, float> GridThreatCache = new();
|
||||
|
||||
public AIDirectorContext(
|
||||
MapData map,
|
||||
PlayerData player,
|
||||
AIDirectorConfig config,
|
||||
HashSet<string> blockedActionKeys = null,
|
||||
HashSet<string> blockedIntentKeys = null)
|
||||
HashSet<string> blockedIntentKeys = null,
|
||||
int executedActionCountThisTurn = 0)
|
||||
{
|
||||
Map = map;
|
||||
Player = player;
|
||||
Config = config ?? AIDirectorConfig.CreateDefault();
|
||||
BlockedActionKeys = blockedActionKeys;
|
||||
BlockedIntentKeys = blockedIntentKeys;
|
||||
ExecutedActionCountThisTurn = executedActionCountThisTurn;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -34,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, _executedIntentKeysThisTurn);
|
||||
var decision = _director.Decide(_mapData, _playerData, null, _executedActionKeysThisTurn, _executedIntentKeysThisTurn, _executedActionKeysThisTurn.Count);
|
||||
#if TH1_AI_DIRECTOR_DIAGNOSTICS || UNITY_EDITOR
|
||||
AIDirectorDiagnostics.RecordDecision(_mapData, _playerData, decision);
|
||||
#endif
|
||||
|
||||
@ -558,6 +558,7 @@ namespace TH1_Logic.Editor
|
||||
result.players = BuildPlayerResults(map);
|
||||
#if TH1_AI_DIRECTOR_DIAGNOSTICS || UNITY_EDITOR
|
||||
result.diagnosticsLogPath = AIDirectorDiagnostics.CurrentLogPathOrEmpty;
|
||||
AIDirectorDiagnostics.FlushAndClose();
|
||||
#endif
|
||||
result.diagnostics = BuildDiagnosticsSummary(result.diagnosticsLogPath);
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user