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 PLAYER-DRIVEN /// run-state director (Calm ↔ Siege). A bare world is seeded with a NetworkTime singleton and a cycle entity /// carrying CycleState + CycleRuntime (+ optionally ThreatState / WaveState / GoalProgress). The global phase /// is only ever Calm or Siege — being out on an expedition is per-player presence, NOT a global phase — so /// these pin: Calm holds with no pending siege; an armed ThreatState.PendingSiegeSize enters Siege and seeds /// WaveState's Spawning entry at the EXACT size; a cleared Siege returns to Calm and charges the goal once; /// and split co-op presence never produces a non-Calm phase. All timing is wrap-safe NetworkTick math. /// public class CyclePhaseSystemTests { 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 Entity MakeCycle(EntityManager em, byte phase, int defendStartWave) { var e = em.CreateEntity(typeof(CycleState), typeof(CycleRuntime)); em.SetComponentData(e, new CycleState { Phase = phase, PhaseEndTick = 0u, CycleNumber = 1 }); em.SetComponentData(e, new CycleRuntime { DefendStartWave = defendStartWave }); return e; } static void AddThreat(EntityManager em, Entity cycle, int pendingSiegeSize, uint armTick) { em.AddComponentData(cycle, new ThreatState { PendingSiegeSize = pendingSiegeSize, ArmTick = armTick }); } static Entity MakeWaveState(EntityManager em, int waveNumber, byte phase, int remainingToSpawn) { var e = em.CreateEntity(typeof(WaveState)); em.SetComponentData(e, new WaveState { WaveNumber = waveNumber, Phase = phase, RemainingToSpawn = remainingToSpawn }); return e; } [Test] public void Calm_Holds_When_No_PendingSiege() { var (world, group) = MakeWorld("CalmHolds", serverTick: 200); using (world) { var em = world.EntityManager; var cycle = MakeCycle(em, CyclePhase.Calm, defendStartWave: 0); AddThreat(em, cycle, pendingSiegeSize: 0, armTick: 0); group.Update(); Assert.AreEqual(CyclePhase.Calm, em.GetComponentData(cycle).Phase, "With no pending siege the base stays Calm — no forced timer."); } } [Test] public void PendingSiege_Enters_Siege_And_Seeds_WaveState_Spawning_With_Exact_Size() { var (world, group) = MakeWorld("PendingSiege", serverTick: 200); using (world) { var em = world.EntityManager; var cycle = MakeCycle(em, CyclePhase.Calm, defendStartWave: 0); AddThreat(em, cycle, pendingSiegeSize: 7, armTick: 0); // armTick 0 => fire immediately var wave = MakeWaveState(em, waveNumber: 5, phase: WavePhase.Lull, remainingToSpawn: 0); group.Update(); Assert.AreEqual(CyclePhase.Siege, em.GetComponentData(cycle).Phase, "An armed pending siege enters Siege."); var w = em.GetComponentData(wave); Assert.AreEqual(WavePhase.Spawning, w.Phase, "WaveState is driven into Spawning (bypassing the Lull escalation recompute)."); Assert.AreEqual(7, w.RemainingToSpawn, "RemainingToSpawn is the EXACT director-chosen siege size (not the escalation curve)."); Assert.AreEqual(6, w.WaveNumber, "WaveNumber advances by one for the siege."); Assert.AreEqual(5, em.GetComponentData(cycle).DefendStartWave, "DefendStartWave captures the pre-bump wave number."); Assert.AreEqual(0, em.GetComponentData(cycle).PendingSiegeSize, "The pending siege is consumed (zeroed) so it fires exactly once."); } } [Test] public void Siege_Exits_To_Calm_On_DefendCleared_And_Charges_Goal_Once() { var (world, group) = MakeWorld("SiegeClears", serverTick: 200); using (world) { var em = world.EntityManager; var cycle = MakeCycle(em, CyclePhase.Siege, defendStartWave: 5); em.AddComponentData(cycle, new GoalProgress { Charge = 0, Target = 10 }); // Wave advanced past the captured start, fully spawned, no Husks alive (none created). MakeWaveState(em, waveNumber: 6, phase: WavePhase.Spawning, remainingToSpawn: 0); group.Update(); Assert.AreEqual(CyclePhase.Calm, em.GetComponentData(cycle).Phase, "A cleared siege returns to Calm."); Assert.AreEqual(1, em.GetComponentData(cycle).Charge, "One goal charge accrues per siege survived (single writer)."); } } [Test] public void Coop_Split_Presence_Keeps_Global_Phase_Calm() { var (world, group) = MakeWorld("CoopSplit", serverTick: 200); using (world) { var em = world.EntityManager; var cycle = MakeCycle(em, CyclePhase.Calm, defendStartWave: 0); AddThreat(em, cycle, pendingSiegeSize: 0, armTick: 0); // One player out on expedition, one home — the GLOBAL phase machine must ignore presence. var pOut = em.CreateEntity(typeof(RegionTag), typeof(PlayerTag)); em.SetComponentData(pOut, new RegionTag { Region = RegionId.Expedition }); var pHome = em.CreateEntity(typeof(RegionTag), typeof(PlayerTag)); em.SetComponentData(pHome, new RegionTag { Region = RegionId.Base }); group.Update(); Assert.AreEqual(CyclePhase.Calm, em.GetComponentData(cycle).Phase, "Split presence (one out, one home) never drives the single global phase — Expedition is per-player."); } } [Test] public void WaveNumber_Is_Synced_From_WaveState_For_The_Hud() { var (world, group) = MakeWorld("WaveSync", serverTick: 200); using (world) { var em = world.EntityManager; var cycle = MakeCycle(em, CyclePhase.Siege, defendStartWave: 5); MakeWaveState(em, waveNumber: 4, phase: WavePhase.Spawning, remainingToSpawn: 2); group.Update(); Assert.AreEqual(4, em.GetComponentData(cycle).WaveNumber, "CycleState.WaveNumber mirrors the server-only WaveState.WaveNumber for the replicated-state-only HUD."); } } [Test] public void Siege_Overrun_Ends_Siege_Drains_Ledger_Despawns_Husks_No_Goal_Charge() { var (world, group) = MakeWorld("SiegeOverrun", serverTick: 200); using (world) { var em = world.EntityManager; var cycle = MakeCycle(em, CyclePhase.Siege, defendStartWave: 5); em.AddComponentData(cycle, new GoalProgress { Charge = 3, Target = 10 }); em.AddComponentData(cycle, new CoreIntegrity { Current = 0, Max = 100, OverrunTick = 0 }); // breached var ledger = em.AddBuffer(cycle); ledger.Add(new StorageEntry { ItemId = 2, Count = 100 }); ledger.Add(new StorageEntry { ItemId = 4, Count = 40 }); MakeWaveState(em, waveNumber: 6, phase: WavePhase.Spawning, remainingToSpawn: 3); em.CreateEntity(typeof(EnemyTag)); // two live husks the team failed to clear em.CreateEntity(typeof(EnemyTag)); group.Update(); Assert.AreEqual(CyclePhase.Calm, em.GetComponentData(cycle).Phase, "an overrun ends the siege -> Calm (soft loss)."); Assert.AreEqual(3, em.GetComponentData(cycle).Charge, "NO goal charge on a loss (you were overrun, not survived)."); var l = em.GetBuffer(cycle); Assert.AreEqual(50, l[0].Count, "ledger row 1 drained 50% (100 -> 50)."); Assert.AreEqual(20, l[1].Count, "ledger row 2 drained 50% (40 -> 20)."); Assert.AreNotEqual(0u, em.GetComponentData(cycle).OverrunTick, "the overrun pulse is stamped for the HUD flash."); using var huskQ = em.CreateEntityQuery(typeof(EnemyTag)); Assert.AreEqual(0, huskQ.CalculateEntityCount(), "remaining husks are despawned (the siege disperses)."); } } [Test] public void Overrun_Resolves_Once_Then_Stays_Calm_Without_Recharging() { var (world, group) = MakeWorld("OverrunOnce", serverTick: 200); using (world) { var em = world.EntityManager; var cycle = MakeCycle(em, CyclePhase.Siege, defendStartWave: 5); em.AddComponentData(cycle, new GoalProgress { Charge = 0, Target = 10 }); em.AddComponentData(cycle, new CoreIntegrity { Current = 0, Max = 100 }); em.AddBuffer(cycle); MakeWaveState(em, waveNumber: 6, phase: WavePhase.Spawning, remainingToSpawn: 0); group.Update(); group.Update(); // second tick: Calm branch -> must not re-resolve or charge Assert.AreEqual(CyclePhase.Calm, em.GetComponentData(cycle).Phase); Assert.AreEqual(0, em.GetComponentData(cycle).Charge, "the loss never charges the goal across ticks."); } } } }