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>
270 lines
11 KiB
C#
270 lines
11 KiB
C#
using System.Collections.Generic;
|
||
using NUnit.Framework;
|
||
using BaseGames.AI;
|
||
using BaseGames.Enemies;
|
||
|
||
namespace BaseGames.Tests.EditMode.AI
|
||
{
|
||
public class PerceptionSkeletonTests
|
||
{
|
||
static AiRuntime Build(FakeAiContext ctx, IUnawareModule unaware = null, IEngagementModule engagement = null)
|
||
{
|
||
var b = new BrainBuilder();
|
||
PerceptionSkeleton.Add(b,
|
||
unaware ?? new SinglePost(LocomotionMode.Patrol),
|
||
engagement ?? new RushEngagement("rush", RushExit.OnLostTarget));
|
||
return new AiRuntime(b.Build(), ctx);
|
||
}
|
||
|
||
// ── 升级 ──────────────────────────────────────────────────────────
|
||
|
||
[Test]
|
||
public void Unaware_ToEngagement_WhenInChaseZone()
|
||
{
|
||
var ctx = new FakeAiContext();
|
||
var rt = Build(ctx);
|
||
ctx.S.Chase = true;
|
||
rt.Tick(0.1f);
|
||
Assert.AreEqual(RushEngagement.Rush, rt.CurrentStateName);
|
||
}
|
||
|
||
[Test]
|
||
public void Unaware_ToAlert_WhenInVision_AndHasAlert()
|
||
{
|
||
var ctx = new FakeAiContext { HasAlert = true };
|
||
var rt = Build(ctx);
|
||
ctx.S.Vision = true;
|
||
rt.Tick(0.1f);
|
||
Assert.AreEqual(PerceptionSkeleton.Alert, rt.CurrentStateName);
|
||
}
|
||
|
||
[Test]
|
||
public void Unaware_StaysUnaware_WhenInVision_ButNoAlertState()
|
||
{
|
||
var ctx = new FakeAiContext { HasAlert = false };
|
||
var rt = Build(ctx);
|
||
ctx.S.Vision = true;
|
||
rt.Tick(0.1f);
|
||
Assert.AreEqual(SinglePost.Post, rt.CurrentStateName);
|
||
}
|
||
|
||
[Test]
|
||
public void ChaseZone_BeatsAlert_WhenBothActive()
|
||
{
|
||
var ctx = new FakeAiContext { HasAlert = true };
|
||
var rt = Build(ctx);
|
||
ctx.S.Chase = true; ctx.S.Vision = true;
|
||
rt.Tick(0.1f);
|
||
Assert.AreEqual(RushEngagement.Rush, rt.CurrentStateName);
|
||
}
|
||
|
||
// 升级边必须优先于未发现层内部计时边
|
||
[Test]
|
||
public void UpgradeEdge_BeatsUnawareInternalDwellEdge()
|
||
{
|
||
var ctx = new FakeAiContext();
|
||
var rt = Build(ctx, new AlternatingIdlePatrol(idleDwell: 1f, patrolDwell: 1f));
|
||
Assert.AreEqual(AlternatingIdlePatrol.Idle, rt.CurrentStateName);
|
||
ctx.S.Chase = true;
|
||
rt.Tick(5f); // 计时早已到点,但追击边优先
|
||
Assert.AreEqual(RushEngagement.Rush, rt.CurrentStateName);
|
||
}
|
||
|
||
// ── 警觉 ──────────────────────────────────────────────────────────
|
||
|
||
[Test]
|
||
public void Alert_ToEngagement_WhenEnteringChaseZone()
|
||
{
|
||
var ctx = new FakeAiContext();
|
||
var rt = Build(ctx);
|
||
ctx.S.Vision = true; rt.Tick(0.1f);
|
||
Assert.AreEqual(PerceptionSkeleton.Alert, rt.CurrentStateName);
|
||
ctx.S.Chase = true; rt.Tick(0.1f);
|
||
Assert.AreEqual(RushEngagement.Rush, rt.CurrentStateName);
|
||
}
|
||
|
||
[Test]
|
||
public void Alert_ToRest_WhenLeavingVision()
|
||
{
|
||
var ctx = new FakeAiContext();
|
||
var rt = Build(ctx);
|
||
ctx.S.Vision = true; rt.Tick(0.1f);
|
||
ctx.S.Vision = false; rt.Tick(0.1f);
|
||
Assert.AreEqual(SinglePost.Post, rt.CurrentStateName);
|
||
}
|
||
|
||
[Test]
|
||
public void Alert_FacesLastKnown()
|
||
{
|
||
var ctx = new FakeAiContext();
|
||
ctx.S.Last = new UnityEngine.Vector2(7f, 2f);
|
||
var rt = Build(ctx);
|
||
ctx.S.Vision = true; rt.Tick(0.1f);
|
||
Assert.AreEqual(new UnityEngine.Vector2(7f, 2f), ctx.L.FacedAt);
|
||
}
|
||
|
||
// ── 降级:追击后永不回警觉 ─────────────────────────────────────────
|
||
|
||
[Test]
|
||
public void AfterEngagement_GoesToRest_NeverBackToAlert()
|
||
{
|
||
var ctx = new FakeAiContext();
|
||
var rt = Build(ctx);
|
||
ctx.S.Chase = true; rt.Tick(0.1f);
|
||
Assert.AreEqual(RushEngagement.Rush, rt.CurrentStateName);
|
||
ctx.S.Chase = false; ctx.S.Vision = false; rt.Tick(0.1f);
|
||
Assert.AreEqual(SinglePost.Post, rt.CurrentStateName); // 不是 Alert
|
||
}
|
||
|
||
// ── 死亡 ──────────────────────────────────────────────────────────
|
||
|
||
[Test]
|
||
public void Died_TransitionsToDeath_FromAnyState()
|
||
{
|
||
var ctx = new FakeAiContext();
|
||
var rt = Build(ctx);
|
||
ctx.S.Chase = true; rt.Tick(0.1f);
|
||
Assert.AreEqual(RushEngagement.Rush, rt.CurrentStateName);
|
||
rt.Send(AiSignal.Died);
|
||
rt.Tick(0.1f);
|
||
Assert.AreEqual(PerceptionSkeleton.Death, rt.CurrentStateName);
|
||
}
|
||
|
||
// 真实死亡路径:PerformDeath 先 ForceState(Dead) 再发 Died,
|
||
// 所以信号被处理时 IsControllable 已为假。事件边必须仍能通过。
|
||
[Test]
|
||
public void Died_WorksEvenWhenNotControllable()
|
||
{
|
||
var ctx = new FakeAiContext();
|
||
var rt = Build(ctx);
|
||
ctx.V.Controllable = false; // 已是 Dead 物理态
|
||
rt.Send(AiSignal.Died);
|
||
rt.Tick(0.1f);
|
||
Assert.AreEqual(PerceptionSkeleton.Death, rt.CurrentStateName);
|
||
}
|
||
|
||
[Test]
|
||
public void Death_IsTerminal_NoConditionEdgesEvaluatedAfterwards()
|
||
{
|
||
var ctx = new FakeAiContext();
|
||
var rt = Build(ctx);
|
||
rt.Send(AiSignal.Died);
|
||
rt.Tick(0.1f);
|
||
ctx.V.Controllable = false; // 死后不可控
|
||
ctx.S.Chase = true; // 即便玩家又进追逐区
|
||
for (int i = 0; i < 10; i++) rt.Tick(0.1f);
|
||
Assert.AreEqual(PerceptionSkeleton.Death, rt.CurrentStateName); // 也不复活
|
||
}
|
||
|
||
// ── 校验 ──────────────────────────────────────────────────────────
|
||
|
||
sealed class BadUnaware : IUnawareModule
|
||
{
|
||
public string Entry => "X";
|
||
public string Rest => "NotInStates";
|
||
public IReadOnlyList<string> States => new[] { "X" };
|
||
public void Declare(BrainBuilder b) => AiStateFragments.Locomotion(b, "X", LocomotionMode.Idle);
|
||
public void Link(BrainBuilder b) { }
|
||
}
|
||
|
||
sealed class UndeclaredUnaware : IUnawareModule
|
||
{
|
||
public string Entry => "Y";
|
||
public string Rest => "Y";
|
||
public IReadOnlyList<string> States => new[] { "Y" };
|
||
public void Declare(BrainBuilder b) { } // 忘了声明行为
|
||
public void Link(BrainBuilder b) { }
|
||
}
|
||
|
||
[Test]
|
||
public void Throws_WhenRestNotInStates()
|
||
{
|
||
var b = new BrainBuilder();
|
||
Assert.Throws<System.InvalidOperationException>(() => PerceptionSkeleton.Add(
|
||
b, new BadUnaware(), new RushEngagement("rush")));
|
||
}
|
||
|
||
[Test]
|
||
public void Throws_WhenUnawareStateNotDeclared()
|
||
{
|
||
var b = new BrainBuilder();
|
||
Assert.Throws<System.InvalidOperationException>(() => PerceptionSkeleton.Add(
|
||
b, new UndeclaredUnaware(), new RushEngagement("rush")));
|
||
}
|
||
|
||
[Test]
|
||
public void Throws_WhenModuleIsNull()
|
||
{
|
||
var b = new BrainBuilder();
|
||
Assert.Throws<System.ArgumentNullException>(() => PerceptionSkeleton.Add(
|
||
b, null, new RushEngagement("rush")));
|
||
}
|
||
|
||
// ── 模块组合:抽象的存在理由,必须端到端验证 ─────────────────────
|
||
|
||
[Test]
|
||
public void Skeleton_WithApproachAttack_EscalatesAndDisengages()
|
||
{
|
||
var ctx = new FakeAiContext();
|
||
var rt = Build(ctx, new SinglePost(LocomotionMode.Patrol), new ApproachAttackEngagement());
|
||
Assert.AreEqual(SinglePost.Post, rt.CurrentStateName);
|
||
|
||
ctx.S.Chase = true; rt.Tick(0.1f);
|
||
Assert.AreEqual(ApproachAttackEngagement.Approach, rt.CurrentStateName);
|
||
|
||
ctx.C.Eligible = true; rt.Tick(0.1f);
|
||
Assert.AreEqual(ApproachAttackEngagement.Attack, rt.CurrentStateName);
|
||
|
||
ctx.C.Running = null; rt.Tick(0.1f); // 招式打完
|
||
Assert.AreEqual(ApproachAttackEngagement.Approach, rt.CurrentStateName);
|
||
|
||
ctx.S.Chase = false; ctx.S.Vision = false; ctx.C.Eligible = false;
|
||
rt.Tick(0.1f);
|
||
Assert.AreEqual(SinglePost.Post, rt.CurrentStateName); // 脱战回 Rest
|
||
}
|
||
|
||
[Test]
|
||
public void Skeleton_WithDisguiseThenPatrol_DisengagesToPatrol_NotDisguise()
|
||
{
|
||
// 唯一 Entry != Rest 的未发现层:出生伪装静止,脱战只回巡逻,伪装态从此不可达。
|
||
var ctx = new FakeAiContext();
|
||
var rt = Build(ctx, new DisguiseThenPatrol());
|
||
Assert.AreEqual(DisguiseThenPatrol.Disguise, rt.CurrentStateName);
|
||
|
||
ctx.S.Chase = true; rt.Tick(0.1f);
|
||
Assert.AreEqual(RushEngagement.Rush, rt.CurrentStateName);
|
||
|
||
ctx.S.Chase = false; ctx.S.Vision = false; rt.Tick(0.1f);
|
||
Assert.AreEqual(DisguiseThenPatrol.Patrol, rt.CurrentStateName); // 不是 Disguise
|
||
|
||
// 再交战一轮,仍然只回巡逻——伪装态不可逆
|
||
ctx.S.Chase = true; rt.Tick(0.1f);
|
||
Assert.AreEqual(RushEngagement.Rush, rt.CurrentStateName);
|
||
ctx.S.Chase = false; rt.Tick(0.1f);
|
||
Assert.AreEqual(DisguiseThenPatrol.Patrol, rt.CurrentStateName);
|
||
}
|
||
|
||
[Test]
|
||
public void EntryCanBeOverridden_ForEnemiesWithPrefixStates()
|
||
{
|
||
// 骨架注释承诺的出路:掉落链 / 出场链这类前置态,调 Add 之后再覆盖 Entry。
|
||
var ctx = new FakeAiContext();
|
||
var b = new BrainBuilder();
|
||
PerceptionSkeleton.Add(b, new SinglePost(LocomotionMode.Patrol),
|
||
new RushEngagement("rush", RushExit.OnLostTarget));
|
||
AiStateFragments.Locomotion(b, "Ceiling", LocomotionMode.Idle)
|
||
.To(SinglePost.Post).When(x => x.Sensor.SeesPlayer(), "landed");
|
||
b.Entry("Ceiling"); // 覆盖骨架设的入口
|
||
|
||
var rt = new AiRuntime(b.Build(), ctx);
|
||
Assert.AreEqual("Ceiling", rt.CurrentStateName);
|
||
|
||
ctx.S.Sees = true; rt.Tick(0.1f);
|
||
Assert.AreEqual(SinglePost.Post, rt.CurrentStateName); // 落地后并入感知层
|
||
|
||
ctx.S.Chase = true; rt.Tick(0.1f);
|
||
Assert.AreEqual(RushEngagement.Rush, rt.CurrentStateName); // 升级边照常生效
|
||
}
|
||
}
|
||
}
|