diff --git a/MD/ChronicIssueList/2026-06-29-ai-director-zero-effect-action-loop.md b/MD/ChronicIssueList/2026-06-29-ai-director-zero-effect-action-loop.md new file mode 100644 index 000000000..e2af88384 --- /dev/null +++ b/MD/ChronicIssueList/2026-06-29-ai-director-zero-effect-action-loop.md @@ -0,0 +1,56 @@ +# TH1-CI-2026-06-29-001 - AI Director zero-effect action loop + +- Status: Fixed in code; 17-player 10-turn batch passed +- First recorded date: 2026-06-29 +- Severity: Critical + +## Raw Symptom + +AI Director 17-player batch with `-Turns 40` repeatedly hit `AI 行为次数过多,可能进入死循环`. + +Affected paths: + +- `Tools/RunAIDirectorBatch.ps1` +- `Unity/Assets/Scripts/TH1_Logic/AI/Director/AIDirectorActionIndex.cs` +- `Unity/Assets/Scripts/TH1_Logic/Action/ActionLogic.cs` `BuildWonderAction` + +## Why This Is Recurring + +The new AI can legally execute actions through the shared action layer, but some legal actions have no useful per-turn progress when selected as a generic fallback. Once all higher-value lanes fail, fallback can repeatedly select the same stable action and fill the AI action guard. + +## Root Cause + +Two independent leaks reached the same loop shape: + +- Kanako sit/unsit are free state toggles. HeroPlaybook may use them intentionally, but fallback treated them as ordinary unit actions and alternated them with no strategic progress. +- `BuildWonderAction.CheckCan` did not mirror `Execute`'s city/config prerequisites. Some wonder actions passed `CheckCan`, were written to the action log, then returned false during `Execute`, leaving the same invalid action available again. + +## Root-Cause Fix + +- Fallback now skips pure/no-progress management actions such as Examine, Kanako sit/unsit, SelectHero, and FinishHeroTask. +- Fallback now prefers attacks, moves, city, grid, and player growth actions before unit actions. +- `BuildWonderAction.CheckCan` now verifies the target grid belongs to a city and that the current player's wonder config exists before allowing execution. + +## Guardrail Added + +The AI Director diagnostic analyzer already reports: + +- max actions per player turn +- repeated stable action keys +- no-effect successful actions + +Batch mode now also disables presentation queue playback, Steam auth warmup, renderer mismatch diagnostics, unavailable-hero warning spam, and full decision cache/lane dumps. These are not part of AI decision quality, but they previously made editor batch runs slow and produced oversized logs. + +## Verification Performed + +- `dotnet build Unity/TH1.Hotfix.csproj --no-restore` passed with existing warnings. +- `dotnet build Unity/TH1.Logic.Editor.csproj --no-restore` passed with existing warnings. +- `Tools/RunAIDirectorBatch.ps1 -Games 1 -Players 17 -Turns 10 -TimeoutSeconds 600 -MaxActions 10000 -MaxActionsPerPlayerTurn 80` passed. + - Result: `failedGames=0`, `reason=ReachedMaxTurns:10`, `netActions=1280`, `maxPlayerTurn=10`. + - Analyzer: max actions per player turn was 19, far below the 80 guard. + - Log sizes: compact diagnostic JSONL was about 9.3 MB; Unity batch log was about 152 KB. + - Log scan found no `Steam auth warmup failed`, `Fragment Timeout`, `Fragement Timeout`, `Blocked unavailable hero select`, `AI 行为次数过多`, `NullReferenceException`, or `Exception:` lines. + +## Remaining Validation Gaps + +Manual Unity Editor playtest still needs to confirm that player-controlled Kanako sit/unsit and valid wonder construction remain usable. A longer 40-turn soak is still useful for balance and late-game behavior, but the previous loop/log/performance blockers are cleared by the 10-turn 17-player batch. diff --git a/MD/ChronicIssueList/index.md b/MD/ChronicIssueList/index.md index fcfc9aa4f..952ebea9e 100644 --- a/MD/ChronicIssueList/index.md +++ b/MD/ChronicIssueList/index.md @@ -6,6 +6,7 @@ | ID | 日期 | 状态 | 严重度 | 问题 | 根治方向 | 记录 | | --- | --- | --- | --- | --- | --- | --- | +| TH1-CI-2026-06-29-001 | 2026-06-29 | Fixed in code; 17-player 10-turn batch passed | Critical | AI Director fallback can repeat zero-effect actions until the AI loop guard fires | Filter no-progress fallback actions, align BuildWonder CheckCan with Execute prerequisites, and keep batch diagnostics compact | [record](2026-06-29-ai-director-zero-effect-action-loop.md) | | TH1-CI-2026-06-27-001 | 2026-06-27 | Fixed in code; guardrail added; Unity validation pending | High | Suika Lv4 falling splash water restriction applied to empty grids but not unit targets on water | Share falling-splash water target validation across ground targeting, unit targeting, and `UnitAttack` CheckCan filtering | [record](2026-06-27-suika-falling-splash-water-targeting.md) | | TH1-CI-2026-06-26-002 | 2026-06-26 | Fixed in code; guardrail added; Unity validation pending | Critical | Aunn shared HP ignored `DamageBearer` substitute-damage paths | Treat shared HP as an invariant over the actual mutated unit, `DamageBearer ?? DamageTarget`, with entrypoint fallback and shared-death cleanup | [record](2026-06-26-aunn-shared-health-damage-bearer.md) | | TH1-CI-2026-06-26-001 | 2026-06-26 | Fixed in code; guardrail added; Unity validation pending | High | Suika Lv4 falling splash can move data without complete landing, projectile, and fog/sight presentation after a 3-grid jump | Use a dedicated Suika falling-splash Fragment that owns jump, splash, final landing, and newly opened fog refresh | [record](2026-06-26-suika-falling-splash-landing-animation.md) | diff --git a/Unity/Assets/Scripts/TH1_Core/Managers/PresentationManager.cs b/Unity/Assets/Scripts/TH1_Core/Managers/PresentationManager.cs index 0104bf6db..3599d7622 100644 --- a/Unity/Assets/Scripts/TH1_Core/Managers/PresentationManager.cs +++ b/Unity/Assets/Scripts/TH1_Core/Managers/PresentationManager.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using Logic; +using Logic.AI; using RuntimeData; using TH1_Anim; using TH1_Anim.Fragments; @@ -102,6 +103,14 @@ namespace TH1_Core.Managers public static void EnqueueTask(ISequencerTask task,bool viewNextFrame =false) { + if (task == null) + { + Debug.LogError("试图添加一个空的任务!"); + return; + } + + if (AIDirectorBatchRuntime.SkipPresentationWait) return; + //处理Debug模式下屏蔽弹窗的情况 if (DebugCenter.Instance.DebugHideCenterMessage) { @@ -114,12 +123,6 @@ namespace TH1_Core.Managers } } - if (task == null) - { - Debug.LogError("试图添加一个空的任务!"); - return; - } - //如果当前不是我的回合,并且进来了一个UISequencerTask,那么要先缓存,直到我的回合再显示出来 if (IsDuplicateCityLevelUpChoiceTask(task) || IsDuplicateTreasureChoiceTask(task) || IsDuplicateDanegeldChoiceTask(task)) return; diff --git a/Unity/Assets/Scripts/TH1_Logic/AI/Director/AIDirectorActionIndex.cs b/Unity/Assets/Scripts/TH1_Logic/AI/Director/AIDirectorActionIndex.cs index 72ce9e0d2..564d278a2 100644 --- a/Unity/Assets/Scripts/TH1_Logic/AI/Director/AIDirectorActionIndex.cs +++ b/Unity/Assets/Scripts/TH1_Logic/AI/Director/AIDirectorActionIndex.cs @@ -201,13 +201,13 @@ namespace Logic.AI.Director public AIActionBase FindBestFallback() { - if (AttackActions.Count > 0) return AttackActions[0]; - if (UnitActions.Count > 0) return UnitActions[0]; - if (MoveActions.Count > 0) return MoveActions[0]; - if (CityActions.Count > 0) return CityActions[0]; - if (GridActions.Count > 0) return GridActions[0]; - if (PlayerActions.Count > 0) return PlayerActions[0]; - return AllActions.Count > 0 ? AllActions[0] : null; + return FindFirstFallbackAction(AttackActions) + ?? FindFirstFallbackAction(MoveActions) + ?? FindFirstFallbackAction(CityActions) + ?? FindFirstFallbackAction(GridActions) + ?? FindFirstFallbackAction(PlayerActions) + ?? FindFirstFallbackAction(UnitActions) + ?? FindFirstFallbackAction(AllActions); } private void Add(AIActionBase action) @@ -369,10 +369,57 @@ namespace Logic.AI.Director if (id == null) return true; if (id.UnitActionType is UnitActionType.Disband or UnitActionType.ForceDisband or UnitActionType.Demolish or UnitActionType.Disperse or UnitActionType.ToggleShenlan) return true; if (id.PlayerActionType == PlayerActionType.FinishHeroTask) return true; + if (IsDirectorExcludedPlayerAction(id.PlayerActionType)) return true; if (id.GridMiscActionType == GridMiscActionType.Destroy) return true; return false; } + public static bool IsDirectorExcludedPlayerAction(PlayerActionType type) + { + return type is PlayerActionType.OfferAlly + or PlayerActionType.AcceptAlly + or PlayerActionType.RefuseAlly + or PlayerActionType.BreakAlly + or PlayerActionType.Embassy + or PlayerActionType.BreakEmbassy + or PlayerActionType.DanegeldDemand + or PlayerActionType.DanegeldPay + or PlayerActionType.DanegeldReject; + } + + private static AIActionBase FindFirstFallbackAction(List actions) + { + foreach (var action in actions) + { + if (IsFallbackAction(action)) return action; + } + + return null; + } + + private static bool IsFallbackAction(AIActionBase action) + { + var id = action?.ActionLogic?.ActionId; + if (id == null) return false; + + if (id.ActionType == CommonActionType.UnitAction + && id.UnitActionType is UnitActionType.Examine + or UnitActionType.KANAKOSIT + or UnitActionType.KANAKOUNSIT) + { + return false; + } + + if (id.ActionType == CommonActionType.PlayerAction + && id.PlayerActionType is PlayerActionType.SelectHero + or PlayerActionType.FinishHeroTask) + { + return false; + } + + return !IsDangerousAction(action); + } + public static string StableActionKey(AIActionBase action) { if (action == null) return string.Empty; diff --git a/Unity/Assets/Scripts/TH1_Logic/AI/Director/AIDirectorDiagnostics.cs b/Unity/Assets/Scripts/TH1_Logic/AI/Director/AIDirectorDiagnostics.cs index bbc094746..f98a7068b 100644 --- a/Unity/Assets/Scripts/TH1_Logic/AI/Director/AIDirectorDiagnostics.cs +++ b/Unity/Assets/Scripts/TH1_Logic/AI/Director/AIDirectorDiagnostics.cs @@ -84,10 +84,17 @@ namespace Logic.AI.Director var record = CreateBaseRecord("Decision", map, player); record.decisionSequence = ++_decisionSequence; record.decision = BuildDecisionSnapshot(decision); - record.cache = BuildCacheSnapshot(map, player, decision.Cache); record.actionPool = BuildActionPoolSnapshot(decision.ActionIndex); - record.lanes = BuildLaneSnapshots(decision); - record.trace = CopyTrace(decision.Trace); + if (!Logic.AI.AIDirectorBatchRuntime.CompactDiagnostics) + { + record.cache = BuildCacheSnapshot(map, player, decision.Cache); + record.lanes = BuildLaneSnapshots(decision); + record.trace = CopyTrace(decision.Trace); + } + else if (!decision.HasAction || decision.Candidate?.IsFallback == true) + { + record.trace = CopyTrace(decision.Trace); + } WriteRecord(record); } diff --git a/Unity/Assets/Scripts/TH1_Logic/AI/Director/AIDirectorLogic.cs b/Unity/Assets/Scripts/TH1_Logic/AI/Director/AIDirectorLogic.cs index 2b7173c2f..54c7bef37 100644 --- a/Unity/Assets/Scripts/TH1_Logic/AI/Director/AIDirectorLogic.cs +++ b/Unity/Assets/Scripts/TH1_Logic/AI/Director/AIDirectorLogic.cs @@ -531,6 +531,8 @@ namespace Logic.AI.Director var id = action.ActionLogic.ActionId.PlayerActionType; if (id is PlayerActionType.SelectTreasureOptionA or PlayerActionType.SelectTreasureOptionB or PlayerActionType.TreasureGainCoin or PlayerActionType.TreasureGainUnit or PlayerActionType.TreasureGainTech or PlayerActionType.TreasureGainCulture or PlayerActionType.TreasureGainCityExp) return 520f; + if (AIDirectorActionIndex.IsDirectorExcludedPlayerAction(id)) + return 0f; var target = action.Param.TargetPlayerData; var feeling = target != null && ctx.Player.GetCountryDiplomacyInfo(target.Id, out var info) && info != null ? info.FeelingValue : 50f; diff --git a/Unity/Assets/Scripts/TH1_Logic/AI/Director/AIDirectorWorldCacheBuilder.cs b/Unity/Assets/Scripts/TH1_Logic/AI/Director/AIDirectorWorldCacheBuilder.cs index 20316582c..c28260194 100644 --- a/Unity/Assets/Scripts/TH1_Logic/AI/Director/AIDirectorWorldCacheBuilder.cs +++ b/Unity/Assets/Scripts/TH1_Logic/AI/Director/AIDirectorWorldCacheBuilder.cs @@ -279,7 +279,7 @@ namespace Logic.AI.Director if (threat != null && threat.IsCritical) plan.Kind = AIDirectorCityPlanKind.EmergencyDefense; else if (threat != null && threat.DangerScore > 0f) plan.Kind = AIDirectorCityPlanKind.Mobilize; else if (IsNearAttackFront(ctx, cache, cityGrid)) plan.Kind = AIDirectorCityPlanKind.Frontline; - else if (CanReasonablyPursueWonder(ctx, city)) plan.Kind = AIDirectorCityPlanKind.Wonder; + else if (CanReasonablyPursueWonder(ctx, cache, city)) plan.Kind = AIDirectorCityPlanKind.Wonder; plan.NeedWall = (plan.Kind == AIDirectorCityPlanKind.EmergencyDefense || plan.Kind == AIDirectorCityPlanKind.Mobilize || @@ -630,9 +630,9 @@ namespace Logic.AI.Director return false; } - private bool CanReasonablyPursueWonder(AIDirectorContext ctx, CityData city) + private bool CanReasonablyPursueWonder(AIDirectorContext ctx, AIDirectorWorldCache cache, CityData city) { - return city != null && ctx.Player.PlayerCoin >= 20 && !ctx.Cache.HasAnyEnemyContact; + return city != null && ctx?.Player != null && cache != null && ctx.Player.PlayerCoin >= 20 && !cache.HasAnyEnemyContact; } private float ScoreCityPlan(AIDirectorCityPlan plan) diff --git a/Unity/Assets/Scripts/TH1_Logic/AI/Kernel/AIKernel.cs b/Unity/Assets/Scripts/TH1_Logic/AI/Kernel/AIKernel.cs index 6b34914d0..d22e99628 100644 --- a/Unity/Assets/Scripts/TH1_Logic/AI/Kernel/AIKernel.cs +++ b/Unity/Assets/Scripts/TH1_Logic/AI/Kernel/AIKernel.cs @@ -20,9 +20,11 @@ namespace Logic.AI #if UNITY_EDITOR public static bool ForceAllPlayersAi; public static bool SkipPresentationWait; + public static bool CompactDiagnostics; #else public const bool ForceAllPlayersAi = false; public const bool SkipPresentationWait = false; + public const bool CompactDiagnostics = false; #endif } diff --git a/Unity/Assets/Scripts/TH1_Logic/Action/ActionLogic.cs b/Unity/Assets/Scripts/TH1_Logic/Action/ActionLogic.cs index a3dd78c47..a0eb687d9 100644 --- a/Unity/Assets/Scripts/TH1_Logic/Action/ActionLogic.cs +++ b/Unity/Assets/Scripts/TH1_Logic/Action/ActionLogic.cs @@ -1474,6 +1474,10 @@ namespace Logic.Action private void ReportBeforeActionDiagnostics(CommonActionParams actionParams) { +#if UNITY_EDITOR + if (Logic.AI.AIDirectorBatchRuntime.SkipPresentationWait) return; +#endif + try { ReportBoatUnitOnLandBeforeAction(actionParams); @@ -2069,6 +2073,10 @@ namespace Logic.Action var player = actionParam.PlayerData; var grid = actionParam.GridData; var map = actionParam.MapData; + if (!map.GetCityDataByTerritoryGid(grid.Id, out _)) + return false; + if (!Table.Instance.GridAndResourceDataAssets.GetWonderInfoByType(_actionId.WonderType, player, out _)) + return false; //找到grid所属于的player,如果是无主领土就return if (!map.GetPlayerDataByTerritoryGridId(grid.Id, out var gridPlayer)) return false; diff --git a/Unity/Assets/Scripts/TH1_Logic/Action/PlayerActionLogic.cs b/Unity/Assets/Scripts/TH1_Logic/Action/PlayerActionLogic.cs index c1389902d..bf0d64840 100644 --- a/Unity/Assets/Scripts/TH1_Logic/Action/PlayerActionLogic.cs +++ b/Unity/Assets/Scripts/TH1_Logic/Action/PlayerActionLogic.cs @@ -839,7 +839,12 @@ namespace TH1_Logic.Action if (info.GiantEmpire != actionParams.PlayerData.Empire) return false; if (!ContentGate.CanUseHeroForPlayer(player, _actionId.GiantType)) { +#if UNITY_EDITOR + if (!Logic.AI.AIDirectorBatchRuntime.SkipPresentationWait) + LogSystem.LogWarning($"Blocked unavailable hero select: player={player.Id}, giant={_actionId.GiantType}"); +#else LogSystem.LogWarning($"Blocked unavailable hero select: player={player.Id}, giant={_actionId.GiantType}"); +#endif return false; } //Step #3 检查当前玩家的HeroData,是否已经出了英雄,且拥有空的槽位 diff --git a/Unity/Assets/Scripts/TH1_Logic/Editor/AIDirectorBatchRunner.cs b/Unity/Assets/Scripts/TH1_Logic/Editor/AIDirectorBatchRunner.cs index 211d39299..4c13bf15b 100644 --- a/Unity/Assets/Scripts/TH1_Logic/Editor/AIDirectorBatchRunner.cs +++ b/Unity/Assets/Scripts/TH1_Logic/Editor/AIDirectorBatchRunner.cs @@ -133,6 +133,7 @@ namespace TH1_Logic.Editor #if UNITY_EDITOR AIDirectorBatchRuntime.ForceAllPlayersAi = false; AIDirectorBatchRuntime.SkipPresentationWait = false; + AIDirectorBatchRuntime.CompactDiagnostics = false; #endif CompleteResult(result, gameDirectory); results.Add(result); @@ -162,6 +163,7 @@ namespace TH1_Logic.Editor AILogic.UseDirectorKernel(); AIDirectorBatchRuntime.ForceAllPlayersAi = true; AIDirectorBatchRuntime.SkipPresentationWait = true; + AIDirectorBatchRuntime.CompactDiagnostics = true; var main = Main.Instance; main.MapConfig = BuildMapConfig(options, gameIndex); @@ -217,6 +219,7 @@ namespace TH1_Logic.Editor #if UNITY_EDITOR AIDirectorBatchRuntime.ForceAllPlayersAi = false; AIDirectorBatchRuntime.SkipPresentationWait = false; + AIDirectorBatchRuntime.CompactDiagnostics = false; #endif try { diff --git a/Unity/Assets/Scripts/TH1_Logic/Oss/OssManager.cs b/Unity/Assets/Scripts/TH1_Logic/Oss/OssManager.cs index d4bd5a260..5dd738c3d 100644 --- a/Unity/Assets/Scripts/TH1_Logic/Oss/OssManager.cs +++ b/Unity/Assets/Scripts/TH1_Logic/Oss/OssManager.cs @@ -206,6 +206,7 @@ namespace TH1_Logic.Oss public void UpdateSteamAuthWarmup() { + if (Logic.AI.AIDirectorBatchRuntime.SkipPresentationWait) return; if (_isSteamAuthWarmupRunning) return; if (DateTime.UtcNow < _nextSteamAuthWarmupTime) return;