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
{
///
/// Plain-Entities tests for — 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 s and projectile lifetime. The system plays its ECB back to the
/// EntityManager immediately, so no separate ECB system is needed.
///
/// The key case is : 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.
///
///
public class ProjectileDamageSystemTests
{
static (World world, SimulationSystemGroup group, EntityManager em) MakeWorld()
{
var world = new World("ProjectileDamageTestWorld");
var group = world.GetOrCreateSystemManaged();
group.AddSystemToUpdateList(world.GetOrCreateSystem());
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(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();
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(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();
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(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();
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(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();
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(target).Length, "No target in the path: no damage.");
Assert.IsFalse(em.Exists(projectile), "A projectile past its range must be destroyed.");
}
static Entity MakeProjectileFx(EntityManager em, float3 pos, float2 dir, float speed, float damage,
float range, float distanceTravelled, int ownerId, byte pierce, byte chain, byte flags = 0)
{
var e = MakeProjectile(em, pos, dir, speed, damage, range, distanceTravelled, ownerId);
em.AddComponentData(e, new ProjectileEffectState { PierceRemaining = pierce, ChainRemaining = chain, Flags = flags });
return e;
}
[Test]
public void Pierce_SurvivesFirstTarget_HitsSecond_NeverReHitsFirst()
{
using var world = MakeWorld().world;
var group = world.GetExistingSystemManaged();
var em = world.EntityManager;
var a = MakeTarget(em, new float3(0f, 0f, 3f), hitRadius: 0.8f, health: 60f);
var b = MakeTarget(em, new float3(0f, 0f, 6f), hitRadius: 0.8f, health: 60f);
// Post-move at z=6; the swept segment [z=0 -> z=6] (speed*dt = 6) covers both; A (z=3) is earliest.
var proj = MakeProjectileFx(em, new float3(0f, 0f, 6f), new float2(0f, 1f),
speed: 60f, damage: 20f, range: 20f, distanceTravelled: 6f, ownerId: 1, pierce: 1, chain: 0);
Tick(world, group, 0.1f);
Assert.AreEqual(1, em.GetBuffer(a).Length, "earliest target A is hit");
Assert.AreEqual(0, em.GetBuffer(b).Length, "only the earliest target is hit per tick");
Assert.IsTrue(em.Exists(proj), "pierce lets the projectile survive the first hit");
Assert.AreEqual(0, em.GetComponentData(proj).PierceRemaining, "pierce consumed");
Tick(world, group, 0.1f); // same position: A now excluded by the hit-set -> B is the earliest
Assert.AreEqual(1, em.GetBuffer(a).Length, "A must NOT be re-hit (hit-set exclusion)");
Assert.AreEqual(1, em.GetBuffer(b).Length, "B hit on the second pass");
Assert.IsFalse(em.Exists(proj), "with pierce spent, the second hit consumes the projectile");
}
[Test]
public void Chain_RetargetsTowardNextEnemy_AfterHit()
{
using var world = MakeWorld().world;
var group = world.GetExistingSystemManaged();
var em = world.EntityManager;
var a = MakeTarget(em, new float3(0f, 0f, 3f), hitRadius: 0.8f, health: 60f); // on-axis, hit first
var b = MakeTarget(em, new float3(2f, 0f, 6f), hitRadius: 0.8f, health: 60f); // off-axis, chain target
var proj = MakeProjectileFx(em, new float3(0f, 0f, 6f), new float2(0f, 1f),
speed: 60f, damage: 20f, range: 20f, distanceTravelled: 6f, ownerId: 1, pierce: 0, chain: 1);
Tick(world, group, 0.1f);
Assert.AreEqual(1, em.GetBuffer(a).Length, "A (on the path) is hit");
Assert.AreEqual(0, em.GetBuffer(b).Length, "B is off the path, not hit by the sweep this tick");
Assert.IsTrue(em.Exists(proj), "chain lets the projectile survive to seek the next enemy");
Assert.AreEqual(0, em.GetComponentData(proj).ChainRemaining, "chain consumed");
var dir = em.GetComponentData(proj).Direction;
Assert.Greater(dir.x, 0.5f, "Direction retargeted toward the off-axis next enemy B (+x)");
}
}
}