Tune AI Director defense and expansion loop

This commit is contained in:
wuwenbo 2026-07-01 18:18:52 +08:00
parent 5947ea048f
commit dc93fab7da
6 changed files with 191 additions and 23 deletions

View File

@ -175,8 +175,8 @@ Development
Defense 的行为倾向:
- Emergency 车道优先回防
- 城市优先训练防守单位、建城墙、移动占城格单位。
- Emergency 车道优先阻止丢城;危险城市如果空防或只剩唯一守军,先补兵/城墙,再考虑攻击和外派
- 城市优先训练防守单位、建城墙、保留占城格单位。
- 科技优先防御、基础兵种、移动和克制。
- 英雄优先治疗、保护、控场、守城。
@ -198,6 +198,8 @@ Expansion 的行为倾向:
- 早期扩张只追可在有限回合内转化的目标,避免全地图远距离追城导致移动量很大但二城率不变。
- Expansion 只扫描已排序的少量高价值扩张目标;扩大城市数不能让每次决策退化成“所有目标 × 所有单位”的全量搜索。
- 单位移动优先满足“本回合能占”或“下回合能站到可占点旁边”,远距离目标只保留作低优先级战线。
- 扩张目标必须考虑占后守备风险;如果目标格或占领单位附近威胁很高、当前已有严重城市威胁、或己方单位数不足以覆盖城市数,则降低占领/靠近优先级。普通城市威胁只影响危险目标,不应把安全二城扩张整体压掉。
- 危险城市的唯一守军不能外派扩张;非唯一守军可以参与近距离、安全、能快速转化的二城扩张,避免防守过度导致城市数停滞。
- Expansion 车道优先处理占领和向目标靠近UnitOpportunity 负责脚下的占领、遗迹和采集补漏。
- Development Front 指向村庄、遗迹、资源和边界。
- 城市优先增长和基础建设。
@ -542,6 +544,7 @@ AI 可以重新实现,但必须遵守 TH1 的游戏架构:
- DevelopmentTarget 只保留 TopN。
- Expansion 从 DevelopmentTarget 中只扫描前 N 个扩张目标。
- UnitMove 不生成整张地图的所有可走格;先从城市威胁、扩张目标、战线、自城、局部战斗收集移动锚点,每个单位只保留最靠近锚点的少量移动候选。
- 同一次决策内,单位到目标格的最佳移动结果可以缓存,避免 Expansion、Emergency、Front、HeroPlaybook 重复扫描同一单位的移动候选。
- 行动候选只生成一次。
- 同一玩家回合内已经执行过的 stableKey 不再参与下一次候选选择。
- 扩张移动、回防移动、前线移动有每回合意图预算;预算耗尽后交给其他车道,避免单一目标吞掉整回合。

View File

@ -430,6 +430,10 @@ SkillBase
```text
函数 FindBestMoveToward(ctx, unit, targetGrid):
cacheKey = (unit.Id, targetGrid.Id)
如果 ctx.ActionIndex.BestMoveCache 包含 cacheKey:
返回缓存结果
actions = ctx.Actions.ByUnit[unit.Id].Moves
best = None
@ -440,6 +444,7 @@ SkillBase
score += GridOpportunityBonus(ctx, unit, endGrid)
best = MaxByScore(best, action, score)
写入 ctx.ActionIndex.BestMoveCache[cacheKey] = best.Action
返回 best.Action
```
@ -1119,6 +1124,10 @@ SkillBase
如果 !ShouldUseEmergency(threat):
继续
candidate = TryEmergencyCityProduction(ctx, threat)
如果 IsUrgentCityDefenseAction(candidate):
返回 candidate
candidate = TryEmergencyAttack(ctx, threat)
如果 candidate.IsValid:
返回 candidate
@ -1134,6 +1143,17 @@ SkillBase
返回 None
```
```text
函数 IsUrgentCityDefenseAction(candidate):
如果 !candidate.IsValid:
返回 false
如果 candidate.ActionId.ActionType == TrainUnit:
返回 true
如果 candidate.ActionId.ActionType == CityAction 且 candidate.ActionId.CityActionType == BuildCityWall:
返回 true
返回 false
```
```text
函数 ShouldUseEmergency(threat):
如果 threat == null:
@ -1246,9 +1266,17 @@ SkillBase
如果 id.ActionType == TrainUnit:
score = 760 + TrainUnitDefenseValue(ctx, action, threat)
如果 threat.DefenderPower <= 0:
score += 240
如果 threat.IsCapital:
score += 120
如果 threat.CanBeThreatenedNextTurn:
score += 100
如果 id.ActionType == CityAction 且 id.CityActionType == BuildCityWall:
score = 740
如果 threat.DefenderPower <= 0:
score += 180
如果 id.ActionType == CityLevelUpAction 且 能选城墙或人口防守收益:
score = 700
@ -1476,7 +1504,10 @@ Expansion Lane 只处理早期扩张的刚性目标,不处理普通资源开
如果 unitGrid != target.Grid:
继续
score = ScoreExpansionTarget(ctx, target) + 420
riskPenalty = ExpansionTargetRiskPenalty(ctx, target, unitGrid)
score = ScoreExpansionTarget(ctx, target) + 420 - riskPenalty
如果 score <= 0:
继续
candidate = Candidate(action, Expansion, score, "扩张占领")
best = MaxCandidate(best, candidate)
@ -1497,14 +1528,14 @@ Expansion Lane 只处理早期扩张的刚性目标,不处理普通资源开
对每个 unit in ctx.Cache.SelfUnits:
如果 unit 没有移动行动点:
继续
如果 unit 是严重城市威胁中的关键守军:
继续
如果 unit 低血且脚下有威胁:
继续
startGrid = unit.Grid
如果 startGrid == target.Grid:
继续
startDistance = Distance(startGrid, target.Grid)
如果 ShouldSkipExpansionUnit(ctx, unit, target, startGrid, startDistance):
继续
action = FindBestMove(unit, target.Grid)
endGrid = ActionEndGrid(action)
@ -1523,6 +1554,10 @@ Expansion Lane 只处理早期扩张的刚性目标,不处理普通资源开
score -= 120
score -= endGrid 到 target 的距离 * 28
score -= GridThreat(ctx, endGrid) * ThreatFactor(target)
riskPenalty = ExpansionTargetRiskPenalty(ctx, target, endGrid)
score -= riskPenalty
如果 score <= 0:
继续
candidate = Candidate(action, Expansion, score, "扩张移动")
candidate.IntentKey = intentKey
@ -1552,6 +1587,47 @@ Expansion Lane 只处理早期扩张的刚性目标,不处理普通资源开
返回 score
```
```text
函数 ExpansionTargetRiskPenalty(ctx, target, actingGrid):
penalty = GridThreat(ctx, target.Grid) * 0.75
如果 actingGrid != null:
penalty += GridThreat(ctx, actingGrid) * 0.25
如果 target.Type == EnemyEmptyCity:
penalty += GridThreat(ctx, target.Grid) * 0.35
如果 有严重城市威胁:
penalty += 180
如果 己方单位数 <= 己方城市数 + 1 且 GridThreat(ctx, target.Grid) > 0:
penalty += 120
返回 penalty
```
```text
函数 ShouldSkipExpansionUnit(ctx, unit, target, startGrid, startDistance):
如果 startDistance > Config.DevelopmentSearchRange + 2:
返回 true
如果 unit 低血且 startGrid 有威胁:
返回 true
如果 unit 不是危险城市守军:
返回 false
如果 unit 是危险城市唯一守军:
返回 true
如果 己方单位数 <= 己方城市数 + 1:
返回 true
如果 target.Grid 有威胁:
返回 true
返回 false
```
---
## 9. HeroPlaybook Lane
@ -2049,7 +2125,10 @@ Byakuren / Miko / Zanmu:
如果 unit 低血 且 front.Type == Attack:
返回 true
如果 unit 正站在己方危险城市中心:
如果 unit 是危险城市的守军,且城市处于 Emergency:
返回 true
如果 unit 是某个危险城市的唯一守军:
返回 true
返回 false
@ -2130,8 +2209,16 @@ Byakuren / Miko / Zanmu:
如果 plan.Kind == EmergencyDefense:
如果 id.ActionType == TrainUnit:
score += TrainUnitDefenseValue(ctx, action, plan.Threat)
如果 plan.Threat.DefenderPower <= 0:
score += 240
如果 plan.Threat.IsCapital:
score += 120
如果 plan.Threat.CanBeThreatenedNextTurn:
score += 100
如果 id.ActionType == CityAction 且 id.CityActionType == BuildCityWall:
score += 220
如果 plan.Threat.DefenderPower <= 0:
score += 180
如果 id.ActionType in [StartWonder, BuildWonder]:
score -= 300
@ -2545,6 +2632,7 @@ Growth: 只遍历合法动作
限制 DevelopmentTarget TopN
限制 Front TopN
限制 LocalBattle 搜索半径
一次决策内缓存 FindBestMoveToward(unit, targetGrid)
科技只看当前可学和一层后继预览
```

View File

@ -25,6 +25,7 @@ namespace Logic.AI.Director
private readonly Dictionary<uint, List<AIActionBase>> _attackGroundByUnit = new();
private readonly Dictionary<uint, List<AIActionBase>> _cityActionsByCity = new();
private readonly Dictionary<uint, List<AIActionBase>> _gridActionsByGrid = new();
private readonly Dictionary<(uint unitId, uint targetGridId), AIActionBase> _bestMoveCache = new();
public static AIDirectorActionIndex Build(AIDirectorContext ctx)
{
@ -228,6 +229,8 @@ namespace Logic.AI.Director
if (unit == null) return null;
if (!_movesByUnit.TryGetValue(unit.Id, out var actions)) return null;
if (targetGrid == null) return actions.Count > 0 ? actions[0] : null;
var cacheKey = (unit.Id, targetGrid.Id);
if (_bestMoveCache.TryGetValue(cacheKey, out var cached)) return cached;
AIActionBase best = null;
var bestDistance = int.MaxValue;
@ -241,6 +244,7 @@ namespace Logic.AI.Director
best = action;
}
_bestMoveCache[cacheKey] = best;
return best;
}

View File

@ -29,6 +29,7 @@ namespace Logic.AI.Director
public static bool Enabled => _enabled;
public static string CurrentLogPath => EnsureSession();
public static string CurrentLogPathOrEmpty => _currentLogPath ?? string.Empty;
public static void SetEnabled(bool enabled)
{
@ -49,7 +50,6 @@ namespace Logic.AI.Director
public static void BeginNewSession()
{
ResetSession();
if (_enabled) EnsureSession();
}
private static void ResetSession()
@ -1532,6 +1532,7 @@ namespace Logic.AI.Director
{
public static bool Enabled => false;
public static string CurrentLogPath => string.Empty;
public static string CurrentLogPathOrEmpty => string.Empty;
public static void SetEnabled(bool enabled) { }
public static void Enable() { }
public static void Disable() { }

View File

@ -102,6 +102,14 @@ namespace Logic.AI.Director
if (threat == null) continue;
if (!ShouldUseEmergency(threat, ctx.Config)) continue;
var urgentCityAction = TryEmergencyCityAction(ctx, decision, threat);
if (IsUrgentCityDefenseAction(urgentCityAction))
{
candidate = urgentCityAction;
decision.AddTrace($"Emergency: urgent city action for city={threat.City?.Id}.", ctx.Config.MaxCandidateTraceCount);
return true;
}
var attack = TryEmergencyAttack(ctx, decision, threat);
if (attack.IsValid)
{
@ -169,9 +177,11 @@ namespace Logic.AI.Director
var grid = unit?.Grid(ctx.Map);
if (grid == null || target?.Grid?.Id != grid.Id) continue;
var score = ScoreExpansionTarget(ctx, target) + 420f;
var targetRiskPenalty = ExpansionTargetRiskPenalty(ctx, target, grid);
var score = ScoreExpansionTarget(ctx, target) + 420f - targetRiskPenalty;
if (score <= 0f) continue;
var current = ctx.ActionIndex.Candidate(action, AIDirectorLane.Expansion, $"Expansion.Capture.{target.TargetType}", score);
AddTerms(current, ("target", ScoreExpansionTarget(ctx, target)), ("capture", 420f));
AddTerms(current, ("target", ScoreExpansionTarget(ctx, target)), ("capture", 420f), ("targetRisk", -targetRiskPenalty));
current.Unit = current.Unit ?? unit;
current.TargetGrid = current.TargetGrid ?? target.Grid;
RecordCandidate(ctx, decision, "ExpansionCapture", current, current.IsValid ? null : "CheckCanFailed");
@ -193,16 +203,15 @@ namespace Logic.AI.Director
foreach (var unit in ctx.Cache.SelfUnits)
{
if (unit == null || unit.GetActionPoint(ActionPointType.Move) <= 0) continue;
if (UnitIsCriticalCityDefender(ctx, unit)) continue;
if (!ctx.Map.GetGridDataByUnitId(unit.Id, out var startGrid)) continue;
if (target?.Grid == null || startGrid.Id == target.Grid.Id) continue;
if (AIDirectorMath.HealthRatio(unit) <= ctx.Config.CriticalHealthRatio && GridThreat(ctx, startGrid) > 0f) continue;
var startDistance = AIDirectorMath.Distance(ctx.Map, startGrid, target.Grid);
if (ShouldSkipExpansionUnit(ctx, unit, target, startGrid, startDistance)) continue;
var action = ctx.ActionIndex.FindBestMove(unit, target.Grid);
var endGrid = action?.Param?.TargetGridData ?? action?.Param?.GridData;
if (endGrid == null) continue;
var startDistance = AIDirectorMath.Distance(ctx.Map, startGrid, target.Grid);
var endDistance = AIDirectorMath.Distance(ctx.Map, endGrid, target.Grid);
if (endDistance >= startDistance && startDistance > 1) continue;
@ -213,7 +222,9 @@ namespace Logic.AI.Director
var mobilityBonus = unit.GetActionPoint(ActionPointType.Move) >= 2 ? 120f : 0f;
var longDragPenalty = startDistance >= 5 && startDistance - endDistance <= 1 ? 140f : 0f;
var safetyPenalty = GridThreat(ctx, endGrid) * (target.TargetType == AIDirectorDevelopmentTargetType.Village ? 0.25f : 0.5f);
var score = targetScore + progressScore + reachBonus + nearConversionBonus + mobilityBonus - longDragPenalty - endDistance * 28f - safetyPenalty;
var targetRiskPenalty = ExpansionTargetRiskPenalty(ctx, target, endGrid);
var score = targetScore + progressScore + reachBonus + nearConversionBonus + mobilityBonus - longDragPenalty - endDistance * 28f - safetyPenalty - targetRiskPenalty;
if (score <= 0f) continue;
var current = ctx.ActionIndex.Candidate(action, AIDirectorLane.Expansion, $"Expansion.Move.{target.TargetType}", score);
AddTerms(
@ -225,7 +236,8 @@ namespace Logic.AI.Director
("mobility", mobilityBonus),
("longDrag", -longDragPenalty),
("distance", -endDistance * 28f),
("threat", -safetyPenalty));
("threat", -safetyPenalty),
("targetRisk", -targetRiskPenalty));
current.Unit = current.Unit ?? unit;
current.TargetGrid = current.TargetGrid ?? target.Grid;
current.IntentKey = intentKey;
@ -640,12 +652,20 @@ namespace Logic.AI.Director
var id = action.ActionLogic.ActionId;
var city = action.Param.CityData;
var score = 360f + (plan?.Priority ?? 0f) * 0.2f;
var threat = plan?.Threat;
var kind = plan?.Kind ?? AIDirectorCityPlanKind.BacklineGrowth;
if (kind == AIDirectorCityPlanKind.EmergencyDefense)
{
if (id.ActionType == CommonActionType.TrainUnit) score += TrainUnitDefenseValue(ctx, action, plan.Threat);
if (id.ActionType == CommonActionType.TrainUnit) score += TrainUnitDefenseValue(ctx, action, threat);
if (id.ActionType == CommonActionType.CityAction && id.CityActionType == CityActionType.BuildCityWall) score += 220f;
if (threat != null)
{
if (threat.DefenderPower <= 0f && id.ActionType == CommonActionType.TrainUnit) score += 240f;
if (threat.DefenderPower <= 0f && id.ActionType == CommonActionType.CityAction && id.CityActionType == CityActionType.BuildCityWall) score += 180f;
if (threat.IsCapital && id.ActionType == CommonActionType.TrainUnit) score += 120f;
if (threat.CanBeThreatenedNextTurn && id.ActionType == CommonActionType.TrainUnit) score += 100f;
}
if (id.ActionType is CommonActionType.StartWonder or CommonActionType.BuildWonder) score -= 300f;
}
else if (kind is AIDirectorCityPlanKind.Mobilize or AIDirectorCityPlanKind.Frontline)
@ -832,7 +852,7 @@ namespace Logic.AI.Director
if (unit == null || unit.GetActionPoint(ActionPointType.Move) <= 0) return true;
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;
if (UnitIsCriticalCityDefender(ctx, unit)) return true;
var target = ResolveFrontTarget(front);
var startGrid = unit.Grid(ctx.Map);
if (target != null
@ -844,13 +864,62 @@ namespace Logic.AI.Director
return false;
}
private bool IsStandingOnCriticalCityCenter(AIDirectorContext ctx, UnitData unit)
private bool IsUrgentCityDefenseAction(AIDirectorActionCandidate candidate)
{
if (unit == null || !ctx.Map.GetGridDataByUnitId(unit.Id, out var grid)) return false;
if (candidate == null || !candidate.IsValid || candidate.ActionId == null) return false;
var id = candidate.ActionId;
if (id.ActionType == CommonActionType.TrainUnit) return true;
return id.ActionType == CommonActionType.CityAction && id.CityActionType == CityActionType.BuildCityWall;
}
private float ExpansionTargetRiskPenalty(AIDirectorContext ctx, AIDirectorDevelopmentTarget target, GridData actingGrid)
{
if (ctx?.Cache == null || target?.Grid == null) return 0f;
var gridThreat = GridThreat(ctx, target.Grid);
var localThreat = actingGrid != null ? GridThreat(ctx, actingGrid) * 0.25f : 0f;
var penalty = gridThreat * 0.75f + localThreat;
if (target.TargetType == AIDirectorDevelopmentTargetType.EnemyEmptyCity) penalty += gridThreat * 0.35f;
if (HasSevereCityThreat(ctx)) penalty += 180f;
if (ctx.Cache.SelfUnits.Count <= ctx.Cache.SelfCities.Count + 1 && gridThreat > 0f) penalty += 120f;
return penalty;
}
private bool ShouldSkipExpansionUnit(
AIDirectorContext ctx,
UnitData unit,
AIDirectorDevelopmentTarget target,
GridData startGrid,
int startDistance)
{
if (ctx == null || unit == null || target?.Grid == null || startGrid == null) return true;
if (startDistance > ctx.Config.DevelopmentSearchRange + 2) return true;
if (AIDirectorMath.HealthRatio(unit) <= ctx.Config.CriticalHealthRatio && GridThreat(ctx, startGrid) > 0f) return true;
if (!UnitIsCriticalCityDefender(ctx, unit)) return false;
if (UnitIsOnlyCriticalCityDefender(ctx, unit)) return true;
if (ctx.Cache.SelfUnits.Count <= ctx.Cache.SelfCities.Count + 1) return true;
return GridThreat(ctx, target.Grid) > 0f;
}
private bool IsOnlyDefenderForThreat(AIDirectorCityThreat threat, UnitData unit)
{
if (threat == null || unit == null) return false;
var count = 0;
foreach (var defender in threat.Defenders)
{
if (defender == null) continue;
count++;
}
return count <= 1 && threat.Defenders.Contains(unit);
}
private bool UnitIsOnlyCriticalCityDefender(AIDirectorContext ctx, UnitData unit)
{
if (unit == null || ctx?.Cache == null) return false;
foreach (var threat in ctx.Cache.CityThreats)
{
if (!threat.IsCritical) continue;
if (threat.CityGrid?.Id == grid.Id) return true;
if (!ShouldUseEmergency(threat, ctx.Config)) continue;
if (IsOnlyDefenderForThreat(threat, unit)) return true;
}
return false;
@ -923,6 +992,7 @@ namespace Logic.AI.Director
foreach (var threat in ctx.Cache.CityThreats)
{
if (!ShouldUseEmergency(threat, ctx.Config)) continue;
if (IsOnlyDefenderForThreat(threat, unit)) return true;
foreach (var defender in threat.Defenders)
{
if (defender?.Id == unit.Id) return true;

View File

@ -170,7 +170,6 @@ namespace TH1_Logic.Editor
AIDirectorBatchRuntime.RandomSeedOverride = options.Seed == 0 ? 0 : options.Seed + gameIndex;
#if TH1_AI_DIRECTOR_DIAGNOSTICS || UNITY_EDITOR
AIDirectorDiagnostics.BeginNewSession();
result.diagnosticsLogPath = AIDirectorDiagnostics.CurrentLogPath;
#endif
var main = Main.Instance;
@ -546,6 +545,9 @@ namespace TH1_Logic.Editor
result.curPlayerTurn = map.CurPlayer?.Turn ?? 0;
result.survivingPlayers = CountSurvivingPlayers(map);
result.players = BuildPlayerResults(map);
#if TH1_AI_DIRECTOR_DIAGNOSTICS || UNITY_EDITOR
result.diagnosticsLogPath = AIDirectorDiagnostics.CurrentLogPathOrEmpty;
#endif
result.diagnostics = BuildDiagnosticsSummary(result.diagnosticsLogPath);
}