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 EditMode tests for the server-only (LANTERN Vortex/LightZone). /// Guards the Build Spec RS-5 invariants: a player-cast zone damages ENEMIES ONLY (no friendly fire), credits /// the CASTER's NetworkId (kills feed KillReward), respects its radius, and despawns on lifetime elapse. The /// system reads a NetworkTime singleton (constructed here) and plays its ECB back immediately, so one /// group.Update() resolves within a single tick. [WorldSystemFilter] is ignored under manual registration. /// public class ZonePulseSystemTests { const uint kTick = 100; // "now" server tick for the fixture static (World world, SimulationSystemGroup group) MakeWorld(string name) { var world = new World(name); var group = world.GetOrCreateSystemManaged(); group.AddSystemToUpdateList(world.GetOrCreateSystem()); group.SortSystems(); world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f)); // NetworkTime singleton at a valid, non-zero ServerTick. var nt = world.EntityManager.CreateEntity(typeof(NetworkTime)); world.EntityManager.SetComponentData(nt, new NetworkTime { ServerTick = new NetworkTick(kTick) }); return (world, group); } static Entity MakeEnemy(EntityManager em, float3 pos, float hp = 100f) { var e = em.CreateEntity(typeof(EnemyTag), typeof(Health), typeof(DamageEvent), typeof(LocalTransform)); em.SetComponentData(e, new Health { Current = hp, Max = hp }); em.SetComponentData(e, LocalTransform.FromPosition(pos)); return e; } static Entity MakeZone(EntityManager em, float3 pos, int casterId, float radius, float dmg, uint nextTick, uint expireTick, byte flags = 0) { var z = em.CreateEntity(typeof(ZoneEffect), typeof(LocalTransform)); em.SetComponentData(z, LocalTransform.FromPosition(pos)); em.SetComponentData(z, new ZoneEffect { CasterNetworkId = casterId, Radius = radius, DamagePerPulse = dmg, NextTick = nextTick, ExpireTick = expireTick, Flags = flags, }); return z; } [Test] public void Pulse_Damages_Enemies_In_Radius_Credits_Caster_And_Spares_Players() { var (world, group) = MakeWorld("ZonePulse_EnemyOnly"); using (world) { var em = world.EntityManager; var inRange = MakeEnemy(em, new float3(2f, 0f, 0f)); var outRange = MakeEnemy(em, new float3(20f, 0f, 0f)); // A living player standing INSIDE the zone must take NO friendly fire. var player = em.CreateEntity(typeof(PlayerTag), typeof(Health), typeof(DamageEvent), typeof(LocalTransform)); em.SetComponentData(player, new Health { Current = 100f, Max = 100f }); em.SetComponentData(player, LocalTransform.FromPosition(new float3(1f, 0f, 0f))); // NextTick == now → DUE this tick (radius 5, 10 dmg/pulse, caster 42, never expires). MakeZone(em, float3.zero, casterId: 42, radius: 5f, dmg: 10f, nextTick: kTick, expireTick: 0u); group.Update(); var inBuf = em.GetBuffer(inRange); Assert.AreEqual(1, inBuf.Length, "An enemy inside the radius takes exactly one pulse."); Assert.AreEqual(10f, inBuf[0].Amount, 1e-4f, "Pulse deals DamagePerPulse."); Assert.AreEqual(42, inBuf[0].SourceNetworkId, "The pulse credits the caster's NetworkId (kill attribution)."); Assert.AreEqual(0, em.GetBuffer(outRange).Length, "An enemy outside the radius is untouched."); Assert.AreEqual(0, em.GetBuffer(player).Length, "A player in the zone takes NO friendly fire (enemy-only)."); } } [Test] public void Born_Zero_NextTick_Lazy_Stamps_And_Does_Not_Storm_Fire() { var (world, group) = MakeWorld("ZonePulse_BornZero"); using (world) { var em = world.EntityManager; var enemy = MakeEnemy(em, new float3(1f, 0f, 0f)); // NextTick 0 = unscheduled: the INVERTED guard must lazy-stamp born-correct, NOT fire this tick. var zone = MakeZone(em, float3.zero, casterId: 7, radius: 5f, dmg: 10f, nextTick: 0u, expireTick: 0u); group.Update(); Assert.AreEqual(0, em.GetBuffer(enemy).Length, "A born-0 NextTick must lazy-stamp (never storm-fire) on the first tick."); Assert.AreNotEqual(0u, em.GetComponentData(zone).NextTick, "NextTick must be stamped born-correct (non-zero) after the first tick."); } } [Test] public void Expired_Zone_Is_Despawned() { var (world, group) = MakeWorld("ZonePulse_Expire"); using (world) { var em = world.EntityManager; // ExpireTick in the past (50 < now 100) → despawn this tick. var zone = MakeZone(em, float3.zero, casterId: 1, radius: 5f, dmg: 10f, nextTick: kTick, expireTick: 50u); group.Update(); Assert.IsFalse(em.Exists(zone), "A zone past its ExpireTick is destroyed (server-authoritative despawn)."); } } } }