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