using NUnit.Framework; using ProjectM.Server; using ProjectM.Simulation; using Unity.Core; using Unity.Entities; using Unity.Mathematics; using Unity.NetCode; namespace ProjectM.Tests { /// /// Plain-Entities EditMode tests for the MC-1 dash i-frame negation in HealthApplyDamageSystem. /// The negation compares each DamageEvent.SourceTick against the dashing player's DashState window /// [StartTick, IFrameUntilTick) using NetworkTick arithmetic (wrap-safe), PER-ELEMENT (unlike the /// whole-buffer RespawnInvuln / GodMode clears). The drain tick (current ServerTick) is seeded valid /// but does NOT affect the result — only SourceTick-vs-window does — which is exactly why a melee strike /// appended a tick earlier in the plain group is still judged against the tick it was AUTHORED. /// The actual N->N+1 cross-group timing is a Play-validation item (MC-1 review agenda #1), not /// EditMode-reproducible (plain worlds register systems unsorted, one group, one tick). /// public class DashIFrameNegationTests { 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 (World world, SimulationSystemGroup group) MakeWorld(string name, uint drainTick) { 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, drainTick); return (world, group); } static Entity MakeDasher(EntityManager em, float hp, uint startTick, uint iFrameUntilTick) { var e = em.CreateEntity(typeof(Health), typeof(DamageEvent), typeof(DashState)); em.SetComponentData(e, new Health { Current = hp, Max = hp }); em.SetComponentData(e, new DashState { Dir = new float2(0, 1), StartTick = startTick, IFrameUntilTick = iFrameUntilTick, RecoverUntilTick = iFrameUntilTick, // irrelevant to the negation }); return e; } [Test] public void Window_Is_Half_Open_StartInclusive_UntilExclusive() { // S=100, W=12 -> window [100, 112). drainTick 113 (arbitrary valid; result is tick-of-current independent). const uint S = 100, W = 12; var (world, group) = MakeWorld("DashHalfOpen", 113); using (world) { var em = world.EntityManager; var e = MakeDasher(em, 100f, S, S + W); var dmg = em.GetBuffer(e); // Distinct amounts so the exact negated set is provable from the surviving Health. dmg.Add(new DamageEvent { Amount = 1f, SourceNetworkId = -1, SourceTick = S - 1 }); // before -> applies dmg.Add(new DamageEvent { Amount = 2f, SourceNetworkId = -1, SourceTick = S }); // start -> negated (inclusive) dmg.Add(new DamageEvent { Amount = 4f, SourceNetworkId = -1, SourceTick = S + 1 }); // inside -> negated dmg.Add(new DamageEvent { Amount = 8f, SourceNetworkId = -1, SourceTick = S + W - 1 }); // last in -> negated dmg.Add(new DamageEvent { Amount = 16f, SourceNetworkId = -1, SourceTick = S + W }); // until -> applies (exclusive) dmg.Add(new DamageEvent { Amount = 32f, SourceNetworkId = -1, SourceTick = S + W + 1 }); // after -> applies group.Update(); // Applied 1 + 16 + 32 = 49; negated 2 + 4 + 8 = 14. Health 100 - 49 = 51. Assert.AreEqual(51f, em.GetComponentData(e).Current, 1e-4f, "Half-open [S, S+W): S, S+1, S+W-1 negated; S-1, S+W, S+W+1 applied."); } } [Test] public void Negation_Is_Wraparound_Safe_NetworkTick_Not_Raw_Uint() { // Window straddles the uint wrap: [S, 3) with S = uint.MaxValue-2. Avoids SourceTick 0 (the sentinel). const uint S = uint.MaxValue - 2; // 4294967293 const uint until = 3u; // (S + 6) wrapped past 0 var (world, group) = MakeWorld("DashWrap", 3); using (world) { var em = world.EntityManager; var e = MakeDasher(em, 1000f, S, until); var dmg = em.GetBuffer(e); dmg.Add(new DamageEvent { Amount = 10f, SourceNetworkId = -1, SourceTick = S + 1 }); // 4294967294, inside -> negated dmg.Add(new DamageEvent { Amount = 20f, SourceNetworkId = -1, SourceTick = 2u }); // wrapped inside -> negated dmg.Add(new DamageEvent { Amount = 40f, SourceNetworkId = -1, SourceTick = 5u }); // after -> applies dmg.Add(new DamageEvent { Amount = 80f, SourceNetworkId = -1, SourceTick = S - 1 }); // 4294967292, before -> applies group.Update(); // NetworkTick (wrap-correct) applies {5, S-1} = 120 -> 880. A raw-uint compare would // mis-bucket {S+1, 2} and apply all four (-> 850), which this exact value rejects. Assert.AreEqual(880f, em.GetComponentData(e).Current, 1e-4f, "Wraparound window negates {S+1, 2} and applies {5, S-1} via NetworkTick, not raw uint."); } } [Test] public void Unstamped_SourceTick_Zero_Is_Never_Negated_FailSafe() { var (world, group) = MakeWorld("DashZeroSentinel", 113); using (world) { var em = world.EntityManager; var e = MakeDasher(em, 100f, 100, 112); // active window em.GetBuffer(e).Add(new DamageEvent { Amount = 25f, SourceNetworkId = -1, SourceTick = 0u }); group.Update(); Assert.AreEqual(75f, em.GetComponentData(e).Current, 1e-4f, "An unstamped (SourceTick==0) hit is never i-framed (fail-safe: damage applies)."); } } [Test] public void Negation_Is_Per_Element_Not_Whole_Buffer() { var (world, group) = MakeWorld("DashPerElement", 113); using (world) { var em = world.EntityManager; var e = MakeDasher(em, 100f, 100, 112); var dmg = em.GetBuffer(e); dmg.Add(new DamageEvent { Amount = 5f, SourceNetworkId = -1, SourceTick = 105 }); // in window -> negated dmg.Add(new DamageEvent { Amount = 30f, SourceNetworkId = -1, SourceTick = 200 }); // out window -> applies group.Update(); Assert.AreEqual(70f, em.GetComponentData(e).Current, 1e-4f, "Per-element: only the in-window event is negated; the out-of-window event still applies " + "(unlike the whole-buffer RespawnInvuln/GodMode clears)."); } } [Test] public void RespawnInvuln_Still_Clears_Whole_Buffer_Before_The_Dash_Loop() { // drainTick 100; RespawnInvuln.UntilTick 200 (active). DashState window does NOT cover the hits, // so if the per-element dash loop ran they would apply — but RespawnInvuln clears the whole buffer first. var (world, group) = MakeWorld("DashRespawnInvuln", 100); using (world) { var em = world.EntityManager; var e = em.CreateEntity(typeof(Health), typeof(DamageEvent), typeof(DashState), typeof(RespawnInvuln)); em.SetComponentData(e, new Health { Current = 100f, Max = 100f }); em.SetComponentData(e, new DashState { StartTick = 500, IFrameUntilTick = 512, RecoverUntilTick = 512 }); em.SetComponentData(e, new RespawnInvuln { UntilTick = 200 }); var dmg = em.GetBuffer(e); dmg.Add(new DamageEvent { Amount = 40f, SourceNetworkId = -1, SourceTick = 100 }); // outside dash window dmg.Add(new DamageEvent { Amount = 30f, SourceNetworkId = -1, SourceTick = 600 }); // outside dash window group.Update(); Assert.AreEqual(100f, em.GetComponentData(e).Current, 1e-4f, "RespawnInvuln clears the WHOLE buffer (and continues) before the per-element dash loop runs."); Assert.AreEqual(0, em.GetBuffer(e).Length, "The buffer is drained under RespawnInvuln."); } } } }