Files
Project-M/Assets/_Project/Tests/EditMode/MeleeComboTests.cs
T
kronic 096c62862f Tests: base mining field, expedition teardown, schedule siege, melee harvest
8 new EditMode tests (302 total, all green): BaseFieldSpawnSystem (target count + Base/Ore + cadence + top-up), ExpeditionFieldSystem teardown preserves the base field, ThreatDirector schedule arming, and melee harvest routing (base->ledger, expedition->personal inventory) which guards the cross-region leak the post-impl review caught. See DR-031.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 15:00:03 -07:00

493 lines
23 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");
}
}
// ---- any-attack harvest (MC-4 melee mines too) ----
static int LedgerCount(EntityManager em, Entity ledger, ushort itemId)
{
var buf = em.GetBuffer<StorageEntry>(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<StorageEntry>(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<ResourceNode>(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<StorageEntry>(ledger);
var p = MakePlayer(em, new float2(0, 1)); // GhostOwner NetworkId 7
em.AddComponent<PlayerTag>(p);
em.AddBuffer<InventorySlot>(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<InventorySlot>(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<ResourceNode>(node).Remaining, "the node is still depleted.");
}
}
}
}