Files
Project-M/Assets/_Project/Tests/EditMode/ProjectileDamageSystemTests.cs
2026-05-31 21:35:12 -07:00

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.");
}
}
}