Improve AI director batch metrics and attack filtering

This commit is contained in:
wuwenbo 2026-07-02 15:55:50 +08:00
parent 37164ea62d
commit e972f58eba
10 changed files with 354 additions and 21 deletions

View File

@ -67,8 +67,9 @@ Read the batch analyzer output in this order:
3. Outcome shape: `avgTurn`, `avgSurvivors`, eliminations, and winners if present.
4. Expansion: alive average city count, all-player average city count, max city count, alive `>=2` and `>=3` city ratios.
5. Power and attrition: alive unit count, score p10/p50/p90, kills, acting-unit deaths.
6. Action quality: no-effect actions, repeated stable actions, max actions per player turn.
7. Decision time: average, p95, max, and top lanes/actions.
6. Hero count: read both `HeroSummary` from `batch_summary.json` and detailed `Hero:` from JSONL. Prefer hero-eligible ratios when the batch includes non-hero factions.
7. Action quality: no-effect actions, repeated stable actions, max actions per player turn.
8. Decision time: average, p95, max, and top lanes/actions.
Do not judge AI quality from a single interrupted or partial run. The batch analyzer filters incomplete logs when the summary only contains completed games, but final before/after claims should use completed batches with the same options.
@ -134,6 +135,7 @@ Intelligence targets for compact 17-player, 20x20, 20-turn Director batches:
- Unit count and score p10/p50/p90 should not collapse while expansion improves.
- Attrition should be interpreted with context: more kills and more deaths may indicate stronger aggression, not necessarily worse play.
- Defense should be read from `Defense:` in the batch analyzer: `cityLost=0` is the target for short compact batches; rising `capitalThreatTurns`, `emptyThreatTurns`, or `worsened > resolved` means Emergency, city production, or Hold/Front logic needs review.
- Hero count should be read from `HeroSummary` first: in 17-player 20-turn batches, many players may be non-hero factions, so use `eligibleAvgSelected`, `eligibleAvgSpawned`, `eligibleAvgMaxSlots`, `eligibleSpawned>=1`, `eligibleSpawned>=2`, and `eligibleSelected>=2` for hero intelligence. The detailed JSONL `Hero final counts` remains useful for action-level deltas and style buckets. Low eligible hero count means review culture-slot unlock, hero selection timing, and spawn-city pressure before judging hero playbook quality.
- Hero style should not collapse to only `General`; `style buckets` should show expected class/faction roles such as Defense, Recovery, Burst, Control, Summon, Economy, Mobility, and HeroLifecycle as heroes appear.
- Special-unit expression should be checked through `Unit skill expression`: `skill-bearing actor actions`, `signature-changing actions`, and `actor signatures` reveal whether new faction units are actually participating.
@ -215,3 +217,5 @@ Tools/RunAIDirectorBatch.ps1 -Games 1 -Players 2 -Turns 1 -TimeoutSeconds 60
```
The runner writes `batch_summary.json` under `Unity/Logs/AI_Batch/<timestamp>/`. Use `-Games 10`, higher `-Turns`, and the normal 17 players for larger AI quality loops.
`Tools/RunAIDirectorBatch.ps1` defaults to not stopping on settlement/game-end checks so quality loops can run to the requested `-Turns` even if a settlement winner appears early. Pass `-StopOnGameEnd` only when validating settlement/endgame behavior.

View File

@ -251,6 +251,9 @@ def analyze_logs(log_paths):
"ready_hero_task_delta": 0,
"forced_hero_task_delta": 0,
"hero_task_progress_delta": 0,
"final_selected_hero_counts": [],
"final_spawned_hero_counts": [],
"final_max_hero_counts": [],
"critical_city_threat_turns": 0,
"capital_threat_turns": 0,
"empty_threatened_city_turns": 0,
@ -300,6 +303,7 @@ def analyze_logs(log_paths):
decision_lane_by_turn_action = {}
decision_reason_by_turn_action = {}
last_turn_summary_by_player = {}
last_probe_by_player = {}
for row in rows:
event_type = row.get("eventType", "")
if event_type == "Decision":
@ -341,6 +345,8 @@ def analyze_logs(log_paths):
if event_type == "TurnStart":
turn_summary = row.get("turnSummary") or {}
player_id = row.get("playerId", 0)
if turn_summary:
last_probe_by_player[player_id] = turn_summary
if int(turn_summary.get("criticalCityThreatCount") or 0) > 0:
aggregate["critical_city_threat_turns"] += 1
if int(turn_summary.get("capitalThreatCount") or 0) > 0:
@ -371,7 +377,10 @@ def analyze_logs(log_paths):
continue
action = execution.get("action") or {}
delta = execution.get("delta") or {}
after_probe = execution.get("after") or {}
player_id = row.get("playerId", 0)
if after_probe:
last_probe_by_player[player_id] = after_probe
key = action_key(action)
turn_action_key = (player_id, row.get("playerTurn", 0), stable_key(action))
decision_lane = decision_lane_by_turn_action.get(turn_action_key, "")
@ -438,6 +447,11 @@ def analyze_logs(log_paths):
if len(grouped_rows) > 1 and not is_tactical_repeat(grouped_rows):
aggregate["top_repeated"][str(key)] += len(grouped_rows)
for probe in last_probe_by_player.values():
aggregate["final_selected_hero_counts"].append(int(probe.get("selectedHeroCount") or 0))
aggregate["final_spawned_hero_counts"].append(int(probe.get("heroCount") or 0))
aggregate["final_max_hero_counts"].append(int(probe.get("maxHeroCount") or 0))
return aggregate
@ -503,6 +517,20 @@ def summarize_batch(batch):
unit_counts = [player.get("unitCount", 0) for _, player in players]
alive_unit_counts = [player.get("unitCount", 0) for _, player in alive_players]
scores = [player.get("score", 0) for _, player in players]
selected_hero_counts = [player.get("selectedHeroCount", 0) for _, player in players]
spawned_hero_counts = [player.get("spawnedHeroCount", 0) for _, player in players]
max_hero_counts = [player.get("maxHeroCount", 0) for _, player in players]
eligible_players = [
(game, player)
for game, player in players
if player.get("heroEligible")
or player.get("selectedHeroCount", 0) > 0
or player.get("spawnedHeroCount", 0) > 0
or player.get("maxHeroCount", 0) > 1
]
eligible_selected_hero_counts = [player.get("selectedHeroCount", 0) for _, player in eligible_players]
eligible_spawned_hero_counts = [player.get("spawnedHeroCount", 0) for _, player in eligible_players]
eligible_max_hero_counts = [player.get("maxHeroCount", 0) for _, player in eligible_players]
total_elapsed = sum(elapsed_values)
total_actions = sum(net_actions)
@ -534,6 +562,26 @@ def summarize_batch(batch):
"score_p10": percentile(scores, 0.10),
"score_p50": percentile(scores, 0.50),
"score_p90": percentile(scores, 0.90),
"hero_eligible_count": len(eligible_players),
"hero_eligible_ratio": len(eligible_players) / max(1, len(players)),
"avg_selected_hero_count": average(selected_hero_counts),
"avg_spawned_hero_count": average(spawned_hero_counts),
"avg_max_hero_count": average(max_hero_counts),
"selected_hero_ge_2_ratio": sum(1 for value in selected_hero_counts if value >= 2) / max(1, len(players)),
"spawned_hero_ge_1_ratio": sum(1 for value in spawned_hero_counts if value >= 1) / max(1, len(players)),
"spawned_hero_ge_2_ratio": sum(1 for value in spawned_hero_counts if value >= 2) / max(1, len(players)),
"eligible_avg_selected_hero_count": average(eligible_selected_hero_counts),
"eligible_avg_spawned_hero_count": average(eligible_spawned_hero_counts),
"eligible_avg_max_hero_count": average(eligible_max_hero_counts),
"eligible_selected_hero_ge_2_ratio": (
sum(1 for value in eligible_selected_hero_counts if value >= 2) / max(1, len(eligible_players))
),
"eligible_spawned_hero_ge_1_ratio": (
sum(1 for value in eligible_spawned_hero_counts if value >= 1) / max(1, len(eligible_players))
),
"eligible_spawned_hero_ge_2_ratio": (
sum(1 for value in eligible_spawned_hero_counts if value >= 2) / max(1, len(eligible_players))
),
"win_players": [
{
"gameIndex": game.get("gameIndex"),
@ -630,6 +678,33 @@ def compact_log_metrics(log_metrics, top):
"ready_hero_task_delta": log_metrics["ready_hero_task_delta"],
"forced_hero_task_delta": log_metrics["forced_hero_task_delta"],
"hero_task_progress_delta": log_metrics["hero_task_progress_delta"],
"final_selected_avg": average(log_metrics["final_selected_hero_counts"]),
"final_spawned_avg": average(log_metrics["final_spawned_hero_counts"]),
"final_max_slots_avg": average(log_metrics["final_max_hero_counts"]),
"final_selected_ge_2_ratio": (
sum(1 for value in log_metrics["final_selected_hero_counts"] if value >= 2)
/ max(1, len(log_metrics["final_selected_hero_counts"]))
),
"final_selected_ge_3_ratio": (
sum(1 for value in log_metrics["final_selected_hero_counts"] if value >= 3)
/ max(1, len(log_metrics["final_selected_hero_counts"]))
),
"final_spawned_ge_1_ratio": (
sum(1 for value in log_metrics["final_spawned_hero_counts"] if value >= 1)
/ max(1, len(log_metrics["final_spawned_hero_counts"]))
),
"final_spawned_ge_2_ratio": (
sum(1 for value in log_metrics["final_spawned_hero_counts"] if value >= 2)
/ max(1, len(log_metrics["final_spawned_hero_counts"]))
),
"final_max_slots_ge_2_ratio": (
sum(1 for value in log_metrics["final_max_hero_counts"] if value >= 2)
/ max(1, len(log_metrics["final_max_hero_counts"]))
),
"final_max_slots_ge_3_ratio": (
sum(1 for value in log_metrics["final_max_hero_counts"] if value >= 3)
/ max(1, len(log_metrics["final_max_hero_counts"]))
),
},
"fallback": {
"top_reasons": log_metrics["fallback_reasons"].most_common(top),
@ -675,6 +750,18 @@ def print_report(batch_path, batch, metrics, log_metrics, warnings, top):
f"allAvgUnits={metrics['avg_unit_count']:.2f} "
f"score(p10/p50/p90)={metrics['score_p10']:.0f}/{metrics['score_p50']:.0f}/{metrics['score_p90']:.0f}"
)
print(
"HeroSummary: "
f"eligible={metrics['hero_eligible_count']} ({metrics['hero_eligible_ratio']:.1%}) "
f"allAvgSelected={metrics['avg_selected_hero_count']:.2f} "
f"allAvgSpawned={metrics['avg_spawned_hero_count']:.2f} "
f"eligibleAvgSelected={metrics['eligible_avg_selected_hero_count']:.2f} "
f"eligibleAvgSpawned={metrics['eligible_avg_spawned_hero_count']:.2f} "
f"eligibleAvgMaxSlots={metrics['eligible_avg_max_hero_count']:.2f} "
f"eligibleSpawned>=1={metrics['eligible_spawned_hero_ge_1_ratio']:.1%} "
f"eligibleSpawned>=2={metrics['eligible_spawned_hero_ge_2_ratio']:.1%} "
f"eligibleSelected>=2={metrics['eligible_selected_hero_ge_2_ratio']:.1%}"
)
if metrics["win_players"]:
print("Wins:")
for winner in metrics["win_players"]:
@ -757,6 +844,26 @@ def print_report(batch_path, batch, metrics, log_metrics, warnings, top):
f"forcedTask={log_metrics['forced_hero_task_delta']} "
f"taskProgress={log_metrics['hero_task_progress_delta']}"
)
final_hero_sample_count = len(log_metrics["final_selected_hero_counts"])
if final_hero_sample_count > 0:
selected_ge_2 = sum(1 for value in log_metrics["final_selected_hero_counts"] if value >= 2) / final_hero_sample_count
selected_ge_3 = sum(1 for value in log_metrics["final_selected_hero_counts"] if value >= 3) / final_hero_sample_count
spawned_ge_1 = sum(1 for value in log_metrics["final_spawned_hero_counts"] if value >= 1) / final_hero_sample_count
spawned_ge_2 = sum(1 for value in log_metrics["final_spawned_hero_counts"] if value >= 2) / final_hero_sample_count
slots_ge_2 = sum(1 for value in log_metrics["final_max_hero_counts"] if value >= 2) / final_hero_sample_count
slots_ge_3 = sum(1 for value in log_metrics["final_max_hero_counts"] if value >= 3) / final_hero_sample_count
print(
" final counts: "
f"avgSelected={average(log_metrics['final_selected_hero_counts']):.2f} "
f"avgSpawned={average(log_metrics['final_spawned_hero_counts']):.2f} "
f"avgMaxSlots={average(log_metrics['final_max_hero_counts']):.2f} "
f"selected>=2={selected_ge_2:.1%} "
f"selected>=3={selected_ge_3:.1%} "
f"spawned>=1={spawned_ge_1:.1%} "
f"spawned>=2={spawned_ge_2:.1%} "
f"slots>=2={slots_ge_2:.1%} "
f"slots>=3={slots_ge_3:.1%}"
)
if log_metrics["hero_reasons"]:
print(" top reasons:")
for key, count in log_metrics["hero_reasons"].most_common(top):

View File

@ -199,6 +199,7 @@ Expansion 的行为倾向:
- Expansion 只扫描已排序的少量高价值扩张目标;扩大城市数不能让每次决策退化成“所有目标 × 所有单位”的全量搜索。
- 单位移动优先满足“本回合能占”或“下回合能站到可占点旁边”,远距离目标只保留作低优先级战线。
- 扩张目标必须考虑占后守备风险;如果目标格或占领单位附近威胁很高、当前已有严重城市威胁、或己方单位数不足以覆盖城市数,则降低占领/靠近优先级。普通城市威胁只影响危险目标,不应把安全二城扩张整体压掉。
- 二城前如果存在严重城市威胁Expansion 不完全关闭,只保留距离 3 格内、目标格无即时威胁、且可快速转化的村庄/空城目标;其他远距离或危险扩张继续让位给 Emergency。
- 危险城市的唯一守军不能外派扩张;非唯一守军可以参与近距离、安全、能快速转化的二城扩张,避免防守过度导致城市数停滞。
- Expansion 车道优先处理占领和向目标靠近UnitOpportunity 负责脚下的占领、遗迹和采集补漏。
- Development Front 指向村庄、遗迹、资源和边界。
@ -352,11 +353,12 @@ HeroManagement 的节奏:
```text
没有选择英雄
→ 先选择最适合当前阵营和局势的英雄
→ 如果已选择英雄数量已经占满当前英雄槽位,先买下一英雄槽位
→ 已选择但未上场时,优先在合法城市训练/复活英雄
→ 英雄已经能参与战局后,再推进可强制完成的英雄任务
```
这个顺序保证 AI 不会只完成任务却长期没有英雄在棋盘上,也不会把英雄当作普通兵随缘训练
这个顺序保证 AI 不会只完成任务却长期没有英雄在棋盘上,也不会因为第二、第三英雄槽位没开而卡住英雄体系
HeroPlaybook 的判断顺序:
@ -496,6 +498,8 @@ Director 不直接推演行为结果,而是从合法 Action 池中选择。
UnitAttackAlly 只由 HeroPlaybook、支援战术或明确的友军互动规则使用不进入 Fallback。友军目标动作如果落到兜底通常说明它缺少英雄或支援规则。
普通 UnitAttack 必须有可预期收益。基础伤害为 0 的纯普通攻击不进入 Emergency、Tactic 或 GenericHero 攻击候选避免防守时把行动浪费在“合法但不掉血”的目标上。带推击、龙船撞击、Reisen 协同或 Kaguya 狼等攻击附加效果的动作可以例外,因为它们可能通过位移、追击、标记或额外伤害产生收益。
危险动作默认不进入普通 AI 选择:
- 解散。

View File

@ -443,6 +443,8 @@ SkillBase
如果 target != null 且 action.TargetUnit != target:
继续
score = ScoreAttackAction(ctx, action)
如果 score <= 0:
继续
best = MaxByScore(best, action, score)
返回 best.Action
@ -1199,7 +1201,9 @@ SkillBase
best = None
对每个 defender in threat.DefenderUnits:
action = FindBestAttackAgainstThreat(ctx, defender, threat)
action = FindBestUsefulAttack(ctx, defender, BestThreatTarget(ctx, threat))
如果 action == None:
action = FindBestUsefulAttack(ctx, defender)
candidate = Candidate(action, Emergency, ScoreEmergencyAttack(ctx, action, threat), "守军攻击城市威胁")
best = MaxCandidate(best, candidate)
@ -1319,6 +1323,10 @@ SkillBase
如果 candidate.IsValid:
返回 candidate
candidate = TryBuyHeroSlotCultureCard(ctx)
如果 candidate.IsValid 且 ShouldPrioritizeHeroSlotBeforeSpawn(ctx):
返回 candidate
candidate = TrySpawnSelectedHero(ctx)
如果 candidate.IsValid:
返回 candidate
@ -1405,6 +1413,18 @@ SkillBase
返回 Candidate(action, HeroManagement, score, "购买英雄槽位卡")
```
```text
函数 ShouldPrioritizeHeroSlotBeforeSpawn(ctx):
heroData = ctx.Player.PlayerHeroData
如果 heroData == null:
返回 false
如果 heroData.MaxHeroCount >= 3:
返回 false
如果 heroData.HeroCount > 0 且 heroData.HeroCount >= heroData.MaxHeroCount:
返回 true
返回 false
```
### 7.5 FinishHeroTask
```text
@ -1466,10 +1486,13 @@ Expansion Lane 只处理早期扩张的刚性目标,不处理普通资源开
best = None
scannedTargetCount = 0
requireSafeUrgentTarget = 有严重城市威胁
对每个 target in ctx.Cache.DevelopmentTargets:
如果 !IsExpansionTarget(ctx, target):
继续
如果 requireSafeUrgentTarget 且 !IsSafeUrgentExpansionTarget(ctx, target):
继续
如果 Config.MaxExpansionTargetScanCount > 0 且 scannedTargetCount >= Config.MaxExpansionTargetScanCount:
break
@ -1489,7 +1512,7 @@ Expansion Lane 只处理早期扩张的刚性目标,不处理普通资源开
```text
函数 ShouldPushExpansion(ctx):
如果 有严重城市威胁:
如果 有严重城市威胁 且 不存在安全紧急扩张目标:
返回 false
如果 ctx.Cache.DevelopmentTargets 为空:
@ -1504,6 +1527,25 @@ Expansion Lane 只处理早期扩张的刚性目标,不处理普通资源开
返回 false
```
```text
函数 HasSafeUrgentExpansionTarget(ctx):
对每个 target in ctx.Cache.DevelopmentTargets:
如果 IsSafeUrgentExpansionTarget(ctx, target):
返回 true
返回 false
函数 IsSafeUrgentExpansionTarget(ctx, target):
如果 己方城市数 >= Config.ExpansionUrgentCityThreshold:
返回 false
如果 target.Type 不在 [Village, EnemyEmptyCity]:
返回 false
如果 target.Distance > 3:
返回 false
如果 GridThreat(ctx, target.Grid) > 0:
返回 false
返回 true
```
### 8.3 IsExpansionTarget
```text
@ -1774,7 +1816,7 @@ Expansion Lane 只处理早期扩张的刚性目标,不处理普通资源开
如果 move 可用:
返回 Candidate(move, HeroPlaybook, 820, "英雄低血撤退")
attack = FindBestAttack(ctx, hero, BestHeroOrKillableEnemy(ctx, hero))
attack = FindBestUsefulAttack(ctx, hero, BestHeroOrKillableEnemy(ctx, hero))
如果 attack 可用:
返回 Candidate(attack, HeroPlaybook, 760, "英雄通用高价值攻击")
@ -1915,8 +1957,10 @@ Byakuren / Miko / Zanmu:
best = None
对每个 battle in ctx.Cache.LocalBattles:
action = FindBestAttack(ctx, battle.SelfUnit, battle.EnemyUnit)
action = FindBestUsefulAttack(ctx, battle.SelfUnit, battle.EnemyUnit)
attackScore = ScoreAttackAction(ctx, action)
如果 attackScore <= 0:
继续
score = 700 + battle.Value + attackScore * 0.1
如果 priorityOnly 且 (score < Config.PriorityTacticScore !IsPriorityTacticAction(ctx, action, attackScore)):
继续
@ -1925,6 +1969,8 @@ Byakuren / Miko / Zanmu:
对每个 action in ctx.Actions.Attacks:
score = ScoreAttackAction(ctx, action)
如果 score <= 0:
继续
如果 priorityOnly 且 (score < Config.PriorityTacticScore !IsPriorityTacticAction(ctx, action, score)):
继续
candidate = Candidate(action, Tactic, score, priorityOnly ? "高价值攻击收益" : "普通攻击收益")
@ -1939,6 +1985,9 @@ Byakuren / Miko / Zanmu:
target = action.TargetUnitData
damage = CalcDamage(ctx.Map, attacker, target)
如果 !CanTreatAttackAsUseful(attacker, target, damage):
返回 false
如果 damage >= target.Health:
返回 true
如果 target 是英雄:
@ -1958,6 +2007,10 @@ Byakuren / Miko / Zanmu:
函数 ScoreAttackAction(ctx, action):
attacker = action.UnitData
target = action.TargetUnitData
damage = CalcDamage(ctx.Map, attacker, target)
如果 !CanTreatAttackAsUseful(attacker, target, damage):
返回 0
score = 620
score += EstimateDamageValue(ctx, attacker, target) * 2
@ -1982,6 +2035,42 @@ Byakuren / Miko / Zanmu:
返回 score
```
```text
函数 FindBestUsefulAttack(ctx, unit, preferredTarget = null):
best = None
bestScore = -INF
对每个 action in ctx.Actions.GetAttackActions(unit):
target = action.TargetUnitData
如果 preferredTarget != null 且 target != preferredTarget:
继续
score = ScoreAttackAction(ctx, action)
如果 score <= 0:
继续
如果 score > bestScore:
bestScore = score
best = action
返回 best
```
```text
函数 CanTreatAttackAsUseful(attacker, target, damage):
如果 attacker == null 或 target == null:
返回 false
如果 damage > 0:
返回 true
如果 attacker 带有攻击附加收益:
返回 true
返回 false
攻击附加收益包括:
YuugiPush
HakureiDragonshipRam
REISENFRENCHATTAK
KaguyaFrenchWolf
```
---
## 11. UnitOpportunity Lane

View File

@ -15,6 +15,7 @@ param(
[switch]$KeepGoing,
[switch]$FullDiagnostics,
[switch]$AllSight,
[switch]$StopOnGameEnd,
[switch]$AllowProjectAlreadyOpen,
[switch]$DryRun
)
@ -101,6 +102,7 @@ $logFile = Join-Path $OutDir "unity_batch.log"
$failFast = if ($KeepGoing) { "false" } else { "true" }
$compactDiagnostics = if ($FullDiagnostics) { "false" } else { "true" }
$allSightValue = if ($AllSight) { "true" } else { "false" }
$stopOnGameEndValue = if ($StopOnGameEnd) { "true" } else { "false" }
$args = @(
"-batchmode",
"-projectPath", $ProjectPath,
@ -119,6 +121,7 @@ $args = @(
"-aiBatchFailFast", $failFast,
"-aiBatchCompactDiagnostics", $compactDiagnostics,
"-aiBatchAllSight", $allSightValue,
"-aiBatchStopOnGameEnd", $stopOnGameEndValue,
"-aiBatchOut", $OutDir
)

View File

@ -147,6 +147,13 @@ namespace Logic.AI.Director
return best;
}
public IEnumerable<AIActionBase> GetAttackActions(UnitData unit)
{
if (unit == null) yield break;
if (!_attacksByUnit.TryGetValue(unit.Id, out var actions)) yield break;
foreach (var action in actions) yield return action;
}
public AIActionBase FindBestAttackAlly(UnitData unit, UnitData target = null)
{
if (unit == null) return null;

View File

@ -145,9 +145,11 @@ namespace Logic.AI.Director
var best = AIDirectorActionCandidate.None;
var scannedTargets = 0;
var requireSafeUrgentTarget = HasSevereCityThreat(ctx);
foreach (var target in ctx.Cache.DevelopmentTargets)
{
if (!IsExpansionTarget(ctx, target)) continue;
if (requireSafeUrgentTarget && !IsSafeUrgentExpansionTarget(ctx, target)) continue;
if (ctx.Config.MaxExpansionTargetScanCount > 0 && scannedTargets >= ctx.Config.MaxExpansionTargetScanCount) break;
scannedTargets++;
@ -254,8 +256,8 @@ namespace Logic.AI.Director
foreach (var defender in threat.Defenders)
{
var target = ChooseBestThreatTarget(ctx, threat);
var action = ctx.ActionIndex.FindBestAttack(defender, target) ?? ctx.ActionIndex.FindBestAttack(defender);
var targetValue = UnitTargetValue(ctx, target);
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 candidate = ctx.ActionIndex.Candidate(action, AIDirectorLane.Emergency, "Emergency.AttackCityThreat", score);
AddTerms(candidate, ("base", 960f), ("danger", threat.DangerScore * 20f), ("targetValue", targetValue));
@ -339,6 +341,14 @@ namespace Logic.AI.Director
return true;
}
var heroSlotCard = TryHeroSlotCultureCard(ctx, decision);
if (heroSlotCard.IsValid && ShouldPrioritizeHeroSlotBeforeSpawn(ctx))
{
candidate = heroSlotCard;
decision.AddTrace($"HeroManagement: buy hero slot card {candidate.ActionId?.CultureCardType}.", ctx.Config.MaxCandidateTraceCount);
return true;
}
var heroSpawn = FindBestHeroSpawn(ctx, decision);
if (heroSpawn.IsValid)
{
@ -347,7 +357,6 @@ namespace Logic.AI.Director
return true;
}
var heroSlotCard = TryHeroSlotCultureCard(ctx, decision);
if (heroSlotCard.IsValid)
{
candidate = heroSlotCard;
@ -378,6 +387,14 @@ namespace Logic.AI.Director
return candidate;
}
private bool ShouldPrioritizeHeroSlotBeforeSpawn(AIDirectorContext ctx)
{
var heroData = ctx?.Player?.PlayerHeroData;
if (heroData == null) return false;
if (heroData.MaxHeroCount >= 3) return false;
return heroData.HeroCount > 0 && heroData.HeroCount >= heroData.MaxHeroCount;
}
private AIDirectorActionCandidate FindBestHeroSpawn(AIDirectorContext ctx, AIDirectorDecision decision)
{
AIDirectorActionCandidate best = AIDirectorActionCandidate.None;
@ -472,11 +489,12 @@ namespace Logic.AI.Director
if (moveCandidate.IsValid) return moveCandidate;
}
var attack = ctx.ActionIndex.FindBestAttack(state.Hero);
var targetValue = UnitTargetValue(ctx, attack?.Param?.TargetUnitData);
var attack = FindBestUsefulAttack(ctx, state.Hero);
var attackScore = ScoreAttackAction(ctx, attack);
var targetValue = attackScore > 0f ? UnitTargetValue(ctx, attack?.Param?.TargetUnitData) : 0f;
var attackCandidate = ctx.ActionIndex.Candidate(attack, AIDirectorLane.HeroPlaybook, "HeroPlaybook.GenericHighValueAttack", 760f + targetValue);
AddTerms(attackCandidate, ("base", 760f), ("targetValue", targetValue));
if (attackCandidate.IsValid) return attackCandidate;
AddTerms(attackCandidate, ("base", 760f), ("targetValue", targetValue), ("attackScore", attackScore));
if (attackCandidate.IsValid && attackScore > 0f) return attackCandidate;
var target = state.Front?.TargetGrid ?? state.Front?.AnchorGrid;
var frontMove = ctx.ActionIndex.FindBestMove(state.Hero, target);
@ -495,8 +513,9 @@ namespace Logic.AI.Director
foreach (var battle in ctx.Cache.LocalBattles)
{
if (battle.SelfUnit == null || battle.EnemyUnit == null) continue;
var action = ctx.ActionIndex.FindBestAttack(battle.SelfUnit, battle.EnemyUnit);
var action = FindBestUsefulAttack(ctx, battle.SelfUnit, battle.EnemyUnit);
var attackScore = ScoreAttackAction(ctx, action);
if (attackScore <= 0f) continue;
var score = 700f + battle.Value + attackScore * 0.1f;
var current = ctx.ActionIndex.Candidate(action, AIDirectorLane.Tactic, $"{reasonPrefix}.LocalBattleAttack", score);
AddTerms(current, ("base", 700f), ("localBattle", battle.Value), ("attackScore", attackScore * 0.1f));
@ -508,6 +527,7 @@ namespace Logic.AI.Director
foreach (var action in ctx.ActionIndex.AttackActions)
{
var score = ScoreAttackAction(ctx, action);
if (score <= 0f) continue;
var current = ctx.ActionIndex.Candidate(action, AIDirectorLane.Tactic, $"{reasonPrefix}.AttackValue", score);
AddTerms(current, ("attackScore", score));
if (priorityOnly && (score < minScore || !IsPriorityTacticAction(ctx, action, score))) continue;
@ -630,6 +650,7 @@ namespace Logic.AI.Director
var attacker = action.Param.UnitData;
var target = action.Param.TargetUnitData;
var damage = Table.Instance.CalcDamage(ctx.Map, attacker, target);
if (!CanTreatAttackAsUseful(attacker, target, damage)) return 0f;
var score = 620f;
score += damage / Mathf.Max(1f, target.GetMaxHealth()) * UnitTargetValue(ctx, target) * 2f;
score += CounterBonus(attacker, target);
@ -641,6 +662,39 @@ namespace Logic.AI.Director
return score;
}
private AIActionBase FindBestUsefulAttack(AIDirectorContext ctx, UnitData unit, UnitData preferredTarget = null)
{
AIActionBase best = null;
var bestScore = float.MinValue;
foreach (var action in ctx.ActionIndex.GetAttackActions(unit))
{
var target = action.Param?.TargetUnitData;
if (preferredTarget != null && target?.Id != preferredTarget.Id) continue;
var score = ScoreAttackAction(ctx, action);
if (score <= 0f || score <= bestScore) continue;
bestScore = score;
best = action;
}
return best;
}
private static bool CanTreatAttackAsUseful(UnitData attacker, UnitData target, int damage)
{
if (attacker == null || target == null) return false;
if (damage > 0) return true;
return HasAttackSideEffect(attacker);
}
private static bool HasAttackSideEffect(UnitData attacker)
{
if (attacker == null) return false;
if (attacker.UnitType == UnitType.KaguyaFrenchWolf) return true;
return attacker.GetSkill(SkillType.YuugiPush, out _)
|| attacker.GetSkill(SkillType.HakureiDragonshipRam, out _)
|| attacker.GetSkill(SkillType.REISENFRENCHATTAK, out _);
}
private float ScoreFrontMove(AIDirectorContext ctx, UnitData unit, AIActionBase action, AIDirectorFront front)
{
if (unit == null || action?.Param == null || front == null) return 0f;
@ -957,7 +1011,8 @@ namespace Logic.AI.Director
private bool ShouldPushExpansion(AIDirectorContext ctx)
{
if (ctx?.Cache == null || (ctx.Cache.HasCriticalCityThreat && HasSevereCityThreat(ctx))) return false;
if (ctx?.Cache == null) return false;
if (ctx.Cache.HasCriticalCityThreat && HasSevereCityThreat(ctx) && !HasSafeUrgentExpansionTarget(ctx)) return false;
if (ctx.Cache.DevelopmentTargets.Count == 0) return false;
if (ctx.Cache.SelfCities.Count < ctx.Config.ExpansionUrgentCityThreshold) return true;
return ctx.Player.Turn <= ctx.Config.ExpansionHardPressureTurn && HasExpansionTarget(ctx);
@ -973,6 +1028,25 @@ namespace Logic.AI.Director
return false;
}
private bool HasSafeUrgentExpansionTarget(AIDirectorContext ctx)
{
foreach (var target in ctx.Cache.DevelopmentTargets)
{
if (IsSafeUrgentExpansionTarget(ctx, target)) return true;
}
return false;
}
private bool IsSafeUrgentExpansionTarget(AIDirectorContext ctx, AIDirectorDevelopmentTarget target)
{
if (ctx?.Cache == null || target?.Grid == null) return false;
if (ctx.Cache.SelfCities.Count >= ctx.Config.ExpansionUrgentCityThreshold) return false;
if (target.TargetType is not (AIDirectorDevelopmentTargetType.Village or AIDirectorDevelopmentTargetType.EnemyEmptyCity)) return false;
if (target.Distance > 3) return false;
return GridThreat(ctx, target.Grid) <= 0f;
}
private bool HasExpansionTarget(AIDirectorContext ctx)
{
foreach (var target in ctx.Cache.DevelopmentTargets)
@ -1042,6 +1116,7 @@ namespace Logic.AI.Director
var attacker = action.Param.UnitData;
var target = action.Param.TargetUnitData;
var damage = Table.Instance.CalcDamage(ctx.Map, attacker, target);
if (!CanTreatAttackAsUseful(attacker, target, damage)) return false;
if (damage >= target.Health) return true;
if (target.TreatedAsHero(ctx.Map, target)) return true;
if (IsThreateningAnyCity(ctx, target)) return true;

View File

@ -21,11 +21,13 @@ namespace Logic.AI
public static bool ForceAllPlayersAi;
public static bool SkipPresentationWait;
public static bool CompactDiagnostics;
public static bool SuppressGameEnd;
public static int RandomSeedOverride;
#else
public const bool ForceAllPlayersAi = false;
public const bool SkipPresentationWait = false;
public const bool CompactDiagnostics = false;
public const bool SuppressGameEnd = false;
public const int RandomSeedOverride = 0;
#endif
}

View File

@ -83,12 +83,12 @@ namespace Logic
if (_curState == GameState.Finished) return;
if (_curState == GameState.Spectate)
{
if (Main.MapData.CheckIfGameEnd(out _)) return;
if (!AIDirectorBatchRuntime.SuppressGameEnd && Main.MapData.CheckIfGameEnd(out _)) return;
Main.MapData.RefreshTurn();
return;
}
if (Main.MapData.CheckIfGameEnd(out _))
if (!AIDirectorBatchRuntime.SuppressGameEnd && Main.MapData.CheckIfGameEnd(out _))
{
ChangeState(GameState.Finished);
return;

View File

@ -136,6 +136,7 @@ namespace TH1_Logic.Editor
AIDirectorBatchRuntime.ForceAllPlayersAi = false;
AIDirectorBatchRuntime.SkipPresentationWait = false;
AIDirectorBatchRuntime.CompactDiagnostics = false;
AIDirectorBatchRuntime.SuppressGameEnd = false;
AIDirectorBatchRuntime.RandomSeedOverride = 0;
#endif
CompleteResult(result, gameDirectory);
@ -167,6 +168,7 @@ namespace TH1_Logic.Editor
AIDirectorBatchRuntime.ForceAllPlayersAi = true;
AIDirectorBatchRuntime.SkipPresentationWait = true;
AIDirectorBatchRuntime.CompactDiagnostics = options.CompactDiagnostics;
AIDirectorBatchRuntime.SuppressGameEnd = !options.StopOnGameEnd;
AIDirectorBatchRuntime.RandomSeedOverride = options.Seed == 0 ? 0 : options.Seed + gameIndex;
#if TH1_AI_DIRECTOR_DIAGNOSTICS || UNITY_EDITOR
AIDirectorDiagnostics.BeginNewSession();
@ -228,6 +230,7 @@ namespace TH1_Logic.Editor
AIDirectorBatchRuntime.ForceAllPlayersAi = false;
AIDirectorBatchRuntime.SkipPresentationWait = false;
AIDirectorBatchRuntime.CompactDiagnostics = false;
AIDirectorBatchRuntime.SuppressGameEnd = false;
AIDirectorBatchRuntime.RandomSeedOverride = 0;
#endif
try
@ -322,10 +325,17 @@ namespace TH1_Logic.Editor
result.curPlayerId = map.CurPlayer?.Id ?? 0;
result.curPlayerTurn = map.CurPlayer?.Turn ?? 0;
result.survivingPlayers = CountSurvivingPlayers(map);
result.gameState = Main.Instance.GameLogic?.GetCurState().ToString() ?? string.Empty;
var gameState = Main.Instance.GameLogic?.GetCurState();
result.gameState = gameState?.ToString() ?? string.Empty;
if (map.CheckIfGameEnd(out var isWin)
|| Main.Instance.GameLogic?.GetCurState() == GameState.Finished)
if (options.StopOnGameEnd && gameState == GameState.Finished)
{
result.success = true;
result.reason = "GameStateFinished";
yield break;
}
if (options.StopOnGameEnd && map.CheckIfGameEnd(out var isWin))
{
result.success = true;
result.reason = isWin ? "GameEnd:SelfOrTeamWin" : "GameEnd";
@ -468,6 +478,7 @@ namespace TH1_Logic.Editor
options.DisableNearbySpawnPoints = GetBoolArg("-aiBatchDisableNearbySpawnPoints", options.DisableNearbySpawnPoints);
options.CompactDiagnostics = GetBoolArg("-aiBatchCompactDiagnostics", options.CompactDiagnostics);
options.AllSight = GetBoolArg("-aiBatchAllSight", options.AllSight);
options.StopOnGameEnd = GetBoolArg("-aiBatchStopOnGameEnd", options.StopOnGameEnd);
var outputDirectory = GetStringArg("-aiBatchOut");
if (!string.IsNullOrWhiteSpace(outputDirectory)) options.OutputDirectory = outputDirectory;
@ -572,6 +583,10 @@ namespace TH1_Logic.Editor
techPoint = player.PlayerTechPoint,
cityCount = CountPlayerCities(map, player.Id),
unitCount = CountPlayerUnits(map, player.Id),
heroEligible = PlayerHasSelectableHero(player),
selectedHeroCount = player.PlayerHeroData?.HeroCount ?? 0,
spawnedHeroCount = CountPlayerHeroes(map, player.Id),
maxHeroCount = player.PlayerHeroData?.MaxHeroCount ?? 0,
isWin = map.MatchSettlement?.IsWin(player.Id) ?? false
});
}
@ -603,6 +618,28 @@ namespace TH1_Logic.Editor
return count;
}
private static bool PlayerHasSelectableHero(PlayerData player)
{
if (player?.PlayerHeroData == null) return false;
var leader = player.PlayerHeroData.GetLeaderGiantType();
return leader != GiantType.None && ContentGate.CanUseHeroForPlayer(player, leader);
}
private static int CountPlayerHeroes(MapData map, uint playerId)
{
var units = map.UnitMap?.UnitList;
if (units == null) return 0;
var count = 0;
foreach (var unit in units)
{
if (unit == null || !unit.IsAlive()) continue;
if (!map.GetPlayerIdByUnitId(unit.Id, out var ownerId) || ownerId != playerId) continue;
if (unit.TreatedAsHero(map, unit)) count++;
}
return count;
}
private static int CountSurvivingPlayers(MapData map)
{
var players = map.PlayerMap?.PlayerDataList;
@ -1179,6 +1216,7 @@ namespace TH1_Logic.Editor
public bool DisableNearbySpawnPoints = false;
public bool CompactDiagnostics = true;
public bool AllSight = false;
public bool StopOnGameEnd = true;
public int Seed = 0;
public string OutputDirectory;
public string ScenePath;
@ -1266,6 +1304,10 @@ namespace TH1_Logic.Editor
public int techPoint;
public int cityCount;
public int unitCount;
public bool heroEligible;
public int selectedHeroCount;
public int spawnedHeroCount;
public int maxHeroCount;
}
[Serializable]