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."); } } }