From d77649fe1631efb09c606a13c7e5cc491c0339c1 Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Wed, 10 Jun 2026 17:23:10 -0700 Subject: [PATCH] Tests: MC-4 combo + cone-math EditMode coverage 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) --- .../Tests/EditMode/MeleeComboTests.cs | 433 ++++++++++++++++++ .../Tests/EditMode/MeleeComboTests.cs.meta | 2 + .../Tests/EditMode/MeleeConeMathTests.cs | 68 +++ .../Tests/EditMode/MeleeConeMathTests.cs.meta | 2 + 4 files changed, 505 insertions(+) create mode 100644 Assets/_Project/Tests/EditMode/MeleeComboTests.cs create mode 100644 Assets/_Project/Tests/EditMode/MeleeComboTests.cs.meta create mode 100644 Assets/_Project/Tests/EditMode/MeleeConeMathTests.cs create mode 100644 Assets/_Project/Tests/EditMode/MeleeConeMathTests.cs.meta 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