using NUnit.Framework; using ProjectM.Server; using ProjectM.Simulation; using Unity.Core; using Unity.Entities; namespace ProjectM.Tests { /// /// Plain-Entities EditMode tests for the server-only HealthApplyDamageSystem. /// Boots a bare ECS world, registers the system in the SimulationSystemGroup, and ticks it. /// The system carries [WorldSystemFilter(ServerSimulation)], but world-system filtering is only /// applied by the netcode bootstrap; when we GetOrCreateSystem and add it to a group manually the /// filter is ignored, so it still runs in this netcode-free world. The system plays its /// EntityCommandBuffer back to state.EntityManager immediately (Temp allocator) per the build /// contract, so no separate ECB system is required for destruction to take effect within a single /// group update. Mirrors PlayerMoveSystemTests and HeartbeatSystemTests. /// public class HealthApplyDamageSystemTests { /// /// Builds a bare world, registers HealthApplyDamageSystem in the SimulationSystemGroup, and /// returns both so each test can create entities and tick. /// static (World world, SimulationSystemGroup group) MakeWorld(string name) { var world = new World(name); var group = world.GetOrCreateSystemManaged(); group.AddSystemToUpdateList(world.GetOrCreateSystem()); group.SortSystems(); // Fixed time so the (time-independent) system runs cleanly; deterministic regardless. world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f)); return (world, group); } [Test] public void Damage_Events_Are_Summed_Subtracted_And_Buffer_Cleared() { var (world, group) = MakeWorld("HealthApplyDamageTestWorld"); using (world) { var em = world.EntityManager; // TrainingDummyTag included so this entity is a valid death candidate too, but with // 50 HP and 35 total damage it survives, so it must NOT be destroyed here. var entity = em.CreateEntity(typeof(Health), typeof(DamageEvent), typeof(TrainingDummyTag)); em.SetComponentData(entity, new Health { Current = 50f, Max = 50f }); var dmg = em.GetBuffer(entity); dmg.Add(new DamageEvent { Amount = 20f, SourceNetworkId = 1 }); dmg.Add(new DamageEvent { Amount = 15f, SourceNetworkId = 2 }); group.Update(); Assert.IsTrue(em.Exists(entity), "Non-lethal damage (35 of 50) must not destroy the entity."); Assert.AreEqual(15f, em.GetComponentData(entity).Current, 1e-4f, "Health.Current should be 50 minus (20 plus 15) = 15."); Assert.AreEqual(0, em.GetBuffer(entity).Length, "The DamageEvent buffer must be cleared after the events are applied."); } } [Test] public void Lethal_Damage_Clamps_Health_To_Zero_And_Destroys_TrainingDummy() { var (world, group) = MakeWorld("HealthApplyDamageLethalDummyWorld"); using (world) { var em = world.EntityManager; var entity = em.CreateEntity(typeof(Health), typeof(DamageEvent), typeof(TrainingDummyTag)); em.SetComponentData(entity, new Health { Current = 50f, Max = 50f }); var dmg = em.GetBuffer(entity); dmg.Add(new DamageEvent { Amount = 80f, SourceNetworkId = 7 }); group.Update(); // The system clamps Current to a floor of 0 then destroys the dummy via an // immediately-played ECB, so the entity is gone within this single group update. Assert.IsFalse(em.Exists(entity), "A lethally-hit TrainingDummyTag entity must be destroyed by the system's ECB."); using var remaining = em.CreateEntityQuery(typeof(TrainingDummyTag)); Assert.AreEqual(0, remaining.CalculateEntityCount(), "No TrainingDummyTag entities should remain after a lethal hit."); } } [Test] public void Lethal_Damage_On_NonDummy_Clamps_To_Zero_Without_Destroying() { // Player death is deferred in M2; only TrainingDummyTag entities are destroyed. // A non-dummy (no TrainingDummyTag) at zero HP must be clamped to 0 and kept alive. var (world, group) = MakeWorld("HealthApplyDamageLethalPlayerWorld"); using (world) { var em = world.EntityManager; var entity = em.CreateEntity(typeof(Health), typeof(DamageEvent)); em.SetComponentData(entity, new Health { Current = 30f, Max = 30f }); var dmg = em.GetBuffer(entity); dmg.Add(new DamageEvent { Amount = 100f, SourceNetworkId = 3 }); group.Update(); Assert.IsTrue(em.Exists(entity), "A non-dummy (player) entity must survive lethal damage in M2; death is deferred."); Assert.AreEqual(0f, em.GetComponentData(entity).Current, 1e-4f, "Health.Current must be clamped to 0 (never negative)."); Assert.AreEqual(0, em.GetBuffer(entity).Length, "The DamageEvent buffer must be cleared after the events are applied."); } } [Test] public void No_Damage_Events_Leaves_Health_Untouched() { // Guards the dmg.Length == 0 early-continue: an empty buffer must not alter Health. var (world, group) = MakeWorld("HealthApplyDamageNoEventsWorld"); using (world) { var em = world.EntityManager; var entity = em.CreateEntity(typeof(Health), typeof(DamageEvent), typeof(TrainingDummyTag)); em.SetComponentData(entity, new Health { Current = 42f, Max = 60f }); group.Update(); Assert.IsTrue(em.Exists(entity), "An undamaged entity must not be destroyed."); Assert.AreEqual(42f, em.GetComponentData(entity).Current, 1e-4f, "Health.Current must be untouched when there are no DamageEvents."); } } } }