Tune AI director defense diagnostics loop

This commit is contained in:
wuwenbo 2026-07-03 18:27:58 +08:00
parent 6c1de79a8f
commit 2cfeb25a1f
8 changed files with 813 additions and 72 deletions

View File

@ -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/actionPoolExecution 保留 action、关键 delta、轻量 before/after不写完整 cache 和冗余执行快照;完整候选明细仅在非 compact 调试时打开。
- 车道只查缓存和动作池。
如果卡顿,优先削减:

View File

@ -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/actionPoolExecution 保留 action、关键 delta、轻量 before/after完整候选明细只在非 compact 调试时打开
科技只看当前可学和一层后继预览
```

View File

@ -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)

View File

@ -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) { }

View File

@ -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;
}

View File

@ -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;
}
}

View File

@ -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

View File

@ -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);
}