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 { /// /// MC-2 tests for the hostile Spitter projectile systems (server-only, plain SimulationSystemGroup): /// EnemyProjectileMoveSystem integrates + writes LastStep; EnemyProjectileDamageSystem swept-hit-tests players + /// structures, REGION-FILTERED, appending a DamageEvent + destroying the spit at-most-once. Covers the two /// review-mandated regressions: swept anti-TUNNELLING (a per-tick step bigger than the target radius still /// registers) and the cross-region damage guard (an Expedition spit must not damage a Base target on its path). /// public class EnemyProjectileTests { static void SetTick(World w, uint tick) { var em = w.EntityManager; using var q = em.CreateEntityQuery(typeof(NetworkTime)); Entity e = q.IsEmpty ? em.CreateEntity(typeof(NetworkTime)) : q.GetSingletonEntity(); em.SetComponentData(e, new NetworkTime { ServerTick = new NetworkTick(tick) }); } static (World, SimulationSystemGroup) MoveWorld() { var w = new World("EnemyProjMove"); var g = w.GetOrCreateSystemManaged(); g.AddSystemToUpdateList(w.GetOrCreateSystem()); g.SortSystems(); w.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 0.1f)); return (w, g); } static (World, SimulationSystemGroup) DamageWorld() { var w = new World("EnemyProjDmg"); var g = w.GetOrCreateSystemManaged(); g.AddSystemToUpdateList(w.GetOrCreateSystem()); g.SortSystems(); w.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 0.1f)); SetTick(w, 200); return (w, g); } static Entity MakeSpit(EntityManager em, float3 pos, float2 dir, float speed, float range, byte region, float lastStep = 0f, float damage = 10f) { var e = em.CreateEntity(); em.AddComponentData(e, LocalTransform.FromPosition(pos)); em.AddComponentData(e, new EnemyProjectile { Direction = dir, Speed = speed, Damage = damage, Range = range, DistanceTravelled = 0f, LastStep = lastStep, Region = region }); return e; } static Entity MakePlayerTarget(EntityManager em, float3 pos, byte region, float radius = 0.6f) { var e = em.CreateEntity(); em.AddComponentData(e, LocalTransform.FromPosition(pos)); em.AddComponentData(e, new Health { Current = 100f, Max = 100f }); em.AddComponentData(e, new HitRadius { Value = radius }); em.AddComponentData(e, new RegionTag { Region = region }); em.AddBuffer(e); em.AddComponent(e); return e; } [Test] public void Move_IntegratesAndStoresLastStep() { var (w, g) = MoveWorld(); using (w) { var em = w.EntityManager; var spit = MakeSpit(em, new float3(0, 1, 0), new float2(1, 0), 10f, 5f, RegionId.Base); g.Update(); // dt 0.1 * speed 10 = step 1 var p = em.GetComponentData(spit); Assert.AreEqual(1f, p.LastStep, 1e-4f, "LastStep = Speed*dt (for the swept segment)"); Assert.AreEqual(1f, p.DistanceTravelled, 1e-4f); Assert.AreEqual(1f, em.GetComponentData(spit).Position.x, 1e-4f, "moved along +X"); } } [Test] public void Damage_HitsSameRegionPlayer_DestroysAtMostOnce() { var (w, g) = DamageWorld(); using (w) { var em = w.EntityManager; var player = MakePlayerTarget(em, new float3(5, 1, 0), RegionId.Base); var spit = MakeSpit(em, new float3(5, 1, 0), new float2(1, 0), 10f, 20f, RegionId.Base, lastStep: 1f); g.Update(); Assert.AreEqual(1, em.GetBuffer(player).Length, "same-region player takes the hit"); Assert.IsFalse(em.Exists(spit), "the spit is consumed on hit"); } } [Test] public void Damage_RegionFilter_ExpeditionSpitSparesBasePlayer() { var (w, g) = DamageWorld(); using (w) { var em = w.EntityManager; var basePlayer = MakePlayerTarget(em, new float3(5, 1, 0), RegionId.Base); var spit = MakeSpit(em, new float3(5, 1, 0), new float2(1, 0), 10f, 20f, RegionId.Expedition, lastStep: 1f); g.Update(); Assert.AreEqual(0, em.GetBuffer(basePlayer).Length, "cross-region spit must NOT damage an off-region player"); Assert.IsTrue(em.Exists(spit), "and it is not consumed by an off-region target"); } } [Test] public void Damage_SweptSegment_NoTunnelThroughSmallTarget() { var (w, g) = DamageWorld(); using (w) { var em = w.EntityManager; // target radius 0.5 at x=5; spit now at x=10 but stepped 8 this tick (start x=2) -> segment [2..10] crosses x=5. var player = MakePlayerTarget(em, new float3(5, 1, 0), RegionId.Base, radius: 0.5f); var spit = MakeSpit(em, new float3(10, 1, 0), new float2(1, 0), 80f, 50f, RegionId.Base, lastStep: 8f); g.Update(); Assert.AreEqual(1, em.GetBuffer(player).Length, "swept segment hits even when the per-tick step exceeds the target radius (no tunnelling)"); } } } }