Fix AI Director batch stability
This commit is contained in:
parent
bccfef69d6
commit
c522c505f4
@ -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.
|
||||
@ -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) |
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,是否已经出了英雄,且拥有空的槽位
|
||||
|
||||
@ -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
|
||||
{
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user