using NUnit.Framework; using BaseGames.AI; using BaseGames.Enemies; namespace BaseGames.Tests.EditMode.AI { /// /// 嘲风定制图的状态序列断言。 /// /// 注意 只入队信号,事件转换在下一次 Tick 的第一步出队处理, /// 因此每个 Send 之后都必须跟一次 Tick 才会真正换态(不是"发了就到")。 /// public class ChaoFengAiTests { static AiRuntime Run(FakeAiContext ctx) => new AiRuntime(new ChaoFengAi().GetOrBuildGraph(), ctx); static FakeAiContext Ctx() { var c = new FakeAiContext(); c.S.Chase = true; // 竞技场内玩家恒在追逐区 c.S.Vision = true; return c; } /// 推进到地面阶段:开战 → 入场演出播完 → 地面逼近态。 static AiRuntime RunToGround(FakeAiContext ctx) { var rt = Run(ctx); rt.Send(AiSignal.Engaged); rt.Tick(0.1f); // 事件出队 → Intro ctx.C.Running = null; // 入场演出播完 rt.Tick(0.1f); // introDone → Ground return rt; } /// 再推进到空中阶段:血量过半 → 阶段过渡 → 过渡结束。 static void AdvanceToAirPhase(FakeAiContext ctx, AiRuntime rt) { ctx.V.Hp = 0.4f; rt.Tick(0.1f); // hp<50% → PhaseTx ctx.B.Transitioning = false; // 过渡结束 rt.Tick(0.1f); // txDone → Air } [Test] public void StartsInWait_UntilEngaged() { var ctx = Ctx(); var rt = Run(ctx); Assert.AreEqual(ChaoFengAi.Wait, rt.CurrentStateName); rt.Tick(0.1f); Assert.AreEqual(ChaoFengAi.Wait, rt.CurrentStateName, "未开战前不应自行进入战斗"); } [Test] public void Engaged_EntersIntro_ThenGround() { var ctx = Ctx(); var rt = Run(ctx); rt.Send(AiSignal.Engaged); rt.Tick(0.1f); Assert.AreEqual(ChaoFengAi.Intro, rt.CurrentStateName); CollectionAssert.Contains(ctx.C.Used, ChaoFengAi.IntroAbility); rt.Tick(0.1f); Assert.AreEqual(ChaoFengAi.Intro, rt.CurrentStateName, "入场演出未播完不应进战"); ctx.C.Running = null; // 入场演出播完 rt.Tick(0.1f); Assert.AreEqual(ChaoFengAi.Ground, rt.CurrentStateName); } [Test] public void Ground_ApproachAttackLoop() { var ctx = Ctx(); var rt = RunToGround(ctx); Assert.AreEqual(ChaoFengAi.Ground, rt.CurrentStateName); ctx.C.Eligible = true; rt.Tick(0.1f); Assert.AreEqual(ChaoFengAi.GroundAttack, rt.CurrentStateName); CollectionAssert.Contains(ctx.C.Used, "best"); ctx.C.Running = null; // 招式打完 rt.Tick(0.1f); Assert.AreEqual(ChaoFengAi.Ground, rt.CurrentStateName); } [Test] public void Ground_DoesNotDisengage_WhenPlayerLeavesAllZones() { // Boss 不脱战:逼近态的 rest 指回自身,脱离感知区也留在地面阶段。 var ctx = Ctx(); var rt = RunToGround(ctx); ctx.S.Chase = false; ctx.S.Vision = false; for (int i = 0; i < 5; i++) rt.Tick(0.1f); Assert.AreEqual(ChaoFengAi.Ground, rt.CurrentStateName, "Boss 不应脱战"); } [Test] public void HpBelowHalf_TransitionsToAirPhase() { var ctx = Ctx(); var rt = RunToGround(ctx); ctx.V.Hp = 0.4f; rt.Tick(0.1f); Assert.AreEqual(ChaoFengAi.PhaseTx, rt.CurrentStateName); Assert.AreEqual(1, ctx.B.BeginCallCount, "阶段过渡只应发起一次"); Assert.AreEqual(1, ctx.B.LastTargetPhase); Assert.Greater(ctx.B.LastDuration, 0f, "过渡期必须有正的无敌时长"); rt.Tick(0.1f); Assert.AreEqual(ChaoFengAi.PhaseTx, rt.CurrentStateName, "过渡期间不得提前转出"); Assert.AreEqual(1, ctx.B.BeginCallCount, "过渡期间不得重复发起"); ctx.B.Transitioning = false; // 过渡结束 rt.Tick(0.1f); Assert.AreEqual(ChaoFengAi.Air, rt.CurrentStateName, "过渡结束必须有出边转出——过渡态无出边会把 Boss 永久锁死且零报错"); } [Test] public void Air_HoverAttackLoop() { var ctx = Ctx(); var rt = RunToGround(ctx); AdvanceToAirPhase(ctx, rt); Assert.AreEqual(ChaoFengAi.Air, rt.CurrentStateName); ctx.C.Eligible = true; rt.Tick(0.1f); Assert.AreEqual(ChaoFengAi.AirAttack, rt.CurrentStateName); CollectionAssert.Contains(ctx.C.Used, "best"); ctx.C.Running = null; rt.Tick(0.1f); Assert.AreEqual(ChaoFengAi.Air, rt.CurrentStateName); } [Test] public void Died_GoesToDeathFromAnyState() { var ctx = Ctx(); var rt = RunToGround(ctx); rt.Send(AiSignal.Died); rt.Tick(0.1f); Assert.AreEqual(ChaoFengAi.Death, rt.CurrentStateName); } [Test] public void AirPhase_DoesNotFallBackToGround() { // 阶段单向推进:进了空中阶段就不再回地面阶段(招池已换) var ctx = Ctx(); var rt = RunToGround(ctx); AdvanceToAirPhase(ctx, rt); ctx.V.Hp = 0.9f; // 即使血量回升 for (int i = 0; i < 5; i++) rt.Tick(0.1f); Assert.AreEqual(ChaoFengAi.Air, rt.CurrentStateName); Assert.AreEqual(1, ctx.B.BeginCallCount, "不应再次发起阶段过渡"); } } }