diff --git a/Assets/_Project/Tests/EditMode/BossAISystemTests.cs b/Assets/_Project/Tests/EditMode/BossAISystemTests.cs new file mode 100644 index 000000000..7677c1dfe --- /dev/null +++ b/Assets/_Project/Tests/EditMode/BossAISystemTests.cs @@ -0,0 +1,159 @@ +using NUnit.Framework; +using ProjectM.Server; +using ProjectM.Simulation; +using Unity.Entities; +using Unity.Mathematics; +using Unity.Transforms; + +namespace ProjectM.Tests +{ + /// + /// Plain-Entities EditMode coverage for the server-only BossAISystem (the sole boss mover/attacker, previously + /// Play-only). Exercises phase gating, expedition-only targeting, the telegraphed radial-slam AoE + its radius + /// boundary, the B4 lunge-vs-slam windup disambiguation (a naive shared-windup reuse would slam on a lunge + /// elapse), and knockback immunity. Values are pinned to Tuning.Boss* so the tests track tuning, not literals. + /// No PhysicsWorldSingleton is created -> sweep=false -> the boss moves/lands unswept (headless-safe). + /// + public class BossAISystemTests + { + static Entity MakeBoss(EntityManager em, float3 pos, float cur, float max, + byte pending = 0, uint windup = 0, uint slamReady = 0, uint lungeReady = 0, uint knockUntil = 0) + { + var e = em.CreateEntity(typeof(EnemyTag), typeof(BossState), typeof(LocalTransform), typeof(EnemyStats), + typeof(Health), typeof(AttackWindup), typeof(KnockbackState), typeof(LungeState)); + em.SetComponentData(e, LocalTransform.FromPosition(pos)); + em.SetComponentData(e, new EnemyStats { MoveSpeed = 3f, AttackRange = 2f, AttackDamage = 10f, AttackCooldownTicks = 30 }); + em.SetComponentData(e, new Health { Current = cur, Max = max }); + em.SetComponentData(e, new BossState { Phase = 1, PendingAttack = pending, SlamReadyTick = slamReady, LungeReadyTick = lungeReady }); + em.SetComponentData(e, new AttackWindup { WindUpUntilTick = windup }); + em.SetComponentData(e, new KnockbackState { UntilTick = knockUntil }); + return e; + } + + static Entity MakePlayer(EntityManager em, float3 pos, byte region = RegionId.Expedition, float hp = 100f) + { + var e = em.CreateEntity(typeof(PlayerTag), typeof(RegionTag), typeof(LocalTransform), typeof(Health), typeof(DamageEvent)); + em.SetComponentData(e, new RegionTag { Region = region }); + em.SetComponentData(e, LocalTransform.FromPosition(pos)); + em.SetComponentData(e, new Health { Current = hp, Max = hp }); + return e; + } + + [Test] + public void Phase_One_When_Above_Half_HP() + { + var (world, group) = TestWorld.Make("Boss_P1", tick: 200, server: true); + using (world) + { + var em = world.EntityManager; + var boss = MakeBoss(em, float3.zero, cur: 60f, max: 100f); + MakePlayer(em, new float3(3f, 0f, 0f)); + group.Update(); + Assert.AreEqual((byte)1, em.GetComponentData(boss).Phase, "Above the phase-2 fraction -> phase 1."); + } + } + + [Test] + public void Phase_Two_At_Or_Below_Half_HP() + { + var (world, group) = TestWorld.Make("Boss_P2", tick: 200, server: true); + using (world) + { + var em = world.EntityManager; + float cur = 100f * Tuning.BossPhase2HealthFraction; // exactly the boundary + var boss = MakeBoss(em, float3.zero, cur: cur, max: 100f); + MakePlayer(em, new float3(3f, 0f, 0f)); + group.Update(); + Assert.AreEqual((byte)2, em.GetComponentData(boss).Phase, "At/below the fraction -> phase 2."); + } + } + + [Test] + public void No_Living_Expedition_Target_Leaves_Boss_Idle() + { + var (world, group) = TestWorld.Make("Boss_NoTgt", tick: 200, server: true); + using (world) + { + var em = world.EntityManager; + var boss = MakeBoss(em, float3.zero, cur: 100f, max: 100f); + MakePlayer(em, new float3(3f, 0f, 0f), region: RegionId.Base); // wrong region + MakePlayer(em, new float3(4f, 0f, 0f), region: RegionId.Expedition, hp: 0f); // dead + + group.Update(); + + Assert.AreEqual(float3.zero, em.GetComponentData(boss).Position, "No valid target -> the boss does not move."); + Assert.AreEqual(0u, em.GetComponentData(boss).WindUpUntilTick, "No target -> no telegraph."); + } + } + + [Test] + public void Slam_Lands_Radial_AoE_On_Windup_Elapse() + { + var (world, group) = TestWorld.Make("Boss_Slam", tick: 200, server: true); + using (world) + { + var em = world.EntityManager; + var boss = MakeBoss(em, float3.zero, cur: 100f, max: 100f, pending: 0, windup: 100); // 100 <= 200 -> elapsed + var player = MakePlayer(em, new float3(1f, 0f, 0f)); // inside slam radius + + group.Update(); + + var dmg = em.GetBuffer(player); + Assert.AreEqual(1, dmg.Length, "A player inside the slam radius takes one hit on landing."); + Assert.AreEqual(Tuning.BossSlamDamage, dmg[0].Amount, 1e-3f); + Assert.AreEqual(-1, dmg[0].SourceNetworkId, "Slam damage is sourced from the boss/environment (-1)."); + Assert.AreEqual(0u, em.GetComponentData(boss).WindUpUntilTick, "Windup cleared after landing."); + } + } + + [Test] + public void Slam_Misses_A_Player_Outside_The_Radius() + { + var (world, group) = TestWorld.Make("Boss_SlamMiss", tick: 200, server: true); + using (world) + { + var em = world.EntityManager; + MakeBoss(em, float3.zero, cur: 100f, max: 100f, pending: 0, windup: 100); + var player = MakePlayer(em, new float3(Tuning.BossSlamRadius + 1f, 0f, 0f)); // just outside + + group.Update(); + + Assert.AreEqual(0, em.GetBuffer(player).Length, "Outside the slam radius takes no hit."); + } + } + + [Test] + public void Lunge_Windup_Elapse_Commits_Travel_And_Does_Not_Slam() + { + var (world, group) = TestWorld.Make("Boss_Lunge", tick: 200, server: true); + using (world) + { + var em = world.EntityManager; + var boss = MakeBoss(em, float3.zero, cur: 100f, max: 100f, pending: 1, windup: 100); // PendingAttack=1 => lunge + var player = MakePlayer(em, new float3(1f, 0f, 0f)); // inside slam radius, but a LUNGE must not slam + + group.Update(); + + Assert.AreEqual(0, em.GetBuffer(player).Length, "A lunge elapse must NOT deal slam damage (B4 disambiguation)."); + Assert.AreEqual(Tuning.BossLungeSpeed, em.GetComponentData(boss).Speed, 1e-3f, "Lunge travel committed."); + Assert.AreEqual(0u, em.GetComponentData(boss).WindUpUntilTick, "Windup cleared on lunge commit."); + } + } + + [Test] + public void Knockback_Residual_Is_Zeroed_Boss_Is_Immune() + { + var (world, group) = TestWorld.Make("Boss_Knock", tick: 200, server: true); + using (world) + { + var em = world.EntityManager; + var boss = MakeBoss(em, float3.zero, cur: 100f, max: 100f, knockUntil: 999u); + MakePlayer(em, new float3(3f, 0f, 0f)); + + group.Update(); + + Assert.AreEqual(0u, em.GetComponentData(boss).UntilTick, "The boss is knockback-immune: residual is zeroed."); + } + } + } +} diff --git a/Assets/_Project/Tests/EditMode/BossAISystemTests.cs.meta b/Assets/_Project/Tests/EditMode/BossAISystemTests.cs.meta new file mode 100644 index 000000000..c84c70e56 --- /dev/null +++ b/Assets/_Project/Tests/EditMode/BossAISystemTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 407138571a76c6f4f821fcbde92327a5 \ No newline at end of file