Files
Project-M/Assets/_Project/Tests/EditMode/DashIFrameNegationTests.cs
T
2026-06-09 23:26:20 -07:00

172 lines
8.9 KiB
C#

using NUnit.Framework;
using ProjectM.Server;
using ProjectM.Simulation;
using Unity.Core;
using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode;
namespace ProjectM.Tests
{
/// <summary>
/// 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).
/// </summary>
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<SimulationSystemGroup>();
group.AddSystemToUpdateList(world.GetOrCreateSystem<HealthApplyDamageSystem>());
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<DamageEvent>(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<Health>(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<DamageEvent>(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<Health>(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<DamageEvent>(e).Add(new DamageEvent { Amount = 25f, SourceNetworkId = -1, SourceTick = 0u });
group.Update();
Assert.AreEqual(75f, em.GetComponentData<Health>(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<DamageEvent>(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<Health>(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<DamageEvent>(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<Health>(e).Current, 1e-4f,
"RespawnInvuln clears the WHOLE buffer (and continues) before the per-element dash loop runs.");
Assert.AreEqual(0, em.GetBuffer<DamageEvent>(e).Length, "The buffer is drained under RespawnInvuln.");
}
}
}
}