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