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 bacdc3fad..2cf27bba4 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 @@ -130,6 +130,19 @@ def find_matching_logs(root: Path, batch_path: Path, batch, explicit_logs): result.append(path) return result + direct_logs = [] + for game in batch.get("results", []) or []: + value = game.get("diagnosticsLogPath") or "" + if not value: + continue + path = Path(value) + if not path.is_absolute(): + path = root / path + if path.exists(): + direct_logs.append(path) + if direct_logs: + return direct_logs + start, end = batch_local_window(batch_path, batch) log_dir = root / "Unity" / "Logs" / "AI_Director_Diagnostics" logs = sorted(log_dir.glob("ai_director_*.jsonl"), key=lambda p: p.stat().st_mtime) @@ -170,6 +183,20 @@ def analyze_logs(log_paths): "decide_ms": [], "top_no_effect": Counter(), "top_repeated": Counter(), + "hero_reasons": Counter(), + "hero_actions": Counter(), + "hero_executed_actions": Counter(), + "hero_lane_counts": Counter(), + "fallback_reasons": Counter(), + "fallback_actions": Counter(), + "fallback_executed_actions": Counter(), + "fallback_no_effect_actions": Counter(), + "hero_delta": 0, + "selected_hero_delta": 0, + "hero_task_delta": 0, + "ready_hero_task_delta": 0, + "forced_hero_task_delta": 0, + "hero_task_progress_delta": 0, } for path in log_paths: @@ -195,16 +222,33 @@ def analyze_logs(log_paths): stable_rows_by_turn = defaultdict(list) turn_rows = defaultdict(list) + decision_lane_by_turn_action = {} for row in rows: event_type = row.get("eventType", "") if event_type == "Decision": decision = row.get("decision") or {} lane = decision.get("lane") or "" + reason = decision.get("reason") or "" + action = decision.get("action") or {} if lane and lane != "None": aggregate["lane_counts"][lane] += 1 decide_ms = decision.get("decideMs") if isinstance(decide_ms, (int, float)): aggregate["decide_ms"].append(float(decide_ms)) + if decision.get("hasAction"): + turn_action_key = ( + row.get("playerId", 0), + row.get("playerTurn", 0), + stable_key(action), + ) + decision_lane_by_turn_action[turn_action_key] = lane + if lane in ("HeroManagement", "HeroPlaybook"): + aggregate["hero_lane_counts"][lane] += 1 + aggregate["hero_reasons"][reason] += 1 + aggregate["hero_actions"][action_key(action)] += 1 + if decision.get("isFallback"): + aggregate["fallback_reasons"][reason] += 1 + aggregate["fallback_actions"][action_key(action)] += 1 continue if event_type != "Execution": @@ -217,7 +261,22 @@ def analyze_logs(log_paths): delta = execution.get("delta") or {} player_id = row.get("playerId", 0) 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, "") aggregate["player_actions"][player_id] += 1 + if decision_lane in ("HeroManagement", "HeroPlaybook"): + aggregate["hero_executed_actions"][key] += 1 + if decision_lane == "Fallback": + aggregate["fallback_executed_actions"][key] += 1 + if has_no_effect_delta(delta): + aggregate["fallback_no_effect_actions"][key] += 1 + + aggregate["hero_delta"] += int(delta.get("heroDelta") or 0) + aggregate["selected_hero_delta"] += int(delta.get("selectedHeroDelta") or 0) + aggregate["hero_task_delta"] += int(delta.get("heroTaskDelta") or 0) + aggregate["ready_hero_task_delta"] += int(delta.get("readyHeroTaskDelta") or 0) + aggregate["forced_hero_task_delta"] += int(delta.get("forcedHeroTaskDelta") or 0) + aggregate["hero_task_progress_delta"] += int(delta.get("heroTaskProgressDelta") or 0) if delta.get("targetUnitDied"): aggregate["kills"] += 1 @@ -372,6 +431,24 @@ def compact_log_metrics(log_metrics, top): }, "top_actions": log_metrics["action_counts"].most_common(top), "top_lanes": log_metrics["lane_counts"].most_common(top), + "hero": { + "lane_counts": log_metrics["hero_lane_counts"].most_common(top), + "top_reasons": log_metrics["hero_reasons"].most_common(top), + "top_actions": log_metrics["hero_actions"].most_common(top), + "top_executed_actions": log_metrics["hero_executed_actions"].most_common(top), + "hero_delta": log_metrics["hero_delta"], + "selected_hero_delta": log_metrics["selected_hero_delta"], + "hero_task_delta": log_metrics["hero_task_delta"], + "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"], + }, + "fallback": { + "top_reasons": log_metrics["fallback_reasons"].most_common(top), + "top_actions": log_metrics["fallback_actions"].most_common(top), + "top_executed_actions": log_metrics["fallback_executed_actions"].most_common(top), + "top_no_effect_actions": log_metrics["fallback_no_effect_actions"].most_common(top), + }, } @@ -447,6 +524,43 @@ def print_report(batch_path, batch, metrics, log_metrics, warnings, top): for key, count in log_metrics["lane_counts"].most_common(top): print(f" {count:>5} {key}") + print("Hero:") + print( + " deltas: " + f"selected={log_metrics['selected_hero_delta']} " + f"spawned={log_metrics['hero_delta']} " + f"task={log_metrics['hero_task_delta']} " + f"readyTask={log_metrics['ready_hero_task_delta']} " + f"forcedTask={log_metrics['forced_hero_task_delta']} " + f"taskProgress={log_metrics['hero_task_progress_delta']}" + ) + if log_metrics["hero_reasons"]: + print(" top reasons:") + for key, count in log_metrics["hero_reasons"].most_common(top): + print(f" {count:>5} {key}") + else: + print(" top reasons: ") + if log_metrics["hero_executed_actions"]: + print(" executed actions:") + for key, count in log_metrics["hero_executed_actions"].most_common(top): + print(f" {count:>5} {key}") + else: + print(" executed actions: ") + + print("Fallback:") + if log_metrics["fallback_actions"]: + print(" selected actions:") + for key, count in log_metrics["fallback_actions"].most_common(top): + print(f" {count:>5} {key}") + else: + print(" selected actions: ") + if log_metrics["fallback_no_effect_actions"]: + print(" no-effect actions:") + for key, count in log_metrics["fallback_no_effect_actions"].most_common(top): + print(f" {count:>5} {key}") + else: + print(" no-effect actions: ") + print("Warnings:") if warnings: for warning in warnings: diff --git a/MD/GameMDFramework/18-AI导演系统策划文档.md b/MD/GameMDFramework/18-AI导演系统策划文档.md index d09a10a48..024fbfb7c 100644 --- a/MD/GameMDFramework/18-AI导演系统策划文档.md +++ b/MD/GameMDFramework/18-AI导演系统策划文档.md @@ -340,7 +340,21 @@ Development 的行为倾向: ## 9. 英雄策略 -英雄不走普通单位公式。英雄由 HeroPlaybook 驱动。 +英雄不走普通单位公式。英雄分两层处理: + +- HeroManagement 负责英雄体系节奏:选英雄、让已选择英雄尽快出场、再推进任务。 +- HeroPlaybook 负责场上英雄怎么发挥机制:治疗、保护、控场、地面攻击、主动技能、收割、站位。 + +HeroManagement 的节奏: + +```text +没有选择英雄 +→ 先选择最适合当前阵营和局势的英雄 +→ 已选择但未上场时,优先在合法城市训练/复活英雄 +→ 英雄已经能参与战局后,再推进可强制完成的英雄任务 +``` + +这个顺序保证 AI 不会只完成任务却长期没有英雄在棋盘上,也不会把英雄当作普通兵随缘训练。 HeroPlaybook 的判断顺序: @@ -472,11 +486,13 @@ Director 不直接推演行为结果,而是从合法 Action 池中选择。 |---|---| | 单位战斗 | UnitAttack、UnitAttackAlly、UnitAttackGround | | 单位移动 | UnitMove | -| 单位行为 | Capture、Examine、Gather、Recover、Upgrade、HeroUpgrade、CultureUnitUpgrade、英雄主动 | +| 单位行为 | Capture、Examine、Gather、Recover、Upgrade、HeroUpgrade、CultureUnitUpgrade、ShipUpgrade、AbsorbMarker、英雄主动 | | 城市行为 | TrainUnit、CityLevelUpAction、CityAction、StartWonder、BuildWonder | | 地块行为 | Gain、Build、GridMisc | | 玩家行为 | LearnTech、PlayerAction;BuyCultureCard 暂不进入 AI 候选,等文化卡策略单独回归 | -| 英雄管理 | SelectHero、FinishHeroTask、出场、复活 | +| 英雄管理 | SelectHero、TrainUnit:Giant 出场/复活、FinishHeroTask | + +UnitAttackAlly 只由 HeroPlaybook、支援战术或明确的友军互动规则使用,不进入 Fallback。友军目标动作如果落到兜底,通常说明它缺少英雄或支援规则。 危险动作默认不进入普通 AI 选择: @@ -559,6 +575,17 @@ AI 可以重新实现,但必须遵守 TH1 的游戏架构: | 外交行为 | 高好感不乱开战,必要时建立使馆或结盟 | | 性能 | 单个 AI 动作决策无明显卡顿 | +当前批跑调优采用以下基线指标,不用单局主观感觉判断 AI 是否变聪明: + +| 指标组 | 观察项 | 目标 | +|---|---|---| +| 正确性 | failedGames、noEffect、repeated、maxActions/playerTurn | 必须为 0 或远低于强制停止线 | +| 扩张 | aliveAvgCities、alive>=2、alive>=3、maxCities | 二城率和三城率稳定提升,不能靠单个高滚玩家掩盖整体弱扩张 | +| 战斗 | UnitAttack 数、kills、actingUnitDeaths、PriorityTactic/Tactic 占比 | 有战果且不过度白送,城市威胁能被处理 | +| 英雄 | selected、spawned、HeroManagement、HeroPlaybook、英雄专属 reason/action | 已选择英雄应尽快上场,英雄动作应体现个人机制 | +| Fallback | Fallback 总数、Fallback actionType、Fallback noEffect | 越接近 0 越好;非 0 时优先把有意义动作归入正式车道 | +| 性能 | actions/sec、avgGame、decision avg/p95/max | 先保证聪明,再把明显尖峰纳入下一轮优化 | + 问题定位: | 现象 | 优先检查 | @@ -568,7 +595,7 @@ AI 可以重新实现,但必须遵守 TH1 的游戏架构: | 英雄不放技能 | HeroState、HeroPlaybook、ActionPool | | 单位乱走 | Front、GridThreat、MoveTarget | | 过度防守 | CityThreat 是否把全玩家领土误算到每座城市 | -| Fallback 偏高 | Recover、UnitAttackAlly、特殊 UnitAction 是否应并入 UnitOpportunity 或 HeroPlaybook | +| Fallback 偏高 | Recover、特殊 UnitAction 是否应并入 UnitOpportunity 或 HeroPlaybook;UnitAttackAlly 不应出现在 Fallback | | 城市不发展 | CityPlan、Growth、ActionPool | | 科技乱学 | StrategicPosture、TechScore | | 回合慢 | ActionPool、移动候选、局部搜索半径 | diff --git a/MD/GameMDFramework/19-AI导演系统逻辑语言.md b/MD/GameMDFramework/19-AI导演系统逻辑语言.md index 1e7327b09..cce2cab06 100644 --- a/MD/GameMDFramework/19-AI导演系统逻辑语言.md +++ b/MD/GameMDFramework/19-AI导演系统逻辑语言.md @@ -1271,11 +1271,11 @@ SkillBase 如果 candidate.IsValid: 返回 candidate - candidate = TryForceFinishHeroTask(ctx) + candidate = TrySpawnSelectedHero(ctx) 如果 candidate.IsValid: 返回 candidate - candidate = TryDeployOrReviveHero(ctx) + candidate = TryForceFinishHeroTask(ctx) 如果 candidate.IsValid: 返回 candidate @@ -1290,7 +1290,58 @@ SkillBase 返回 Candidate(action, HeroManagement, 890, "选择英雄") ``` -### 7.3 FinishHeroTask +### 7.3 SpawnSelectedHero + +```text +函数 TrySpawnSelectedHero(ctx): + 如果 ctx.Player 没有已选择英雄: + 返回 None + + best = None + + 对每个 cityPlan in ctx.Cache.CityPlans: + 对每个 action in ctx.Actions.ByCity[cityPlan.City.Id]: + 如果 !IsSelectedHeroSpawnAction(ctx.Player, action): + 继续 + + score = 880 + score += max(cityPlan.Priority, 0) * 0.08 + + 如果 cityPlan.Kind == EmergencyDefense: + score += 40 + 如果 cityPlan.Kind == Mobilize: + score += 30 + 如果 cityPlan.Kind == Frontline: + score += 25 + + candidate = Candidate(action, HeroManagement, score, "英雄出场") + candidate.City = cityPlan.City + candidate.TargetGrid = cityPlan.CityGrid + best = MaxCandidate(best, candidate) + + 如果 best.IsValid: + 返回 best + + 对每个 action in ctx.Actions.CityActions: + 如果 IsSelectedHeroSpawnAction(ctx.Player, action): + candidate = Candidate(action, HeroManagement, 880, "英雄出场") + best = MaxCandidate(best, candidate) + + 返回 best +``` + +```text +函数 IsSelectedHeroSpawnAction(player, action): + id = action.ActionId + 返回 id.ActionType == TrainUnit + 且 id.UnitType == Giant + 且 id.GiantType != None + 且 player.PlayerHeroData.HasHero(id.GiantType) +``` + +英雄出场仍然走 TrainUnitAction 的 CheckCan 和 Execute。AI 只负责更早选择这个合法动作,不直接改变英雄状态。 + +### 7.4 FinishHeroTask ```text 函数 TryForceFinishHeroTask(ctx): @@ -1335,25 +1386,6 @@ SkillBase 返回 false ``` -### 7.4 DeployOrReviveHero - -```text -函数 TryDeployOrReviveHero(ctx): - best = None - - 对每个 cityPlan in ctx.Cache.CityPlans: - 如果 cityPlan.Kind == EmergencyDefense 且 城市无法安全出英雄: - 继续 - - 对每个 action in ctx.Actions.ByCity[cityPlan.City.Id]: - 如果 action 是英雄出场或复活动作: - score = 780 + ScoreHeroDeployValue(ctx, action, cityPlan) - candidate = Candidate(action, HeroManagement, score, "英雄出场或复活") - best = MaxCandidate(best, candidate) - - 返回 best -``` - --- ## 8. Expansion Lane @@ -1870,6 +1902,10 @@ Byakuren / Miko / Zanmu: 返回 ScoreCultureUpgrade(ctx, unit, action) 如果 type == Recover: 返回 ScoreRecover(ctx, unit, action) + 如果 type == ShipUpgrade: + 返回 ScoreShipUpgrade(ctx, unit, action) + 如果 type == AbsorbMarker: + 返回 ScoreAbsorbMarker(ctx, unit, action) 返回 0 ``` @@ -1915,9 +1951,12 @@ Byakuren / Miko / Zanmu: ```text 函数 ScoreRecover(ctx, unit, action): hp = HealthRatio(unit) - 如果 hp > Config.LowHpRatio: + hasNegativeState = unit 有 KomeijiFear 或 SAKUYATIRED + 如果 hp > Config.LowHpRatio 且 !hasNegativeState: 返回 0 - score = 560 + (1 - hp) * 240 + score = 520 + (1 - hp) * 220 + 如果 hasNegativeState: + score += 160 如果 unit 是英雄: score += 120 如果 unit 当前能击杀高价值目标: @@ -1925,6 +1964,36 @@ Byakuren / Miko / Zanmu: 返回 score ``` +```text +函数 ScoreShipUpgrade(ctx, unit, action): + 如果 action 不是合法船升级: + 返回 0 + cost = GetShipUpgradeCost(unit, action.ActionId.UnitType) + 如果 ctx.Player.Coin < cost: + 返回 0 + + score = 560 + UnitPower(unit) * 0.2 + 如果 cost == 0: + score += 120 + 否则: + score -= cost * 18 + 如果 ctx.Cache.StrategicPosture 是 Expansion 或 Attack: + score += 90 + 返回 score +``` + +```text +函数 ScoreAbsorbMarker(ctx, unit, action): + missing = 1 - HealthRatio(unit) + 如果 missing <= 0.05: + 返回 0 + + score = 500 + missing * 260 + 如果 unit 是英雄: + score += 100 + 返回 score +``` + ```text 函数 ScoreUpgrade(ctx, unit, action): 如果 unit 当前承担 Emergency 或可击杀高价值目标: @@ -2230,16 +2299,17 @@ Byakuren / Miko / Zanmu: 函数 TryFallback(ctx): order = [ ctx.Actions.Attacks, - ctx.Actions.UnitActions, - ctx.Actions.Moves, + ctx.Actions.AttackGrounds, ctx.Actions.CityActions, ctx.Actions.GridActions, ctx.Actions.PlayerActions, - ctx.Actions.All + ctx.Actions.UnitActions ] 对每个 list in order: 对每个 action in list: + 如果 action.ActionType == UnitAttackAlly: + 继续 candidate = Candidate(action, Fallback, 1, "兜底合法动作") 如果 candidate.IsValid: candidate.IsFallback = true diff --git a/MD/GameMDFramework/20-AI导演系统测试与诊断流程.md b/MD/GameMDFramework/20-AI导演系统测试与诊断流程.md index 0650ccace..303b9e893 100644 --- a/MD/GameMDFramework/20-AI导演系统测试与诊断流程.md +++ b/MD/GameMDFramework/20-AI导演系统测试与诊断流程.md @@ -26,6 +26,17 @@ AI 测试必须回答四个问题: | 评分错误 | 车道对了但目标很蠢 | 修同类动作排序 | | 执行失败 | `executed=false` | 查 `CompleteExecute`、参数刷新、网络状态 | +本轮自循环统一使用一组可比较指标,避免只凭单局观感判断聪明程度: + +| 指标组 | 字段 | 判断方式 | +|---|---|---| +| 正确性 | `failedGames`、`noEffect`、`repeated`、`maxActions/playerTurn` | 先保证没有错误、死循环和无收益动作 | +| 扩张 | `aliveAvgCities`、`alive>=2`、`alive>=3`、`maxCities` | 看整体城市数和二城/三城率是否提升 | +| 战斗 | `UnitAttack`、`kills`、`actingUnitDeaths`、`PriorityTactic/Tactic` | 看是否有战果,是否过度送兵 | +| 英雄 | `selected`、`spawned`、`HeroManagement`、`HeroPlaybook`、英雄 reason/action | 看英雄是否上场、是否用个人机制 | +| Fallback | Fallback 总数、actionType、no-effect | 用来发现规则缺口 | +| 性能 | `actions/sec`、`avgGame`、`decision avg/p95/max` | 只处理明显尖峰,不牺牲聪明度做极端优化 | + --- ## 2. 诊断开关 @@ -613,16 +624,24 @@ decideMs 尖峰且 actionPool.all 高 自循环的固定流程: ```text -1. 固定 10 个 seed、地图、阵营组合。 -2. 跑一批 JSONL。 -3. 聚合上面的指标。 +1. 固定 Seed、地图尺寸、玩家数、回合数、难度、动作预算。 +2. 用 Editor batch runner 跑一批 JSONL 和 batch_summary.json。 +3. 先用 analyze_ai_batch_quality.py 聚合指标,不直接打开大日志。 4. 找异常最高的 3 类问题。 5. 对每类问题抽 3-5 条 eventSequence 做人工复核。 6. 判断改 18、19、代码还是 Action 生成。 -7. 修改后用同一批 seed 回归。 +7. 修改后用同一批 Seed 和同一批参数回归。 8. 对比指标是否改善。 ``` +固定 Seed 的含义: + +```text +Tools/RunAIDirectorBatch.ps1 -Seed 424242 ... +``` + +BatchRunner 会在 Editor/batch 环境把 `MapData.Net.RandomSeed`、Unity 随机种子和地图高度噪声种子固定住。多局批跑时使用 `Seed + gameIndex`。`batch_summary.json` 中的 `diagnosticsLogPath` 是分析器匹配 JSONL 的权威路径,不用再按时间戳猜日志。 + --- ## 10. 问题回写流程 @@ -682,6 +701,13 @@ delta证据: 开始调整 18/19 的策略思路 ``` +推荐命令: + +```powershell +Tools/RunAIDirectorBatch.ps1 -Games 1 -Players 17 -Width 30 -Height 30 -Turns 12 -Seed 424242 -TimeoutSeconds 420 -MaxActions 9000 -MaxActionsPerPlayerTurn 120 -Difficulty LUNATIC -KeepGoing +python .codex/skills/th1-ai-director/scripts/analyze_ai_batch_quality.py --batch Unity/Logs/AI_Batch//batch_summary.json --top 12 +``` + 第四轮: ```text diff --git a/Tools/RunAIDirectorBatch.ps1 b/Tools/RunAIDirectorBatch.ps1 index 7488c1f55..960be11eb 100644 --- a/Tools/RunAIDirectorBatch.ps1 +++ b/Tools/RunAIDirectorBatch.ps1 @@ -9,6 +9,7 @@ param( [int]$TimeoutSeconds = 1800, [int]$MaxActions = 20000, [int]$MaxActionsPerPlayerTurn = 260, + [int]$Seed = 0, [string]$OutDir, [string]$Difficulty = "LUNATIC", [switch]$KeepGoing, @@ -113,6 +114,7 @@ $args = @( "-aiBatchTimeoutSeconds", $TimeoutSeconds, "-aiBatchMaxActions", $MaxActions, "-aiBatchMaxActionsPerPlayerTurn", $MaxActionsPerPlayerTurn, + "-aiBatchSeed", $Seed, "-aiBatchDifficulty", $Difficulty, "-aiBatchFailFast", $failFast, "-aiBatchCompactDiagnostics", $compactDiagnostics, diff --git a/Unity/Assets/Scripts/TH1_Logic/AI/Director/AIDirectorActionIndex.cs b/Unity/Assets/Scripts/TH1_Logic/AI/Director/AIDirectorActionIndex.cs index 4a9d17e5a..20231ae53 100644 --- a/Unity/Assets/Scripts/TH1_Logic/AI/Director/AIDirectorActionIndex.cs +++ b/Unity/Assets/Scripts/TH1_Logic/AI/Director/AIDirectorActionIndex.cs @@ -257,7 +257,6 @@ namespace Logic.AI.Director public AIActionBase FindBestFallback() { return FindFirstFallbackAction(AttackActions) - ?? FindFirstFallbackAction(AttackAllyActions) ?? FindFirstFallbackAction(AttackGroundActions) ?? FindFirstFallbackAction(CityActions) ?? FindFirstFallbackAction(GridActions) @@ -457,6 +456,11 @@ namespace Logic.AI.Director var id = action?.ActionLogic?.ActionId; if (id == null) return false; + if (id.ActionType == CommonActionType.UnitAttackAlly) + { + return false; + } + if (id.ActionType == CommonActionType.UnitAction && id.UnitActionType is UnitActionType.Examine or UnitActionType.KANAKOSIT diff --git a/Unity/Assets/Scripts/TH1_Logic/AI/Director/AIDirectorLogic.cs b/Unity/Assets/Scripts/TH1_Logic/AI/Director/AIDirectorLogic.cs index 40b3e3e84..e61ccefd2 100644 --- a/Unity/Assets/Scripts/TH1_Logic/AI/Director/AIDirectorLogic.cs +++ b/Unity/Assets/Scripts/TH1_Logic/AI/Director/AIDirectorLogic.cs @@ -327,6 +327,14 @@ namespace Logic.AI.Director return true; } + var heroSpawn = FindBestHeroSpawn(ctx, decision); + if (heroSpawn.IsValid) + { + candidate = heroSpawn; + decision.AddTrace($"HeroManagement: spawn hero {candidate.AIAction?.ActionLogic?.ActionId?.GiantType}.", ctx.Config.MaxCandidateTraceCount); + return true; + } + var finishTask = ctx.ActionIndex.FindBestHeroTaskFinish(ctx.Player); candidate = ctx.ActionIndex.Candidate(finishTask, AIDirectorLane.HeroManagement, "HeroManagement.FinishLowestTask", 870f); AddTerms(candidate, ("base", 870f)); @@ -340,6 +348,59 @@ namespace Logic.AI.Director return false; } + private AIDirectorActionCandidate FindBestHeroSpawn(AIDirectorContext ctx, AIDirectorDecision decision) + { + AIDirectorActionCandidate best = AIDirectorActionCandidate.None; + foreach (var plan in ctx.Cache.CityPlans) + { + if (plan?.City == null) continue; + foreach (var action in ctx.ActionIndex.GetCityActions(plan.City)) + { + if (!IsSelectedHeroSpawnAction(ctx.Player, action)) continue; + + var cityPlanBonus = (plan.Priority > 0f ? plan.Priority : 0f) * 0.08f; + var roleBonus = plan.Kind switch + { + AIDirectorCityPlanKind.EmergencyDefense => 40f, + AIDirectorCityPlanKind.Mobilize => 30f, + AIDirectorCityPlanKind.Frontline => 25f, + _ => 0f + }; + var priority = 880f + cityPlanBonus + roleBonus; + var current = ctx.ActionIndex.Candidate(action, AIDirectorLane.HeroManagement, "HeroManagement.SpawnHero", priority); + AddTerms(current, ("base", 880f), ("cityPlan", cityPlanBonus), ("role", roleBonus)); + current.City = current.City ?? plan.City; + current.TargetGrid = current.TargetGrid ?? plan.CityGrid; + RecordCandidate(ctx, decision, "SpawnHero", current, current.IsValid ? null : "CheckCanFailed"); + best = MaxCandidate(best, current); + } + } + + if (best.IsValid) return best; + + foreach (var action in ctx.ActionIndex.CityActions) + { + if (!IsSelectedHeroSpawnAction(ctx.Player, action)) continue; + var current = ctx.ActionIndex.Candidate(action, AIDirectorLane.HeroManagement, "HeroManagement.SpawnHero", 880f); + AddTerms(current, ("base", 880f)); + RecordCandidate(ctx, decision, "SpawnHero", current, current.IsValid ? null : "CheckCanFailed"); + best = MaxCandidate(best, current); + } + + return best; + } + + private static bool IsSelectedHeroSpawnAction(PlayerData player, AIActionBase action) + { + var id = action?.ActionLogic?.ActionId; + if (id == null) return false; + return id.ActionType == CommonActionType.TrainUnit + && id.UnitType == UnitType.Giant + && id.GiantType != GiantType.None + && player?.PlayerHeroData != null + && player.PlayerHeroData.HasHero(id.GiantType); + } + private bool TryHeroPlaybookLane(AIDirectorContext ctx, AIDirectorDecision decision, out AIDirectorActionCandidate candidate) { candidate = AIDirectorActionCandidate.None; diff --git a/Unity/Assets/Scripts/TH1_Logic/AI/Director/AIDirectorTypes.cs b/Unity/Assets/Scripts/TH1_Logic/AI/Director/AIDirectorTypes.cs index aece65f20..379bbd4ff 100644 --- a/Unity/Assets/Scripts/TH1_Logic/AI/Director/AIDirectorTypes.cs +++ b/Unity/Assets/Scripts/TH1_Logic/AI/Director/AIDirectorTypes.cs @@ -65,7 +65,9 @@ namespace Logic.AI.Director HeroUpgrade, Upgrade, CultureUnitUpgrade, - Recover + Recover, + ShipUpgrade, + AbsorbMarker } public enum AIDirectorHeroRole diff --git a/Unity/Assets/Scripts/TH1_Logic/AI/Director/AIDirectorWorldCacheBuilder.cs b/Unity/Assets/Scripts/TH1_Logic/AI/Director/AIDirectorWorldCacheBuilder.cs index 21abb6e2f..514e02d17 100644 --- a/Unity/Assets/Scripts/TH1_Logic/AI/Director/AIDirectorWorldCacheBuilder.cs +++ b/Unity/Assets/Scripts/TH1_Logic/AI/Director/AIDirectorWorldCacheBuilder.cs @@ -39,7 +39,7 @@ namespace Logic.AI.Director foreach (var action in actions.UnitActions) { if (action?.Param?.UnitData == null || action.ActionLogic?.ActionId == null) continue; - var type = GetOpportunityType(action.ActionLogic.ActionId.UnitActionType); + var type = GetOpportunityType(action); if (type == AIDirectorUnitOpportunityType.None) continue; var value = ScoreUnitOpportunity(ctx, action, type); if (value <= 0f) continue; @@ -511,9 +511,13 @@ namespace Logic.AI.Director }; } - private AIDirectorUnitOpportunityType GetOpportunityType(UnitActionType type) + private AIDirectorUnitOpportunityType GetOpportunityType(AIActionBase action) { - return type switch + var id = action?.ActionLogic?.ActionId; + if (id == null) return AIDirectorUnitOpportunityType.None; + if (UnitActionUpgradeHelper.IsShipUpgradeTarget(id.UnitType)) return AIDirectorUnitOpportunityType.ShipUpgrade; + + return id.UnitActionType switch { UnitActionType.Capture => AIDirectorUnitOpportunityType.Capture, UnitActionType.Examine => AIDirectorUnitOpportunityType.Examine, @@ -522,6 +526,7 @@ namespace Logic.AI.Director UnitActionType.Upgrade => AIDirectorUnitOpportunityType.Upgrade, UnitActionType.CultureUnitUpgrade => AIDirectorUnitOpportunityType.CultureUnitUpgrade, UnitActionType.Recover => AIDirectorUnitOpportunityType.Recover, + UnitActionType.AbsorbRedMist or UnitActionType.HakureiAbsorbRune => AIDirectorUnitOpportunityType.AbsorbMarker, _ => AIDirectorUnitOpportunityType.None }; } @@ -539,6 +544,8 @@ namespace Logic.AI.Director AIDirectorUnitOpportunityType.Upgrade => 610f, AIDirectorUnitOpportunityType.CultureUnitUpgrade => 600f, AIDirectorUnitOpportunityType.Recover => ScoreRecover(ctx, unit), + AIDirectorUnitOpportunityType.ShipUpgrade => ScoreShipUpgrade(ctx, action, unit), + AIDirectorUnitOpportunityType.AbsorbMarker => ScoreAbsorbMarker(ctx, unit), _ => 0f }; @@ -562,12 +569,37 @@ namespace Logic.AI.Director { if (unit == null) return 0f; var hp = AIDirectorMath.HealthRatio(unit); - if (hp > ctx.Config.LowHealthRatio) return 0f; - var score = 560f + (1f - hp) * 240f; + var hasNegativeState = unit.GetSkill(SkillType.KomeijiFear, out _) || unit.GetSkill(SkillType.SAKUYATIRED, out _); + if (hp > ctx.Config.LowHealthRatio && !hasNegativeState) return 0f; + var score = 520f + (1f - hp) * 220f; + if (hasNegativeState) score += 160f; if (unit.TreatedAsHero(ctx.Map, unit)) score += 120f; return score; } + private float ScoreShipUpgrade(AIDirectorContext ctx, AIActionBase action, UnitData unit) + { + if (unit == null || action?.ActionLogic?.ActionId == null) return 0f; + if (!UnitActionUpgradeHelper.TryGetShipUpgradeCost(unit, action.ActionLogic.ActionId.UnitType, out var cost)) return 0f; + if (ctx.Player.PlayerCoin < cost) return 0f; + + var score = 560f + AIDirectorMath.UnitPower(unit) * 0.2f; + if (cost == 0) score += 120f; + else score -= cost * 18f; + if (ctx.Cache.StrategicPosture is AIDirectorStrategicPosture.Expansion or AIDirectorStrategicPosture.Attack) score += 90f; + return score; + } + + private float ScoreAbsorbMarker(AIDirectorContext ctx, UnitData unit) + { + if (unit == null) return 0f; + var missing = 1f - AIDirectorMath.HealthRatio(unit); + if (missing <= 0.05f) return 0f; + var score = 500f + missing * 260f; + if (unit.TreatedAsHero(ctx.Map, unit)) score += 100f; + return score; + } + private bool ExistsHighValueDevelopmentTargetNearCity(AIDirectorContext ctx, AIDirectorWorldCache cache) { foreach (var target in cache.DevelopmentTargets) diff --git a/Unity/Assets/Scripts/TH1_Logic/AI/Kernel/AIKernel.cs b/Unity/Assets/Scripts/TH1_Logic/AI/Kernel/AIKernel.cs index d22e99628..a5895fcb5 100644 --- a/Unity/Assets/Scripts/TH1_Logic/AI/Kernel/AIKernel.cs +++ b/Unity/Assets/Scripts/TH1_Logic/AI/Kernel/AIKernel.cs @@ -21,10 +21,12 @@ namespace Logic.AI public static bool ForceAllPlayersAi; public static bool SkipPresentationWait; public static bool CompactDiagnostics; + public static int RandomSeedOverride; #else public const bool ForceAllPlayersAi = false; public const bool SkipPresentationWait = false; public const bool CompactDiagnostics = false; + public const int RandomSeedOverride = 0; #endif } diff --git a/Unity/Assets/Scripts/TH1_Logic/Core/Main.cs b/Unity/Assets/Scripts/TH1_Logic/Core/Main.cs index 465887e71..b734c868b 100644 --- a/Unity/Assets/Scripts/TH1_Logic/Core/Main.cs +++ b/Unity/Assets/Scripts/TH1_Logic/Core/Main.cs @@ -242,6 +242,13 @@ namespace TH1_Logic.Core { MapData = new MapData(MapConfig, NetMode.Single); MapData.Net.Mode = NetMode.Single; +#if UNITY_EDITOR + if (AIDirectorBatchRuntime.RandomSeedOverride != 0) + { + MapData.Net.RandomSeed = AIDirectorBatchRuntime.RandomSeedOverride; + UnityEngine.Random.InitState(AIDirectorBatchRuntime.RandomSeedOverride); + } +#endif //step #1 初始化Audio InitGameAudio(); diff --git a/Unity/Assets/Scripts/TH1_Logic/Editor/AIDirectorBatchRunner.cs b/Unity/Assets/Scripts/TH1_Logic/Editor/AIDirectorBatchRunner.cs index 5af3a39fa..2595d8497 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.RandomSeedOverride = 0; #endif CompleteResult(result, gameDirectory); results.Add(result); @@ -166,6 +167,7 @@ namespace TH1_Logic.Editor AIDirectorBatchRuntime.ForceAllPlayersAi = true; AIDirectorBatchRuntime.SkipPresentationWait = true; AIDirectorBatchRuntime.CompactDiagnostics = options.CompactDiagnostics; + AIDirectorBatchRuntime.RandomSeedOverride = options.Seed == 0 ? 0 : options.Seed + gameIndex; #if TH1_AI_DIRECTOR_DIAGNOSTICS || UNITY_EDITOR AIDirectorDiagnostics.BeginNewSession(); result.diagnosticsLogPath = AIDirectorDiagnostics.CurrentLogPath; @@ -176,6 +178,7 @@ namespace TH1_Logic.Editor result.playerCount = (int)main.MapConfig.PlayerCount; result.width = (int)main.MapConfig.Width; result.height = (int)main.MapConfig.Height; + result.randomSeed = AIDirectorBatchRuntime.RandomSeedOverride; result.aiKernel = AIKernelRegistry.CurrentKernelType.ToString(); Debug.Log($"[AI.Batch] Start game {gameIndex + 1}/{options.Games}: players={result.playerCount}, size={result.width}x{result.height}"); @@ -226,6 +229,7 @@ namespace TH1_Logic.Editor AIDirectorBatchRuntime.ForceAllPlayersAi = false; AIDirectorBatchRuntime.SkipPresentationWait = false; AIDirectorBatchRuntime.CompactDiagnostics = false; + AIDirectorBatchRuntime.RandomSeedOverride = 0; #endif try { @@ -397,10 +401,7 @@ namespace TH1_Logic.Editor { var civId = PickCivId(i + gameIndex, used); used.Add(civId); - if (!config.SetSinglePlayerSlotCiv(i, civId, civId)) - { - config.SetPlayerSlotRandomCiv(i, NetMode.Single); - } + config.SetSinglePlayerSlotCiv(i, civId, civId); } config.EnsurePlayerSlots(NetMode.Single); @@ -463,6 +464,7 @@ namespace TH1_Logic.Editor options.MaxActionsPerPlayerTurn = GetIntArg("-aiBatchMaxActionsPerPlayerTurn", options.MaxActionsPerPlayerTurn); options.StagnantFrameLimit = GetIntArg("-aiBatchStagnantFrames", options.StagnantFrameLimit); options.AnimationSpeed = GetFloatArg("-aiBatchAnimationSpeed", options.AnimationSpeed); + options.Seed = GetIntArg("-aiBatchSeed", options.Seed); options.FailFast = GetBoolArg("-aiBatchFailFast", options.FailFast); options.DisableNearbySpawnPoints = GetBoolArg("-aiBatchDisableNearbySpawnPoints", options.DisableNearbySpawnPoints); options.CompactDiagnostics = GetBoolArg("-aiBatchCompactDiagnostics", options.CompactDiagnostics); @@ -665,6 +667,14 @@ namespace TH1_Logic.Editor var executedActionTypes = new Dictionary(); var laneActionTypes = new Dictionary(); var noEffectActionTypes = new Dictionary(); + var heroReasons = new Dictionary(); + var heroActionTypes = new Dictionary(); + var heroExecutedActionTypes = new Dictionary(); + var fallbackActionTypes = new Dictionary(); + var fallbackReasons = new Dictionary(); + var fallbackExecutedActionTypes = new Dictionary(); + var fallbackNoEffectActionTypes = new Dictionary(); + var decisionLaneByAction = new Dictionary(); try { @@ -695,12 +705,37 @@ namespace TH1_Logic.Editor var lane = decision.Value("lane") ?? string.Empty; var reason = decision.Value("reason") ?? string.Empty; - var actionType = decision["action"]?.Value("actionType") ?? string.Empty; + var action = decision["action"]; + var actionType = action?.Value("actionType") ?? string.Empty; + var actionKey = BuildActionMetricKey(action); + var turnActionKey = BuildTurnActionKey(record, action); Increment(lanes, lane); Increment(reasons, reason); Increment(selectedActionTypes, actionType); Increment(laneActionTypes, $"{lane}:{actionType}"); - if (decision.Value("isFallback") ?? false) summary.fallbackDecisions++; + if (!string.IsNullOrEmpty(turnActionKey)) decisionLaneByAction[turnActionKey] = lane; + + if (lane == "HeroManagement") + { + summary.heroManagementDecisions++; + Increment(heroReasons, reason); + Increment(heroActionTypes, actionKey); + } + else if (lane == "HeroPlaybook") + { + summary.heroPlaybookDecisions++; + Increment(heroReasons, reason); + Increment(heroActionTypes, actionKey); + if (reason.StartsWith("HeroPlaybook.", StringComparison.Ordinal)) summary.genericHeroDecisions++; + else summary.heroRuleDecisions++; + } + + if (decision.Value("isFallback") ?? false) + { + summary.fallbackDecisions++; + Increment(fallbackReasons, reason); + Increment(fallbackActionTypes, actionKey); + } } else if (eventType == "Execution") { @@ -708,6 +743,7 @@ namespace TH1_Logic.Editor var execution = record["execution"]; var action = execution?["action"]; var actionType = action?.Value("actionType") ?? string.Empty; + var actionKey = BuildActionMetricKey(action); Increment(executedActionTypes, actionType); var playerTurnKey = $"{record.Value("playerId") ?? 0}:{record.Value("playerTurn") ?? 0}"; @@ -721,10 +757,33 @@ namespace TH1_Logic.Editor if (!executedStableKeysByPlayerTurn.Add(stableKeyInTurn)) summary.repeatedExecutions++; } - if ((execution?.Value("executed") ?? false) && !HasMeaningfulDelta(execution["delta"])) + var turnActionKey = BuildTurnActionKey(record, action); + decisionLaneByAction.TryGetValue(turnActionKey, out var executedLane); + if (executedLane == "HeroManagement" || executedLane == "HeroPlaybook") + { + Increment(heroExecutedActionTypes, actionKey); + } + else if (executedLane == "Fallback") + { + Increment(fallbackExecutedActionTypes, actionKey); + } + + var delta = execution?["delta"]; + if (execution?.Value("executed") ?? false) + { + summary.heroDelta += delta?.Value("heroDelta") ?? 0; + summary.selectedHeroDelta += delta?.Value("selectedHeroDelta") ?? 0; + summary.heroTaskDelta += delta?.Value("heroTaskDelta") ?? 0; + summary.readyHeroTaskDelta += delta?.Value("readyHeroTaskDelta") ?? 0; + summary.forcedHeroTaskDelta += delta?.Value("forcedHeroTaskDelta") ?? 0; + summary.heroTaskProgressDelta += delta?.Value("heroTaskProgressDelta") ?? 0; + } + + if ((execution?.Value("executed") ?? false) && !HasMeaningfulDelta(delta)) { summary.noEffectExecutions++; Increment(noEffectActionTypes, actionType); + if (executedLane == "Fallback") Increment(fallbackNoEffectActionTypes, actionKey); } } } @@ -744,9 +803,46 @@ namespace TH1_Logic.Editor summary.topExecutedActionTypes = TopCounts(executedActionTypes, 12); summary.topLaneActionTypes = TopCounts(laneActionTypes, 20); summary.noEffectActionTypes = TopCounts(noEffectActionTypes, 12); + summary.topHeroReasons = TopCounts(heroReasons, 16); + summary.topHeroActionTypes = TopCounts(heroActionTypes, 12); + summary.topHeroExecutedActionTypes = TopCounts(heroExecutedActionTypes, 12); + summary.topFallbackReasons = TopCounts(fallbackReasons, 12); + summary.topFallbackActionTypes = TopCounts(fallbackActionTypes, 12); + summary.topFallbackExecutedActionTypes = TopCounts(fallbackExecutedActionTypes, 12); + summary.fallbackNoEffectActionTypes = TopCounts(fallbackNoEffectActionTypes, 12); return summary; } + private static string BuildTurnActionKey(JToken record, JToken action) + { + var stableKey = action?.Value("stableKey"); + if (string.IsNullOrEmpty(stableKey)) return string.Empty; + return $"{record?.Value("playerId") ?? 0}:{record?.Value("playerTurn") ?? 0}:{stableKey}"; + } + + private static string BuildActionMetricKey(JToken action) + { + if (action == null) return string.Empty; + var actionType = action.Value("actionType") ?? string.Empty; + var subType = actionType switch + { + "UnitAction" => action.Value("unitActionType") ?? string.Empty, + "PlayerAction" => action.Value("playerActionType") ?? string.Empty, + "CityAction" => action.Value("cityActionType") ?? string.Empty, + "CityLevelUpAction" => action.Value("cityLevelUpActionType") ?? string.Empty, + "GridMisc" => action.Value("gridMiscActionType") ?? string.Empty, + "LearnTech" => action.Value("techType") ?? string.Empty, + "BuyCultureCard" => action.Value("cultureCardType") ?? string.Empty, + "Gain" => action.Value("resourceType") ?? string.Empty, + "TrainUnit" => action.Value("unitType") ?? string.Empty, + _ => string.Empty + }; + + return string.IsNullOrEmpty(subType) || subType == "None" + ? actionType + : $"{actionType}:{subType}"; + } + private static bool HasMeaningfulDelta(JToken delta) { if (delta == null) return false; @@ -910,6 +1006,7 @@ namespace TH1_Logic.Editor public bool DisableNearbySpawnPoints = false; public bool CompactDiagnostics = true; public bool AllSight = false; + public int Seed = 0; public string OutputDirectory; public string ScenePath; public AIDifficult Difficulty = AIDifficult.LUNATIC; @@ -928,6 +1025,7 @@ namespace TH1_Logic.Editor MaxActionsPerPlayerTurn = Mathf.Max(0, MaxActionsPerPlayerTurn); StagnantFrameLimit = Mathf.Max(0, StagnantFrameLimit); AnimationSpeed = Mathf.Max(1f, AnimationSpeed); + Seed = Mathf.Max(0, Seed); if (string.IsNullOrWhiteSpace(OutputDirectory)) { var root = Directory.GetParent(Application.dataPath).FullName; @@ -969,6 +1067,7 @@ namespace TH1_Logic.Editor public int playerCount; public int width; public int height; + public int randomSeed; public int frames; public int netActions; public uint maxPlayerTurn; @@ -1004,9 +1103,19 @@ namespace TH1_Logic.Editor public int decisions; public int noActionDecisions; public int fallbackDecisions; + public int heroManagementDecisions; + public int heroPlaybookDecisions; + public int heroRuleDecisions; + public int genericHeroDecisions; public int executions; public int noEffectExecutions; public int repeatedExecutions; + public int heroDelta; + public int selectedHeroDelta; + public int heroTaskDelta; + public int readyHeroTaskDelta; + public int forcedHeroTaskDelta; + public int heroTaskProgressDelta; public float avgDecideMs; public float p95DecideMs; public float maxDecideMs; @@ -1025,6 +1134,13 @@ namespace TH1_Logic.Editor public List topExecutedActionTypes = new(); public List topLaneActionTypes = new(); public List noEffectActionTypes = new(); + public List topHeroReasons = new(); + public List topHeroActionTypes = new(); + public List topHeroExecutedActionTypes = new(); + public List topFallbackReasons = new(); + public List topFallbackActionTypes = new(); + public List topFallbackExecutedActionTypes = new(); + public List fallbackNoEffectActionTypes = new(); } [Serializable] diff --git a/Unity/Assets/Scripts/TH1_Logic/Map/MapGenerator.cs b/Unity/Assets/Scripts/TH1_Logic/Map/MapGenerator.cs index 02c6d2c21..ba121ff8b 100644 --- a/Unity/Assets/Scripts/TH1_Logic/Map/MapGenerator.cs +++ b/Unity/Assets/Scripts/TH1_Logic/Map/MapGenerator.cs @@ -11,6 +11,7 @@ using System; using System.Collections.Generic; using System.Linq; using Logic.Pool; +using Logic.AI; using RuntimeData; using TH1_Logic.Core; using Unity.VisualScripting; @@ -156,12 +157,17 @@ namespace Logic //海陆层使用的地形高度数据 private void GenerateHeightMap() { + System.Random batchRandom = null; +#if UNITY_EDITOR + if (AIDirectorBatchRuntime.RandomSeedOverride != 0) + batchRandom = new System.Random(AIDirectorBatchRuntime.RandomSeedOverride ^ 0x5F3759DF); +#endif for (var x = 0; x < _width; x++) { for (var y = 0; y < _height; y++) { - float seda = new System.Random().Next(1, 30); - float sedb = new System.Random().Next(2, 40); + float seda = batchRandom?.Next(1, 30) ?? new System.Random().Next(1, 30); + float sedb = batchRandom?.Next(2, 40) ?? new System.Random().Next(2, 40); // 使用种子值来改变噪声的生成,保证每次生成的噪声不同 var xCoord = (float)x / _width * Scale / seda; var yCoord = (float)y / _height * Scale / sedb;