using NUnit.Framework;
using ProjectM.Server;
using ProjectM.Simulation;
using Unity.Core;
using Unity.Entities;
using Unity.NetCode;
namespace ProjectM.Tests
{
///
/// Plain-Entities EditMode tests for the server-only — timed/removable
/// StatModifiers. A bare world is seeded with a NetworkTime singleton and a player carrying paired StatModifier
/// + TimedModifier buffers (same SourceId). Pins: a modifier persists until its tick then is removed by SourceId
/// (along with its tracker row); independent expiry of multiple timed mods; the clear-by-SourceId helper; and
/// the TickUtil.NonZero guard so a wrap-tick expiry never collides with the 0 = "inert" sentinel. The
/// replicated StatModifier layout is untouched (separate tracker buffer) so there is no ghost re-bake.
///
public class TimedModifierExpirySystemTests
{
static (World world, SimulationSystemGroup group) MakeWorld(string name, uint serverTick)
{
var world = new World(name);
var group = world.GetOrCreateSystemManaged();
group.AddSystemToUpdateList(world.GetOrCreateSystem());
group.SortSystems();
world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
SetServerTick(world, serverTick);
return (world, group);
}
static void SetServerTick(World world, uint tick)
{
var em = world.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 Entity MakePlayer(EntityManager em)
{
var e = em.CreateEntity();
em.AddBuffer(e);
em.AddBuffer(e);
return e;
}
static void AddTimedMod(EntityManager em, Entity e, byte target, float value, uint sourceId, uint untilTick)
{
em.GetBuffer(e).Add(new StatModifier { Target = target, Op = (byte)ModOp.Flat, Value = value, SourceId = sourceId });
em.GetBuffer(e).Add(new TimedModifier { SourceId = sourceId, UntilTick = untilTick });
}
[Test]
public void Modifier_Persists_Before_Expiry_And_Is_Removed_After()
{
var (world, group) = MakeWorld("TimedExpire", serverTick: 200);
using (world)
{
var em = world.EntityManager;
var p = MakePlayer(em);
AddTimedMod(em, p, (byte)StatTarget.Damage, 10f, sourceId: 0xABCDu, untilTick: 300);
group.Update(); // serverTick 200 < 300 -> not due
Assert.AreEqual(1, em.GetBuffer(p).Length, "Modifier persists before its expiry tick.");
Assert.AreEqual(1, em.GetBuffer(p).Length);
SetServerTick(world, 300);
group.Update(); // due (300 not newer than 300)
Assert.AreEqual(0, em.GetBuffer(p).Length, "Expired modifier is removed by SourceId.");
Assert.AreEqual(0, em.GetBuffer(p).Length, "The timed-tracker row is removed too.");
}
}
[Test]
public void Timed_Modifiers_Expire_Independently()
{
var (world, group) = MakeWorld("TimedIndependent", serverTick: 200);
using (world)
{
var em = world.EntityManager;
var p = MakePlayer(em);
AddTimedMod(em, p, (byte)StatTarget.Damage, 10f, sourceId: 1u, untilTick: 250);
AddTimedMod(em, p, (byte)StatTarget.MoveSpeed, 0.2f, sourceId: 2u, untilTick: 350);
SetServerTick(world, 250);
group.Update();
var mods = em.GetBuffer(p);
Assert.AreEqual(1, mods.Length, "Only the first timed modifier expires at tick 250.");
Assert.AreEqual(2u, mods[0].SourceId, "The longer-lived modifier (SourceId 2) survives.");
SetServerTick(world, 350);
group.Update();
Assert.AreEqual(0, em.GetBuffer(p).Length, "The second expires at its own tick.");
}
}
[Test]
public void RemoveBySourceId_Clears_On_Demand()
{
var (world, group) = MakeWorld("TimedClearByType", serverTick: 200);
using (world)
{
var em = world.EntityManager;
var p = MakePlayer(em);
var b = em.GetBuffer(p);
b.Add(new StatModifier { Target = (byte)StatTarget.Damage, Op = (byte)ModOp.Flat, Value = 5f, SourceId = 7u });
b.Add(new StatModifier { Target = (byte)StatTarget.Damage, Op = (byte)ModOp.Flat, Value = 9f, SourceId = 8u });
int removed = TimedModifierUtil.RemoveBySourceId(em.GetBuffer(p), 7u);
Assert.AreEqual(1, removed, "Clear-by-SourceId removes exactly the matching row.");
var mods = em.GetBuffer(p);
Assert.AreEqual(1, mods.Length);
Assert.AreEqual(8u, mods[0].SourceId, "The non-matching modifier is untouched.");
}
}
[Test]
public void NonZero_UntilTick_Never_Collides_With_The_Zero_Sentinel()
{
var (world, group) = MakeWorld("TimedWrap", serverTick: 1);
using (world)
{
var em = world.EntityManager;
var p = MakePlayer(em);
uint until = TickUtil.NonZero(0u); // a grant/death exactly at tick 0 must not read as 'inert'
Assert.AreNotEqual(0u, until, "TickUtil.NonZero coerces 0 -> 1 so a scheduled expiry is never the inert sentinel.");
AddTimedMod(em, p, (byte)StatTarget.Damage, 3f, sourceId: 9u, untilTick: until);
group.Update(); // serverTick 1 >= until(1) -> due
Assert.AreEqual(0, em.GetBuffer(p).Length, "A modifier scheduled at the wrap sentinel still expires.");
}
}
}
}