diff --git a/.codex/skills/th1-ai-director/SKILL.md b/.codex/skills/th1-ai-director/SKILL.md index ffce86efd..6d79f8aad 100644 --- a/.codex/skills/th1-ai-director/SKILL.md +++ b/.codex/skills/th1-ai-director/SKILL.md @@ -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//`. 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. diff --git a/.codex/skills/th1-ai-director/scripts/analyze_ai_batch_quality.py b/.codex/skills/th1-ai-director/scripts/analyze_ai_batch_quality.py index 876eb2025..c8aec3191 100644 --- a/.codex/skills/th1-ai-director/scripts/analyze_ai_batch_quality.py +++ b/.codex/skills/th1-ai-director/scripts/analyze_ai_batch_quality.py @@ -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): diff --git a/MD/GameMDFramework/18-AI导演系统策划文档.md b/MD/GameMDFramework/18-AI导演系统策划文档.md index 2132530a6..8a4946a73 100644 --- a/MD/GameMDFramework/18-AI导演系统策划文档.md +++ b/MD/GameMDFramework/18-AI导演系统策划文档.md @@ -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 选择: - 解散。 diff --git a/MD/GameMDFramework/19-AI导演系统逻辑语言.md b/MD/GameMDFramework/19-AI导演系统逻辑语言.md index 07f2d8c5a..01e4a442a 100644 --- a/MD/GameMDFramework/19-AI导演系统逻辑语言.md +++ b/MD/GameMDFramework/19-AI导演系统逻辑语言.md @@ -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 diff --git a/Tools/RunAIDirectorBatch.ps1 b/Tools/RunAIDirectorBatch.ps1 index 960be11eb..89c4efb4a 100644 --- a/Tools/RunAIDirectorBatch.ps1 +++ b/Tools/RunAIDirectorBatch.ps1 @@ -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 ) diff --git a/Unity/Assets/Scripts/TH1_Logic/AI/Director/AIDirectorActionIndex.cs b/Unity/Assets/Scripts/TH1_Logic/AI/Director/AIDirectorActionIndex.cs index 681010410..f1889c604 100644 --- a/Unity/Assets/Scripts/TH1_Logic/AI/Director/AIDirectorActionIndex.cs +++ b/Unity/Assets/Scripts/TH1_Logic/AI/Director/AIDirectorActionIndex.cs @@ -147,6 +147,13 @@ namespace Logic.AI.Director return best; } + public IEnumerable 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; diff --git a/Unity/Assets/Scripts/TH1_Logic/AI/Director/AIDirectorLogic.cs b/Unity/Assets/Scripts/TH1_Logic/AI/Director/AIDirectorLogic.cs index ee64bfb40..1a4fe724e 100644 --- a/Unity/Assets/Scripts/TH1_Logic/AI/Director/AIDirectorLogic.cs +++ b/Unity/Assets/Scripts/TH1_Logic/AI/Director/AIDirectorLogic.cs @@ -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; diff --git a/Unity/Assets/Scripts/TH1_Logic/AI/Kernel/AIKernel.cs b/Unity/Assets/Scripts/TH1_Logic/AI/Kernel/AIKernel.cs index a5895fcb5..cc13b1c67 100644 --- a/Unity/Assets/Scripts/TH1_Logic/AI/Kernel/AIKernel.cs +++ b/Unity/Assets/Scripts/TH1_Logic/AI/Kernel/AIKernel.cs @@ -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 } diff --git a/Unity/Assets/Scripts/TH1_Logic/Core/GameLogic.cs b/Unity/Assets/Scripts/TH1_Logic/Core/GameLogic.cs index c3d79b2e2..6452e116e 100644 --- a/Unity/Assets/Scripts/TH1_Logic/Core/GameLogic.cs +++ b/Unity/Assets/Scripts/TH1_Logic/Core/GameLogic.cs @@ -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; diff --git a/Unity/Assets/Scripts/TH1_Logic/Editor/AIDirectorBatchRunner.cs b/Unity/Assets/Scripts/TH1_Logic/Editor/AIDirectorBatchRunner.cs index cbd45e143..34cc2be48 100644 --- a/Unity/Assets/Scripts/TH1_Logic/Editor/AIDirectorBatchRunner.cs +++ b/Unity/Assets/Scripts/TH1_Logic/Editor/AIDirectorBatchRunner.cs @@ -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]