152 lines
7.3 KiB
C#
152 lines
7.3 KiB
C#
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.");
|
|
}
|
|
}
|
|
}
|