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"); } } // ---- any-attack harvest (MC-4 melee mines too) ---- static int LedgerCount(EntityManager em, Entity ledger, ushort itemId) { var buf = em.GetBuffer(ledger); for (int i = 0; i < buf.Length; i++) if (buf[i].ItemId == itemId) return buf[i].Count; return 0; } [Test] public void Cleave_Harvests_A_Base_Node_To_The_Shared_Ledger() { var (world, group) = MakeWorld("MeleeHarvestBase", 100, server: true); using (world) { var em = world.EntityManager; var ledger = em.CreateEntity(typeof(ResourceLedger)); em.AddBuffer(ledger); var p = MakePlayer(em, new float2(0, 1)); // at origin, facing +Z var node = em.CreateEntity(); em.AddComponentData(node, LocalTransform.FromPosition(new float3(0, 0, 2))); // in the light cone em.AddComponentData(node, new ResourceNode { ResourceId = ResourceId.Ore, Remaining = 30, HarvestPerHit = 5f }); em.AddComponentData(node, new RegionTag { Region = RegionId.Base }); Press(em, p); group.Update(); Assert.AreEqual(5, LedgerCount(em, ledger, ResourceId.Ore), "a base-node melee hit credits the shared ledger (the build pool)."); Assert.AreEqual(25, em.GetComponentData(node).Remaining, "the node is depleted by HarvestPerHit (written back for the chip VFX)."); } } [Test] public void Cleave_Harvests_An_Expedition_Node_To_Personal_Inventory_Not_The_Ledger() { var (world, group) = MakeWorld("MeleeHarvestExp", 100, server: true); using (world) { var em = world.EntityManager; var ledger = em.CreateEntity(typeof(ResourceLedger)); em.AddBuffer(ledger); var p = MakePlayer(em, new float2(0, 1)); // GhostOwner NetworkId 7 em.AddComponent(p); em.AddBuffer(p); var node = em.CreateEntity(); em.AddComponentData(node, LocalTransform.FromPosition(new float3(0, 0, 2))); em.AddComponentData(node, new ResourceNode { ResourceId = ResourceId.Aether, Remaining = 30, HarvestPerHit = 5f }); em.AddComponentData(node, new RegionTag { Region = RegionId.Expedition }); Press(em, p); group.Update(); var inv = em.GetBuffer(p); Assert.AreEqual(5, InventoryMath.CountOf(inv, ResourceId.Aether), "an expedition-node melee hit lands in the swinging player's PERSONAL inventory."); Assert.AreEqual(0, LedgerCount(em, ledger, ResourceId.Aether), "an expedition harvest does NOT credit the shared base ledger (DR-026 personal haul)."); Assert.AreEqual(25, em.GetComponentData(node).Remaining, "the node is still depleted."); } } } }