Fix AI Director batch stability

This commit is contained in:
wuwenbo 2026-06-29 17:57:40 +08:00
parent bccfef69d6
commit c522c505f4
12 changed files with 154 additions and 19 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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是否已经出了英雄且拥有空的槽位

View File

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

View File

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