diff --git a/Assets/_Project/Tests/EditMode/MeleeComboTests.cs b/Assets/_Project/Tests/EditMode/MeleeComboTests.cs
new file mode 100644
index 000000000..d7307acdb
--- /dev/null
+++ b/Assets/_Project/Tests/EditMode/MeleeComboTests.cs
@@ -0,0 +1,433 @@
+using NUnit.Framework;
+using ProjectM.Simulation;
+using Unity.Core;
+using Unity.Entities;
+using Unity.Mathematics;
+using Unity.NetCode;
+using Unity.Transforms;
+
+namespace ProjectM.Tests
+{
+ ///
+ /// 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.
+ ///
+ 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();
+ group.AddSystemToUpdateList(world.GetOrCreateSystem());
+ 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(e);
+ em.AddComponent(e);
+ em.SetComponentEnabled(e, false);
+ return e;
+ }
+
+ static Entity MakeEnemy(EntityManager em, float3 pos, float hp = 50f, bool knockback = true)
+ {
+ var e = em.CreateEntity();
+ em.AddComponent(e);
+ em.AddComponentData(e, new Health { Current = hp, Max = 50f });
+ em.AddComponentData(e, LocalTransform.FromPosition(pos));
+ em.AddBuffer(e);
+ if (knockback) em.AddComponentData(e, new KnockbackState());
+ return e;
+ }
+
+ static void Press(EntityManager em, Entity player)
+ {
+ var pi = em.GetComponentData(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(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(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(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(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(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(p);
+ group.Update(); // same tick, Attack still set -> locked, no re-trigger
+ var second = em.GetComponentData(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(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(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(p).Step, "dash active: a swing press does not advance the combo");
+ Assert.AreEqual(10f, em.GetComponentData(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(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(behind).Length, "the enemy behind is outside the cone");
+ Assert.AreEqual(0, em.GetBuffer(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(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(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(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(p).Step, "the client still PREDICTS the swing state");
+ Assert.AreEqual(0, em.GetBuffer(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();
+ group.AddSystemToUpdateList(world.GetOrCreateSystem());
+ group.SortSystems();
+ world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
+ SetServerTick(world, 100);
+ var em = world.EntityManager;
+ var e = em.CreateEntity();
+ em.AddComponent(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(e);
+ em.AddComponent(e);
+ em.SetComponentEnabled(e, false);
+
+ group.Update();
+
+ var mc = em.GetComponentData(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(p).Step, "comboLen=2: step 1 chains to the finisher (2)");
+ var d = em.GetBuffer(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(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(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(enemy).Length,
+ "both co-op players' cleaves accumulate on the shared enemy's DamageEvent buffer");
+ }
+ }
+
+ }
+}
diff --git a/Assets/_Project/Tests/EditMode/MeleeComboTests.cs.meta b/Assets/_Project/Tests/EditMode/MeleeComboTests.cs.meta
new file mode 100644
index 000000000..d7efe5263
--- /dev/null
+++ b/Assets/_Project/Tests/EditMode/MeleeComboTests.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 0af52c7d172579945a6dfde5ea06b234
\ No newline at end of file
diff --git a/Assets/_Project/Tests/EditMode/MeleeConeMathTests.cs b/Assets/_Project/Tests/EditMode/MeleeConeMathTests.cs
new file mode 100644
index 000000000..90b0cb7d5
--- /dev/null
+++ b/Assets/_Project/Tests/EditMode/MeleeConeMathTests.cs
@@ -0,0 +1,68 @@
+using NUnit.Framework;
+using ProjectM.Simulation;
+using Unity.Mathematics;
+
+namespace ProjectM.Tests
+{
+ ///
+ /// MC-4 — pure cone hit-test (): the planar XZ range gate + the
+ /// dot(bearing, facing) >= cos(halfAngle) cone gate, plus the coincident / zero-facing / non-positive-range
+ /// guards. Shares the exact predicate AutoTarget uses, but as a per-candidate boolean for the collect-all cleave.
+ ///
+ public class MeleeConeMathTests
+ {
+ const float Cos60 = 0.5f; // cos(60deg): half-angle 60 -> 120deg full cone
+
+ [Test]
+ public void In_Range_And_Straight_Ahead_Hits()
+ {
+ Assert.IsTrue(MeleeConeMath.InCone(float3.zero, new float2(0, 1), 3f, Cos60, new float3(0, 0, 2)));
+ }
+
+ [Test]
+ public void Out_Of_Range_Misses()
+ {
+ Assert.IsFalse(MeleeConeMath.InCone(float3.zero, new float2(0, 1), 3f, Cos60, new float3(0, 0, 4)));
+ }
+
+ [Test]
+ public void At_90_Degrees_Outside_Cone_Misses()
+ {
+ // bearing +X vs facing +Z: dot = 0 < cos60 (0.5) -> outside the cone.
+ Assert.IsFalse(MeleeConeMath.InCone(float3.zero, new float2(0, 1), 3f, Cos60, new float3(2, 0, 0)));
+ }
+
+ [Test]
+ public void At_45_Degrees_Inside_Cone_Hits()
+ {
+ // bearing ~45deg: dot ~0.707 > 0.5 -> inside.
+ Assert.IsTrue(MeleeConeMath.InCone(float3.zero, new float2(0, 1), 5f, Cos60, new float3(2, 0, 2)));
+ }
+
+ [Test]
+ public void Behind_The_Player_Misses()
+ {
+ Assert.IsFalse(MeleeConeMath.InCone(float3.zero, new float2(0, 1), 5f, Cos60, new float3(0, 0, -2)));
+ }
+
+ [Test]
+ public void Coincident_Target_Excluded()
+ {
+ Assert.IsFalse(MeleeConeMath.InCone(float3.zero, new float2(0, 1), 3f, Cos60, float3.zero));
+ }
+
+ [Test]
+ public void Zero_Facing_Or_NonPositive_Range_Misses()
+ {
+ Assert.IsFalse(MeleeConeMath.InCone(float3.zero, float2.zero, 3f, Cos60, new float3(0, 0, 2)), "zero facing -> miss");
+ Assert.IsFalse(MeleeConeMath.InCone(float3.zero, new float2(0, 1), 0f, Cos60, new float3(0, 0, 1)), "range 0 -> miss");
+ }
+
+ [Test]
+ public void Uses_Planar_XZ_And_Ignores_Y()
+ {
+ // straight ahead in XZ but far in Y: still a hit (Y ignored, top-down).
+ Assert.IsTrue(MeleeConeMath.InCone(float3.zero, new float2(0, 1), 3f, Cos60, new float3(0, 5, 2)));
+ }
+ }
+}
diff --git a/Assets/_Project/Tests/EditMode/MeleeConeMathTests.cs.meta b/Assets/_Project/Tests/EditMode/MeleeConeMathTests.cs.meta
new file mode 100644
index 000000000..4c5c8e675
--- /dev/null
+++ b/Assets/_Project/Tests/EditMode/MeleeConeMathTests.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 2dbd5a5c09dd1fb4dbcb68a1afb15274
\ No newline at end of file