Vault Re-Alignment
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
using NUnit.Framework;
|
||||
using ProjectM.Server;
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Core;
|
||||
using Unity.Entities;
|
||||
using Unity.Mathematics;
|
||||
using Unity.NetCode;
|
||||
using Unity.Transforms;
|
||||
|
||||
namespace ProjectM.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// Plain-Entities EditMode tests for the MC-1 Charger branch in EnemyAISystem. A Husk variant baked with
|
||||
/// LungeState commits to a fixed-direction lunge on wind-up elapse (UNLIKE the Grunt, it does NOT cancel when
|
||||
/// the target leaves range — the commit is the punishable tell), deals contact damage if it connects, and
|
||||
/// staggers (extends EnemyAttackCooldown + clears the lunge + opens a telemetry whiff window) if it overshoots
|
||||
/// or wall-stops. Knockback cancels an in-flight lunge so EnemyAISystem stays the SOLE Position writer.
|
||||
/// </summary>
|
||||
public class ChargerTests
|
||||
{
|
||||
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 serverTick)
|
||||
{
|
||||
var world = new World(name);
|
||||
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
|
||||
group.AddSystemToUpdateList(world.GetOrCreateSystem<EnemyAISystem>());
|
||||
group.SortSystems();
|
||||
world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
|
||||
SetServerTick(world, serverTick);
|
||||
return (world, group);
|
||||
}
|
||||
|
||||
static Entity MakePlayer(EntityManager em, float3 pos)
|
||||
{
|
||||
var e = em.CreateEntity();
|
||||
em.AddComponentData(e, LocalTransform.FromPosition(pos));
|
||||
em.AddComponentData(e, new Health { Current = 100f, Max = 100f });
|
||||
em.AddComponent<PlayerTag>(e);
|
||||
em.AddBuffer<DamageEvent>(e);
|
||||
return e;
|
||||
}
|
||||
|
||||
static Entity MakeCharger(EntityManager em, float3 pos)
|
||||
{
|
||||
var e = em.CreateEntity();
|
||||
em.AddComponentData(e, LocalTransform.FromPosition(pos));
|
||||
em.AddComponentData(e, new EnemyStats { MoveSpeed = 3f, AttackRange = 1.6f, AttackDamage = 12f, AttackCooldownTicks = 36 });
|
||||
em.AddComponentData(e, new EnemyAttackCooldown { NextAttackTick = 0 });
|
||||
em.AddComponentData(e, new KnockbackState());
|
||||
em.AddComponentData(e, new AttackWindup());
|
||||
em.AddComponentData(e, new LungeState());
|
||||
em.AddComponent<EnemyTag>(e);
|
||||
return e;
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Commit_Fires_Even_When_Target_Left_Range()
|
||||
{
|
||||
var (world, group) = MakeWorld("ChargerCommit", 200);
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
MakePlayer(em, new float3(10, 1, 0)); // far out of AttackRange (1.6)
|
||||
var charger = MakeCharger(em, new float3(0, 1, 0));
|
||||
em.SetComponentData(charger, new AttackWindup { WindUpUntilTick = 200 }); // elapses this tick
|
||||
|
||||
group.Update(); // tick 200
|
||||
|
||||
var lunge = em.GetComponentData<LungeState>(charger);
|
||||
Assert.AreNotEqual(0u, lunge.UntilTick, "Charger commits the lunge even with the target out of range (no cancel-on-leave-range).");
|
||||
Assert.Greater(lunge.Dir.x, 0.5f, "Lunge direction is locked toward the target at commit (+X).");
|
||||
Assert.AreEqual(0u, em.GetComponentData<AttackWindup>(charger).WindUpUntilTick, "The wind-up clears on commit.");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Overshoot_Whiff_Staggers_And_Opens_A_Punish_Window()
|
||||
{
|
||||
var (world, group) = MakeWorld("ChargerWhiff", 206);
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
MakePlayer(em, new float3(-10, 1, 0)); // player is behind; the lunge goes +X, never connects
|
||||
var charger = MakeCharger(em, new float3(0, 1, 0));
|
||||
em.SetComponentData(charger, new LungeState { Dir = new float2(1, 0), Speed = 16f, UntilTick = 205 }); // expiring lunge
|
||||
em.CreateEntity(typeof(DevTelemetry)); // so the whiff telemetry increment is observable
|
||||
|
||||
group.Update(); // tick 206 > 205 -> lunge timer elapsed without landing -> overshoot whiff
|
||||
|
||||
Assert.AreEqual(0u, em.GetComponentData<LungeState>(charger).UntilTick, "A whiffed lunge is cleared.");
|
||||
Assert.AreEqual(TickUtil.NonZero(206 + 36), em.GetComponentData<EnemyAttackCooldown>(charger).NextAttackTick,
|
||||
"An overshoot whiff extends the attack cooldown by the stagger window (the punish window).");
|
||||
using var tq = em.CreateEntityQuery(typeof(DevTelemetry));
|
||||
Assert.AreEqual(1u, tq.GetSingleton<DevTelemetry>().ChargerWhiffWindowsOpened, "A whiff opens one telemetry punish window.");
|
||||
Assert.AreEqual(TickUtil.NonZero(206 + 36), em.GetComponentData<LungeState>(charger).StaggerUntilTick,
|
||||
"The whiff stamps the scoreable StaggerUntilTick window (ChargerWhiffPunishesLanded source).");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Knockback_Cancels_An_InFlight_Lunge()
|
||||
{
|
||||
var (world, group) = MakeWorld("ChargerKnockback", 305);
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
MakePlayer(em, new float3(10, 1, 0));
|
||||
var charger = MakeCharger(em, new float3(0, 1, 0));
|
||||
em.SetComponentData(charger, new LungeState { Dir = new float2(1, 0), Speed = 16f, UntilTick = 320 }); // mid-lunge +X
|
||||
em.SetComponentData(charger, new KnockbackState { Dir = new float2(-1, 0), Speed = 10f, UntilTick = 315 }); // recoil -X
|
||||
|
||||
group.Update(); // tick 305: knockback (until 315) wins
|
||||
|
||||
Assert.AreEqual(0u, em.GetComponentData<LungeState>(charger).UntilTick,
|
||||
"Knockback cancels the in-flight lunge (no two-writer contention on Position).");
|
||||
Assert.Less(em.GetComponentData<LocalTransform>(charger).Position.x, 0f,
|
||||
"The recoiling Charger moved along its knockback direction (-X), not its lunge direction.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c31affe7e592820448b105987c883868
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 638643ac1f79886438103e43ece254cf
|
||||
@@ -0,0 +1,235 @@
|
||||
using NUnit.Framework;
|
||||
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 predicted DashSystem (velocity/sharpness OVERRIDE + start/cooldown)
|
||||
/// and the PlayerDeathStateSystem dash cleanup. The systems carry [UpdateInGroup(PredictedSimulationSystemGroup)]
|
||||
/// but world-system filtering is ignored when added to a group manually, so they run in this netcode-free world.
|
||||
/// The override/cleanup logic is fully headless; the input-driven START path uses PlayerInput.Dash.IsSet (the
|
||||
/// real apply-under-prediction is a Play-validation item).
|
||||
/// </summary>
|
||||
public class DashSystemTests
|
||||
{
|
||||
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<T>(string name, uint serverTick) where T : unmanaged, ISystem
|
||||
{
|
||||
var world = new World(name);
|
||||
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
|
||||
group.AddSystemToUpdateList(world.GetOrCreateSystem<T>());
|
||||
group.SortSystems();
|
||||
world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
|
||||
SetServerTick(world, serverTick);
|
||||
return (world, group);
|
||||
}
|
||||
|
||||
// Dash speed derived from the baked knobs: 4.0 units / (12 ticks / 60) = 20 units/s.
|
||||
const float ExpectedDashSpeed = 20f;
|
||||
|
||||
static Entity MakeDasher(EntityManager em, float2 facing)
|
||||
{
|
||||
var e = em.CreateEntity();
|
||||
em.AddComponentData(e, new DashState());
|
||||
em.AddComponentData(e, new DashCooldown { NextTick = 0 });
|
||||
em.AddComponentData(e, new CharacterControl { MoveVelocity = float3.zero });
|
||||
em.AddComponentData(e, CharacterComponent.GetDefault()); // GroundedMovementSharpness = 15
|
||||
em.AddComponentData(e, new PlayerInput());
|
||||
em.AddComponentData(e, new PlayerFacing { Direction = facing });
|
||||
em.AddComponent<Simulate>(e); // enabled by default
|
||||
em.AddComponent<Dead>(e);
|
||||
em.SetComponentEnabled<Dead>(e, false); // alive
|
||||
return e;
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void IFrame_Window_Overrides_Velocity_To_Dash_Speed_And_Sharpness_To_Blink()
|
||||
{
|
||||
var (world, group) = MakeWorld<DashSystem>("DashOverride", 100);
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
var e = MakeDasher(em, new float2(0, 1));
|
||||
em.SetComponentData(e, new DashState { Dir = new float2(0, 1), StartTick = 100, IFrameUntilTick = 112, RecoverUntilTick = 121 });
|
||||
em.SetComponentData(e, new CharacterControl { MoveVelocity = new float3(5, 0, 0) }); // as if PlayerControlSystem wrote input velocity
|
||||
|
||||
group.Update(); // tick 100: i-frame window [100,112) active
|
||||
|
||||
var ctrl = em.GetComponentData<CharacterControl>(e).MoveVelocity;
|
||||
Assert.AreEqual(0f, ctrl.x, 1e-3f, "Dash heading (0,1) overrides X to 0.");
|
||||
Assert.AreEqual(0f, ctrl.y, 1e-3f, "Planar dash keeps Y at 0.");
|
||||
Assert.AreEqual(ExpectedDashSpeed, ctrl.z, 1e-3f, "i-frame window overrides velocity to Dir*dashSpeed (20).");
|
||||
Assert.AreEqual(200f, em.GetComponentData<CharacterComponent>(e).GroundedMovementSharpness, 1e-3f,
|
||||
"i-frame window raises GroundedMovementSharpness to ~200 (the blink).");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Recovery_Tail_Locks_Movement_And_Restores_Sharpness()
|
||||
{
|
||||
var (world, group) = MakeWorld<DashSystem>("DashRecover", 105);
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
var e = MakeDasher(em, new float2(0, 1));
|
||||
em.SetComponentData(e, new DashState { Dir = new float2(0, 1), StartTick = 80, IFrameUntilTick = 100, RecoverUntilTick = 109 });
|
||||
var cc = CharacterComponent.GetDefault(); cc.GroundedMovementSharpness = 200f; em.SetComponentData(e, cc);
|
||||
em.SetComponentData(e, new CharacterControl { MoveVelocity = new float3(5, 0, 0) });
|
||||
|
||||
group.Update(); // tick 105: iFrame ended (100<=105), recover active (109>105)
|
||||
|
||||
Assert.AreEqual(0f, math.length(em.GetComponentData<CharacterControl>(e).MoveVelocity), 1e-3f,
|
||||
"Recovery tail locks movement to zero (the punishable window).");
|
||||
Assert.AreEqual(15f, em.GetComponentData<CharacterComponent>(e).GroundedMovementSharpness, 1e-3f,
|
||||
"Recovery tail restores sharpness to the default (crisp stop).");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void After_Window_Restores_Sharpness_And_Leaves_Input_Velocity_Untouched()
|
||||
{
|
||||
var (world, group) = MakeWorld<DashSystem>("DashRestore", 200);
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
var e = MakeDasher(em, new float2(0, 1));
|
||||
em.SetComponentData(e, new DashState { Dir = new float2(0, 1), StartTick = 80, IFrameUntilTick = 100, RecoverUntilTick = 109 });
|
||||
var cc = CharacterComponent.GetDefault(); cc.GroundedMovementSharpness = 200f; em.SetComponentData(e, cc);
|
||||
em.SetComponentData(e, new CharacterControl { MoveVelocity = new float3(7, 0, 0) }); // input velocity, must survive
|
||||
|
||||
group.Update(); // tick 200: window fully elapsed
|
||||
|
||||
Assert.AreEqual(15f, em.GetComponentData<CharacterComponent>(e).GroundedMovementSharpness, 1e-3f,
|
||||
"Sharpness restored to default after the dash window elapses.");
|
||||
Assert.AreEqual(7f, em.GetComponentData<CharacterControl>(e).MoveVelocity.x, 1e-3f,
|
||||
"Outside the window DashSystem does NOT touch MoveVelocity (PlayerControlSystem's input stands).");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Dash_Starts_On_Press_When_Ready_And_Sets_Window_And_Cooldown()
|
||||
{
|
||||
var (world, group) = MakeWorld<DashSystem>("DashStart", 100);
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
var e = MakeDasher(em, new float2(0, 1));
|
||||
var pi = new PlayerInput(); pi.Dash.Set(); em.SetComponentData(e, pi);
|
||||
Assert.IsTrue(em.GetComponentData<PlayerInput>(e).Dash.IsSet,
|
||||
"Precondition: a Set() dash reads IsSet=true (guards the InputEvent assumption).");
|
||||
|
||||
group.Update(); // tick 100
|
||||
|
||||
var ds = em.GetComponentData<DashState>(e);
|
||||
Assert.AreEqual(100u, ds.StartTick, "StartTick = now.");
|
||||
Assert.AreEqual(112u, ds.IFrameUntilTick, "IFrameUntilTick = now + 12.");
|
||||
Assert.AreEqual(121u, ds.RecoverUntilTick, "RecoverUntilTick = now + 12 + 9.");
|
||||
Assert.AreEqual(145u, em.GetComponentData<DashCooldown>(e).NextTick, "Cooldown = now + 45.");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Dash_Start_Is_Idempotent_At_The_Same_Tick()
|
||||
{
|
||||
var (world, group) = MakeWorld<DashSystem>("DashIdem", 100);
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
var e = MakeDasher(em, new float2(0, 1));
|
||||
var pi = new PlayerInput(); pi.Dash.Set(); em.SetComponentData(e, pi);
|
||||
|
||||
group.Update(); // starts the dash at tick 100
|
||||
var first = em.GetComponentData<DashState>(e);
|
||||
group.Update(); // same ServerTick, Dash still set -> must NOT re-start (mid-window)
|
||||
var second = em.GetComponentData<DashState>(e);
|
||||
|
||||
Assert.AreEqual(first.StartTick, second.StartTick, "Re-running the start tick must not re-trigger the dash.");
|
||||
Assert.AreEqual(first.IFrameUntilTick, second.IFrameUntilTick, "The window is set exactly once.");
|
||||
Assert.AreEqual(first.RecoverUntilTick, second.RecoverUntilTick, "The window is set exactly once.");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Dash_Is_Gated_By_Cooldown_Until_It_Expires()
|
||||
{
|
||||
var (world, group) = MakeWorld<DashSystem>("DashCooldown", 130);
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
var e = MakeDasher(em, new float2(0, 1));
|
||||
// As if dashed at 100: window elapsed by 130, cooldown until 145.
|
||||
em.SetComponentData(e, new DashState { Dir = new float2(0, 1), StartTick = 100, IFrameUntilTick = 112, RecoverUntilTick = 121 });
|
||||
em.SetComponentData(e, new DashCooldown { NextTick = 145 });
|
||||
|
||||
var pi = new PlayerInput(); pi.Dash.Set(); em.SetComponentData(e, pi);
|
||||
group.Update(); // tick 130 < cooldown 145 -> NO new dash
|
||||
Assert.AreEqual(100u, em.GetComponentData<DashState>(e).StartTick, "On cooldown: a press does not start a new dash.");
|
||||
|
||||
SetServerTick(world, 150); // past the cooldown
|
||||
pi = new PlayerInput(); pi.Dash.Set(); em.SetComponentData(e, pi);
|
||||
group.Update();
|
||||
Assert.AreEqual(150u, em.GetComponentData<DashState>(e).StartTick, "Past cooldown: a press starts a new dash.");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Death_Mid_Dash_Clears_Window_And_Restores_Sharpness()
|
||||
{
|
||||
var (world, group) = MakeWorld<PlayerDeathStateSystem>("DashDeath", 100);
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
var e = em.CreateEntity();
|
||||
em.AddComponent<PlayerTag>(e);
|
||||
em.AddComponentData(e, new Health { Current = 0f, Max = 100f }); // dead
|
||||
em.AddComponentData(e, new CharacterControl { MoveVelocity = new float3(3, 0, 0) });
|
||||
em.AddComponentData(e, new DashState { Dir = new float2(1, 0), StartTick = 90, IFrameUntilTick = 110, RecoverUntilTick = 119 }); // in-flight
|
||||
var cc = CharacterComponent.GetDefault(); cc.GroundedMovementSharpness = 200f; em.AddComponentData(e, cc);
|
||||
em.AddComponent<Simulate>(e);
|
||||
em.AddComponent<Dead>(e);
|
||||
em.SetComponentEnabled<Dead>(e, false);
|
||||
|
||||
group.Update();
|
||||
|
||||
Assert.IsTrue(em.IsComponentEnabled<Dead>(e), "Health<=0 derives Dead enabled.");
|
||||
Assert.AreEqual(0u, em.GetComponentData<DashState>(e).IFrameUntilTick, "Death clears the dash window (no stale i-frames on respawn).");
|
||||
Assert.AreEqual(15f, em.GetComponentData<CharacterComponent>(e).GroundedMovementSharpness, 1e-3f, "Death restores base sharpness.");
|
||||
Assert.AreEqual(0f, math.length(em.GetComponentData<CharacterControl>(e).MoveVelocity), 1e-3f, "Death zeroes movement.");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Rollback_ReSimulated_PreDash_Tick_Gets_No_Override()
|
||||
{
|
||||
// DashState is NON-replicated, so prediction rollback does NOT restore it: a re-simulated tick
|
||||
// BEFORE StartTick still sees the post-press window. The override must include the StartTick lower
|
||||
// bound or it stomps dash velocity onto pre-dash ticks (dash-start overshoot under real latency).
|
||||
var (world, group) = MakeWorld<DashSystem>("DashRollback", 95); // serverTick 95 < StartTick 100
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
var e = MakeDasher(em, new float2(0, 1));
|
||||
em.SetComponentData(e, new DashState { Dir = new float2(0, 1), StartTick = 100, IFrameUntilTick = 112, RecoverUntilTick = 121 });
|
||||
em.SetComponentData(e, new CharacterControl { MoveVelocity = new float3(5, 0, 0) }); // input velocity of the pre-dash tick
|
||||
|
||||
group.Update();
|
||||
|
||||
Assert.AreEqual(5f, em.GetComponentData<CharacterControl>(e).MoveVelocity.x, 1e-3f,
|
||||
"A re-simulated PRE-dash tick keeps PlayerControl's input velocity (no dash override, no recovery lock).");
|
||||
Assert.AreEqual(15f, em.GetComponentData<CharacterComponent>(e).GroundedMovementSharpness, 1e-3f,
|
||||
"A re-simulated PRE-dash tick keeps base sharpness.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4c276f751d9353641a1d66d42d7e68ee
|
||||
@@ -10,6 +10,7 @@
|
||||
"Unity.Collections",
|
||||
"Unity.Mathematics",
|
||||
"Unity.Physics",
|
||||
"Unity.CharacterController",
|
||||
"Unity.NetCode",
|
||||
"UnityEngine.TestRunner",
|
||||
"UnityEditor.TestRunner"
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
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>
|
||||
/// EditMode coverage for the MC-0/MC-1 DevTelemetry counter WIRING (the fun-gate is measured, not argued):
|
||||
/// DashIFrameNegatedHits + DashState.NegatedCount (HealthApplyDamageSystem), DashesWasted (DashSystem
|
||||
/// window-close edge, server-gated on the DevTelemetry singleton), and ChargerWhiffPunishesLanded
|
||||
/// (player-sourced hit inside a Charger's StaggerUntilTick window, scored ONCE per window).
|
||||
/// </summary>
|
||||
public class TelemetryCountersTests
|
||||
{
|
||||
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<T>(string name, uint serverTick, bool withTelemetry)
|
||||
where T : unmanaged, ISystem
|
||||
{
|
||||
var world = new World(name);
|
||||
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
|
||||
group.AddSystemToUpdateList(world.GetOrCreateSystem<T>());
|
||||
group.SortSystems();
|
||||
world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
|
||||
SetServerTick(world, serverTick);
|
||||
if (withTelemetry)
|
||||
world.EntityManager.CreateEntity(typeof(DevTelemetry));
|
||||
return (world, group);
|
||||
}
|
||||
|
||||
static DevTelemetry Telemetry(EntityManager em)
|
||||
{
|
||||
using var q = em.CreateEntityQuery(typeof(DevTelemetry));
|
||||
return q.GetSingleton<DevTelemetry>();
|
||||
}
|
||||
|
||||
static Entity MakeDashingPlayer(EntityManager em, DashState ds)
|
||||
{
|
||||
var e = em.CreateEntity();
|
||||
em.AddComponentData(e, ds);
|
||||
em.AddComponentData(e, new DashCooldown { NextTick = 0 });
|
||||
em.AddComponentData(e, new CharacterControl { MoveVelocity = float3.zero });
|
||||
em.AddComponentData(e, CharacterComponent.GetDefault());
|
||||
em.AddComponentData(e, new PlayerInput());
|
||||
em.AddComponentData(e, new PlayerFacing { Direction = new float2(0, 1) });
|
||||
em.AddComponent<Simulate>(e);
|
||||
em.AddComponent<Dead>(e);
|
||||
em.SetComponentEnabled<Dead>(e, false);
|
||||
return e;
|
||||
}
|
||||
|
||||
static Entity MakeStaggeredCharger(EntityManager em, uint staggerUntil)
|
||||
{
|
||||
var e = em.CreateEntity(typeof(Health), typeof(DamageEvent), typeof(LungeState));
|
||||
em.SetComponentData(e, new Health { Current = 200f, Max = 200f });
|
||||
em.SetComponentData(e, new LungeState { StaggerUntilTick = staggerUntil });
|
||||
return e;
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Negation_Increments_DevTelemetry_And_DashState_NegatedCount()
|
||||
{
|
||||
var (world, group) = MakeWorld<HealthApplyDamageSystem>("TelemNegate", 113, withTelemetry: true);
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
var e = em.CreateEntity(typeof(Health), typeof(DamageEvent), typeof(DashState));
|
||||
em.SetComponentData(e, new Health { Current = 100f, Max = 100f });
|
||||
em.SetComponentData(e, new DashState { StartTick = 100, IFrameUntilTick = 112, RecoverUntilTick = 121 });
|
||||
var dmg = em.GetBuffer<DamageEvent>(e);
|
||||
dmg.Add(new DamageEvent { Amount = 5f, SourceNetworkId = -1, SourceTick = 101 }); // in-window -> negated
|
||||
dmg.Add(new DamageEvent { Amount = 6f, SourceNetworkId = -1, SourceTick = 111 }); // in-window -> negated
|
||||
dmg.Add(new DamageEvent { Amount = 7f, SourceNetworkId = -1, SourceTick = 113 }); // outside -> applies
|
||||
|
||||
group.Update();
|
||||
|
||||
Assert.AreEqual(2u, Telemetry(em).DashIFrameNegatedHits, "Both in-window hits counted.");
|
||||
Assert.AreEqual(2u, em.GetComponentData<DashState>(e).NegatedCount,
|
||||
"NegatedCount written back onto DashState (the wasted-dash signal source).");
|
||||
Assert.AreEqual(93f, em.GetComponentData<Health>(e).Current, 1e-4f, "Only the outside hit applied.");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Wasted_Dash_Counts_Once_On_Window_Close_With_Zero_Negations()
|
||||
{
|
||||
// Window fully elapsed at tick 120 (recover ended at 109) and nothing was negated.
|
||||
var (world, group) = MakeWorld<DashSystem>("TelemWasted", 120, withTelemetry: true);
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
var e = MakeDashingPlayer(em, new DashState
|
||||
{ Dir = new float2(0, 1), StartTick = 80, IFrameUntilTick = 100, RecoverUntilTick = 109, NegatedCount = 0 });
|
||||
|
||||
group.Update();
|
||||
Assert.AreEqual(1u, Telemetry(em).DashesWasted, "A dash whose window negated nothing scores wasted.");
|
||||
Assert.AreEqual(0u, em.GetComponentData<DashState>(e).RecoverUntilTick, "Close edge clears the window (one-shot).");
|
||||
|
||||
group.Update();
|
||||
Assert.AreEqual(1u, Telemetry(em).DashesWasted, "The close edge fires exactly once.");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Effective_Dash_Is_Not_Wasted()
|
||||
{
|
||||
var (world, group) = MakeWorld<DashSystem>("TelemEffective", 120, withTelemetry: true);
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
MakeDashingPlayer(em, new DashState
|
||||
{ Dir = new float2(0, 1), StartTick = 80, IFrameUntilTick = 100, RecoverUntilTick = 109, NegatedCount = 2 });
|
||||
|
||||
group.Update();
|
||||
Assert.AreEqual(0u, Telemetry(em).DashesWasted, "A dash that negated hits is not wasted.");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Without_Telemetry_Singleton_The_Close_Edge_Leaves_DashState_Intact()
|
||||
{
|
||||
// Client-world behavior: no DevTelemetry singleton -> no zeroing, so rollback re-simulation of the
|
||||
// tail window stays correct on the client (the server is the only world that clears).
|
||||
var (world, group) = MakeWorld<DashSystem>("TelemClientNoZero", 120, withTelemetry: false);
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
var e = MakeDashingPlayer(em, new DashState
|
||||
{ Dir = new float2(0, 1), StartTick = 80, IFrameUntilTick = 100, RecoverUntilTick = 109 });
|
||||
|
||||
group.Update();
|
||||
Assert.AreEqual(109u, em.GetComponentData<DashState>(e).RecoverUntilTick,
|
||||
"No telemetry singleton (client world): the window is never zeroed by the close edge.");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Player_Hit_On_Staggered_Charger_Scores_Punish_Once()
|
||||
{
|
||||
var (world, group) = MakeWorld<HealthApplyDamageSystem>("TelemPunish", 120, withTelemetry: true);
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
var e = MakeStaggeredCharger(em, staggerUntil: 150); // stagger window active at 120
|
||||
var dmg = em.GetBuffer<DamageEvent>(e);
|
||||
dmg.Add(new DamageEvent { Amount = 10f, SourceNetworkId = 1, SourceTick = 119 }); // player hit
|
||||
dmg.Add(new DamageEvent { Amount = 10f, SourceNetworkId = 1, SourceTick = 119 }); // same drain, same window
|
||||
|
||||
group.Update();
|
||||
|
||||
Assert.AreEqual(1u, Telemetry(em).ChargerWhiffPunishesLanded,
|
||||
"A stagger window counts at most ONE punish (ratio to windows-opened stays <= 1).");
|
||||
Assert.AreEqual(0u, em.GetComponentData<LungeState>(e).StaggerUntilTick,
|
||||
"Scoring zeroes StaggerUntilTick (the one-shot).");
|
||||
Assert.AreEqual(180f, em.GetComponentData<Health>(e).Current, 1e-4f, "Both hits still apply damage.");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void NonPlayer_Hit_On_Staggered_Charger_Does_Not_Score()
|
||||
{
|
||||
var (world, group) = MakeWorld<HealthApplyDamageSystem>("TelemPunishTurret", 120, withTelemetry: true);
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
var e = MakeStaggeredCharger(em, staggerUntil: 150);
|
||||
em.GetBuffer<DamageEvent>(e).Add(new DamageEvent { Amount = 10f, SourceNetworkId = -1, SourceTick = 119 });
|
||||
|
||||
group.Update();
|
||||
|
||||
Assert.AreEqual(0u, Telemetry(em).ChargerWhiffPunishesLanded,
|
||||
"Environment/turret damage (SourceNetworkId=-1) never scores a punish.");
|
||||
Assert.AreEqual(150u, em.GetComponentData<LungeState>(e).StaggerUntilTick,
|
||||
"The window stays scoreable for a real player hit.");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Expired_Stagger_Window_Does_Not_Score()
|
||||
{
|
||||
var (world, group) = MakeWorld<HealthApplyDamageSystem>("TelemPunishLate", 200, withTelemetry: true);
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
var e = MakeStaggeredCharger(em, staggerUntil: 150); // already over at 200
|
||||
em.GetBuffer<DamageEvent>(e).Add(new DamageEvent { Amount = 10f, SourceNetworkId = 1, SourceTick = 199 });
|
||||
|
||||
group.Update();
|
||||
|
||||
Assert.AreEqual(0u, Telemetry(em).ChargerWhiffPunishesLanded,
|
||||
"A hit after the stagger window elapses is not a punish.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9e5cfa8e6208aa14ea84b6dc8510bb40
|
||||
Reference in New Issue
Block a user