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."); } } } }