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