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++) { var h = em.CreateEntity(typeof(EnemyTag)); em.AddComponentData(h, new RegionTag { Region = RegionId.Base }); } 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."); } } [Test] public void Schedule_First_Pass_Seeds_NextTick_Without_Firing() { var (world, group) = MakeWorld("ThreatScheduleSeed", serverTick: 200); using (world) { var em = world.EntityManager; var config = DefaultConfig(); config.PostExpeditionEnabled = 0; config.ScheduleEnabled = 1; config.ScheduleIntervalTicks = 100; var dir = MakeDirector(em, CyclePhase.Calm, new ThreatState { NextScheduledTick = 0 }, config); group.Update(); var ts = em.GetComponentData(dir); Assert.AreEqual(300u, ts.NextScheduledTick, "The first pass seeds the next scheduled tick one interval out (200 + 100)."); Assert.AreEqual(0, ts.PendingSiegeSize, "The first pass only seeds — it does not arm a siege immediately."); } } [Test] public void Schedule_Arms_Siege_On_Cadence_Without_An_Expedition() { var (world, group) = MakeWorld("ThreatScheduleFire", serverTick: 400); using (world) { var em = world.EntityManager; var config = DefaultConfig(); config.PostExpeditionEnabled = 0; // isolate the schedule source config.ScheduleEnabled = 1; config.ScheduleIntervalTicks = 100; config.ScheduleSizePerWave = 0; config.SizeBase = 5; config.PostExpeditionDelayTicks = 10; // NextScheduledTick 300 <= now 400 => the scheduled siege is due. var dir = MakeDirector(em, CyclePhase.Calm, new ThreatState { NextScheduledTick = 300 }, config); group.Update(); var ts = em.GetComponentData(dir); Assert.AreEqual(5, ts.PendingSiegeSize, "A due scheduled tick arms a SizeBase siege with NO expedition trip."); Assert.AreEqual(410u, ts.ArmTick, "The scheduled siege telegraphs at now + delay (400 + 10)."); Assert.AreEqual(500u, ts.NextScheduledTick, "The next scheduled siege is one interval out (400 + 100)."); } } } }