using NUnit.Framework; using ProjectM.Server; using ProjectM.Simulation; using Unity.Core; using Unity.Entities; using Unity.NetCode; namespace ProjectM.Tests { /// /// Plain-Entities EditMode tests for the server-only — the composite /// base-attack scheduler. A bare world is seeded with a NetworkTime singleton and a CycleDirector entity /// carrying CycleState + ThreatState + ThreatConfig. These pin the post-expedition source (a return arms a /// siege of the configured size, with simultaneous returns de-duped to one), that the event-siege size is the /// config floor — never the WaveSystem escalation curve — that the telegraph ArmTick is now + delay, and that /// an unattended siege auto-collapses after the timeout (no soft-lock). All timing is wrap-safe NetworkTick. /// public class ThreatDirectorSystemTests { static (World world, SimulationSystemGroup group) MakeWorld(string name, uint serverTick) { var world = new World(name); var group = world.GetOrCreateSystemManaged(); group.AddSystemToUpdateList(world.GetOrCreateSystem()); group.SortSystems(); world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f)); var em = world.EntityManager; var nt = em.CreateEntity(typeof(NetworkTime)); em.SetComponentData(nt, new NetworkTime { ServerTick = new NetworkTick(serverTick) }); return (world, group); } static ThreatConfig DefaultConfig() => new ThreatConfig { PostExpeditionEnabled = 1, PostExpeditionDelayTicks = 300, SizeBase = 5, SizePerExpeditionResource = 0, StartCondition = ThreatStartCondition.Immediate, SiegeTimeoutTicks = 3600, }; static Entity MakeDirector(EntityManager em, byte phase, ThreatState threat, ThreatConfig config) { var e = em.CreateEntity(typeof(CycleState), typeof(ThreatState), typeof(ThreatConfig)); em.SetComponentData(e, new CycleState { Phase = phase, CycleNumber = 1 }); em.SetComponentData(e, threat); em.SetComponentData(e, config); return e; } [Test] public void PostExpedition_Return_Edge_Sets_PendingSiegeSize() { var (world, group) = MakeWorld("ThreatReturn", serverTick: 200); using (world) { var em = world.EntityManager; var dir = MakeDirector(em, CyclePhase.Calm, new ThreatState { PendingReturns = 1 }, DefaultConfig()); group.Update(); var ts = em.GetComponentData(dir); Assert.AreEqual(5, ts.PendingSiegeSize, "A return arms a siege of SizeBase Husks."); Assert.AreNotEqual(0u, ts.ArmTick, "The siege is armed with a telegraph tick."); Assert.AreEqual(0, ts.PendingReturns, "The return is consumed."); } } [Test] public void Multi_Player_Simultaneous_Return_Charges_Pending_Once() { var (world, group) = MakeWorld("ThreatMultiReturn", serverTick: 200); using (world) { var em = world.EntityManager; var dir = MakeDirector(em, CyclePhase.Calm, new ThreatState { PendingReturns = 3 }, DefaultConfig()); group.Update(); var ts = em.GetComponentData(dir); Assert.AreEqual(5, ts.PendingSiegeSize, "Three simultaneous returns still arm exactly one siege (de-dup)."); Assert.AreEqual(0, ts.PendingReturns, "All returns are consumed."); } } [Test] public void Siege_Size_Equals_Config_Not_Escalation_Curve() { var (world, group) = MakeWorld("ThreatSizeConfig", serverTick: 200); using (world) { var em = world.EntityManager; var dir = MakeDirector(em, CyclePhase.Calm, new ThreatState { PendingReturns = 1 }, DefaultConfig()); // A high wave number must NOT influence the event-siege size. var w = em.CreateEntity(typeof(WaveState)); em.SetComponentData(w, new WaveState { WaveNumber = 30 }); group.Update(); Assert.AreEqual(5, em.GetComponentData(dir).PendingSiegeSize, "Event-siege size is the config SizeBase, never the WaveSystem escalation curve."); } } [Test] public void StartCondition_Immediate_Arms_Via_ArmTick() { var (world, group) = MakeWorld("ThreatArm", serverTick: 1000); using (world) { var em = world.EntityManager; var config = DefaultConfig(); config.PostExpeditionDelayTicks = 120; var dir = MakeDirector(em, CyclePhase.Calm, new ThreatState { PendingReturns = 1 }, config); group.Update(); Assert.AreEqual(1120u, em.GetComponentData(dir).ArmTick, "Immediate start arms the siege at now + the telegraph delay (1000 + 120)."); } } [Test] public void Empty_Base_Siege_Auto_Resolves_Bounded() { var (world, group) = MakeWorld("ThreatTimeout", serverTick: 200); using (world) { var em = world.EntityManager; var config = DefaultConfig(); config.SiegeTimeoutTicks = 60; // SiegeStartTick 100, now 200 => 100 ticks elapsed > 60 timeout. var dir = MakeDirector(em, CyclePhase.Siege, new ThreatState { SiegeStartTick = 100 }, config); var w = em.CreateEntity(typeof(WaveState)); em.SetComponentData(w, new WaveState { RemainingToSpawn = 2, Phase = WavePhase.Spawning }); // Three Husks still on the field with no one to clear them. for (int i = 0; i < 3; i++) em.CreateEntity(typeof(EnemyTag)); group.Update(); using var huskQuery = em.CreateEntityQuery(typeof(EnemyTag)); Assert.AreEqual(0, huskQuery.CalculateEntityCount(), "A timed-out (unattended) siege culls the remaining Husks so it can never soft-lock."); Assert.AreEqual(0, em.GetComponentData(w).RemainingToSpawn, "The wave stops spawning when the siege collapses."); Assert.AreEqual(0u, em.GetComponentData(dir).SiegeStartTick, "The siege clock resets after collapse."); } } } }