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 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 States => new[] { "Y" }; public void Declare(BrainBuilder b) { } // 忘了声明行为 public void Link(BrainBuilder b) { } } [Test] public void Throws_WhenRestNotInStates() { var b = new BrainBuilder(); Assert.Throws(() => PerceptionSkeleton.Add( b, new BadUnaware(), new RushEngagement("rush"))); } [Test] public void Throws_WhenUnawareStateNotDeclared() { var b = new BrainBuilder(); Assert.Throws(() => PerceptionSkeleton.Add( b, new UndeclaredUnaware(), new RushEngagement("rush"))); } [Test] public void Throws_WhenModuleIsNull() { var b = new BrainBuilder(); Assert.Throws(() => 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); // 升级边照常生效 } } }