Files
Project-M/Assets/_Project/Tests/EditMode/HealthApplyDamageSystemTests.cs
T
kronic 589e712db3 Hygiene B2: dead-code removal
Delete (unreferenced in any scene, per operator decision):
- TrainingDummy* chain (authoring/spawner/system/components/prefab) + UpgradePickup* chain (authoring/spawner/systems/components/prefab); remove TrainingDummyTag from the 3 live combat queries (AbilityFire/Melee/HealthApplyDamage now key on EnemyTag) and repoint HealthApplyDamageSystemTests to EnemyTag.
- Dead RPC RegionTransitSystem + RegionTransitRequest (no sender, ungated) + its test.
- Heartbeat + HeartbeatSystem (no WorldSystemFilter, ran no-op every tick) + its test.
- BuildSendSystem dead conveyor/pylon dev hooks (s_ConveyorDir, [/] rotation, Place{Pylon,Harvester,Conveyor} statics).
- Deprecated CyclePhase alias consts (Expedition/Defend/Build/*Ticks); repoint WaveSystemTests to Siege/Calm.
- Unread FeelConfig fields (HitFlashDurationMs, RumbleHeavy) + dangling HudUi doc-comments + stale crefs.

451/451 EditMode tests pass. CLAUDE.md HeartbeatSystemTests example ref to be repointed in B7.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 23:16:53 -07:00

198 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. Uses the plain-Entities world+tick pattern shared across the suite.
/// </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;
// EnemyTag 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(EnemyTag));
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_Enemy()
{
var (world, group) = MakeWorld("HealthApplyDamageLethalEnemyWorld");
using (world)
{
var em = world.EntityManager;
var entity = em.CreateEntity(typeof(Health), typeof(DamageEvent), typeof(EnemyTag));
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 enemy via an
// immediately-played ECB, so the entity is gone within this single group update.
Assert.IsFalse(em.Exists(entity),
"A lethally-hit EnemyTag entity must be destroyed by the system's ECB.");
using var remaining = em.CreateEntityQuery(typeof(EnemyTag));
Assert.AreEqual(0, remaining.CalculateEntityCount(),
"No EnemyTag entities should remain after a lethal hit.");
}
}
[Test]
public void Lethal_Damage_On_Untagged_Entity_Clamps_To_Zero_Without_Destroying()
{
// Only death candidates (EnemyTag / Destructible) are destroyed. An untagged entity
// (e.g. a player, whose death is deferred) 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),
"An untagged (player) entity must survive lethal damage; 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(EnemyTag));
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.");
}
}
}
}