Initial Combat Implementation
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user