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 { /// /// MC-2 system tests for the EnemyAISystem SPITTER pass (server-only, plain SimulationSystemGroup). Covers the /// headline ranged mechanic end-to-end: an in-band, ready Spitter commits a telegraphed wind-up then on elapse /// spawns a spit carrying the FIRING Spitter's Region (fired from EXPEDITION so a dropped Region copy — which would /// leave the prefab default 0 = Base — fails the assertion), aimed at the target. The HOLD-RANGE gate (DR-041) is /// pinned by negative tests: a Spitter ADVANCING from out of band does NOT telegraph; a cornered Spitter fires /// point-blank. The discriminator partition (no double-move) is asserted DIRECTLY (the wind-up value alone can't /// prove it — the Spitter pass runs last and overwrites it). Soft-fail over the concurrent cap = short retry, no /// full-cooldown burn. Plain-Entities world, faked NetworkTime + a SpitterProjectilePrefab singleton; the prefab /// entity is Prefab-tagged so it is excluded from the live-spit count and cloned (minus the tag) on Instantiate. /// public class SpitterBrainTests { static void SetTick(World w, uint tick) { var em = w.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 (World, SimulationSystemGroup) AiWorld(uint tick) { var w = new World("SpitterBrain"); var g = w.GetOrCreateSystemManaged(); g.AddSystemToUpdateList(w.GetOrCreateSystem()); g.SortSystems(); w.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f)); SetTick(w, tick); return (w, g); } static Entity MakeSpitPrefab(EntityManager em, float range = 16f) { var e = em.CreateEntity(); em.AddComponentData(e, LocalTransform.FromPosition(float3.zero)); em.AddComponentData(e, new EnemyProjectile { Direction = new float2(0, 1), Speed = 11f, Damage = 0f, Range = range, Region = 0 }); em.AddComponent(e); // excluded from the live-spit query; stripped on Instantiate return e; } static void SetSpitSingleton(EntityManager em, Entity prefab, int maxLive) { var s = em.CreateEntity(typeof(SpitterProjectilePrefab)); em.SetComponentData(s, new SpitterProjectilePrefab { Prefab = prefab, MaxLiveProjectiles = maxLive }); } static Entity MakeSpitter(EntityManager em, float3 pos, byte region, int windupTicks = 1, int cooldown = 60) { var e = em.CreateEntity(); em.AddComponentData(e, LocalTransform.FromPosition(pos)); em.AddComponent(e); em.AddComponentData(e, new EnemyStats { MoveSpeed = 4f, AttackRange = 1.5f, AttackDamage = 8f, AttackCooldownTicks = cooldown }); em.AddComponentData(e, new EnemyAttackCooldown { NextAttackTick = 0u }); em.AddComponentData(e, new KnockbackState { Dir = default, Speed = 0f, UntilTick = 0u }); em.AddComponentData(e, new AttackWindup { WindUpUntilTick = 0u }); em.AddComponentData(e, new SpitterState { PreferredRange = 9f, RangeTolerance = 1.5f, ProjectileSpeed = 11f, CorneredRange = 3f, WindupTicks = windupTicks, NextShotTick = 0u }); em.AddComponentData(e, new RegionTag { Region = region }); return e; } static void MakePlayer(EntityManager em, float3 pos, byte region) { var e = em.CreateEntity(); em.AddComponentData(e, LocalTransform.FromPosition(pos)); em.AddComponentData(e, new Health { Current = 100f, Max = 100f }); em.AddComponentData(e, new RegionTag { Region = region }); em.AddComponent(e); } static int CountSpits(EntityManager em) { using var q = em.CreateEntityQuery(ComponentType.ReadOnly()); return q.CalculateEntityCount(); } [Test] public void Spitter_InBand_CommitsThenFires_SpitCarriesFiringRegion() { var (w, g) = AiWorld(200); using (w) { var em = w.EntityManager; var prefab = MakeSpitPrefab(em, range: 16f); SetSpitSingleton(em, prefab, maxLive: 24); // Fire from EXPEDITION (!=0): a dropped Region copy would leave 0 (Base) and fail the region assert. MakePlayer(em, new float3(0, 1, 0), RegionId.Expedition); var spitter = MakeSpitter(em, new float3(9, 1, 0), RegionId.Expedition, windupTicks: 1); // distance == PreferredRange -> in-band g.Update(); // tick 200: in-band + ready -> commit the telegraph wind-up to 201 Assert.AreEqual(TickUtil.NonZero(201u), em.GetComponentData(spitter).WindUpUntilTick, "an in-band, ready Spitter commits a wind-up of SpitterState.WindupTicks (the partition itself is asserted in Spitter_IsExcludedFromGruntAndChargerPasses)"); Assert.AreEqual(0, CountSpits(em), "no spit yet — still telegraphing the dodge window"); SetTick(w, 202); // the wind-up tick (201) has now elapsed g.Update(); Assert.AreEqual(1, CountSpits(em), "the spit fires when the wind-up elapses in-band"); using var q = em.CreateEntityQuery(ComponentType.ReadOnly()); var spit = q.GetSingleton(); Assert.AreEqual(RegionId.Expedition, spit.Region, "the spit carries the FIRING Spitter's region (Expedition!=0 -> a dropped copy fails this)"); Assert.Less(spit.Direction.x, 0f, "aimed back toward the player at the origin"); Assert.AreEqual(0u, em.GetComponentData(spitter).WindUpUntilTick, "the wind-up is cleared after firing"); } } [Test] public void Spitter_OutOfBand_DoesNotCommitWindup() { var (w, g) = AiWorld(200); using (w) { var em = w.EntityManager; MakePlayer(em, new float3(0, 1, 0), RegionId.Base); var spitter = MakeSpitter(em, new float3(40, 1, 0), RegionId.Base, windupTicks: 1); // dist 40 >> PreferredRange+tol -> advancing g.Update(); Assert.AreEqual(0u, em.GetComponentData(spitter).WindUpUntilTick, "a Spitter ADVANCING from out of band must NOT telegraph/fire (the hold-range gate, DR-041)"); } } [Test] public void Spitter_Cornered_CommitsWindupPointBlank() { var (w, g) = AiWorld(200); using (w) { var em = w.EntityManager; MakePlayer(em, new float3(0, 1, 0), RegionId.Base); var spitter = MakeSpitter(em, new float3(2, 1, 0), RegionId.Base, windupTicks: 5); // dist 2 < CorneredRange 3 -> point-blank g.Update(); Assert.AreNotEqual(0u, em.GetComponentData(spitter).WindUpUntilTick, "a cornered Spitter (target inside CorneredRange) fires point-blank rather than holding fire"); } } [Test] public void Spitter_IsExcludedFromGruntAndChargerPasses() { var w = new World("SpitterRouting"); using (w) { var em = w.EntityManager; MakeSpitter(em, new float3(9, 1, 0), RegionId.Base); // The three EnemyAISystem pass partitions, asserted directly so a regression in any WithNone guard is caught. using var gruntQ = em.CreateEntityQuery(new EntityQueryDesc { All = new[] { ComponentType.ReadOnly() }, None = new[] { ComponentType.ReadOnly(), ComponentType.ReadOnly() }, }); using var chargerQ = em.CreateEntityQuery(new EntityQueryDesc { All = new[] { ComponentType.ReadOnly() }, None = new[] { ComponentType.ReadOnly() }, }); using var spitterQ = em.CreateEntityQuery(new EntityQueryDesc { All = new[] { ComponentType.ReadOnly(), ComponentType.ReadOnly() }, None = new[] { ComponentType.ReadOnly() }, }); Assert.AreEqual(0, gruntQ.CalculateEntityCount(), "a Spitter must NOT be visited by the Grunt pass (WithNone)"); Assert.AreEqual(0, chargerQ.CalculateEntityCount(), "a Spitter must NOT be visited by the Charger pass (WithNone)"); Assert.AreEqual(1, spitterQ.CalculateEntityCount(), "a Spitter IS visited by exactly the Spitter pass"); } } [Test] public void Spitter_OverSoftCap_SkipsFire_ShortRetryNoCooldownBurn() { var (w, g) = AiWorld(200); using (w) { var em = w.EntityManager; var prefab = MakeSpitPrefab(em); SetSpitSingleton(em, prefab, maxLive: 2); // pre-fill the live-spit pool to the cap (no Prefab tag -> counted by the soft-cap query) for (int i = 0; i < 2; i++) { var s = em.CreateEntity(); em.AddComponentData(s, LocalTransform.FromPosition(new float3(i, 1, 0))); em.AddComponentData(s, new EnemyProjectile { Direction = new float2(0, 1), Speed = 11f, Range = 16f, Region = RegionId.Base }); } MakePlayer(em, new float3(0, 1, 0), RegionId.Base); var spitter = MakeSpitter(em, new float3(9, 1, 0), RegionId.Base, windupTicks: 1, cooldown: 60); g.Update(); // tick 200: in-band -> commit the wind-up to 201 SetTick(w, 202); // elapsed g.Update(); // at the cap -> soft-fail Assert.AreEqual(2, CountSpits(em), "at the concurrent cap the Spitter does NOT spawn another spit"); Assert.AreEqual(TickUtil.NonZero(210u), em.GetComponentData(spitter).NextShotTick, "soft-fail schedules a short retry (now+8 = 210), NOT a full cooldown (now+60 = 262)"); } } } }