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 EditMode tests for the server-only (hitscan defense turret). /// A bare world is seeded with a NetworkTime singleton, a Charge pool (the /// turret's EB-2 ammo), a turret (PlacedStructure + Turret + RegionTag + LocalTransform) and Husks (Health + /// EnemyTag + RegionTag + LocalTransform + a DamageEvent buffer). The system snapshots living Husks, fires at /// the nearest in-range one in its OWN region on the PlacedStructure.NextTick cooldown — but ONLY when /// it can withdraw Charge from the ledger; out of Charge = /// soft-fail (no shot, no cooldown burn, so it fires the instant Charge returns). These tests pin range /// filtering, region gating, the wrap-safe cooldown, and the EB-2 Charge spend / soft-fail. /// public class TurretFireSystemTests { // Range/region/cooldown tests never need to run dry; the spend tests pass an explicit small pool. const int AmpleCharge = 1_000_000; static (World world, SimulationSystemGroup group, Entity ledger) MakeWorld( string name, uint serverTick, int charge = AmpleCharge) { var world = new World(name); var group = world.GetOrCreateSystemManaged(); group.AddSystemToUpdateList(world.GetOrCreateSystem()); group.SortSystems(); world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f)); SetServerTick(world, serverTick); var em = world.EntityManager; var ledger = em.CreateEntity(typeof(ResourceLedger)); var buf = em.AddBuffer(ledger); if (charge > 0) buf.Add(new StorageEntry { ItemId = ResourceId.Charge, Count = charge }); return (world, group, ledger); } static void SetServerTick(World world, uint tick) { var em = world.EntityManager; using var q = em.CreateEntityQuery(typeof(NetworkTime)); Entity e = q.IsEmpty ? em.CreateEntity(typeof(NetworkTime)) : q.GetSingletonEntity(); em.SetComponentData(e, new NetworkTime { ServerTick = new NetworkTick(tick) }); } static Entity MakeTurret(EntityManager em, float3 pos, byte region, float range, int cooldown, float damage) { var e = em.CreateEntity(); em.AddComponentData(e, LocalTransform.FromPosition(pos)); em.AddComponentData(e, new RegionTag { Region = region }); em.AddComponentData(e, new PlacedStructure { Type = StructureType.Turret, NextTick = 0 }); em.AddComponentData(e, new Turret { Range = range, CooldownTicks = cooldown, Damage = damage }); return e; } static Entity MakeHusk(EntityManager em, float3 pos, byte region, float health) { var e = em.CreateEntity(); em.AddComponentData(e, LocalTransform.FromPosition(pos)); em.AddComponentData(e, new RegionTag { Region = region }); em.AddComponentData(e, new Health { Current = health, Max = health }); em.AddComponent(e); em.AddBuffer(e); return e; } static int LedgerCharge(EntityManager em, Entity ledger) { var buf = em.GetBuffer(ledger); for (int i = 0; i < buf.Length; i++) if (buf[i].ItemId == ResourceId.Charge) return buf[i].Count; return 0; } [Test] public void Turret_Fires_At_InRange_Husk_In_Its_Region() { var (world, group, _) = MakeWorld("TurretFireWorld", serverTick: 200); using (world) { var em = world.EntityManager; var turret = MakeTurret(em, new float3(5, 1, 5), RegionId.Base, range: 20f, cooldown: 30, damage: 10f); var husk = MakeHusk(em, new float3(10, 1, 10), RegionId.Base, health: 50f); group.Update(); var dmg = em.GetBuffer(husk); Assert.AreEqual(1, dmg.Length, "An in-range, same-region Husk takes exactly one turret hit."); Assert.AreEqual(10f, dmg[0].Amount, 1e-4f, "DamageEvent carries the turret's damage."); Assert.AreEqual(-1, dmg[0].SourceNetworkId, "Turret damage uses the -1 (world) source id."); Assert.AreNotEqual(0u, em.GetComponentData(turret).NextTick, "The cooldown tick is stamped after firing."); } } [Test] public void Turret_Ignores_Husk_Out_Of_Range() { var (world, group, _) = MakeWorld("TurretRangeWorld", serverTick: 200); using (world) { var em = world.EntityManager; MakeTurret(em, new float3(0, 1, 0), RegionId.Base, range: 5f, cooldown: 30, damage: 10f); var husk = MakeHusk(em, new float3(100, 1, 0), RegionId.Base, health: 50f); group.Update(); Assert.AreEqual(0, em.GetBuffer(husk).Length, "A Husk beyond Range takes no hit."); } } [Test] public void Turret_Ignores_Husk_In_A_Different_Region() { var (world, group, _) = MakeWorld("TurretRegionWorld", serverTick: 200); using (world) { var em = world.EntityManager; MakeTurret(em, new float3(0, 1, 0), RegionId.Base, range: 50f, cooldown: 30, damage: 10f); var husk = MakeHusk(em, new float3(2, 1, 2), RegionId.Expedition, health: 50f); group.Update(); Assert.AreEqual(0, em.GetBuffer(husk).Length, "A Husk in a different region is never targeted (region gating)."); } } [Test] public void Turret_Respects_Cooldown_Then_Fires_Again() { var (world, group, _) = MakeWorld("TurretCooldownWorld", serverTick: 200); using (world) { var em = world.EntityManager; MakeTurret(em, new float3(0, 1, 0), RegionId.Base, range: 50f, cooldown: 30, damage: 10f); var husk = MakeHusk(em, new float3(3, 1, 0), RegionId.Base, health: 5000f); group.Update(); // fires at tick 200, NextTick -> 230 Assert.AreEqual(1, em.GetBuffer(husk).Length, "Fires on the first ready tick."); SetServerTick(world, 210); group.Update(); // 210 < 230 -> still cooling down Assert.AreEqual(1, em.GetBuffer(husk).Length, "No second shot while cooling down."); SetServerTick(world, 240); group.Update(); // 240 >= 230 -> ready again Assert.AreEqual(2, em.GetBuffer(husk).Length, "Fires again once the cooldown elapses."); } } // ---- EB-2: turret ammo (Charge) spend + soft-fail ---- [Test] public void Turret_Out_Of_Charge_Soft_Fails_No_Shot_No_Cooldown_Burn() { var (world, group, ledger) = MakeWorld("TurretNoChargeWorld", serverTick: 200, charge: 0); using (world) { var em = world.EntityManager; var turret = MakeTurret(em, new float3(0, 1, 0), RegionId.Base, range: 50f, cooldown: 30, damage: 10f); var husk = MakeHusk(em, new float3(3, 1, 0), RegionId.Base, health: 50f); group.Update(); Assert.AreEqual(0, em.GetBuffer(husk).Length, "With an empty Charge pool the turret cannot fire (soft-fail)."); Assert.AreEqual(0u, em.GetComponentData(turret).NextTick, "A soft-failed shot burns no cooldown — the turret stays ready for the instant Charge returns."); Assert.AreEqual(0, LedgerCharge(em, ledger), "No Charge is consumed when no shot is fired."); } } [Test] public void Turret_Consumes_One_Charge_Per_Shot() { var (world, group, ledger) = MakeWorld("TurretSpendWorld", serverTick: 200, charge: 5); using (world) { var em = world.EntityManager; MakeTurret(em, new float3(0, 1, 0), RegionId.Base, range: 50f, cooldown: 30, damage: 10f); var husk = MakeHusk(em, new float3(3, 1, 0), RegionId.Base, health: 50f); group.Update(); Assert.AreEqual(1, em.GetBuffer(husk).Length, "A funded turret fires."); Assert.AreEqual(5 - Tuning.TurretChargeCostPerShot, LedgerCharge(em, ledger), "Exactly TurretChargeCostPerShot Charge is withdrawn for the shot."); } } [Test] public void Two_Turrets_Share_A_Finite_Charge_Pool_Second_Soft_Fails() { // One Charge, two turrets, one Husk: the first turret (query/creation order) spends the last Charge and // fires; the second withdraws 0 and soft-fails. The Husk takes exactly one hit and the pool empties. var (world, group, ledger) = MakeWorld("TurretSharedPoolWorld", serverTick: 200, charge: 1); using (world) { var em = world.EntityManager; MakeTurret(em, new float3(-2, 1, 0), RegionId.Base, range: 50f, cooldown: 30, damage: 10f); MakeTurret(em, new float3(2, 1, 0), RegionId.Base, range: 50f, cooldown: 30, damage: 10f); var husk = MakeHusk(em, new float3(0, 1, 0), RegionId.Base, health: 500f); group.Update(); Assert.AreEqual(1, em.GetBuffer(husk).Length, "A single Charge funds exactly one of the two turrets this tick."); Assert.AreEqual(0, LedgerCharge(em, ledger), "The shared Charge pool is drained to zero."); } } } }