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