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