66edbdec69
10 new EditMode tests (312 total, all green): HealthApplyDamage destroys a Destructible at 0 + a wounded structure survives clamped; PickWeightedNearest x5 (player-only, structure-preferred-by-weight, player-in-the-way wins, raze undefended base, no targets); persistence (StructureSave.HP round-trip + writes v3, v2 in the load floor, SaveApply.ToPending maps the wounded HP - the staging-copy bug the pre-code review caught); + the StructureAggroWeight default pin. See DR-032. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
199 lines
9.4 KiB
C#
199 lines
9.4 KiB
C#
using NUnit.Framework;
|
|
using ProjectM.Server;
|
|
using ProjectM.Simulation;
|
|
using Unity.Core;
|
|
using Unity.Entities;
|
|
|
|
namespace ProjectM.Tests
|
|
{
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public class HealthApplyDamageSystemTests
|
|
{
|
|
/// <summary>
|
|
/// Builds a bare world, registers HealthApplyDamageSystem in the SimulationSystemGroup, and
|
|
/// returns both so each test can create entities and tick.
|
|
/// </summary>
|
|
static (World world, SimulationSystemGroup group) MakeWorld(string name)
|
|
{
|
|
var world = new World(name);
|
|
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
|
|
group.AddSystemToUpdateList(world.GetOrCreateSystem<HealthApplyDamageSystem>());
|
|
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<DamageEvent>(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<Health>(entity).Current, 1e-4f,
|
|
"Health.Current should be 50 minus (20 plus 15) = 15.");
|
|
Assert.AreEqual(0, em.GetBuffer<DamageEvent>(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<DamageEvent>(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<DamageEvent>(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<Health>(entity).Current, 1e-4f,
|
|
"Health.Current must be clamped to 0 (never negative).");
|
|
Assert.AreEqual(0, em.GetBuffer<DamageEvent>(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<Health>(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<DebugGodMode>(entity, true);
|
|
|
|
var dmg = em.GetBuffer<DamageEvent>(entity);
|
|
dmg.Add(new DamageEvent { Amount = 80f, SourceNetworkId = 9 });
|
|
|
|
group.Update();
|
|
|
|
Assert.AreEqual(50f, em.GetComponentData<Health>(entity).Current, 1e-4f,
|
|
"An enabled DebugGodMode entity ignores all damage.");
|
|
Assert.AreEqual(0, em.GetBuffer<DamageEvent>(entity).Length,
|
|
"The damage buffer is still drained (cleared) under god-mode.");
|
|
}
|
|
}
|
|
|
|
[Test]
|
|
public void Lethal_Damage_Destroys_A_Destructible_Structure()
|
|
{
|
|
var (world, group) = MakeWorld("HealthApplyDamageDestructibleWorld");
|
|
using (world)
|
|
{
|
|
var em = world.EntityManager;
|
|
// EB-1: a structure = Health + DamageEvent + Destructible, NO EffectiveCharacterStats (clamps to 0).
|
|
var e = em.CreateEntity(typeof(Health), typeof(DamageEvent), typeof(Destructible));
|
|
em.SetComponentData(e, new Health { Current = 100f, Max = 100f });
|
|
var dmg = em.GetBuffer<DamageEvent>(e);
|
|
dmg.Add(new DamageEvent { Amount = 60f, SourceNetworkId = -1 });
|
|
dmg.Add(new DamageEvent { Amount = 60f, SourceNetworkId = -1 }); // two Husk hits SUM (120 > 100)
|
|
|
|
group.Update();
|
|
|
|
Assert.IsFalse(em.Exists(e), "A Destructible at <=0 is destroyed exactly once (summed multi-hit, one DestroyEntity).");
|
|
using var q = em.CreateEntityQuery(typeof(Destructible));
|
|
Assert.AreEqual(0, q.CalculateEntityCount());
|
|
}
|
|
}
|
|
|
|
[Test]
|
|
public void Wounded_Destructible_Survives_And_Clamps_To_Zero_Floor()
|
|
{
|
|
var (world, group) = MakeWorld("HealthApplyDamageWoundedStructureWorld");
|
|
using (world)
|
|
{
|
|
var em = world.EntityManager;
|
|
var e = em.CreateEntity(typeof(Health), typeof(DamageEvent), typeof(Destructible));
|
|
em.SetComponentData(e, new Health { Current = 100f, Max = 100f });
|
|
em.GetBuffer<DamageEvent>(e).Add(new DamageEvent { Amount = 35f, SourceNetworkId = -1 });
|
|
|
|
group.Update();
|
|
|
|
Assert.IsTrue(em.Exists(e), "A non-lethally-hit structure survives WOUNDED (the persisted state).");
|
|
Assert.AreEqual(65f, em.GetComponentData<Health>(e).Current, 1e-4f, "No stats ceiling -> 100-35 = 65.");
|
|
}
|
|
}
|
|
|
|
}
|
|
}
|