using NUnit.Framework; using BaseGames.AI; namespace BaseGames.Tests.EditMode.AI { public class AiRuntimeTests { static AiGraph Graph() { var b = new BrainBuilder(); b.Entry("Patrol"); b.Global().To("Dead").OnEvent(AiSignal.Died); b.State("Patrol") .Tick(c => c.Locomotion.SetMode(LocomotionMode.Patrol)) .To("Chase").When(c => c.Sensor.SeesPlayer(), "SeesPlayer"); b.State("Chase") .OnEnter(c => c.Locomotion.Face(c.Sensor.LastKnown)) .OnExit(c => c.Locomotion.Stop()) .Tick(c => c.Locomotion.MoveTo(c.Sensor.LastKnown)) .To("Search").When(c => c.Sensor.LostFor(2f), "LostFor(2s)"); b.State("Search") .To("Patrol").After(3f); b.State("Dead"); return b.Build(); } [Test] public void StartsAtEntry() { var rt = new AiRuntime(Graph(), new FakeAiContext()); Assert.AreEqual("Patrol", rt.CurrentStateName); } [Test] public void Tick_RunsCurrentStateTickAction() { var ctx = new FakeAiContext(); var rt = new AiRuntime(Graph(), ctx); rt.Tick(0.1f); CollectionAssert.Contains(ctx.L.Calls, "SetMode:Patrol"); } [Test] public void ConditionTransition_FiresAndRunsEnterExit() { var ctx = new FakeAiContext(); var rt = new AiRuntime(Graph(), ctx); ctx.S.Sees = true; rt.Tick(0.1f); Assert.AreEqual("Chase", rt.CurrentStateName); CollectionAssert.Contains(ctx.L.Calls, "Face"); // Chase.OnEnter } [Test] public void Gate_SuspendsDecisionWhenNotControllable() { var ctx = new FakeAiContext(); var rt = new AiRuntime(Graph(), ctx); ctx.S.Sees = true; ctx.V.Controllable = false; // 受击中 rt.Tick(0.1f); Assert.AreEqual("Patrol", rt.CurrentStateName); // 未转换 Assert.IsTrue(rt.IsSuspended); CollectionAssert.DoesNotContain(ctx.L.Calls, "SetMode:Patrol"); // Tick 也不跑 } [Test] public void Gate_ResumesAndReevaluatesWhenControllable() { var ctx = new FakeAiContext(); var rt = new AiRuntime(Graph(), ctx); ctx.S.Sees = true; ctx.V.Controllable = false; rt.Tick(0.1f); ctx.V.Controllable = true; // 恢复 rt.Tick(0.1f); Assert.AreEqual("Chase", rt.CurrentStateName); } [Test] public void After_UsesStateTimer() { var ctx = new FakeAiContext(); var rt = new AiRuntime(Graph(), ctx); ctx.S.Sees = true; rt.Tick(0.1f); // -> Chase ctx.S.Sees = false; ctx.S.LostForValue = 5f; rt.Tick(0.1f); // -> Search Assert.AreEqual("Search", rt.CurrentStateName); rt.Tick(1f); rt.Tick(1f); Assert.AreEqual("Search", rt.CurrentStateName); // 未满 3s rt.Tick(1.5f); Assert.AreEqual("Patrol", rt.CurrentStateName); // 累计 >3s } [Test] public void Switch_RunsPreviousStateOnExit() { var ctx = new FakeAiContext(); var rt = new AiRuntime(Graph(), ctx); ctx.S.Sees = true; rt.Tick(0.1f); // Patrol -> Chase ctx.S.Sees = false; ctx.S.LostForValue = 5f; rt.Tick(0.1f);// Chase -> Search (触发 Chase.OnExit) CollectionAssert.Contains(ctx.L.Calls, "Stop"); } [Test] public void Construct_InvokesEntryOnEnter() { var b = new BrainBuilder(); b.Entry("Start"); b.State("Start").OnEnter(c => c.Locomotion.SetMode(LocomotionMode.Idle)); var ctx = new FakeAiContext(); var rt = new AiRuntime(b.Build(), ctx); CollectionAssert.Contains(ctx.L.Calls, "SetMode:Idle"); } [Test] public void EventTransition_FiresOnSignal_EvenBeforeTick() { var ctx = new FakeAiContext(); var rt = new AiRuntime(Graph(), ctx); rt.Send(AiSignal.Died); rt.Tick(0.1f); Assert.AreEqual("Dead", rt.CurrentStateName); } [Test] public void EventTransition_FiresEvenWhenNotControllable() { // 死亡等事件不受让位门限制(门只挡决策推进/条件评估,不挡事件) var ctx = new FakeAiContext(); var rt = new AiRuntime(Graph(), ctx); ctx.V.Controllable = false; rt.Send(AiSignal.Died); rt.Tick(0.1f); Assert.AreEqual("Dead", rt.CurrentStateName); } [Test] public void Trace_RecordsTriggerReason() { var ctx = new FakeAiContext(); var rt = new AiRuntime(Graph(), ctx); ctx.S.Sees = true; rt.Tick(0.1f); Assert.AreEqual(1, rt.Trace.Count); Assert.AreEqual("Patrol", rt.Trace[0].From); Assert.AreEqual("Chase", rt.Trace[0].To); StringAssert.Contains("SeesPlayer", rt.Trace[0].Trigger); } [Test] public void Reset_ReturnsToEntryAndClearsState() { var ctx = new FakeAiContext(); var rt = new AiRuntime(Graph(), ctx); ctx.S.Sees = true; rt.Tick(0.1f); // -> Chase ctx.BB.Set("scratch", 42); rt.Reset(); Assert.AreEqual("Patrol", rt.CurrentStateName); Assert.IsEmpty(rt.Trace); Assert.IsFalse(ctx.BB.Has("scratch")); } [Test] public void LocalEventTransition_Fires() { var b = new BrainBuilder(); b.Entry("Idle"); b.State("Idle").To("Flee").OnEvent(AiSignal.Died); b.State("Flee"); var ctx = new FakeAiContext(); var rt = new AiRuntime(b.Build(), ctx); rt.Send(AiSignal.Died); rt.Tick(0.1f); Assert.AreEqual("Flee", rt.CurrentStateName); } [Test] public void EventSelfTransition_DoesNotReenter() { var b = new BrainBuilder(); b.Entry("Dead"); int enterCount = 0; b.State("Dead").OnEnter(c => enterCount++); b.Global().To("Dead").OnEvent(AiSignal.Died); var ctx = new FakeAiContext(); var rt = new AiRuntime(b.Build(), ctx); // 构造即进入 Dead → enterCount==1 rt.Send(AiSignal.Died); rt.Tick(0.1f); // 已在 Dead,自转换应 no-op,不再 +1 Assert.AreEqual(1, enterCount); Assert.AreEqual("Dead", rt.CurrentStateName); } [Test] public void TwoRuntimes_ShareGraph_IndependentStateAndTimer() { var graph = Graph(); // 共享同一张不可变图 var a = new FakeAiContext(); var rtA = new AiRuntime(graph, a); var b = new FakeAiContext(); var rtB = new AiRuntime(graph, b); // A 发现玩家推进,B 不动 —— 状态独立 a.S.Sees = true; rtA.Tick(0.1f); Assert.AreEqual("Chase", rtA.CurrentStateName); Assert.AreEqual("Patrol", rtB.CurrentStateName); // 两者各自进入 Search(计时归零) a.S.Sees = false; a.S.LostForValue = 5f; rtA.Tick(0.1f); // A -> Search b.S.Sees = true; rtB.Tick(0.1f); // B -> Chase b.S.Sees = false; b.S.LostForValue = 5f; rtB.Tick(0.1f); // B -> Search // 只推进 A 的 After 计时越过 3s;B 计时独立、不动 rtA.Tick(2f); rtA.Tick(2f); // A Search 计时 ~4 -> After -> Patrol Assert.AreEqual("Patrol", rtA.CurrentStateName); Assert.AreEqual("Search", rtB.CurrentStateName); // B 独立,仍在 Search } } }