d77649fe16
23 plain-Entities tests: cone predicate (range/bearing/coincident/planar), combo advance/chain-window/lockout/reset/idempotency, movement-commit + SwingStartTick rollback lower-bound, dash precedence (active window + same-tick tie + recovery tail), server cleave (cone select, finisher scaling, knockback, dead-exclusion, client-no-damage), comboLen=2 finisher, two-player co-op cleave, death-clears. 294/294 green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
434 lines
20 KiB
C#
434 lines
20 KiB
C#
using NUnit.Framework;
|
|
using ProjectM.Simulation;
|
|
using Unity.Core;
|
|
using Unity.Entities;
|
|
using Unity.Mathematics;
|
|
using Unity.NetCode;
|
|
using Unity.Transforms;
|
|
|
|
namespace ProjectM.Tests
|
|
{
|
|
/// <summary>
|
|
/// MC-4 — plain-Entities EditMode tests for the predicted MeleeComboSystem: the combo state machine (advance /
|
|
/// chain-window / lockout / reset / idempotent absolute-write start), the movement-commit (SwingStartTick rollback
|
|
/// lower-bound + dash precedence), and the SERVER-only cleave (cone selection, finisher scaling, knockback,
|
|
/// dead-enemy exclusion, client-no-damage). The state half runs on a plain world; the cleave half needs a
|
|
/// GameServer-flagged world so WorldUnmanaged.IsServer() gates the damage on (the same pattern ProjectileDamageSystem
|
|
/// relies on). True under-prediction rollback is a Play-validation item; here absolute-write idempotency is asserted.
|
|
/// </summary>
|
|
public class MeleeComboTests
|
|
{
|
|
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 tick, bool server = false)
|
|
{
|
|
var world = server ? new World(name, WorldFlags.Game | WorldFlags.GameServer) : new World(name);
|
|
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
|
|
group.AddSystemToUpdateList(world.GetOrCreateSystem<MeleeComboSystem>());
|
|
group.SortSystems();
|
|
world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
|
|
SetServerTick(world, tick);
|
|
return (world, group);
|
|
}
|
|
|
|
static Entity MakePlayer(EntityManager em, float2 facing, float3 pos = default)
|
|
{
|
|
var e = em.CreateEntity();
|
|
em.AddComponentData(e, new MeleeCombo());
|
|
em.AddComponentData(e, new CharacterControl { MoveVelocity = float3.zero });
|
|
em.AddComponentData(e, new PlayerInput());
|
|
em.AddComponentData(e, new PlayerFacing { Direction = facing });
|
|
em.AddComponentData(e, LocalTransform.FromPosition(pos));
|
|
em.AddComponentData(e, new GhostOwner { NetworkId = 7 });
|
|
em.AddComponentData(e, new DashState());
|
|
em.AddComponent<Simulate>(e);
|
|
em.AddComponent<Dead>(e);
|
|
em.SetComponentEnabled<Dead>(e, false);
|
|
return e;
|
|
}
|
|
|
|
static Entity MakeEnemy(EntityManager em, float3 pos, float hp = 50f, bool knockback = true)
|
|
{
|
|
var e = em.CreateEntity();
|
|
em.AddComponent<EnemyTag>(e);
|
|
em.AddComponentData(e, new Health { Current = hp, Max = 50f });
|
|
em.AddComponentData(e, LocalTransform.FromPosition(pos));
|
|
em.AddBuffer<DamageEvent>(e);
|
|
if (knockback) em.AddComponentData(e, new KnockbackState());
|
|
return e;
|
|
}
|
|
|
|
static void Press(EntityManager em, Entity player)
|
|
{
|
|
var pi = em.GetComponentData<PlayerInput>(player);
|
|
pi.Attack.Set();
|
|
em.SetComponentData(player, pi);
|
|
}
|
|
|
|
// ---- state machine (plain world) ----
|
|
|
|
[Test]
|
|
public void Swing_Starts_On_Press_From_Idle_Step1()
|
|
{
|
|
var (world, group) = MakeWorld("MeleeStart", 100);
|
|
using (world)
|
|
{
|
|
var em = world.EntityManager;
|
|
var p = MakePlayer(em, new float2(0, 1));
|
|
Press(em, p);
|
|
group.Update();
|
|
var mc = em.GetComponentData<MeleeCombo>(p);
|
|
Assert.AreEqual(1, mc.Step, "first swing is step 1");
|
|
Assert.AreEqual(100u, mc.SwingStartTick, "SwingStartTick = now");
|
|
Assert.AreEqual(116u, mc.LockUntilTick, "LockUntilTick = now + recover 16");
|
|
}
|
|
}
|
|
|
|
[Test]
|
|
public void Lockout_Blocks_A_Repress_Mid_Swing()
|
|
{
|
|
var (world, group) = MakeWorld("MeleeLock", 100);
|
|
using (world)
|
|
{
|
|
var em = world.EntityManager;
|
|
var p = MakePlayer(em, new float2(0, 1));
|
|
em.SetComponentData(p, new MeleeCombo { Step = 1, SwingStartTick = 95, LockUntilTick = 116 });
|
|
Press(em, p);
|
|
group.Update(); // now 100 < lock 116 -> locked
|
|
var mc = em.GetComponentData<MeleeCombo>(p);
|
|
Assert.AreEqual(1, mc.Step, "locked: no new swing");
|
|
Assert.AreEqual(95u, mc.SwingStartTick, "swing start unchanged while locked");
|
|
}
|
|
}
|
|
|
|
[Test]
|
|
public void Chain_Advances_Step_When_Repressed_In_Grace_Window()
|
|
{
|
|
var (world, group) = MakeWorld("MeleeChain", 116); // lock just ended
|
|
using (world)
|
|
{
|
|
var em = world.EntityManager;
|
|
var p = MakePlayer(em, new float2(0, 1));
|
|
em.SetComponentData(p, new MeleeCombo { Step = 1, SwingStartTick = 100, LockUntilTick = 116 });
|
|
Press(em, p);
|
|
group.Update(); // 116 in [116, 116+18) -> chain to 2
|
|
Assert.AreEqual(2, em.GetComponentData<MeleeCombo>(p).Step, "re-press in the grace window chains to step 2");
|
|
}
|
|
}
|
|
|
|
[Test]
|
|
public void Finisher_Chain_And_Longer_Recovery()
|
|
{
|
|
var (world, group) = MakeWorld("MeleeFinish", 116);
|
|
using (world)
|
|
{
|
|
var em = world.EntityManager;
|
|
var p = MakePlayer(em, new float2(0, 1));
|
|
em.SetComponentData(p, new MeleeCombo { Step = 2, SwingStartTick = 100, LockUntilTick = 116 });
|
|
Press(em, p);
|
|
group.Update(); // step 2 -> 3 (finisher); recover = round(16 * 1.8) = 29 -> lock 116 + 29 = 145
|
|
var mc = em.GetComponentData<MeleeCombo>(p);
|
|
Assert.AreEqual(3, mc.Step, "step 2 chains to the finisher (3)");
|
|
Assert.AreEqual(145u, mc.LockUntilTick, "finisher recover = round(16 * 1.8) = 29 ticks");
|
|
}
|
|
}
|
|
|
|
[Test]
|
|
public void Repress_After_Grace_Resets_To_Step1()
|
|
{
|
|
var (world, group) = MakeWorld("MeleeReset", 140); // 140 > lock 116 + grace 18 = 134
|
|
using (world)
|
|
{
|
|
var em = world.EntityManager;
|
|
var p = MakePlayer(em, new float2(0, 1));
|
|
em.SetComponentData(p, new MeleeCombo { Step = 2, SwingStartTick = 100, LockUntilTick = 116 });
|
|
Press(em, p);
|
|
group.Update();
|
|
Assert.AreEqual(1, em.GetComponentData<MeleeCombo>(p).Step, "a press past the grace window restarts the combo at 1");
|
|
}
|
|
}
|
|
|
|
[Test]
|
|
public void Start_Is_Idempotent_At_The_Same_Tick()
|
|
{
|
|
var (world, group) = MakeWorld("MeleeIdem", 100);
|
|
using (world)
|
|
{
|
|
var em = world.EntityManager;
|
|
var p = MakePlayer(em, new float2(0, 1));
|
|
Press(em, p);
|
|
group.Update();
|
|
var first = em.GetComponentData<MeleeCombo>(p);
|
|
group.Update(); // same tick, Attack still set -> locked, no re-trigger
|
|
var second = em.GetComponentData<MeleeCombo>(p);
|
|
Assert.AreEqual(first.Step, second.Step, "re-running the start tick must not advance the combo");
|
|
Assert.AreEqual(first.SwingStartTick, second.SwingStartTick, "swing start set exactly once");
|
|
Assert.AreEqual(first.LockUntilTick, second.LockUntilTick, "lock set exactly once");
|
|
}
|
|
}
|
|
|
|
[Test]
|
|
public void Movement_Is_Scaled_During_The_Swing_Window()
|
|
{
|
|
var (world, group) = MakeWorld("MeleeMove", 100);
|
|
using (world)
|
|
{
|
|
var em = world.EntityManager;
|
|
var p = MakePlayer(em, new float2(0, 1));
|
|
em.SetComponentData(p, new MeleeCombo { Step = 1, SwingStartTick = 95, LockUntilTick = 116 }); // mid-swing
|
|
em.SetComponentData(p, new CharacterControl { MoveVelocity = new float3(0, 0, 10) });
|
|
group.Update(); // no press; just the movement commit
|
|
Assert.AreEqual(3.5f, em.GetComponentData<CharacterControl>(p).MoveVelocity.z, 1e-3f,
|
|
"mid-swing scales MoveVelocity by MeleeSwingMoveScale (0.35)");
|
|
}
|
|
}
|
|
|
|
[Test]
|
|
public void ReSimulated_PreSwing_Tick_Gets_No_Movement_Scale()
|
|
{
|
|
// SwingStartTick is the lower bound: a re-simulated tick BEFORE the swing keeps PlayerControl's velocity.
|
|
var (world, group) = MakeWorld("MeleePreSwing", 90); // 90 < SwingStartTick 95
|
|
using (world)
|
|
{
|
|
var em = world.EntityManager;
|
|
var p = MakePlayer(em, new float2(0, 1));
|
|
em.SetComponentData(p, new MeleeCombo { Step = 1, SwingStartTick = 95, LockUntilTick = 116 });
|
|
em.SetComponentData(p, new CharacterControl { MoveVelocity = new float3(0, 0, 10) });
|
|
group.Update();
|
|
Assert.AreEqual(10f, em.GetComponentData<CharacterControl>(p).MoveVelocity.z, 1e-3f,
|
|
"a re-simulated PRE-swing tick keeps PlayerControl's velocity (SwingStartTick lower bound)");
|
|
}
|
|
}
|
|
|
|
[Test]
|
|
public void Active_Dash_Blocks_Swing_And_Movement_Scale()
|
|
{
|
|
var (world, group) = MakeWorld("MeleeDashCancel", 100);
|
|
using (world)
|
|
{
|
|
var em = world.EntityManager;
|
|
var p = MakePlayer(em, new float2(0, 1));
|
|
em.SetComponentData(p, new DashState { StartTick = 90, IFrameUntilTick = 112, RecoverUntilTick = 121 });
|
|
em.SetComponentData(p, new MeleeCombo { Step = 1, SwingStartTick = 95, LockUntilTick = 116 });
|
|
em.SetComponentData(p, new CharacterControl { MoveVelocity = new float3(0, 0, 10) });
|
|
Press(em, p);
|
|
group.Update();
|
|
Assert.AreEqual(1, em.GetComponentData<MeleeCombo>(p).Step, "dash active: a swing press does not advance the combo");
|
|
Assert.AreEqual(10f, em.GetComponentData<CharacterControl>(p).MoveVelocity.z, 1e-3f,
|
|
"dash active: melee does NOT scale movement (the dash owns it)");
|
|
}
|
|
}
|
|
|
|
// ---- server cleave (GameServer world) ----
|
|
|
|
[Test]
|
|
public void Cleave_Damages_Living_Enemies_In_Cone_Only()
|
|
{
|
|
var (world, group) = MakeWorld("MeleeCleave", 100, server: true);
|
|
using (world)
|
|
{
|
|
var em = world.EntityManager;
|
|
var p = MakePlayer(em, new float2(0, 1)); // facing +Z, at origin
|
|
var inCone = MakeEnemy(em, new float3(0, 0, 2)); // ahead, in range/cone -> hit
|
|
var behind = MakeEnemy(em, new float3(0, 0, -2)); // behind -> miss
|
|
var dead = MakeEnemy(em, new float3(0, 0, 1.5f), hp: 0f); // in cone but dead -> excluded
|
|
Press(em, p);
|
|
group.Update();
|
|
|
|
var dIn = em.GetBuffer<DamageEvent>(inCone);
|
|
Assert.AreEqual(1, dIn.Length, "the in-cone living enemy takes one cleave hit");
|
|
Assert.AreEqual(18f, dIn[0].Amount, 1e-3f, "light-hit damage = MeleeDamage default 18");
|
|
Assert.AreEqual(100u, dIn[0].SourceTick, "SourceTick stamped = swing tick");
|
|
Assert.AreEqual(7, dIn[0].SourceNetworkId, "attributed to the swinging player's NetworkId");
|
|
Assert.AreEqual(0, em.GetBuffer<DamageEvent>(behind).Length, "the enemy behind is outside the cone");
|
|
Assert.AreEqual(0, em.GetBuffer<DamageEvent>(dead).Length, "a dead enemy is excluded from the cleave");
|
|
}
|
|
}
|
|
|
|
[Test]
|
|
public void Finisher_Hits_Harder_And_Wider()
|
|
{
|
|
var (world, group) = MakeWorld("MeleeFinisherDmg", 116, server: true);
|
|
using (world)
|
|
{
|
|
var em = world.EntityManager;
|
|
var p = MakePlayer(em, new float2(0, 1));
|
|
em.SetComponentData(p, new MeleeCombo { Step = 2, SwingStartTick = 100, LockUntilTick = 116 }); // chain to 3
|
|
// distance 4: beyond light range 2.6, within finisher range 2.6 * 1.8 = 4.68 -> only the finisher reaches.
|
|
var far = MakeEnemy(em, new float3(0, 0, 4));
|
|
Press(em, p);
|
|
group.Update();
|
|
var d = em.GetBuffer<DamageEvent>(far);
|
|
Assert.AreEqual(1, d.Length, "the finisher's widened range reaches the far enemy");
|
|
Assert.AreEqual(18f * 1.8f, d[0].Amount, 1e-2f, "finisher damage = 18 * finisher mult 1.8");
|
|
}
|
|
}
|
|
|
|
[Test]
|
|
public void Light_Swing_Does_Not_Reach_The_Finisher_Range()
|
|
{
|
|
var (world, group) = MakeWorld("MeleeLightRange", 100, server: true);
|
|
using (world)
|
|
{
|
|
var em = world.EntityManager;
|
|
var p = MakePlayer(em, new float2(0, 1));
|
|
var far = MakeEnemy(em, new float3(0, 0, 4)); // beyond light range 2.6
|
|
Press(em, p); // step 1 light
|
|
group.Update();
|
|
Assert.AreEqual(0, em.GetBuffer<DamageEvent>(far).Length, "a light swing (range 2.6) does not reach an enemy at distance 4");
|
|
}
|
|
}
|
|
|
|
[Test]
|
|
public void Cleave_Stamps_Knockback_Away_From_The_Player()
|
|
{
|
|
var (world, group) = MakeWorld("MeleeKnock", 100, server: true);
|
|
using (world)
|
|
{
|
|
var em = world.EntityManager;
|
|
var p = MakePlayer(em, new float2(0, 1));
|
|
var enemy = MakeEnemy(em, new float3(0, 0, 2));
|
|
Press(em, p);
|
|
group.Update();
|
|
var kb = em.GetComponentData<KnockbackState>(enemy);
|
|
Assert.Greater(kb.Speed, 0f, "knockback speed stamped");
|
|
Assert.AreEqual(0f, kb.Dir.x, 1e-3f, "knockback points straight away (+Z): no X component");
|
|
Assert.AreEqual(1f, kb.Dir.y, 1e-3f, "knockback dir planar-y = world +Z (away from the player)");
|
|
Assert.AreNotEqual(0u, kb.UntilTick, "knockback window scheduled");
|
|
}
|
|
}
|
|
|
|
[Test]
|
|
public void Client_World_Predicts_The_Swing_But_Applies_No_Damage()
|
|
{
|
|
var (world, group) = MakeWorld("MeleeClientNoDamage", 100, server: false);
|
|
using (world)
|
|
{
|
|
var em = world.EntityManager;
|
|
var p = MakePlayer(em, new float2(0, 1));
|
|
var enemy = MakeEnemy(em, new float3(0, 0, 2));
|
|
Press(em, p);
|
|
group.Update();
|
|
Assert.AreEqual(1, em.GetComponentData<MeleeCombo>(p).Step, "the client still PREDICTS the swing state");
|
|
Assert.AreEqual(0, em.GetBuffer<DamageEvent>(enemy).Length,
|
|
"but a non-server world never applies enemy damage (server-authoritative)");
|
|
}
|
|
}
|
|
|
|
// ---- death cleanup ----
|
|
|
|
[Test]
|
|
public void Death_Clears_The_Combo()
|
|
{
|
|
var world = new World("MeleeDeath");
|
|
using (world)
|
|
{
|
|
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
|
|
group.AddSystemToUpdateList(world.GetOrCreateSystem<PlayerDeathStateSystem>());
|
|
group.SortSystems();
|
|
world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
|
|
SetServerTick(world, 100);
|
|
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 = float3.zero });
|
|
em.AddComponentData(e, new MeleeCombo { Step = 2, SwingStartTick = 90, LockUntilTick = 116 }); // mid-combo
|
|
em.AddComponent<Simulate>(e);
|
|
em.AddComponent<Dead>(e);
|
|
em.SetComponentEnabled<Dead>(e, false);
|
|
|
|
group.Update();
|
|
|
|
var mc = em.GetComponentData<MeleeCombo>(e);
|
|
Assert.AreEqual(0, mc.Step, "death clears the combo step");
|
|
Assert.AreEqual(0u, mc.LockUntilTick, "death clears the combo lock");
|
|
}
|
|
}
|
|
|
|
[Test]
|
|
public void ComboLength2_Makes_Step2_The_Finisher()
|
|
{
|
|
var (world, group) = MakeWorld("MeleeLen2", 116, server: true);
|
|
using (world)
|
|
{
|
|
var em = world.EntityManager;
|
|
var cfg = TuningConfig.Defaults();
|
|
cfg.MeleeComboLength = 2f;
|
|
em.SetComponentData(em.CreateEntity(typeof(TuningConfig)), cfg);
|
|
|
|
var p = MakePlayer(em, new float2(0, 1));
|
|
em.SetComponentData(p, new MeleeCombo { Step = 1, SwingStartTick = 100, LockUntilTick = 116 }); // chain
|
|
var enemy = MakeEnemy(em, new float3(0, 0, 2));
|
|
Press(em, p);
|
|
group.Update();
|
|
|
|
Assert.AreEqual(2, em.GetComponentData<MeleeCombo>(p).Step, "comboLen=2: step 1 chains to the finisher (2)");
|
|
var d = em.GetBuffer<DamageEvent>(enemy);
|
|
Assert.AreEqual(1, d.Length, "the finisher cleave lands");
|
|
Assert.AreEqual(18f * 1.8f, d[0].Amount, 1e-2f, "comboLen=2 step-2 IS the finisher: 18 * 1.8");
|
|
}
|
|
}
|
|
|
|
[Test]
|
|
public void Same_Tick_Attack_And_Dash_Yields_To_The_Dash()
|
|
{
|
|
var (world, group) = MakeWorld("MeleeAttackDashTie", 100);
|
|
using (world)
|
|
{
|
|
var em = world.EntityManager;
|
|
var p = MakePlayer(em, new float2(0, 1)); // idle
|
|
var pi = new PlayerInput();
|
|
pi.Attack.Set();
|
|
pi.Dash.Set();
|
|
em.SetComponentData(p, pi);
|
|
group.Update();
|
|
Assert.AreEqual(0, em.GetComponentData<MeleeCombo>(p).Step,
|
|
"Attack + Dash the same tick: the dash wins, no swing starts (Step stays idle)");
|
|
}
|
|
}
|
|
|
|
[Test]
|
|
public void Attack_During_Dash_Recovery_Tail_Is_Blocked()
|
|
{
|
|
var (world, group) = MakeWorld("MeleeDashRecoveryBlock", 110);
|
|
using (world)
|
|
{
|
|
var em = world.EntityManager;
|
|
var p = MakePlayer(em, new float2(0, 1));
|
|
// i-frames ended at 100, recovery active until 121: tick 110 is the recovery-only tail.
|
|
em.SetComponentData(p, new DashState { StartTick = 90, IFrameUntilTick = 100, RecoverUntilTick = 121 });
|
|
Press(em, p);
|
|
group.Update();
|
|
Assert.AreEqual(0, em.GetComponentData<MeleeCombo>(p).Step,
|
|
"a swing press during the dash RECOVERY tail (not just i-frames) is blocked");
|
|
}
|
|
}
|
|
|
|
[Test]
|
|
public void Two_Players_Both_Cleave_The_Same_Enemy()
|
|
{
|
|
var (world, group) = MakeWorld("MeleeCoOpCleave", 100, server: true);
|
|
using (world)
|
|
{
|
|
var em = world.EntityManager;
|
|
var a = MakePlayer(em, new float2(0, 1), new float3(-0.3f, 0, 0));
|
|
var b = MakePlayer(em, new float2(0, 1), new float3(0.3f, 0, 0));
|
|
var enemy = MakeEnemy(em, new float3(0, 0, 2)); // in both players' cones
|
|
Press(em, a);
|
|
Press(em, b);
|
|
group.Update();
|
|
Assert.AreEqual(2, em.GetBuffer<DamageEvent>(enemy).Length,
|
|
"both co-op players' cleaves accumulate on the shared enemy's DamageEvent buffer");
|
|
}
|
|
}
|
|
|
|
}
|
|
}
|