Vault Re-Alignment
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user