fix(ai): 应用 Task 1+2 代码审查意见

ResourceFull 未挂 BossResource 时改为显式抛——原先静默返回 false,
是 CLAUDE.md 第 6 条禁止的下游兜底,且与紧邻的 Boss facet 行为不一致。
FakeBossControl.DistanceToAnchor 不再忽略 index。测试改用 TearDown 清理
(本项目关闭了 Domain/Scene Reload,失败断言会把物体永久留在打开的场景里)。
BeginPhaseTransition 去掉死的默认参数;接口补充重入契约说明。

顺带去重:FakeAiContext 实现 IEnemyActor,删除两个测试内的本地 Ctx 副本
——一个接口成员改动要同步三份是复发性成本。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-30 12:31:39 +08:00
co-authored by Claude Opus 5
parent 3665504a58
commit 2a1ada4388
7 changed files with 60 additions and 77 deletions
@@ -1,6 +1,7 @@
using System.Collections.Generic;
using UnityEngine;
using BaseGames.AI;
using BaseGames.Enemies;
namespace BaseGames.Tests.EditMode.AI
{
@@ -44,7 +45,7 @@ namespace BaseGames.Tests.EditMode.AI
public bool HpBelow(float ratio) => Hp < ratio;
}
public sealed class FakeAiContext : IAiContext
public sealed class FakeAiContext : IAiContext, IEnemyActor
{
public FakeSensor S = new FakeSensor();
public FakeLocomotion L = new FakeLocomotion();
@@ -52,11 +53,13 @@ namespace BaseGames.Tests.EditMode.AI
public FakeVitals V = new FakeVitals();
public Blackboard BB = new Blackboard();
public FakeBossControl B = new FakeBossControl();
public bool HasAlert = true;
public ISensor Sensor => S;
public IEnemyLocomotion Locomotion => L;
public ICombatant Combat => C;
public IActorVitals Vitals => V;
public Blackboard Blackboard => BB;
public IBossControl Boss => B;
public bool HasAlertState => HasAlert;
}
}
@@ -13,9 +13,9 @@ namespace BaseGames.Tests.EditMode.AI
public float LastDuration = -1f;
public int BeginCallCount;
// 锚点:测试按下标直给坐标;距离由测试直接设定
// 锚点:测试按下标直给坐标;距离由 SelfPosition 与该下标锚点算出(不忽略 index)
public UnityEngine.Vector2[] Anchors = new UnityEngine.Vector2[0];
public float DistanceToAnchorValue;
public UnityEngine.Vector2 SelfPosition;
public int CurrentPhase => Phase;
public bool IsPhaseTransitioning => Transitioning;
@@ -30,6 +30,7 @@ namespace BaseGames.Tests.EditMode.AI
}
public UnityEngine.Vector2 AnchorAt(int index) => Anchors[index];
public float DistanceToAnchor(int index) => DistanceToAnchorValue;
public float DistanceToAnchor(int index)
=> UnityEngine.Vector2.Distance(SelfPosition, Anchors[index]);
}
}
@@ -7,23 +7,6 @@ namespace BaseGames.Tests.EditMode.AI
{
public class PerceptionRecipeSoTests
{
sealed class Ctx : IAiContext, IEnemyActor
{
public FakeSensor S = new FakeSensor();
public FakeLocomotion L = new FakeLocomotion();
public FakeCombat C = new FakeCombat();
public FakeVitals V = new FakeVitals();
public Blackboard BB = new Blackboard();
public FakeBossControl B = new FakeBossControl();
public ISensor Sensor => S;
public IEnemyLocomotion Locomotion => L;
public ICombatant Combat => C;
public IActorVitals Vitals => V;
public Blackboard Blackboard => BB;
public IBossControl Boss => B;
public bool HasAlertState => true;
}
static PerceptionRecipeSO MakeE001Recipe()
{
var so = ScriptableObject.CreateInstance<PerceptionRecipeSO>();
@@ -61,7 +44,7 @@ namespace BaseGames.Tests.EditMode.AI
public void Graph_BehavesLikeE001()
{
var so = MakeE001Recipe();
var ctx = new Ctx();
var ctx = new FakeAiContext();
var rt = new AiRuntime(so.GetOrBuildGraph(), ctx);
Assert.AreEqual(DisguiseThenPatrol.Disguise, rt.CurrentStateName);
ctx.S.Chase = true;
@@ -7,26 +7,7 @@ namespace BaseGames.Tests.EditMode.AI
{
public class PerceptionSkeletonTests
{
sealed class Ctx : IAiContext, IEnemyActor
{
public FakeSensor S = new FakeSensor();
public FakeLocomotion L = new FakeLocomotion();
public FakeCombat C = new FakeCombat();
public FakeVitals V = new FakeVitals();
public Blackboard BB = new Blackboard();
public FakeBossControl B = new FakeBossControl();
public bool HasAlert = true;
public ISensor Sensor => S;
public IEnemyLocomotion Locomotion => L;
public ICombatant Combat => C;
public IActorVitals Vitals => V;
public Blackboard Blackboard => BB;
public IBossControl Boss => B;
public bool HasAlertState => HasAlert;
}
static AiRuntime Build(Ctx ctx, IUnawareModule unaware = null, IEngagementModule engagement = null)
static AiRuntime Build(FakeAiContext ctx, IUnawareModule unaware = null, IEngagementModule engagement = null)
{
var b = new BrainBuilder();
PerceptionSkeleton.Add(b,
@@ -40,7 +21,7 @@ namespace BaseGames.Tests.EditMode.AI
[Test]
public void Unaware_ToEngagement_WhenInChaseZone()
{
var ctx = new Ctx();
var ctx = new FakeAiContext();
var rt = Build(ctx);
ctx.S.Chase = true;
rt.Tick(0.1f);
@@ -50,7 +31,7 @@ namespace BaseGames.Tests.EditMode.AI
[Test]
public void Unaware_ToAlert_WhenInVision_AndHasAlert()
{
var ctx = new Ctx { HasAlert = true };
var ctx = new FakeAiContext { HasAlert = true };
var rt = Build(ctx);
ctx.S.Vision = true;
rt.Tick(0.1f);
@@ -60,7 +41,7 @@ namespace BaseGames.Tests.EditMode.AI
[Test]
public void Unaware_StaysUnaware_WhenInVision_ButNoAlertState()
{
var ctx = new Ctx { HasAlert = false };
var ctx = new FakeAiContext { HasAlert = false };
var rt = Build(ctx);
ctx.S.Vision = true;
rt.Tick(0.1f);
@@ -70,7 +51,7 @@ namespace BaseGames.Tests.EditMode.AI
[Test]
public void ChaseZone_BeatsAlert_WhenBothActive()
{
var ctx = new Ctx { HasAlert = true };
var ctx = new FakeAiContext { HasAlert = true };
var rt = Build(ctx);
ctx.S.Chase = true; ctx.S.Vision = true;
rt.Tick(0.1f);
@@ -81,7 +62,7 @@ namespace BaseGames.Tests.EditMode.AI
[Test]
public void UpgradeEdge_BeatsUnawareInternalDwellEdge()
{
var ctx = new Ctx();
var ctx = new FakeAiContext();
var rt = Build(ctx, new AlternatingIdlePatrol(idleDwell: 1f, patrolDwell: 1f));
Assert.AreEqual(AlternatingIdlePatrol.Idle, rt.CurrentStateName);
ctx.S.Chase = true;
@@ -94,7 +75,7 @@ namespace BaseGames.Tests.EditMode.AI
[Test]
public void Alert_ToEngagement_WhenEnteringChaseZone()
{
var ctx = new Ctx();
var ctx = new FakeAiContext();
var rt = Build(ctx);
ctx.S.Vision = true; rt.Tick(0.1f);
Assert.AreEqual(PerceptionSkeleton.Alert, rt.CurrentStateName);
@@ -105,7 +86,7 @@ namespace BaseGames.Tests.EditMode.AI
[Test]
public void Alert_ToRest_WhenLeavingVision()
{
var ctx = new Ctx();
var ctx = new FakeAiContext();
var rt = Build(ctx);
ctx.S.Vision = true; rt.Tick(0.1f);
ctx.S.Vision = false; rt.Tick(0.1f);
@@ -115,7 +96,7 @@ namespace BaseGames.Tests.EditMode.AI
[Test]
public void Alert_FacesLastKnown()
{
var ctx = new Ctx();
var ctx = new FakeAiContext();
ctx.S.Last = new UnityEngine.Vector2(7f, 2f);
var rt = Build(ctx);
ctx.S.Vision = true; rt.Tick(0.1f);
@@ -127,7 +108,7 @@ namespace BaseGames.Tests.EditMode.AI
[Test]
public void AfterEngagement_GoesToRest_NeverBackToAlert()
{
var ctx = new Ctx();
var ctx = new FakeAiContext();
var rt = Build(ctx);
ctx.S.Chase = true; rt.Tick(0.1f);
Assert.AreEqual(RushEngagement.Rush, rt.CurrentStateName);
@@ -140,7 +121,7 @@ namespace BaseGames.Tests.EditMode.AI
[Test]
public void Died_TransitionsToDeath_FromAnyState()
{
var ctx = new Ctx();
var ctx = new FakeAiContext();
var rt = Build(ctx);
ctx.S.Chase = true; rt.Tick(0.1f);
Assert.AreEqual(RushEngagement.Rush, rt.CurrentStateName);
@@ -154,7 +135,7 @@ namespace BaseGames.Tests.EditMode.AI
[Test]
public void Died_WorksEvenWhenNotControllable()
{
var ctx = new Ctx();
var ctx = new FakeAiContext();
var rt = Build(ctx);
ctx.V.Controllable = false; // 已是 Dead 物理态
rt.Send(AiSignal.Died);
@@ -165,7 +146,7 @@ namespace BaseGames.Tests.EditMode.AI
[Test]
public void Death_IsTerminal_NoConditionEdgesEvaluatedAfterwards()
{
var ctx = new Ctx();
var ctx = new FakeAiContext();
var rt = Build(ctx);
rt.Send(AiSignal.Died);
rt.Tick(0.1f);
@@ -224,7 +205,7 @@ namespace BaseGames.Tests.EditMode.AI
[Test]
public void Skeleton_WithApproachAttack_EscalatesAndDisengages()
{
var ctx = new Ctx();
var ctx = new FakeAiContext();
var rt = Build(ctx, new SinglePost(LocomotionMode.Patrol), new ApproachAttackEngagement());
Assert.AreEqual(SinglePost.Post, rt.CurrentStateName);
@@ -246,7 +227,7 @@ namespace BaseGames.Tests.EditMode.AI
public void Skeleton_WithDisguiseThenPatrol_DisengagesToPatrol_NotDisguise()
{
// 唯一 Entry != Rest 的未发现层:出生伪装静止,脱战只回巡逻,伪装态从此不可达。
var ctx = new Ctx();
var ctx = new FakeAiContext();
var rt = Build(ctx, new DisguiseThenPatrol());
Assert.AreEqual(DisguiseThenPatrol.Disguise, rt.CurrentStateName);
@@ -267,7 +248,7 @@ namespace BaseGames.Tests.EditMode.AI
public void EntryCanBeOverridden_ForEnemiesWithPrefixStates()
{
// 骨架注释承诺的出路:掉落链 / 出场链这类前置态,调 Add 之后再覆盖 Entry。
var ctx = new Ctx();
var ctx = new FakeAiContext();
var b = new BrainBuilder();
PerceptionSkeleton.Add(b, new SinglePost(LocomotionMode.Patrol),
new RushEngagement("rush", RushExit.OnLostTarget));
@@ -8,14 +8,26 @@ namespace BaseGames.Tests.EditMode.Enemies
{
public class EnemyBrainContextBossFacetTests
{
// EnemyBase.Awake 会对缺失的碰撞体/引用报错,这些与本测试无关。
[SetUp] public void SetUp() => LogAssert.ignoreFailingMessages = true;
[TearDown] public void TearDown() => LogAssert.ignoreFailingMessages = false;
// 本项目关闭了 Domain/Scene ReloadEditMode 测试跑在当前打开的场景上:
// 建出来的物体必须在 TearDown 里销毁,断言失败时才不会把物体永久留在场景里。
GameObject _go;
static T MakeEnemy<T>(string name) where T : EnemyBase
// EnemyBase.Awake 会对缺失的碰撞体/引用报错,这些与本测试无关。
[SetUp]
public void SetUp() => LogAssert.ignoreFailingMessages = true;
[TearDown]
public void TearDown()
{
var go = new GameObject(name);
return go.AddComponent<T>();
LogAssert.ignoreFailingMessages = false;
if (_go != null) UnityEngine.Object.DestroyImmediate(_go);
_go = null;
}
T MakeEnemy<T>(string name) where T : EnemyBase
{
_go = new GameObject(name);
return _go.AddComponent<T>();
}
[Test]
@@ -25,8 +37,6 @@ namespace BaseGames.Tests.EditMode.Enemies
var ctx = new EnemyBrainContext(enemy);
Assert.Throws<InvalidOperationException>(() => { var _ = ctx.Boss; });
UnityEngine.Object.DestroyImmediate(enemy.gameObject);
}
[Test]
@@ -38,9 +48,8 @@ namespace BaseGames.Tests.EditMode.Enemies
Assert.AreSame(boss, ctx.Boss);
Assert.AreEqual(0, ctx.Boss.CurrentPhase);
Assert.IsFalse(ctx.Boss.IsPhaseTransitioning);
Assert.IsFalse(ctx.Boss.ResourceFull); // 未挂 BossResource
UnityEngine.Object.DestroyImmediate(boss.gameObject);
// 未挂 BossResource:图查询资源满值属配置错误,必须抛而不是静默返回 false
Assert.Throws<InvalidOperationException>(() => { var _ = ctx.Boss.ResourceFull; });
}
}
}
+7 -6
View File
@@ -1,8 +1,8 @@
namespace BaseGames.AI
{
/// <summary>
/// Boss 专属决策面。挂在 <see cref="IAiContext"/> 上供 Boss 图读取阶段状态、发起阶段过渡
/// 查询竞技场锚点。只暴露"决策需要知道的",过渡演出与无敌帧由实现方(BossBase)负责。
/// Boss 专属决策面。挂在 <see cref="IAiContext"/> 上供 Boss 图读取阶段状态、发起阶段过渡
/// 只暴露"决策需要知道的",过渡演出与无敌帧由实现方(BossBase)负责。
/// 非 Boss 敌人访问 <see cref="IAiContext.Boss"/> 会抛异常——这是有意的显式失败。
/// </summary>
public interface IBossControl
@@ -13,13 +13,14 @@ namespace BaseGames.AI
/// <summary>是否处于阶段过渡(无敌 + 过渡演出)期间。</summary>
bool IsPhaseTransitioning { get; }
/// <summary>Boss 资源是否已满(无资源组件时恒为 false。</summary>
/// <summary>Boss 资源是否已满。未挂资源组件时抛异常——图问了资源却没配组件是配置错误。</summary>
bool ResourceFull { get; }
/// <summary>发起阶段过渡:无敌 invincibleDuration 秒后切入 targetPhase。</summary>
/// <summary>发起阶段过渡:无敌 invincibleDuration 秒后切入 targetPhase。
/// 过渡进行中重复调用会被忽略并告警——应在 OnEnter 调用一次,不要放在 Tick。</summary>
void BeginPhaseTransition(int targetPhase, float invincibleDuration);
// 竞技场锚点两成员(AnchorAt / DistanceToAnchor在 Task 10 与
// BossArenaAnchors 组件一并加入——此刻加会让 Task 2–8 期间 BossBase 编译不过
// 竞技场锚点两成员(AnchorAt / DistanceToAnchor随 BossArenaAnchors 组件一并加入,
// 届时 BossBase 同步实现
}
}
@@ -38,8 +38,13 @@ namespace BaseGames.Enemies
/// <summary>当前 Boss 阶段索引。IBossControl 实现。</summary>
public int CurrentPhase => _currentPhase;
/// <summary>Boss 资源是否已满(未挂资源组件时恒为 false。IBossControl 实现。</summary>
public bool ResourceFull => _bossResource != null && _bossResource.IsFull;
/// <summary>Boss 资源是否已满。IBossControl 实现。
/// 未挂 BossResource 即抛——AI 图问了资源却没配资源组件是配置错误,必须暴露。</summary>
public bool ResourceFull => _bossResource != null
? _bossResource.IsFull
: throw new System.InvalidOperationException(
$"[BossBase] '{name}' 的 AI 图查询了资源满值,但未挂 BossResource 组件。" +
"请挂上该组件,或从 AI 图里移除资源相关的边。");
private Coroutine _counterStaggerCoroutine;
// 缓存加权候选与其有效权重(两者等长、下标对应),避免 UseBossSkillWeighted() 每次 new List → GC 分配
@@ -192,7 +197,7 @@ namespace BaseGames.Enemies
/// </summary>
/// <param name="targetPhase">过渡目标阶段索引。</param>
/// <param name="invincibleDuration">无敌帧持续时间(秒)。</param>
public void BeginPhaseTransition(int targetPhase, float invincibleDuration = 1.5f)
public void BeginPhaseTransition(int targetPhase, float invincibleDuration)
{
if (IsPhaseTransitioning)
{