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.");
}
}
[Test]
public void God_Mode_Skips_All_Damage()
{
var (world, group) = MakeWorld("HealthApplyDamageGodModeWorld");
using (world)
{
var em = world.EntityManager;
var entity = em.CreateEntity(typeof(Health), typeof(DamageEvent), typeof(DebugGodMode));
em.SetComponentData(entity, new Health { Current = 50f, Max = 50f });
em.SetComponentEnabled(entity, true);
var dmg = em.GetBuffer(entity);
dmg.Add(new DamageEvent { Amount = 80f, SourceNetworkId = 9 });
group.Update();
Assert.AreEqual(50f, em.GetComponentData(entity).Current, 1e-4f,
"An enabled DebugGodMode entity ignores all damage.");
Assert.AreEqual(0, em.GetBuffer(entity).Length,
"The damage buffer is still drained (cleared) under god-mode.");
}
}
}
}