diff --git a/MD/GameMDFramework/18-AI导演系统策划文档.md b/MD/GameMDFramework/18-AI导演系统策划文档.md index 385582365..72d2bd024 100644 --- a/MD/GameMDFramework/18-AI导演系统策划文档.md +++ b/MD/GameMDFramework/18-AI导演系统策划文档.md @@ -178,6 +178,8 @@ Defense 的行为倾向: - Emergency 车道优先阻止丢城;危险城市如果空防或只剩唯一守军,先补兵/城墙,再考虑攻击和外派。 - 城市优先训练防守单位、建城墙、保留占城格单位。 - 如果危险城市中心为空,城市生产要优先补可站城/守城单位;扩张变强后不能让空城威胁长期升高。 +- 如果危险城市中心为空且敌方下回合可占,Emergency 改为“补兵/回城心优先”;只有能直接击杀最后一个威胁单位的攻击才能压过驻守,普通攻击不能继续把城心暴露给占领。 +- 正在站危险城市中心的守军不能被调离城心去救另一座城;其他周边守军仍可参与回防,避免防线互相抽空。 - 科技优先防御、基础兵种、移动和克制。 - 英雄优先治疗、保护、控场、守城。 @@ -441,7 +443,7 @@ HeroPlaybook 的判断顺序: - 按英雄等级和技能入口识别可用 Action。 - 治疗和保护类动作优先给残血英雄和高价值友军。 - 地面攻击类动作优先选敌军密集、敌城中心或可触发召唤的格子。 -- 刺客和先锋英雄在有合法有效攻击目标时,攻击优先级略高于普通 MoveToFront;否则已上场英雄容易只移动、不形成个人威胁。 +- 刺客、先锋、法师、控制英雄在有合法有效攻击或地面控制目标时,动作优先级高于普通 MoveToFront;否则已上场英雄容易只移动、不形成个人威胁。 - 自身主动类动作只在满足局部价值时使用。 - 没有专属规则时退回通用英雄攻击、恢复和 Front 站位。 @@ -598,6 +600,14 @@ AI 可以重新实现,但必须遵守 TH1 的游戏架构: | Fallback | Fallback 总数、Fallback actionType、Fallback noEffect | 越接近 0 越好;非 0 时优先把有意义动作归入正式车道 | | 性能 | actions/sec、avgGame、decision avg/p95/max | 先保证聪明,再把明显尖峰纳入下一轮优化 | +小兵定位调优原则: + +- 远程和攻城单位不是普通前排。它们应优先找安全输出位,有伤害且低反击风险时更愿意攻击。 +- 远程和攻城单位前线移动时,不直接选择离目标最近的格子;应从该单位本回合少量合法移动候选中挑“能进射程、威胁低、仍在推进”的输出位,避免为了聪明移动引入全量搜索。 +- 特殊单位分为压力单位和脆弱衍生单位。压力单位可以参与推进;脆弱召唤物、幻象、灵球、骨堆、小萃香等不能被 Front 当普通近战送进高威胁格。 +- 脆弱特殊衍生单位不承担危险扩张任务;目标格已经有即时威胁时,让普通兵、机动兵或主力英雄去处理。 +- 特色单位的目标不是单纯提高出场次数,而是要在伤害、控制、召唤、献祭、占领或技能签名变化中表现出可读价值。 + 防守指标解释: | 指标 | 含义 | diff --git a/MD/GameMDFramework/19-AI导演系统逻辑语言.md b/MD/GameMDFramework/19-AI导演系统逻辑语言.md index 1e955f840..5abde7e10 100644 --- a/MD/GameMDFramework/19-AI导演系统逻辑语言.md +++ b/MD/GameMDFramework/19-AI导演系统逻辑语言.md @@ -1150,6 +1150,18 @@ SkillBase 如果 IsUrgentCityDefenseAction(candidate): 返回 candidate + 如果 threat.CityGrid 没有己方驻守 且 threat.CanBeThreatenedNextTurn: + attack = TryEmergencyAttack(ctx, threat) + 如果 attack 能直接击杀最后一个威胁单位: + 返回 attack + + move = TryEmergencyMoveToCity(ctx, threat) + 如果 move.IsValid: + 返回 move + + 如果 attack.IsValid: + 返回 attack + candidate = TryEmergencyAttack(ctx, threat) 如果 candidate.IsValid: 返回 candidate @@ -1219,6 +1231,11 @@ SkillBase score += 180 如果 CanKillByAction(ctx, action): score += 160 + 如果 threat.CityGrid 没有己方驻守 且 threat.CanBeThreatenedNextTurn: + 如果 本次攻击不能直接击杀最后一个威胁单位: + score -= threat.IsCapital ? 340 : 240 + 如果 action.Unit 是该威胁的唯一守军 或 action.Unit 本可回到城心: + score -= 180 返回 score ``` @@ -1245,6 +1262,8 @@ SkillBase endGrid = GetActionEndGrid(action) 如果 !IsUsefulMoveToward(ctx, startGrid, endGrid, threat.CityGrid): 继续 + 如果 unit 正站在某个危险城市中心 且 endGrid 会离开当前城心: + 继续 candidate = Candidate(action, Emergency, ScoreEmergencyMove(ctx, unit, action, threat), "回防城市") candidate.IntentKey = intentKey @@ -1264,6 +1283,13 @@ SkillBase score -= Distance(endGrid, threat.CityGrid) * 14 如果 unit 是英雄: score += 60 + 如果 threat.CityGrid 没有己方驻守: + 如果 Distance(endGrid, threat.CityGrid) == 0: + score += 360 + 否则: + score += max(0, 220 - Distance(endGrid, threat.CityGrid) * 35) + 如果 threat.IsCapital: + score += 140 返回 score ``` @@ -1296,6 +1322,8 @@ SkillBase score += 120 如果 threat.CanBeThreatenedNextTurn: score += 100 + 如果 threat.CityGrid 没有己方驻守: + score += threat.IsCapital ? 300 : 220 如果 id.ActionType == CityAction 且 id.CityActionType == BuildCityWall: score = 740 @@ -1614,6 +1642,8 @@ Expansion Lane 只处理早期扩张的刚性目标,不处理普通资源开 如果 ShouldSkipExpansionUnit(ctx, unit, target, startGrid, startDistance): 继续 + 如果 unit 是脆弱特殊衍生单位 且 target.Grid 有即时威胁: + 继续 action = FindBestMove(unit, target.Grid) endGrid = ActionEndGrid(action) @@ -1819,10 +1849,24 @@ Expansion Lane 只处理早期扩张的刚性目标,不处理普通资源开 attack = FindBestUsefulAttack(ctx, hero, BestHeroOrKillableEnemy(ctx, hero)) 如果 attack 可用: roleBonus = 0 - 如果 state.Role 是 Assassin 或 Vanguard: - roleBonus = 90 + 如果 state.Role 是 Assassin: + roleBonus = 140 + 否则如果 state.Role 是 Vanguard: + roleBonus = 115 + 否则如果 state.Role 是 Caster 或 Control: + roleBonus = 75 返回 Candidate(attack, HeroPlaybook, 760 + TargetValue(attack.Target) + roleBonus, "英雄通用高价值攻击") + 如果 state.Role 是 Caster 或 Control: + ground = FindBestAttackGround(ctx, hero, state.Front.TargetGrid 或 state.Front.AnchorGrid 或 PrimaryFront.TargetGrid) + 如果 ground 可用: + score = 710 + 如果 state.IsOnFront: + score += 80 + 如果 ctx.Cache.HasAnyEnemyContact: + score += 60 + 返回 Candidate(ground, HeroPlaybook, score, "英雄通用地面控制") + 返回 None ``` @@ -2675,6 +2719,100 @@ Fallback 的目标是不断回合,不负责聪明。大量进入 Fallback 说 对每个 enemy in 当前进攻目标玩家单位: score += CounterValue(unitType, enemy.UnitFullType) + 如果 当前需要扩张: + score += TrainUnitExpansionValue(unitType) + + 如果 unitType 是远程单位: + 如果 已接敌: + score += 95 + 否则: + score += 45 + + 如果 unitType 是攻城单位或机动单位: + score += 45 + + 如果 unitType 是特殊压力单位: + 如果 已接敌: + score += 70 + 否则: + score += 30 + + 返回 score +``` + +```text +函数 FindBestFrontMove(ctx, unit, target, front): + 如果 unit 不是远程或攻城: + 返回 FindBestMove(unit, target) + + 如果 front 不是 Attack 或 Defense: + 返回 FindBestMove(unit, target) + + best = 空 + bestScore = 负无穷 + + 对每个 moveAction in ActionIndex.GetMoveActions(unit) 的前 8 个候选: + endGrid = ActionEndGrid(moveAction) + 如果 endGrid 没有朝 target 推进: + continue + + distance = Distance(endGrid, target) + threat = GridThreat(ctx, endGrid) + score = -distance * 18 - threat * 0.7 + + 如果 distance <= unit.AttackRange: + score += 280 + + 如果 threat <= 0: + score += 120 + 否则如果 threat <= UnitPower(unit) * 0.25: + score += 55 + 否则: + score -= 160 + + best = Max(score) + + 如果 best 存在: + 返回 best + + 返回 FindBestMove(unit, target) +``` + +```text +函数 ScoreFrontMove(ctx, unit, action, front): + endGrid = ActionEndGrid(action) + target = ResolveFrontTarget(front) + distance = Distance(endGrid, target) + threat = GridThreat(ctx, endGrid) + + score = FrontBaseScore(front) + score -= distance * 16 + score -= threat * 0.4 + + 如果 unit 下回合能压到 target: + score += 80 + + 如果 unit 是远程或攻城: + 如果 front 是 Attack 或 Defense: + 如果 distance <= unit.AttackRange: + score += 150 + 否则: + score -= 35 + 如果 threat <= UnitPower(unit) * 0.25: + score += 115 + 否则: + score -= 140 + 如果 distance <= unit.AttackRange 且 threat <= 0: + score += 80 + + 如果 unit 是脆弱特殊衍生单位 且 front 是 Attack: + 如果 unit 血量偏低: + score -= 180 + 如果 不能安全压到 target: + score -= 220 + 否则: + score -= 50 + 返回 score ``` diff --git a/Tools/RunAIDirectorBatch.ps1 b/Tools/RunAIDirectorBatch.ps1 index 89c4efb4a..24944f750 100644 --- a/Tools/RunAIDirectorBatch.ps1 +++ b/Tools/RunAIDirectorBatch.ps1 @@ -5,6 +5,7 @@ param( [int]$Players = 17, [int]$Width = 30, [int]$Height = 30, + [Alias("MaxTurns")] [int]$Turns = 100, [int]$TimeoutSeconds = 1800, [int]$MaxActions = 20000, diff --git a/Unity/Assets/Scripts/TH1_Logic/AI/Director/AIDirectorActionIndex.cs b/Unity/Assets/Scripts/TH1_Logic/AI/Director/AIDirectorActionIndex.cs index f1889c604..39ee2bdde 100644 --- a/Unity/Assets/Scripts/TH1_Logic/AI/Director/AIDirectorActionIndex.cs +++ b/Unity/Assets/Scripts/TH1_Logic/AI/Director/AIDirectorActionIndex.cs @@ -272,6 +272,13 @@ namespace Logic.AI.Director return best; } + public IEnumerable GetMoveActions(UnitData unit) + { + if (unit == null) yield break; + if (!_movesByUnit.TryGetValue(unit.Id, out var actions)) yield break; + foreach (var action in actions) yield return action; + } + public AIActionBase FindAnyByType(CommonActionType actionType) { foreach (var action in AllActions) diff --git a/Unity/Assets/Scripts/TH1_Logic/AI/Director/AIDirectorLogic.cs b/Unity/Assets/Scripts/TH1_Logic/AI/Director/AIDirectorLogic.cs index 496578204..a23258412 100644 --- a/Unity/Assets/Scripts/TH1_Logic/AI/Director/AIDirectorLogic.cs +++ b/Unity/Assets/Scripts/TH1_Logic/AI/Director/AIDirectorLogic.cs @@ -110,6 +110,32 @@ namespace Logic.AI.Director return true; } + if (ShouldPrioritizeCityCenterGuard(threat)) + { + var guardAttack = TryEmergencyAttack(ctx, decision, threat); + if (CanEmergencyAttackResolveCityCenterRisk(ctx, threat, guardAttack)) + { + candidate = guardAttack; + decision.AddTrace($"Emergency: clear last city-center threat for city={threat.City?.Id}.", ctx.Config.MaxCandidateTraceCount); + return true; + } + + var guardMove = TryEmergencyMove(ctx, decision, threat); + if (guardMove.IsValid) + { + candidate = guardMove; + decision.AddTrace($"Emergency: guard empty threatened city={threat.City?.Id}.", ctx.Config.MaxCandidateTraceCount); + return true; + } + + if (guardAttack.IsValid) + { + candidate = guardAttack; + decision.AddTrace($"Emergency: fallback attack city threat for city={threat.City?.Id}.", ctx.Config.MaxCandidateTraceCount); + return true; + } + } + var attack = TryEmergencyAttack(ctx, decision, threat); if (attack.IsValid) { @@ -138,6 +164,13 @@ namespace Logic.AI.Director return false; } + private static bool ShouldPrioritizeCityCenterGuard(AIDirectorCityThreat threat) + { + return threat != null + && !threat.HasCityCenterDefender + && threat.CanBeThreatenedNextTurn; + } + private bool TryExpansionLane(AIDirectorContext ctx, AIDirectorDecision decision, out AIDirectorActionCandidate candidate) { candidate = AIDirectorActionCandidate.None; @@ -209,6 +242,7 @@ namespace Logic.AI.Director if (target?.Grid == null || startGrid.Id == target.Grid.Id) continue; var startDistance = AIDirectorMath.Distance(ctx.Map, startGrid, target.Grid); if (ShouldSkipExpansionUnit(ctx, unit, target, startGrid, startDistance)) continue; + if (IsFragileSpecialUnit(unit) && GridThreat(ctx, target.Grid) > 0f) continue; var action = ctx.ActionIndex.FindBestMove(unit, target.Grid); var endGrid = action?.Param?.TargetGridData ?? action?.Param?.GridData; @@ -259,9 +293,11 @@ namespace Logic.AI.Director var target = ChooseBestThreatTarget(ctx, threat); var action = FindBestUsefulAttack(ctx, defender, target) ?? FindBestUsefulAttack(ctx, defender); var targetValue = UnitTargetValue(ctx, action?.Param?.TargetUnitData ?? target); - var score = 960f + threat.DangerScore * 20f + targetValue; + var attackScore = ScoreAttackAction(ctx, action); + var emptyCityRiskPenalty = EmptyCityDefenseRiskPenalty(ctx, threat, defender, action, attackScore); + var score = 960f + threat.DangerScore * 20f + targetValue - emptyCityRiskPenalty; var candidate = ctx.ActionIndex.Candidate(action, AIDirectorLane.Emergency, "Emergency.AttackCityThreat", score); - AddTerms(candidate, ("base", 960f), ("danger", threat.DangerScore * 20f), ("targetValue", targetValue)); + AddTerms(candidate, ("base", 960f), ("danger", threat.DangerScore * 20f), ("targetValue", targetValue), ("emptyCityRisk", -emptyCityRiskPenalty), ("attackScore", attackScore)); candidate.Unit = candidate.Unit ?? defender; candidate.TargetUnit = candidate.TargetUnit ?? target; candidate.City = candidate.City ?? threat.City; @@ -290,13 +326,18 @@ namespace Logic.AI.Director 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); + if (UnitIsThreatenedCityCenterGuard(ctx, unit) && endGrid?.Id != unitGrid?.Id) continue; var unitPower = AIDirectorMath.UnitPower(unit); 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 + progressBonus + distancePenalty + heroBonus; + var cityCenterBonus = !threat.HasCityCenterDefender + ? endDistance == 0 ? 360f : Mathf.Max(0, 220f - endDistance * 35f) + : 0f; + var capitalBonus = threat.IsCapital && !threat.HasCityCenterDefender ? 140f : 0f; + var score = 900f + threat.DangerScore * 20f + unitPower + progressBonus + distancePenalty + heroBonus + cityCenterBonus + capitalBonus; var candidate = ctx.ActionIndex.Candidate(action, AIDirectorLane.Emergency, "Emergency.MoveToCity", score); - AddTerms(candidate, ("base", 900f), ("danger", threat.DangerScore * 20f), ("unitPower", unitPower), ("progress", progressBonus), ("distance", distancePenalty), ("hero", heroBonus)); + AddTerms(candidate, ("base", 900f), ("danger", threat.DangerScore * 20f), ("unitPower", unitPower), ("progress", progressBonus), ("distance", distancePenalty), ("hero", heroBonus), ("cityCenter", cityCenterBonus), ("capital", capitalBonus)); candidate.Unit = candidate.Unit ?? unit; candidate.City = candidate.City ?? threat.City; candidate.TargetGrid = candidate.TargetGrid ?? threat.CityGrid; @@ -317,6 +358,10 @@ namespace Logic.AI.Director { var score = ScoreCityGrowth(ctx, action, plan); if (score <= 0f) continue; + if (!threat.HasCityCenterDefender && action?.ActionLogic?.ActionId?.ActionType == CommonActionType.TrainUnit) + { + score += threat.IsCapital ? 300f : 220f; + } var candidate = ctx.ActionIndex.Candidate(action, AIDirectorLane.Emergency, "Emergency.CityAction", score + 250f); AddTerms(candidate, ("cityGrowth", score), ("emergencyBonus", 250f)); candidate.City = candidate.City ?? threat.City; @@ -493,11 +538,27 @@ namespace Logic.AI.Director var attack = FindBestUsefulAttack(ctx, state.Hero); var attackScore = ScoreAttackAction(ctx, attack); var targetValue = attackScore > 0f ? UnitTargetValue(ctx, attack?.Param?.TargetUnitData) : 0f; - var roleAttackBonus = state.Role is AIDirectorHeroRole.Assassin or AIDirectorHeroRole.Vanguard ? 90f : 0f; + var roleAttackBonus = state.Role switch + { + AIDirectorHeroRole.Assassin => 140f, + AIDirectorHeroRole.Vanguard => 115f, + AIDirectorHeroRole.Caster or AIDirectorHeroRole.Control => 75f, + _ => 0f + }; var attackCandidate = ctx.ActionIndex.Candidate(attack, AIDirectorLane.HeroPlaybook, "HeroPlaybook.GenericHighValueAttack", 760f + targetValue + roleAttackBonus); AddTerms(attackCandidate, ("base", 760f), ("targetValue", targetValue), ("roleAttack", roleAttackBonus), ("attackScore", attackScore)); if (attackCandidate.IsValid && attackScore > 0f) return attackCandidate; + if (state.Role is AIDirectorHeroRole.Caster or AIDirectorHeroRole.Control) + { + var groundTarget = state.Front?.TargetGrid ?? state.Front?.AnchorGrid ?? ctx.Cache.PrimaryFront?.TargetGrid; + var ground = ctx.ActionIndex.FindBestAttackGround(state.Hero, groundTarget) ?? ctx.ActionIndex.FindBestAttackGround(state.Hero); + var groundScore = 710f + (state.IsOnFront ? 80f : 0f) + (ctx.Cache.HasAnyEnemyContact ? 60f : 0f); + var groundCandidate = ctx.ActionIndex.Candidate(ground, AIDirectorLane.HeroPlaybook, "HeroPlaybook.GenericGroundControl", groundScore); + AddTerms(groundCandidate, ("base", 710f), ("onFront", state.IsOnFront ? 80f : 0f), ("enemyContact", ctx.Cache.HasAnyEnemyContact ? 60f : 0f)); + if (groundCandidate.IsValid) return groundCandidate; + } + 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); @@ -577,7 +638,7 @@ namespace Logic.AI.Director foreach (var unit in ctx.Cache.SelfUnits) { if (ShouldSkipFrontMove(ctx, unit, front)) continue; - var action = ctx.ActionIndex.FindBestMove(unit, target); + var action = FindBestFrontMove(ctx, unit, target, front); var startGrid = unit.Grid(ctx.Map); var endGrid = action?.Param?.TargetGridData ?? action?.Param?.GridData; if (!IsUsefulMoveToward(ctx, startGrid, endGrid, target)) continue; @@ -656,7 +717,21 @@ namespace Logic.AI.Director var score = 620f; score += damage / Mathf.Max(1f, target.GetMaxHealth()) * UnitTargetValue(ctx, target) * 2f; score += CounterBonus(attacker, target); - score -= CounterThreat(ctx, attacker, target); + var counterThreat = CounterThreat(ctx, attacker, target); + score -= counterThreat; + if (IsRangedUnit(attacker.UnitType)) + { + score += counterThreat <= 0f ? 110f : 45f; + if (damage > 0) score += 55f; + } + else if (IsSiegeUnit(attacker.UnitType)) + { + score += counterThreat <= 0f ? 95f : 35f; + } + else if (IsFragileSpecialUnit(attacker) && damage < target.Health && counterThreat > 0f) + { + score -= 120f; + } if (damage >= target.Health) score += 160f; if (target.TreatedAsHero(ctx.Map, target)) score += 120f; if (IsThreateningAnyCity(ctx, target)) score += 120f; @@ -713,13 +788,68 @@ namespace Logic.AI.Director _ => 0f }; - score -= AIDirectorMath.Distance(ctx.Map, endGrid, target) * 16f; - score -= GridThreat(ctx, endGrid) * 0.4f; + var distance = AIDirectorMath.Distance(ctx.Map, endGrid, target); + var gridThreat = GridThreat(ctx, endGrid); + score -= distance * 16f; + score -= gridThreat * 0.4f; if (CanNextTurnPressureTarget(ctx, unit, endGrid, target)) score += 80f; if (unit.GetActionPoint(ActionPointType.Move) >= 2 && front.FrontType != AIDirectorFrontType.Hold) score += 40f; + if (IsRangedUnit(unit.UnitType) || IsSiegeUnit(unit.UnitType)) + { + if (front.FrontType is AIDirectorFrontType.Attack or AIDirectorFrontType.Defense) + { + var inRange = distance <= unit.GetAttackRange(ctx.Map); + if (inRange) score += 150f; + else score -= 35f; + if (gridThreat <= AIDirectorMath.UnitPower(unit) * 0.25f) score += 115f; + else score -= 140f; + if (inRange && gridThreat <= 0f) score += 80f; + } + } + else if (IsFragileSpecialUnit(unit) && front.FrontType == AIDirectorFrontType.Attack) + { + if (AIDirectorMath.HealthRatio(unit) <= ctx.Config.LowHealthRatio) score -= 180f; + score -= CanNextTurnPressureTarget(ctx, unit, endGrid, target) && gridThreat <= 0f ? 50f : 220f; + } return score; } + private AIActionBase FindBestFrontMove( + AIDirectorContext ctx, + UnitData unit, + GridData target, + AIDirectorFront front) + { + if (ctx?.ActionIndex == null || unit == null || target == null) return null; + if (!IsRangedUnit(unit.UnitType) && !IsSiegeUnit(unit.UnitType)) return ctx.ActionIndex.FindBestMove(unit, target); + if (front?.FrontType is not (AIDirectorFrontType.Attack or AIDirectorFrontType.Defense)) return ctx.ActionIndex.FindBestMove(unit, target); + + var startGrid = unit.Grid(ctx.Map); + AIActionBase best = null; + var bestScore = float.MinValue; + var scanned = 0; + foreach (var action in ctx.ActionIndex.GetMoveActions(unit)) + { + if (++scanned > 8) break; + var endGrid = action?.Param?.TargetGridData ?? action?.Param?.GridData; + if (endGrid == null) continue; + if (!IsUsefulMoveToward(ctx, startGrid, endGrid, target)) continue; + var distance = AIDirectorMath.Distance(ctx.Map, endGrid, target); + var threat = GridThreat(ctx, endGrid); + var score = -distance * 18f - threat * 0.7f; + if (distance <= unit.GetAttackRange(ctx.Map)) score += 280f; + if (threat <= 0f) score += 120f; + else if (threat <= AIDirectorMath.UnitPower(unit) * 0.25f) score += 55f; + else score -= 160f; + + if (score <= bestScore) continue; + bestScore = score; + best = action; + } + + return best ?? ctx.ActionIndex.FindBestMove(unit, target); + } + private float ScoreCityGrowth(AIDirectorContext ctx, AIActionBase action, AIDirectorCityPlan plan) { if (action?.ActionLogic?.ActionId == null) return 0f; @@ -1104,6 +1234,19 @@ namespace Logic.AI.Director return false; } + private bool UnitIsThreatenedCityCenterGuard(AIDirectorContext ctx, UnitData unit) + { + var unitGrid = unit?.Grid(ctx.Map); + if (unitGrid == null) return false; + foreach (var threat in ctx.Cache.CityThreats) + { + if (threat?.CityGrid == null || threat.CityGrid.Id != unitGrid.Id) continue; + if (ShouldUseEmergency(threat, ctx.Config) || threat.CanBeThreatenedNextTurn) return true; + } + + return false; + } + private bool IsThreateningAnyCity(AIDirectorContext ctx, UnitData target) { if (target == null) return false; @@ -1134,6 +1277,50 @@ namespace Logic.AI.Director return attackScore >= ctx.Config.PriorityTacticScore + 60f && counterThreat <= targetValue * 0.15f; } + private float EmptyCityDefenseRiskPenalty( + AIDirectorContext ctx, + AIDirectorCityThreat threat, + UnitData defender, + AIActionBase action, + float attackScore) + { + if (ctx == null || threat == null || defender == null) return 0f; + if (threat.HasCityCenterDefender || !threat.CanBeThreatenedNextTurn) return 0f; + if (action?.Param?.TargetUnitData == null) return 0f; + + var target = action.Param.TargetUnitData; + var damage = Table.Instance.CalcDamage(ctx.Map, defender, target); + var killThreat = damage >= target.Health; + var onlyDefender = IsOnlyDefenderForThreat(threat, defender); + var cityGrid = threat.CityGrid; + var defenderGrid = defender.Grid(ctx.Map); + var defenderCanOccupyCity = cityGrid != null + && defenderGrid != null + && AIDirectorMath.Distance(ctx.Map, defenderGrid, cityGrid) <= defender.GetActionPoint(ActionPointType.Move); + + if (killThreat && threat.EnemyUnits.Count <= 1) return 0f; + + var penalty = threat.IsCapital ? 340f : 240f; + if (onlyDefender || defenderCanOccupyCity) penalty += 180f; + if (attackScore >= ctx.Config.PriorityTacticScore + 180f && killThreat) penalty *= 0.45f; + return penalty; + } + + private static bool CanEmergencyAttackResolveCityCenterRisk( + AIDirectorContext ctx, + AIDirectorCityThreat threat, + AIDirectorActionCandidate candidate) + { + if (ctx == null || threat == null || candidate?.AIAction?.Param == null) return false; + var action = candidate.AIAction; + var attacker = action.Param.UnitData; + var target = action.Param.TargetUnitData; + if (attacker == null || target == null) return false; + if (threat.EnemyUnits.Count > 1) return false; + if (!threat.EnemyUnits.Contains(target)) return false; + return Table.Instance.CalcDamage(ctx.Map, attacker, target) >= target.Health; + } + private bool TargetOwnerFeelingHighAndNotWar(AIDirectorContext ctx, UnitData target) { if (target == null || !ctx.Map.GetPlayerDataByUnitId(target.Id, out var owner) || owner == null) return false; @@ -1258,7 +1445,8 @@ namespace Logic.AI.Director if (ShouldPushExpansion(ctx)) score += TrainUnitExpansionValue(id.UnitType); if (ctx.Cache.StrategicPosture == AIDirectorStrategicPosture.Attack) score += 60f; if (IsMobilityUnit(id.UnitType) || IsSiegeUnit(id.UnitType)) score += 45f; - if (IsRangedUnit(id.UnitType) && ctx.Cache.HasAnyEnemyContact) score += 35f; + if (IsRangedUnit(id.UnitType)) score += ctx.Cache.HasAnyEnemyContact ? 95f : 45f; + if (IsSpecialPressureUnit(id.UnitType)) score += ctx.Cache.HasAnyEnemyContact ? 70f : 30f; return score; } @@ -1282,9 +1470,10 @@ namespace Logic.AI.Director UnitType.Defender or UnitType.HakureiRoundShieldman => 90f, UnitType.Warrior or UnitType.KaguyaFrenchWarrior or UnitType.KaguyaFrenchAnimalWarrior or UnitType.NoUseHakureiBerserkWarrior or UnitType.NoUseHakureiBerserker => 70f, UnitType.Swordsman => 95f, - UnitType.Archer or UnitType.KomeijiIndianArcher => 78f, + UnitType.Archer or UnitType.KomeijiIndianArcher => 88f, UnitType.Rider or UnitType.Knights or UnitType.MoriyaRider or UnitType.MoriyaKnight or UnitType.KomeijiIndianRider or UnitType.KomeijiIndianKnight or UnitType.HakureiValkyrie => 88f, UnitType.Catapult or UnitType.KaguyaFrenchCatapult or UnitType.KomeijiIndianCatapult => 105f, + UnitType.MoriyaHebi or UnitType.KaguyaFrenchWolf or UnitType.RemiliaEgyptianKoakuma or UnitType.RemiliaEgyptianKoakumaLion or UnitType.KomeijiIndianBigGuy or UnitType.KomeijiIndianJuggernaut => 118f, UnitType.Giant or UnitType.GiantJuggernaut => 140f, _ => 50f }; @@ -1317,6 +1506,27 @@ namespace Logic.AI.Director return unitType is UnitType.Defender or UnitType.HakureiRoundShieldman; } + private static bool IsSpecialPressureUnit(UnitType unitType) + { + return unitType is UnitType.MoriyaHebi or UnitType.KaguyaFrenchWolf + or UnitType.RemiliaEgyptianKoakuma or UnitType.RemiliaEgyptianKoakumaLion + or UnitType.KomeijiIndianBigGuy or UnitType.KomeijiIndianJuggernaut; + } + + private static bool IsFragileSpecialUnit(UnitData unit) + { + if (unit == null) return false; + return unit.UnitType is UnitType.KaguyaFrenchReisenIllusion + or UnitType.SumirekoNorwayOrb + or UnitType.SumirekoDenmarkOrb + or UnitType.SumirekoEnglandOrb + or UnitType.SuikaMini + or UnitType.BonePile + or UnitType.Dagger + or UnitType.DaggerShip + || unit.UnitType == UnitType.MoriyaHebi && unit.UnitLevel < 3; + } + private static float UnitMobilityExpansionBonus(UnitData unit) { if (unit == null) return 0f; diff --git a/Unity/Assets/Scripts/TH1_Logic/AI/Director/AIDirectorTypes.cs b/Unity/Assets/Scripts/TH1_Logic/AI/Director/AIDirectorTypes.cs index 0f1951e49..506648494 100644 --- a/Unity/Assets/Scripts/TH1_Logic/AI/Director/AIDirectorTypes.cs +++ b/Unity/Assets/Scripts/TH1_Logic/AI/Director/AIDirectorTypes.cs @@ -238,6 +238,7 @@ namespace Logic.AI.Director public bool IsCritical; public bool IsCapital; public bool HasWall; + public bool HasCityCenterDefender; public bool HasEnemyOnTerritory; public bool CanBeThreatenedNextTurn; public float DangerScore; diff --git a/Unity/Assets/Scripts/TH1_Logic/AI/Director/AIDirectorWorldCacheBuilder.cs b/Unity/Assets/Scripts/TH1_Logic/AI/Director/AIDirectorWorldCacheBuilder.cs index 514e02d17..2f003e4f3 100644 --- a/Unity/Assets/Scripts/TH1_Logic/AI/Director/AIDirectorWorldCacheBuilder.cs +++ b/Unity/Assets/Scripts/TH1_Logic/AI/Director/AIDirectorWorldCacheBuilder.cs @@ -153,7 +153,8 @@ namespace Logic.AI.Director City = city, CityGrid = cityGrid, IsCapital = city.IsCapital, - HasWall = city.CityWall + HasWall = city.CityWall, + HasCityCenterDefender = CityCenterHasFriendlyGuard(ctx, cityGrid) }; foreach (var enemy in cache.EnemyUnits) @@ -193,6 +194,7 @@ namespace Logic.AI.Director - threat.DefenderPower + (threat.HasEnemyOnTerritory ? 4f : 0f) + (threat.CanBeThreatenedNextTurn ? 6f : 0f) + + (!threat.HasCityCenterDefender && threat.CanBeThreatenedNextTurn ? 4f : 0f) + (threat.IsCapital ? 3f : 0f) - (threat.HasWall ? 2f : 0f); threat.IsCritical = threat.CanBeThreatenedNextTurn @@ -211,6 +213,17 @@ namespace Logic.AI.Director return AIDirectorMath.Distance(ctx.Map, enemyGrid, cityGrid) <= Mathf.Max(1, reach); } + private static bool CityCenterHasFriendlyGuard(AIDirectorContext ctx, GridData cityGrid) + { + if (ctx?.Map == null || ctx.Player == null || cityGrid == null) return false; + return cityGrid.RealUnit(ctx.Map, out var unit) + && unit != null + && unit.IsAlive() + && ctx.Map.GetPlayerDataByUnitId(unit.Id, out var owner) + && owner != null + && ctx.Map.SameUnion(ctx.Player.Id, owner.Id); + } + private void BuildStrategicPosture(AIDirectorContext ctx, AIDirectorWorldCache cache) { if (cache.HasCriticalCityThreat)