Initial Combat Implementation

This commit is contained in:
Luis Gonzalez
2026-05-31 21:35:12 -07:00
parent 7fa77ce821
commit 1f647dd5e1
166 changed files with 93337 additions and 91 deletions
@@ -0,0 +1,82 @@
using NUnit.Framework;
using ProjectM.Simulation;
using Unity.Collections;
using Unity.Entities;
namespace ProjectM.Tests
{
/// <summary>
/// Builds an AbilityDatabaseBlob with BlobBuilder (the same shape the AbilityDatabaseAuthoring baker
/// produces) and verifies the id lookups round-trip values including FixedString names, and that a
/// missing id returns false. Version-independent, no ECS world.
/// </summary>
public class AbilityDatabaseBlobTests
{
static BlobAssetReference<AbilityDatabaseBlob> Build()
{
using var builder = new BlobBuilder(Allocator.Temp);
ref var root = ref builder.ConstructRoot<AbilityDatabaseBlob>();
var abilities = builder.Allocate(ref root.Abilities, 2);
abilities[0] = new AbilityDefBlob
{
Id = (byte)AbilityId.Primary, Damage = 20f, ProjectileSpeed = 25f, Range = 20f,
AutoTargetRange = 12f, AutoTargetConeRadians = 0.6f, CooldownTicks = 12, Name = "Primary"
};
abilities[1] = new AbilityDefBlob
{
Id = (byte)AbilityId.FastLight, Damage = 8f, ProjectileSpeed = 40f, Range = 16f,
AutoTargetRange = 12f, AutoTargetConeRadians = 0.6f, CooldownTicks = 5, Name = "FastLight"
};
var chars = builder.Allocate(ref root.Characters, 1);
chars[0] = new CharacterStatsBlob
{
Id = (byte)CharacterId.Default, MoveSpeed = 6f, TurnRateRadiansPerSec = 12.5f,
MaxHealth = 100f, Name = "Default"
};
return builder.CreateBlobAssetReference<AbilityDatabaseBlob>(Allocator.Persistent);
}
[Test]
public void TryGetAbility_Hit_Returns_Fields()
{
var blob = Build();
try
{
Assert.IsTrue(blob.Value.TryGetAbility((byte)AbilityId.FastLight, out var def));
Assert.AreEqual(8f, def.Damage, 1e-4f);
Assert.AreEqual(40f, def.ProjectileSpeed, 1e-4f);
Assert.AreEqual(5, def.CooldownTicks);
Assert.AreEqual("FastLight", def.Name.ToString());
}
finally { blob.Dispose(); }
}
[Test]
public void TryGetAbility_Miss_Returns_False()
{
var blob = Build();
try
{
Assert.IsFalse(blob.Value.TryGetAbility((byte)AbilityId.SlowHeavy, out _));
}
finally { blob.Dispose(); }
}
[Test]
public void TryGetCharacter_Hit_Returns_Fields()
{
var blob = Build();
try
{
Assert.IsTrue(blob.Value.TryGetCharacter((byte)CharacterId.Default, out var def));
Assert.AreEqual(6f, def.MoveSpeed, 1e-4f);
Assert.AreEqual(100f, def.MaxHealth, 1e-4f);
Assert.AreEqual("Default", def.Name.ToString());
}
finally { blob.Dispose(); }
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 3235ea57e35c04dc296b882a59d2aeee
@@ -0,0 +1,180 @@
using NUnit.Framework;
using ProjectM.Simulation;
using Unity.Collections;
using Unity.Mathematics;
namespace ProjectM.Tests
{
/// <summary>
/// Pure unit tests for <see cref="AutoTarget.Resolve"/>, the Burst-safe auto-target assist
/// helper. No ECS world is needed: the method is a deterministic, allocation-free function over
/// a <see cref="NativeArray{T}"/> of candidate XZ world positions, so each case just allocates a
/// Temp array, invokes Resolve, asserts the returned planar direction, and disposes. Covers the
/// contract cases: candidate inside the cone+range steers the shot, candidates behind / outside
/// the cone or out of range fall back to the raw aim, the nearer of two candidates wins, ties
/// break by smallest index, near-zero aim is returned unchanged, and an empty candidate set is a
/// no-op. Netcode-free and version-independent, mirroring PlayerMoveSystemTests.
/// </summary>
public class AutoTargetTests
{
// Tolerance for comparing normalized planar directions.
const float Tol = 1e-4f;
static void AssertDirEqual(float2 expected, float2 actual, string message)
{
Assert.AreEqual(expected.x, actual.x, Tol, message + " (x)");
Assert.AreEqual(expected.y, actual.y, Tol, message + " (y)");
}
[Test]
public void Resolve_CandidateDirectlyAhead_WithinConeAndRange_PointsAtIt()
{
// Shooter at origin aiming +Z; a candidate sits straight ahead, well inside range and
// exactly on the aim axis, so the resolved direction must point at it (== raw aim here).
var from = float3.zero;
var rawAim = new float2(0f, 1f);
using var candidates = new NativeArray<float3>(new[] { new float3(0f, 0f, 5f) }, Allocator.Temp);
var dir = AutoTarget.Resolve(from, rawAim, autoTargetRange: 12f,
coneHalfAngleRadians: math.radians(35f), candidates);
AssertDirEqual(new float2(0f, 1f), dir, "Candidate dead ahead should be targeted.");
}
[Test]
public void Resolve_OffAxisCandidate_WithinCone_SteersTowardsIt()
{
// Candidate is offset on +X but within the 35-degree cone of a +Z aim. The shot should
// bend toward the candidate rather than keep the raw aim.
var from = float3.zero;
var rawAim = new float2(0f, 1f);
// ~21.8 degrees off the +Z axis: within a 35-degree half-angle cone.
var target = new float3(2f, 0f, 5f);
using var candidates = new NativeArray<float3>(new[] { target }, Allocator.Temp);
var dir = AutoTarget.Resolve(from, rawAim, autoTargetRange: 12f,
coneHalfAngleRadians: math.radians(35f), candidates);
var expected = math.normalize(new float2(target.x, target.z));
AssertDirEqual(expected, dir, "In-cone off-axis candidate should be targeted.");
}
[Test]
public void Resolve_CandidateBehind_ReturnsRawAim()
{
// Candidate is directly behind the shooter (-Z) while aiming +Z: outside any forward cone.
var from = float3.zero;
var rawAim = new float2(0f, 1f);
using var candidates = new NativeArray<float3>(new[] { new float3(0f, 0f, -5f) }, Allocator.Temp);
var dir = AutoTarget.Resolve(from, rawAim, autoTargetRange: 12f,
coneHalfAngleRadians: math.radians(35f), candidates);
AssertDirEqual(rawAim, dir, "A candidate behind the shooter must not be targeted.");
}
[Test]
public void Resolve_CandidateOutsideCone_ReturnsRawAim()
{
// Candidate is 90 degrees to the side (+X) of a +Z aim: well outside a 35-degree cone.
var from = float3.zero;
var rawAim = new float2(0f, 1f);
using var candidates = new NativeArray<float3>(new[] { new float3(5f, 0f, 0f) }, Allocator.Temp);
var dir = AutoTarget.Resolve(from, rawAim, autoTargetRange: 12f,
coneHalfAngleRadians: math.radians(35f), candidates);
AssertDirEqual(rawAim, dir, "A candidate outside the cone must not be targeted.");
}
[Test]
public void Resolve_CandidateOutsideRange_ReturnsRawAim()
{
// Candidate is dead ahead (in cone) but beyond autoTargetRange.
var from = float3.zero;
var rawAim = new float2(0f, 1f);
using var candidates = new NativeArray<float3>(new[] { new float3(0f, 0f, 20f) }, Allocator.Temp); // range is 12, so out of reach.
var dir = AutoTarget.Resolve(from, rawAim, autoTargetRange: 12f,
coneHalfAngleRadians: math.radians(35f), candidates);
AssertDirEqual(rawAim, dir, "A candidate beyond range must not be targeted.");
}
[Test]
public void Resolve_TwoCandidates_NearerChosen()
{
// Both candidates lie on the +Z aim axis and inside range; the nearer one must win.
var from = float3.zero;
var rawAim = new float2(0f, 1f);
var near = new float3(1f, 0f, 4f);
var far = new float3(-1f, 0f, 9f);
// Deliberately list the far one first to prove distance, not order, decides.
using var candidates = new NativeArray<float3>(new[] { far, near }, Allocator.Temp);
var dir = AutoTarget.Resolve(from, rawAim, autoTargetRange: 12f,
coneHalfAngleRadians: math.radians(45f), candidates);
var expected = math.normalize(new float2(near.x, near.z));
AssertDirEqual(expected, dir, "The nearer in-cone candidate should be targeted.");
}
[Test]
public void Resolve_EqualDistanceCandidates_BreaksTieBySmallestIndex()
{
// Two candidates at the same planar distance and both in-cone: the contract specifies the
// tie is broken by the smallest candidate index (candidates[0]).
var from = float3.zero;
var rawAim = new float2(0f, 1f);
var first = new float3(1f, 0f, 5f);
var second = new float3(-1f, 0f, 5f); // same planar distance as `first`.
using var candidates = new NativeArray<float3>(new[] { first, second }, Allocator.Temp);
var dir = AutoTarget.Resolve(from, rawAim, autoTargetRange: 12f,
coneHalfAngleRadians: math.radians(45f), candidates);
var expected = math.normalize(new float2(first.x, first.z));
AssertDirEqual(expected, dir, "Equal-distance ties must resolve to the lowest index.");
}
[Test]
public void Resolve_EmptyCandidateArray_ReturnsRawAim()
{
// No candidates at all: the raw aim is returned unchanged.
var from = float3.zero;
var rawAim = math.normalize(new float2(0.3f, 1f));
using var candidates = new NativeArray<float3>(0, Allocator.Temp);
var dir = AutoTarget.Resolve(from, rawAim, autoTargetRange: 12f,
coneHalfAngleRadians: math.radians(35f), candidates);
AssertDirEqual(rawAim, dir, "An empty candidate set must return the raw aim.");
}
[Test]
public void Resolve_CandidateAtShooterPosition_IsSkipped_ReturnsRawAim()
{
// A candidate at (effectively) zero planar distance from the shooter must be skipped
// (no well-defined bearing); with only that candidate present, raw aim is returned.
var from = new float3(3f, 1f, 3f);
var rawAim = new float2(0f, 1f);
using var candidates = new NativeArray<float3>(new[] { new float3(from.x, 0f, from.z) }, Allocator.Temp); // same XZ as the shooter (Y ignored).
var dir = AutoTarget.Resolve(from, rawAim, autoTargetRange: 12f,
coneHalfAngleRadians: math.radians(35f), candidates);
AssertDirEqual(rawAim, dir, "A zero-distance candidate must be skipped.");
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: cca94bbd238124185b23cdff55e15221
@@ -0,0 +1,134 @@
using NUnit.Framework;
using ProjectM.Server;
using ProjectM.Simulation;
using Unity.Core;
using Unity.Entities;
namespace ProjectM.Tests
{
/// <summary>
/// Plain-Entities EditMode tests for the server-only HealthApplyDamageSystem.
/// Boots a bare ECS world, registers the system in the SimulationSystemGroup, and ticks it.
/// The system carries [WorldSystemFilter(ServerSimulation)], but world-system filtering is only
/// applied by the netcode bootstrap; when we GetOrCreateSystem and add it to a group manually the
/// filter is ignored, so it still runs in this netcode-free world. The system plays its
/// EntityCommandBuffer back to state.EntityManager immediately (Temp allocator) per the build
/// contract, so no separate ECB system is required for destruction to take effect within a single
/// group update. Mirrors PlayerMoveSystemTests and HeartbeatSystemTests.
/// </summary>
public class HealthApplyDamageSystemTests
{
/// <summary>
/// Builds a bare world, registers HealthApplyDamageSystem in the SimulationSystemGroup, and
/// returns both so each test can create entities and tick.
/// </summary>
static (World world, SimulationSystemGroup group) MakeWorld(string name)
{
var world = new World(name);
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
group.AddSystemToUpdateList(world.GetOrCreateSystem<HealthApplyDamageSystem>());
group.SortSystems();
// Fixed time so the (time-independent) system runs cleanly; deterministic regardless.
world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
return (world, group);
}
[Test]
public void Damage_Events_Are_Summed_Subtracted_And_Buffer_Cleared()
{
var (world, group) = MakeWorld("HealthApplyDamageTestWorld");
using (world)
{
var em = world.EntityManager;
// TrainingDummyTag included so this entity is a valid death candidate too, but with
// 50 HP and 35 total damage it survives, so it must NOT be destroyed here.
var entity = em.CreateEntity(typeof(Health), typeof(DamageEvent), typeof(TrainingDummyTag));
em.SetComponentData(entity, new Health { Current = 50f, Max = 50f });
var dmg = em.GetBuffer<DamageEvent>(entity);
dmg.Add(new DamageEvent { Amount = 20f, SourceNetworkId = 1 });
dmg.Add(new DamageEvent { Amount = 15f, SourceNetworkId = 2 });
group.Update();
Assert.IsTrue(em.Exists(entity),
"Non-lethal damage (35 of 50) must not destroy the entity.");
Assert.AreEqual(15f, em.GetComponentData<Health>(entity).Current, 1e-4f,
"Health.Current should be 50 minus (20 plus 15) = 15.");
Assert.AreEqual(0, em.GetBuffer<DamageEvent>(entity).Length,
"The DamageEvent buffer must be cleared after the events are applied.");
}
}
[Test]
public void Lethal_Damage_Clamps_Health_To_Zero_And_Destroys_TrainingDummy()
{
var (world, group) = MakeWorld("HealthApplyDamageLethalDummyWorld");
using (world)
{
var em = world.EntityManager;
var entity = em.CreateEntity(typeof(Health), typeof(DamageEvent), typeof(TrainingDummyTag));
em.SetComponentData(entity, new Health { Current = 50f, Max = 50f });
var dmg = em.GetBuffer<DamageEvent>(entity);
dmg.Add(new DamageEvent { Amount = 80f, SourceNetworkId = 7 });
group.Update();
// The system clamps Current to a floor of 0 then destroys the dummy via an
// immediately-played ECB, so the entity is gone within this single group update.
Assert.IsFalse(em.Exists(entity),
"A lethally-hit TrainingDummyTag entity must be destroyed by the system's ECB.");
using var remaining = em.CreateEntityQuery(typeof(TrainingDummyTag));
Assert.AreEqual(0, remaining.CalculateEntityCount(),
"No TrainingDummyTag entities should remain after a lethal hit.");
}
}
[Test]
public void Lethal_Damage_On_NonDummy_Clamps_To_Zero_Without_Destroying()
{
// Player death is deferred in M2; only TrainingDummyTag entities are destroyed.
// A non-dummy (no TrainingDummyTag) at zero HP must be clamped to 0 and kept alive.
var (world, group) = MakeWorld("HealthApplyDamageLethalPlayerWorld");
using (world)
{
var em = world.EntityManager;
var entity = em.CreateEntity(typeof(Health), typeof(DamageEvent));
em.SetComponentData(entity, new Health { Current = 30f, Max = 30f });
var dmg = em.GetBuffer<DamageEvent>(entity);
dmg.Add(new DamageEvent { Amount = 100f, SourceNetworkId = 3 });
group.Update();
Assert.IsTrue(em.Exists(entity),
"A non-dummy (player) entity must survive lethal damage in M2; death is deferred.");
Assert.AreEqual(0f, em.GetComponentData<Health>(entity).Current, 1e-4f,
"Health.Current must be clamped to 0 (never negative).");
Assert.AreEqual(0, em.GetBuffer<DamageEvent>(entity).Length,
"The DamageEvent buffer must be cleared after the events are applied.");
}
}
[Test]
public void No_Damage_Events_Leaves_Health_Untouched()
{
// Guards the dmg.Length == 0 early-continue: an empty buffer must not alter Health.
var (world, group) = MakeWorld("HealthApplyDamageNoEventsWorld");
using (world)
{
var em = world.EntityManager;
var entity = em.CreateEntity(typeof(Health), typeof(DamageEvent), typeof(TrainingDummyTag));
em.SetComponentData(entity, new Health { Current = 42f, Max = 60f });
group.Update();
Assert.IsTrue(em.Exists(entity), "An undamaged entity must not be destroyed.");
Assert.AreEqual(42f, em.GetComponentData<Health>(entity).Current, 1e-4f,
"Health.Current must be untouched when there are no DamageEvents.");
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 67bd06ed4d42e49dd98f66049eb01913
@@ -10,9 +10,10 @@ namespace ProjectM.Tests
/// <summary>
/// Plain-Entities determinism test for <see cref="PlayerMoveSystem"/> (the M1 predicted move
/// system). Boots a bare ECS world, registers the system in the SimulationSystemGroup, creates a
/// synthetic player (PlayerInput + PlayerMoveStats + LocalTransform + enabled Simulate), injects
/// a fixed delta-time, ticks N times, and asserts the position advanced by exactly
/// MoveSpeed * dt * N. Version-independent and netcode-free, mirroring HeartbeatSystemTests.
/// synthetic player (PlayerInput + EffectiveCharacterStats + LocalTransform + enabled Simulate),
/// injects a fixed delta-time, ticks N times, and asserts the position advanced by exactly
/// MoveSpeed * dt * N. As of M3 move speed is read from EffectiveCharacterStats (the data-driven
/// effective stat) rather than the removed PlayerMoveStats. Version-independent and netcode-free.
/// </summary>
public class PlayerMoveSystemTests
{
@@ -27,14 +28,14 @@ namespace ProjectM.Tests
var em = world.EntityManager;
var entity = em.CreateEntity(
typeof(PlayerInput), typeof(PlayerMoveStats), typeof(LocalTransform), typeof(Simulate));
typeof(PlayerInput), typeof(EffectiveCharacterStats), typeof(LocalTransform), typeof(Simulate));
const float moveSpeed = 5f;
const float dt = 0.1f;
const int ticks = 10;
em.SetComponentData(entity, LocalTransform.FromPosition(float3.zero));
em.SetComponentData(entity, new PlayerMoveStats { MoveSpeed = moveSpeed, TurnRateRadiansPerSec = 0f });
em.SetComponentData(entity, new EffectiveCharacterStats { MoveSpeed = moveSpeed, TurnRateRadiansPerSec = 0f, MaxHealth = 0f });
em.SetComponentData(entity, new PlayerInput { Move = new float2(1f, 0f), Aim = float2.zero });
for (int i = 0; i < ticks; i++)
@@ -66,9 +67,9 @@ namespace ProjectM.Tests
var em = world.EntityManager;
var e = em.CreateEntity(
typeof(PlayerInput), typeof(PlayerMoveStats), typeof(LocalTransform), typeof(Simulate));
typeof(PlayerInput), typeof(EffectiveCharacterStats), typeof(LocalTransform), typeof(Simulate));
em.SetComponentData(e, LocalTransform.FromPosition(float3.zero));
em.SetComponentData(e, new PlayerMoveStats { MoveSpeed = 3f, TurnRateRadiansPerSec = 0f });
em.SetComponentData(e, new EffectiveCharacterStats { MoveSpeed = 3f, TurnRateRadiansPerSec = 0f, MaxHealth = 0f });
em.SetComponentData(e, new PlayerInput { Move = new float2(0f, 1f), Aim = float2.zero });
for (int i = 0; i < ticks; i++)
@@ -3,6 +3,7 @@
"rootNamespace": "ProjectM.Tests",
"references": [
"ProjectM.Simulation",
"ProjectM.Server",
"Unity.Entities",
"Unity.Transforms",
"Unity.Collections",
@@ -0,0 +1,151 @@
using NUnit.Framework;
using ProjectM.Server;
using ProjectM.Simulation;
using Unity.Core;
using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode;
using Unity.Transforms;
namespace ProjectM.Tests
{
/// <summary>
/// Plain-Entities tests for <see cref="ProjectileDamageSystem"/> — the server-authoritative
/// projectile hit resolver. Boots a bare ECS world, registers the system in the
/// SimulationSystemGroup (the [WorldSystemFilter] is ignored under manual registration), creates
/// synthetic targets and projectiles, injects a fixed delta-time, ticks once, and asserts the
/// queued <see cref="DamageEvent"/>s and projectile lifetime. The system plays its ECB back to the
/// EntityManager immediately, so no separate ECB system is needed.
/// <para>
/// The key case is <see cref="FastProjectile_SweptTest_DoesNotTunnel"/>: it pins the swept
/// (segment-vs-sphere) hit test that replaced a naive point check, which let fast projectiles —
/// and any projectile while the server tick-batches under load — pass straight through a target in
/// a single step. This was caught only at runtime (no EditMode coverage existed), hence this test.
/// </para>
/// </summary>
public class ProjectileDamageSystemTests
{
static (World world, SimulationSystemGroup group, EntityManager em) MakeWorld()
{
var world = new World("ProjectileDamageTestWorld");
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
group.AddSystemToUpdateList(world.GetOrCreateSystem<ProjectileDamageSystem>());
group.SortSystems();
return (world, group, world.EntityManager);
}
static Entity MakeTarget(EntityManager em, float3 pos, float hitRadius, float health, int ownerId = int.MinValue)
{
var e = em.CreateEntity();
em.AddComponentData(e, LocalTransform.FromPosition(pos));
em.AddComponentData(e, new HitRadius { Value = hitRadius });
em.AddComponentData(e, new Health { Current = health, Max = health });
em.AddBuffer<DamageEvent>(e);
if (ownerId != int.MinValue)
em.AddComponentData(e, new GhostOwner { NetworkId = ownerId });
return e;
}
// pos is the projectile's POST-move position for the tick (what the system reads); the system
// reconstructs the travel segment as [pos - dir*speed*dt, pos].
static Entity MakeProjectile(EntityManager em, float3 pos, float2 dir, float speed, float damage,
float range, float distanceTravelled, int ownerId)
{
var e = em.CreateEntity();
em.AddComponentData(e, LocalTransform.FromPosition(pos));
em.AddComponentData(e, new Projectile
{
Direction = dir,
Speed = speed,
Damage = damage,
Range = range,
DistanceTravelled = distanceTravelled,
});
em.AddComponentData(e, new GhostOwner { NetworkId = ownerId });
return e;
}
static void Tick(World world, SimulationSystemGroup group, float dt)
{
world.SetTime(new TimeData(elapsedTime: dt, deltaTime: dt));
group.Update();
}
[Test]
public void DirectHit_AppendsDamageEvent_AndDestroysProjectile()
{
using var world = MakeWorld().world;
var group = world.GetExistingSystemManaged<SimulationSystemGroup>();
var em = world.EntityManager;
var target = MakeTarget(em, new float3(0f, 0f, 5f), hitRadius: 0.8f, health: 60f);
var projectile = MakeProjectile(em, new float3(0f, 0f, 5f), new float2(0f, 1f),
speed: 10f, damage: 20f, range: 20f, distanceTravelled: 5f, ownerId: 1);
Tick(world, group, 0.1f);
var dmg = em.GetBuffer<DamageEvent>(target);
Assert.AreEqual(1, dmg.Length, "Overlapping projectile should append exactly one DamageEvent.");
Assert.AreEqual(20f, dmg[0].Amount, 1e-3f, "DamageEvent should carry the projectile's damage.");
Assert.IsFalse(em.Exists(projectile), "A projectile that hits a target must be destroyed.");
}
[Test]
public void FastProjectile_SweptTest_DoesNotTunnel()
{
// Target sits at z=5. The projectile's post-move position is z=10 (already past the target),
// having moved 10 units this tick (speed 100 * dt 0.1). A point check at z=10 would miss
// (distance 5 >> 1.0); the swept segment [z=0 -> z=10] passes through the target -> hit.
using var world = MakeWorld().world;
var group = world.GetExistingSystemManaged<SimulationSystemGroup>();
var em = world.EntityManager;
var target = MakeTarget(em, new float3(0f, 0f, 5f), hitRadius: 0.8f, health: 60f);
var projectile = MakeProjectile(em, new float3(0f, 0f, 10f), new float2(0f, 1f),
speed: 100f, damage: 20f, range: 50f, distanceTravelled: 10f, ownerId: 1);
Tick(world, group, 0.1f);
Assert.AreEqual(1, em.GetBuffer<DamageEvent>(target).Length,
"A fast projectile whose step overshoots the target must still register a swept hit (no tunnelling).");
Assert.IsFalse(em.Exists(projectile), "The tunnelling projectile must be consumed on hit.");
}
[Test]
public void OwnerOwnedTarget_IsSkipped_NoSelfHit()
{
using var world = MakeWorld().world;
var group = world.GetExistingSystemManaged<SimulationSystemGroup>();
var em = world.EntityManager;
// Target owned by the same NetworkId as the projectile's owner -> must be skipped.
var ownTarget = MakeTarget(em, new float3(0f, 0f, 5f), hitRadius: 0.8f, health: 60f, ownerId: 7);
var projectile = MakeProjectile(em, new float3(0f, 0f, 5f), new float2(0f, 1f),
speed: 10f, damage: 20f, range: 50f, distanceTravelled: 5f, ownerId: 7);
Tick(world, group, 0.1f);
Assert.AreEqual(0, em.GetBuffer<DamageEvent>(ownTarget).Length,
"A projectile must not damage a target owned by its own caster.");
Assert.IsTrue(em.Exists(projectile), "With no valid target hit and range remaining, the projectile survives.");
}
[Test]
public void SpentProjectile_BeyondRange_IsDestroyed_WithoutDamage()
{
using var world = MakeWorld().world;
var group = world.GetExistingSystemManaged<SimulationSystemGroup>();
var em = world.EntityManager;
// Target far off the path; projectile has already travelled past its range.
var target = MakeTarget(em, new float3(100f, 0f, 100f), hitRadius: 0.8f, health: 60f);
var projectile = MakeProjectile(em, new float3(0f, 0f, 0f), new float2(0f, 1f),
speed: 5f, damage: 20f, range: 10f, distanceTravelled: 12f, ownerId: 1);
Tick(world, group, 0.1f);
Assert.AreEqual(0, em.GetBuffer<DamageEvent>(target).Length, "No target in the path: no damage.");
Assert.IsFalse(em.Exists(projectile), "A projectile past its range must be destroyed.");
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 1b189c315505844cf854ce5c43f7a91b
@@ -0,0 +1,118 @@
using NUnit.Framework;
using ProjectM.Simulation;
using Unity.Core;
using Unity.Entities;
using Unity.Mathematics;
using Unity.Transforms;
namespace ProjectM.Tests
{
/// <summary>
/// Plain-Entities determinism test for <see cref="ProjectileMoveSystem"/> (the M2 predicted
/// projectile integrator). Boots a bare ECS world, registers the system in the
/// SimulationSystemGroup, creates a synthetic projectile (LocalTransform + Projectile + enabled
/// Simulate), injects a fixed delta-time, ticks N times, and asserts the position advanced by
/// exactly Speed * dt * N along the planar (XZ) Direction and that DistanceTravelled accumulated
/// the same total. Version-independent and netcode-free, mirroring PlayerMoveSystemTests.
/// The system's <c>[UpdateInGroup(PredictedSimulationSystemGroup)]</c> attribute is inert here
/// because the system is added to the SimulationSystemGroup manually.
/// </summary>
public class ProjectileMoveSystemTests
{
[Test]
public void Projectile_Advances_By_Speed_Times_Dt_Each_Tick()
{
using var world = new World("ProjectileMoveTestWorld");
var simulationGroup = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
var moveSystem = world.GetOrCreateSystem<ProjectileMoveSystem>();
simulationGroup.AddSystemToUpdateList(moveSystem);
simulationGroup.SortSystems();
var em = world.EntityManager;
var entity = em.CreateEntity(
typeof(LocalTransform), typeof(Projectile), typeof(Simulate));
const float speed = 10f;
const float dt = 0.1f;
const int ticks = 5;
em.SetComponentData(entity, LocalTransform.FromPosition(float3.zero));
em.SetComponentData(entity, new Projectile
{
Direction = new float2(1f, 0f),
SpawnId = 0u,
Speed = speed,
Damage = 20f,
Range = 100f,
DistanceTravelled = 0f,
});
for (int i = 0; i < ticks; i++)
{
// Fixed delta so the predicted integration is fully deterministic (no wall-clock).
world.SetTime(new TimeData(elapsedTime: dt * (i + 1), deltaTime: dt));
simulationGroup.Update();
}
var position = em.GetComponentData<LocalTransform>(entity).Position;
var distance = em.GetComponentData<Projectile>(entity).DistanceTravelled;
Assert.AreEqual(speed * dt * ticks, position.x, 1e-3f,
"X should advance by Speed * dt each tick for Direction=(1,0).");
Assert.AreEqual(0f, position.y, 1e-3f, "Travel is planar; Y should stay 0.");
Assert.AreEqual(0f, position.z, 1e-3f, "Direction=(1,0) maps to +X only; Z should stay 0.");
Assert.AreEqual(speed * dt * ticks, distance, 1e-3f,
"DistanceTravelled should accumulate Speed * dt each tick.");
}
[Test]
public void Projectile_Integration_Is_Idempotent_Across_Equal_Tick_Batches()
{
// Determinism/idempotence: the same Direction, Speed and dt must yield the same result
// regardless of how the ticks are grouped (mirrors the prediction loop re-simulating a
// tick on rollback). Returns (finalPosition, distanceTravelled).
(float3 Position, float Distance) RunTicks(int ticks)
{
using var world = new World("ProjectileMoveDetWorld");
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
group.AddSystemToUpdateList(world.GetOrCreateSystem<ProjectileMoveSystem>());
group.SortSystems();
var em = world.EntityManager;
var e = em.CreateEntity(
typeof(LocalTransform), typeof(Projectile), typeof(Simulate));
em.SetComponentData(e, LocalTransform.FromPosition(float3.zero));
em.SetComponentData(e, new Projectile
{
Direction = new float2(0f, 1f),
SpawnId = 0u,
Speed = 7f,
Damage = 20f,
Range = 100f,
DistanceTravelled = 0f,
});
for (int i = 0; i < ticks; i++)
{
world.SetTime(new TimeData(0.05f * (i + 1), 0.05f));
group.Update();
}
var proj = em.GetComponentData<Projectile>(e);
return (em.GetComponentData<LocalTransform>(e).Position, proj.DistanceTravelled);
}
var a = RunTicks(20);
var b = RunTicks(20);
Assert.AreEqual(a.Position.z, b.Position.z, 1e-4f,
"Two identical runs must produce identical positions.");
Assert.AreEqual(a.Distance, b.Distance, 1e-4f,
"Two identical runs must produce identical DistanceTravelled.");
Assert.AreEqual(7f * 0.05f * 20f, a.Position.z, 1e-3f,
"Direction=(0,1) maps to +Z by Speed*dt*N.");
Assert.AreEqual(7f * 0.05f * 20f, a.Distance, 1e-3f,
"DistanceTravelled equals Speed*dt*N.");
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: b00155f6d58444c9f86110d0dc3916fc
@@ -0,0 +1,89 @@
using NUnit.Framework;
using ProjectM.Simulation;
using Unity.Collections;
namespace ProjectM.Tests
{
/// <summary>
/// Pure-function tests for <see cref="StatMath.Apply"/> (no ECS world), mirroring AutoTargetTests.
/// Pins the ARPG fold order: effective = (base + sum flat) * (1 + sum percentAdd) * product(1 + percentMult).
/// </summary>
public class StatMathTests
{
static StatModifier Mod(StatTarget target, ModOp op, float value) =>
new StatModifier { Target = (byte)target, Op = (byte)op, Value = value };
static float Apply(float baseValue, StatTarget target, params StatModifier[] mods)
{
using var arr = new NativeArray<StatModifier>(mods, Allocator.Temp);
return StatMath.Apply(baseValue, target, arr);
}
[Test]
public void Empty_Returns_Base()
{
using var arr = new NativeArray<StatModifier>(0, Allocator.Temp);
Assert.AreEqual(10f, StatMath.Apply(10f, StatTarget.Damage, arr), 1e-4f);
}
[Test]
public void Flat_Adds()
{
Assert.AreEqual(15f, Apply(10f, StatTarget.Damage, Mod(StatTarget.Damage, ModOp.Flat, 5f)), 1e-4f);
}
[Test]
public void Flats_Sum()
{
Assert.AreEqual(18f, Apply(10f, StatTarget.Damage,
Mod(StatTarget.Damage, ModOp.Flat, 5f),
Mod(StatTarget.Damage, ModOp.Flat, 3f)), 1e-4f);
}
[Test]
public void PercentAdd_Pools()
{
// base 10 * (1 + 0.2 + 0.3) = 15
Assert.AreEqual(15f, Apply(10f, StatTarget.Damage,
Mod(StatTarget.Damage, ModOp.PercentAdd, 0.2f),
Mod(StatTarget.Damage, ModOp.PercentAdd, 0.3f)), 1e-4f);
}
[Test]
public void PercentMult_Multiplies_Separately()
{
// base 10 * (1+0.5) * (1+0.2) = 18 (note: distinct from a pooled +0.7 = 17)
Assert.AreEqual(18f, Apply(10f, StatTarget.Damage,
Mod(StatTarget.Damage, ModOp.PercentMult, 0.5f),
Mod(StatTarget.Damage, ModOp.PercentMult, 0.2f)), 1e-4f);
}
[Test]
public void Combined_Applies_In_Standard_Order()
{
// (10 + 5) * (1 + 0.2) * (1 + 0.5) = 27
Assert.AreEqual(27f, Apply(10f, StatTarget.Damage,
Mod(StatTarget.Damage, ModOp.Flat, 5f),
Mod(StatTarget.Damage, ModOp.PercentAdd, 0.2f),
Mod(StatTarget.Damage, ModOp.PercentMult, 0.5f)), 1e-4f);
}
[Test]
public void Other_Targets_Are_Ignored()
{
// A MoveSpeed modifier must not affect a Damage query.
Assert.AreEqual(10f, Apply(10f, StatTarget.Damage,
Mod(StatTarget.MoveSpeed, ModOp.Flat, 100f),
Mod(StatTarget.MoveSpeed, ModOp.PercentAdd, 5f)), 1e-4f);
}
[Test]
public void Mixed_Targets_Fold_Independently()
{
var dmg = Mod(StatTarget.Damage, ModOp.Flat, 5f);
var spd = Mod(StatTarget.MoveSpeed, ModOp.PercentAdd, 0.5f);
Assert.AreEqual(15f, Apply(10f, StatTarget.Damage, dmg, spd), 1e-4f);
Assert.AreEqual(9f, Apply(6f, StatTarget.MoveSpeed, dmg, spd), 1e-4f);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 51ec74c01b07b4b3db2cb78049eb2a5e
@@ -0,0 +1,117 @@
using NUnit.Framework;
using ProjectM.Simulation;
using Unity.Collections;
using Unity.Entities;
namespace ProjectM.Tests
{
/// <summary>
/// Plain-Entities test for <see cref="StatRecomputeSystem"/>: builds an AbilityDatabase blob singleton
/// + a player-like entity (refs / modifier buffer / effective components), ticks the
/// SimulationSystemGroup, and asserts the effective stats equal the folded (base + modifiers) values
/// and stay stable across repeated ticks (the every-tick recompute is idempotent). Version-independent.
/// </summary>
public class StatRecomputeSystemTests
{
const byte AbilityPrimary = (byte)AbilityId.Primary;
const byte CharDefault = (byte)CharacterId.Default;
static BlobAssetReference<AbilityDatabaseBlob> BuildDb()
{
using var b = new BlobBuilder(Allocator.Temp);
ref var root = ref b.ConstructRoot<AbilityDatabaseBlob>();
var a = b.Allocate(ref root.Abilities, 1);
a[0] = new AbilityDefBlob
{
Id = AbilityPrimary, Damage = 20f, ProjectileSpeed = 25f, Range = 20f,
AutoTargetRange = 12f, AutoTargetConeRadians = 0.6f, CooldownTicks = 12, Name = "Primary"
};
var c = b.Allocate(ref root.Characters, 1);
c[0] = new CharacterStatsBlob
{
Id = CharDefault, MoveSpeed = 6f, TurnRateRadiansPerSec = 12.5f, MaxHealth = 100f, Name = "Default"
};
return b.CreateBlobAssetReference<AbilityDatabaseBlob>(Allocator.Persistent);
}
static (World world, Entity player) MakeWorld(out BlobAssetReference<AbilityDatabaseBlob> blob)
{
var world = new World("StatRecomputeTestWorld");
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
group.AddSystemToUpdateList(world.GetOrCreateSystem<StatRecomputeSystem>());
group.SortSystems();
var em = world.EntityManager;
blob = BuildDb();
var dbEntity = em.CreateEntity(typeof(AbilityDatabase));
em.SetComponentData(dbEntity, new AbilityDatabase { Value = blob });
var player = em.CreateEntity(
typeof(AbilityRef), typeof(CharacterStatsRef), typeof(StatModifier),
typeof(EffectiveAbilityStats), typeof(EffectiveCharacterStats), typeof(Simulate));
em.SetComponentData(player, new AbilityRef { Id = AbilityPrimary });
em.SetComponentData(player, new CharacterStatsRef { Id = CharDefault });
return (world, player);
}
static void AddMod(World world, Entity player, StatTarget target, ModOp op, float value)
{
var buf = world.EntityManager.GetBuffer<StatModifier>(player);
buf.Add(new StatModifier { Target = (byte)target, Op = (byte)op, Value = value });
}
[Test]
public void NoModifiers_Effective_Equals_Base()
{
var (world, player) = MakeWorld(out var blob);
try
{
world.GetExistingSystemManaged<SimulationSystemGroup>().Update();
var ea = world.EntityManager.GetComponentData<EffectiveAbilityStats>(player);
var ec = world.EntityManager.GetComponentData<EffectiveCharacterStats>(player);
Assert.AreEqual(20f, ea.Damage, 1e-3f);
Assert.AreEqual(25f, ea.ProjectileSpeed, 1e-3f);
Assert.AreEqual(12, ea.CooldownTicks);
Assert.AreEqual(6f, ec.MoveSpeed, 1e-3f);
Assert.AreEqual(100f, ec.MaxHealth, 1e-3f);
}
finally { world.Dispose(); blob.Dispose(); }
}
[Test]
public void Modifiers_Fold_Into_Effective()
{
var (world, player) = MakeWorld(out var blob);
try
{
AddMod(world, player, StatTarget.Damage, ModOp.Flat, 5f);
AddMod(world, player, StatTarget.Damage, ModOp.PercentAdd, 0.5f); // (20+5)*1.5 = 37.5
AddMod(world, player, StatTarget.MoveSpeed, ModOp.PercentAdd, 0.5f); // 6*1.5 = 9
world.GetExistingSystemManaged<SimulationSystemGroup>().Update();
var ea = world.EntityManager.GetComponentData<EffectiveAbilityStats>(player);
var ec = world.EntityManager.GetComponentData<EffectiveCharacterStats>(player);
Assert.AreEqual(37.5f, ea.Damage, 1e-3f);
Assert.AreEqual(9f, ec.MoveSpeed, 1e-3f);
}
finally { world.Dispose(); blob.Dispose(); }
}
[Test]
public void Recompute_Is_Idempotent_Across_Ticks()
{
var (world, player) = MakeWorld(out var blob);
try
{
AddMod(world, player, StatTarget.Damage, ModOp.Flat, 10f); // 20+10 = 30
var group = world.GetExistingSystemManaged<SimulationSystemGroup>();
group.Update();
var first = world.EntityManager.GetComponentData<EffectiveAbilityStats>(player).Damage;
for (int i = 0; i < 5; i++) group.Update();
var last = world.EntityManager.GetComponentData<EffectiveAbilityStats>(player).Damage;
Assert.AreEqual(30f, first, 1e-3f);
Assert.AreEqual(first, last, 1e-4f);
}
finally { world.Dispose(); blob.Dispose(); }
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: e291ee28fc7e846c487eddc9facfa7bb
@@ -0,0 +1,73 @@
using NUnit.Framework;
using ProjectM.Server;
using ProjectM.Simulation;
using Unity.Entities;
using Unity.Mathematics;
using Unity.Transforms;
namespace ProjectM.Tests
{
/// <summary>
/// Plain-Entities test for <see cref="UpgradePickupSystem"/> (server grant). A player overlapping a
/// pickup gets the pickup's modifier appended to its StatModifier buffer and the pickup is destroyed;
/// a player out of range leaves the buffer empty and the pickup alive. The system's ECB plays back
/// immediately (server pattern), so no separate ECB system is needed.
/// </summary>
public class UpgradePickupSystemTests
{
static (World world, Entity player, Entity pickup) MakeWorld(float3 playerPos, float3 pickupPos, float radius)
{
var world = new World("UpgradePickupTestWorld");
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
group.AddSystemToUpdateList(world.GetOrCreateSystem<UpgradePickupSystem>());
group.SortSystems();
var em = world.EntityManager;
var player = em.CreateEntity(typeof(PlayerTag), typeof(StatModifier), typeof(LocalTransform));
em.SetComponentData(player, LocalTransform.FromPosition(playerPos));
var pickup = em.CreateEntity(typeof(UpgradePickup), typeof(HitRadius), typeof(LocalTransform));
em.SetComponentData(pickup, LocalTransform.FromPosition(pickupPos));
em.SetComponentData(pickup, new HitRadius { Value = radius });
em.SetComponentData(pickup, new UpgradePickup
{
Target = (byte)StatTarget.Damage, Op = (byte)ModOp.Flat, Value = 25f, SourceId = 7u
});
return (world, player, pickup);
}
[Test]
public void Overlap_Grants_Modifier_And_Destroys_Pickup()
{
var (world, player, pickup) = MakeWorld(float3.zero, new float3(0.5f, 0f, 0f), 1f);
try
{
world.GetExistingSystemManaged<SimulationSystemGroup>().Update();
var buf = world.EntityManager.GetBuffer<StatModifier>(player);
Assert.AreEqual(1, buf.Length);
Assert.AreEqual((byte)StatTarget.Damage, buf[0].Target);
Assert.AreEqual((byte)ModOp.Flat, buf[0].Op);
Assert.AreEqual(25f, buf[0].Value, 1e-4f);
Assert.AreEqual(7u, buf[0].SourceId);
Assert.IsFalse(world.EntityManager.Exists(pickup), "Pickup should be destroyed after grant.");
}
finally { world.Dispose(); }
}
[Test]
public void Out_Of_Range_Leaves_Buffer_Empty_And_Pickup_Alive()
{
var (world, player, pickup) = MakeWorld(float3.zero, new float3(10f, 0f, 0f), 1f);
try
{
world.GetExistingSystemManaged<SimulationSystemGroup>().Update();
var buf = world.EntityManager.GetBuffer<StatModifier>(player);
Assert.AreEqual(0, buf.Length);
Assert.IsTrue(world.EntityManager.Exists(pickup), "Out-of-range pickup should remain.");
}
finally { world.Dispose(); }
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 34f9fd7580c614586b6a5c68a0e6393c