using NUnit.Framework; using ProjectM.Server; using ProjectM.Simulation; using Unity.Core; using Unity.Entities; using Unity.Mathematics; using Unity.NetCode; using Unity.Transforms; namespace ProjectM.Tests { /// /// Plain-Entities tests for — the server-authoritative /// projectile hit resolver. Boots a bare ECS world, registers the system in the /// SimulationSystemGroup (the [WorldSystemFilter] is ignored under manual registration), creates /// synthetic targets and projectiles, injects a fixed delta-time, ticks once, and asserts the /// queued s and projectile lifetime. The system plays its ECB back to the /// EntityManager immediately, so no separate ECB system is needed. /// /// The key case is : it pins the swept /// (segment-vs-sphere) hit test that replaced a naive point check, which let fast projectiles — /// and any projectile while the server tick-batches under load — pass straight through a target in /// a single step. This was caught only at runtime (no EditMode coverage existed), hence this test. /// /// public class ProjectileDamageSystemTests { static (World world, SimulationSystemGroup group, EntityManager em) MakeWorld() { var world = new World("ProjectileDamageTestWorld"); var group = world.GetOrCreateSystemManaged(); group.AddSystemToUpdateList(world.GetOrCreateSystem()); group.SortSystems(); return (world, group, world.EntityManager); } static Entity MakeTarget(EntityManager em, float3 pos, float hitRadius, float health, int ownerId = int.MinValue) { var e = em.CreateEntity(); em.AddComponentData(e, LocalTransform.FromPosition(pos)); em.AddComponentData(e, new HitRadius { Value = hitRadius }); em.AddComponentData(e, new Health { Current = health, Max = health }); em.AddBuffer(e); if (ownerId != int.MinValue) em.AddComponentData(e, new GhostOwner { NetworkId = ownerId }); return e; } // pos is the projectile's POST-move position for the tick (what the system reads); the system // reconstructs the travel segment as [pos - dir*speed*dt, pos]. static Entity MakeProjectile(EntityManager em, float3 pos, float2 dir, float speed, float damage, float range, float distanceTravelled, int ownerId) { var e = em.CreateEntity(); em.AddComponentData(e, LocalTransform.FromPosition(pos)); em.AddComponentData(e, new Projectile { Direction = dir, Speed = speed, Damage = damage, Range = range, DistanceTravelled = distanceTravelled, }); em.AddComponentData(e, new GhostOwner { NetworkId = ownerId }); return e; } static void Tick(World world, SimulationSystemGroup group, float dt) { world.SetTime(new TimeData(elapsedTime: dt, deltaTime: dt)); group.Update(); } [Test] public void DirectHit_AppendsDamageEvent_AndDestroysProjectile() { using var world = MakeWorld().world; var group = world.GetExistingSystemManaged(); var em = world.EntityManager; var target = MakeTarget(em, new float3(0f, 0f, 5f), hitRadius: 0.8f, health: 60f); var projectile = MakeProjectile(em, new float3(0f, 0f, 5f), new float2(0f, 1f), speed: 10f, damage: 20f, range: 20f, distanceTravelled: 5f, ownerId: 1); Tick(world, group, 0.1f); var dmg = em.GetBuffer(target); Assert.AreEqual(1, dmg.Length, "Overlapping projectile should append exactly one DamageEvent."); Assert.AreEqual(20f, dmg[0].Amount, 1e-3f, "DamageEvent should carry the projectile's damage."); Assert.IsFalse(em.Exists(projectile), "A projectile that hits a target must be destroyed."); } [Test] public void FastProjectile_SweptTest_DoesNotTunnel() { // Target sits at z=5. The projectile's post-move position is z=10 (already past the target), // having moved 10 units this tick (speed 100 * dt 0.1). A point check at z=10 would miss // (distance 5 >> 1.0); the swept segment [z=0 -> z=10] passes through the target -> hit. using var world = MakeWorld().world; var group = world.GetExistingSystemManaged(); var em = world.EntityManager; var target = MakeTarget(em, new float3(0f, 0f, 5f), hitRadius: 0.8f, health: 60f); var projectile = MakeProjectile(em, new float3(0f, 0f, 10f), new float2(0f, 1f), speed: 100f, damage: 20f, range: 50f, distanceTravelled: 10f, ownerId: 1); Tick(world, group, 0.1f); Assert.AreEqual(1, em.GetBuffer(target).Length, "A fast projectile whose step overshoots the target must still register a swept hit (no tunnelling)."); Assert.IsFalse(em.Exists(projectile), "The tunnelling projectile must be consumed on hit."); } [Test] public void OwnerOwnedTarget_IsSkipped_NoSelfHit() { using var world = MakeWorld().world; var group = world.GetExistingSystemManaged(); var em = world.EntityManager; // Target owned by the same NetworkId as the projectile's owner -> must be skipped. var ownTarget = MakeTarget(em, new float3(0f, 0f, 5f), hitRadius: 0.8f, health: 60f, ownerId: 7); var projectile = MakeProjectile(em, new float3(0f, 0f, 5f), new float2(0f, 1f), speed: 10f, damage: 20f, range: 50f, distanceTravelled: 5f, ownerId: 7); Tick(world, group, 0.1f); Assert.AreEqual(0, em.GetBuffer(ownTarget).Length, "A projectile must not damage a target owned by its own caster."); Assert.IsTrue(em.Exists(projectile), "With no valid target hit and range remaining, the projectile survives."); } [Test] public void SpentProjectile_BeyondRange_IsDestroyed_WithoutDamage() { using var world = MakeWorld().world; var group = world.GetExistingSystemManaged(); var em = world.EntityManager; // Target far off the path; projectile has already travelled past its range. var target = MakeTarget(em, new float3(100f, 0f, 100f), hitRadius: 0.8f, health: 60f); var projectile = MakeProjectile(em, new float3(0f, 0f, 0f), new float2(0f, 1f), speed: 5f, damage: 20f, range: 10f, distanceTravelled: 12f, ownerId: 1); Tick(world, group, 0.1f); Assert.AreEqual(0, em.GetBuffer(target).Length, "No target in the path: no damage."); Assert.IsFalse(em.Exists(projectile), "A projectile past its range must be destroyed."); } static Entity MakeProjectileFx(EntityManager em, float3 pos, float2 dir, float speed, float damage, float range, float distanceTravelled, int ownerId, byte pierce, byte chain, byte flags = 0) { var e = MakeProjectile(em, pos, dir, speed, damage, range, distanceTravelled, ownerId); em.AddComponentData(e, new ProjectileEffectState { PierceRemaining = pierce, ChainRemaining = chain, Flags = flags }); return e; } [Test] public void Pierce_SurvivesFirstTarget_HitsSecond_NeverReHitsFirst() { using var world = MakeWorld().world; var group = world.GetExistingSystemManaged(); var em = world.EntityManager; var a = MakeTarget(em, new float3(0f, 0f, 3f), hitRadius: 0.8f, health: 60f); var b = MakeTarget(em, new float3(0f, 0f, 6f), hitRadius: 0.8f, health: 60f); // Post-move at z=6; the swept segment [z=0 -> z=6] (speed*dt = 6) covers both; A (z=3) is earliest. var proj = MakeProjectileFx(em, new float3(0f, 0f, 6f), new float2(0f, 1f), speed: 60f, damage: 20f, range: 20f, distanceTravelled: 6f, ownerId: 1, pierce: 1, chain: 0); Tick(world, group, 0.1f); Assert.AreEqual(1, em.GetBuffer(a).Length, "earliest target A is hit"); Assert.AreEqual(0, em.GetBuffer(b).Length, "only the earliest target is hit per tick"); Assert.IsTrue(em.Exists(proj), "pierce lets the projectile survive the first hit"); Assert.AreEqual(0, em.GetComponentData(proj).PierceRemaining, "pierce consumed"); Tick(world, group, 0.1f); // same position: A now excluded by the hit-set -> B is the earliest Assert.AreEqual(1, em.GetBuffer(a).Length, "A must NOT be re-hit (hit-set exclusion)"); Assert.AreEqual(1, em.GetBuffer(b).Length, "B hit on the second pass"); Assert.IsFalse(em.Exists(proj), "with pierce spent, the second hit consumes the projectile"); } [Test] public void Chain_RetargetsTowardNextEnemy_AfterHit() { using var world = MakeWorld().world; var group = world.GetExistingSystemManaged(); var em = world.EntityManager; var a = MakeTarget(em, new float3(0f, 0f, 3f), hitRadius: 0.8f, health: 60f); // on-axis, hit first var b = MakeTarget(em, new float3(2f, 0f, 6f), hitRadius: 0.8f, health: 60f); // off-axis, chain target var proj = MakeProjectileFx(em, new float3(0f, 0f, 6f), new float2(0f, 1f), speed: 60f, damage: 20f, range: 20f, distanceTravelled: 6f, ownerId: 1, pierce: 0, chain: 1); Tick(world, group, 0.1f); Assert.AreEqual(1, em.GetBuffer(a).Length, "A (on the path) is hit"); Assert.AreEqual(0, em.GetBuffer(b).Length, "B is off the path, not hit by the sweep this tick"); Assert.IsTrue(em.Exists(proj), "chain lets the projectile survive to seek the next enemy"); Assert.AreEqual(0, em.GetComponentData(proj).ChainRemaining, "chain consumed"); var dir = em.GetComponentData(proj).Direction; Assert.Greater(dir.x, 0.5f, "Direction retargeted toward the off-axis next enemy B (+x)"); } } }