using NUnit.Framework; using ProjectM.Server; using ProjectM.Simulation; using Unity.Core; using Unity.Entities; using Unity.NetCode; namespace ProjectM.Tests { /// /// END-2 (SL-3) — plain-Entities EditMode tests for the final-siege win/lose spine: /// arming + 's FinalDefense-gated Victory/Loss latches + the /// SiegeTimeout guard. A bare world is seeded with a NetworkTime singleton and a /// CycleDirector entity carrying the full run-state set (CycleState/CycleRuntime/ThreatState/ThreatConfig/ /// GoalProgress/CoreIntegrity/RunPhase/RunOutcome/SaveRequest + a ledger). These pin: the goal cap arms a bigger /// final siege EXACTLY once and CyclePhaseSystem enters it once; a survived NORMAL siege no longer charges the goal (DR-042); a survived final siege /// latches Victory (no extra charge); a Core breach during the final siege latches Loss with NONE of the END-1 /// soft-loss side effects (no ledger drain, no OverrunTick); a NORMAL-phase overrun STILL takes the END-1 soft /// path (the key regression); a restored Victory does not re-arm; and the SiegeTimeout cull is disabled during the /// final siege so a timeout can't fake a Victory. All timing is wrap-safe NetworkTick math. /// public class EndgameWinLoseTests { // ---- harness ---- static (World world, SimulationSystemGroup group) MakeWorld(string name, uint serverTick) { var world = new World(name); var group = world.GetOrCreateSystemManaged(); // CyclePhaseSystem then GoalReachedSystem ([UpdateAfter(CyclePhaseSystem)] is honored by SortSystems). group.AddSystemToUpdateList(world.GetOrCreateSystem()); 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 (World world, SimulationSystemGroup group) MakeThreatWorld(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); } // SizeBase 5 / ScheduleSizePerWave 1 / immediate (delay 0) arm / no timeout — the END-2 arming math is // (5 + 1*wave) * FinalSiegeMultiplier. static ThreatConfig Cfg() => new ThreatConfig { PostExpeditionEnabled = 0, ScheduleEnabled = 0, PostExpeditionDelayTicks = 0, SizeBase = 5, ScheduleSizePerWave = 1, StartCondition = ThreatStartCondition.Immediate, SiegeTimeoutTicks = 0, }; static Entity MakeDirector(EntityManager em, byte phase, int defendStartWave, int charge, int target, int core, byte runPhase, byte runOutcome) { var e = em.CreateEntity(); em.AddComponentData(e, new CycleState { Phase = phase, PhaseEndTick = 0u, CycleNumber = 1 }); em.AddComponentData(e, new CycleRuntime { DefendStartWave = defendStartWave }); em.AddComponentData(e, new ThreatState()); em.AddComponentData(e, Cfg()); em.AddComponentData(e, new GoalProgress { Charge = charge, Target = target }); em.AddComponentData(e, new CoreIntegrity { Current = core, Max = 100, OverrunTick = 0u }); em.AddComponentData(e, new RunPhase { Value = runPhase }); em.AddComponentData(e, new RunOutcome { Value = runOutcome }); em.AddComponentData(e, new SaveRequest { Pending = 0 }); em.AddBuffer(e); return e; } static Entity MakeWave(EntityManager em, int waveNumber, byte phase, int remaining) { var e = em.CreateEntity(typeof(WaveState)); em.SetComponentData(e, new WaveState { WaveNumber = waveNumber, Phase = phase, RemainingToSpawn = remaining }); return e; } static int ExpectedFinalSize(int sizeBase, int perWave, int wave) => (int)((sizeBase + perWave * wave) * TuningConfig.Defaults().FinalSiegeMultiplier); // ---- tests ---- [Test] public void GoalReached_Arms_Final_Siege_Then_CyclePhase_Enters_It_Once() { var (world, group) = MakeWorld("End2Arm", serverTick: 200); using (world) { var em = world.EntityManager; var dir = MakeDirector(em, CyclePhase.Calm, defendStartWave: 0, charge: 4, target: 4, core: 100, RunPhaseId.Normal, RunOutcomeId.InProgress); var wave = MakeWave(em, waveNumber: 4, phase: WavePhase.Lull, remaining: 0); int expected = ExpectedFinalSize(5, 1, 4); // (5 + 4) * 2.5 = 22 // Tick 1: CyclePhase Calm (nothing pending) -> GoalReached arms the FINAL siege + flips FinalDefense. group.Update(); Assert.AreEqual(expected, em.GetComponentData(dir).PendingSiegeSize, "final siege armed at (SizeBase + perWave*wave) * FinalSiegeMultiplier (visibly bigger than a normal siege)."); Assert.Greater(expected, 5 + 1 * 4, "the final siege is strictly larger than the would-be normal siege."); Assert.AreEqual(RunPhaseId.FinalDefense, em.GetComponentData(dir).Value, "RunPhase flips to FinalDefense exactly when the goal cap is reached."); Assert.AreEqual(CyclePhase.Calm, em.GetComponentData(dir).Phase, "still Calm on the arm tick (CyclePhase consumes the pending siege the next tick)."); // Tick 2: CyclePhase Calm consumes the armed siege -> Siege; GoalReached no-ops (RunPhase != Normal). group.Update(); Assert.AreEqual(CyclePhase.Siege, em.GetComponentData(dir).Phase, "the final siege starts."); Assert.AreEqual(expected, em.GetComponentData(wave).RemainingToSpawn, "WaveState is seeded with the EXACT multiplied final-siege size."); Assert.AreEqual(0, em.GetComponentData(dir).PendingSiegeSize, "the final siege is consumed exactly once (no re-arm by GoalReached while in FinalDefense)."); } } [Test] public void Survived_Normal_Siege_Neither_Charges_Goal_Nor_Arms_Final() { var (world, group) = MakeWorld("End2Clamp", serverTick: 200); using (world) { var em = world.EntityManager; // DR-042: surviving a NORMAL siege one short of the cap must neither charge the goal nor arm the final. var dir = MakeDirector(em, CyclePhase.Siege, defendStartWave: 5, charge: 3, target: 4, core: 100, RunPhaseId.Normal, RunOutcomeId.InProgress); MakeWave(em, waveNumber: 6, phase: WavePhase.Spawning, remaining: 0); // DefendCleared group.Update(); Assert.AreEqual(3, em.GetComponentData(dir).Charge, "a survived normal siege does NOT charge the goal (DR-042: base-siege survival is not win-progress)."); Assert.AreEqual(RunPhaseId.Normal, em.GetComponentData(dir).Value, "the final siege is NOT armed by a survived siege near the cap."); Assert.AreEqual(0, em.GetComponentData(dir).PendingSiegeSize, "nothing is armed (the cap is only crossed by an expedition clear)."); } } [Test] public void Victory_Latches_Once_On_Final_DefendCleared() { var (world, group) = MakeWorld("End2Victory", serverTick: 200); using (world) { var em = world.EntityManager; var dir = MakeDirector(em, CyclePhase.Siege, defendStartWave: 5, charge: 4, target: 4, core: 100, RunPhaseId.FinalDefense, RunOutcomeId.InProgress); MakeWave(em, waveNumber: 6, phase: WavePhase.Spawning, remaining: 0); // cleared, no husks alive group.Update(); Assert.AreEqual(RunOutcomeId.Victory, em.GetComponentData(dir).Value, "surviving the final siege latches Victory."); Assert.AreEqual(CyclePhase.Calm, em.GetComponentData(dir).Phase, "the run ends in Calm."); Assert.AreEqual(4, em.GetComponentData(dir).Charge, "a Victory does NOT increment the already-capped goal."); // A second tick must not change the latched outcome (GoalReached + the branch are inert once decided). group.Update(); Assert.AreEqual(RunOutcomeId.Victory, em.GetComponentData(dir).Value, "Victory is latched (stable across ticks)."); } } [Test] public void Loss_Latches_On_Final_Core_Breach_Without_Soft_Side_Effects() { var (world, group) = MakeWorld("End2Loss", serverTick: 200); using (world) { var em = world.EntityManager; var dir = MakeDirector(em, CyclePhase.Siege, defendStartWave: 5, charge: 4, target: 4, core: 0, RunPhaseId.FinalDefense, RunOutcomeId.InProgress); // Core breached during the final siege var ledger = em.GetBuffer(dir); ledger.Add(new StorageEntry { ItemId = ResourceId.Ore, Count = 100 }); ledger.Add(new StorageEntry { ItemId = ResourceId.Charge, Count = 40 }); MakeWave(em, waveNumber: 6, phase: WavePhase.Spawning, remaining: 3); em.CreateEntity(typeof(EnemyTag), typeof(RegionTag)); em.CreateEntity(typeof(EnemyTag), typeof(RegionTag)); group.Update(); Assert.AreEqual(RunOutcomeId.Loss, em.GetComponentData(dir).Value, "a Core breach during the FINAL siege latches a terminal Loss."); Assert.AreEqual(CyclePhase.Calm, em.GetComponentData(dir).Phase, "the run ends."); var l = em.GetBuffer(dir); Assert.AreEqual(100, l[0].Count, "terminal Loss does NOT drain the ledger (unlike the soft overrun)."); Assert.AreEqual(40, l[1].Count, "terminal Loss does NOT drain the ledger."); Assert.AreEqual(0u, em.GetComponentData(dir).OverrunTick, "terminal Loss does NOT stamp OverrunTick (the dedicated Loss banner shows instead of the soft flash)."); using var huskQ = em.CreateEntityQuery(typeof(EnemyTag)); Assert.AreEqual(0, huskQ.CalculateEntityCount(), "the siege disperses (remaining husks despawned)."); } } [Test] public void Normal_Overrun_Stays_Soft_When_RunPhase_Normal() { // REGRESSION: END-2 must not change END-1's soft-loss for a NORMAL (non-final) siege overrun. var (world, group) = MakeWorld("End2NormalSoft", serverTick: 200); using (world) { var em = world.EntityManager; var dir = MakeDirector(em, CyclePhase.Siege, defendStartWave: 5, charge: 3, target: 10, core: 0, RunPhaseId.Normal, RunOutcomeId.InProgress); // breached, but NOT the final siege var ledger = em.GetBuffer(dir); ledger.Add(new StorageEntry { ItemId = ResourceId.Ore, Count = 100 }); ledger.Add(new StorageEntry { ItemId = ResourceId.Charge, Count = 40 }); MakeWave(em, waveNumber: 6, phase: WavePhase.Spawning, remaining: 0); em.CreateEntity(typeof(EnemyTag), typeof(RegionTag)); em.CreateEntity(typeof(EnemyTag), typeof(RegionTag)); group.Update(); Assert.AreEqual(CyclePhase.Calm, em.GetComponentData(dir).Phase, "the soft loss ends the siege -> Calm."); Assert.AreEqual(RunOutcomeId.InProgress, em.GetComponentData(dir).Value, "a NORMAL overrun must NOT latch a terminal outcome (END-1 soft-loss preserved)."); var l = em.GetBuffer(dir); Assert.AreEqual(50, l[0].Count, "the soft loss drains the ledger 50% (END-1 behaviour, unchanged)."); Assert.AreEqual(20, l[1].Count, "the soft loss drains the ledger 50%."); Assert.AreNotEqual(0u, em.GetComponentData(dir).OverrunTick, "the soft loss stamps OverrunTick for the HUD flash (END-1 behaviour, unchanged)."); Assert.AreEqual(3, em.GetComponentData(dir).Charge, "no goal charge on a loss."); } } [Test] public void Restored_Victory_Does_Not_Rearm_Final_Siege() { // Born-correct of a finished-run Continue (SaveData v5): RunOutcome=Victory restored; RunPhase boots Normal // (server-only, not persisted). The RunOutcome guard must keep GoalReached inert so the win is durable. var (world, group) = MakeWorld("End2Restore", serverTick: 200); using (world) { var em = world.EntityManager; var dir = MakeDirector(em, CyclePhase.Calm, defendStartWave: 0, charge: 4, target: 4, core: 100, RunPhaseId.Normal, RunOutcomeId.Victory); MakeWave(em, waveNumber: 4, phase: WavePhase.Lull, remaining: 0); group.Update(); Assert.AreEqual(0, em.GetComponentData(dir).PendingSiegeSize, "a restored Victory does NOT re-arm the final siege (Continue loads finished)."); Assert.AreEqual(RunPhaseId.Normal, em.GetComponentData(dir).Value, "RunPhase stays Normal (no flip)."); Assert.AreEqual(RunOutcomeId.Victory, em.GetComponentData(dir).Value, "the win persists."); } } [Test] public void Final_Siege_Is_Not_Culled_By_SiegeTimeout() { // F5: the SiegeTimeout cull must be disabled during the final siege — otherwise a timeout-cull trips // DefendCleared and fakes a Victory. (The NORMAL-phase timeout cull is covered by ThreatDirectorSystemTests.) var (world, group) = MakeThreatWorld("End2NoTimeoutCull", serverTick: 1000); using (world) { var em = world.EntityManager; var e = em.CreateEntity(); em.AddComponentData(e, new CycleState { Phase = CyclePhase.Siege, CycleNumber = 1 }); var cfg = Cfg(); cfg.SiegeTimeoutTicks = 10; // would normally fire: 1000 - 900 = 100 ticks elapsed >> 10 em.AddComponentData(e, cfg); em.AddComponentData(e, new ThreatState { SiegeStartTick = 900 }); em.AddComponentData(e, new RunPhase { Value = RunPhaseId.FinalDefense }); em.AddComponentData(e, new RunOutcome { Value = RunOutcomeId.InProgress }); var w = em.CreateEntity(typeof(WaveState)); em.SetComponentData(w, new WaveState { RemainingToSpawn = 5, Phase = WavePhase.Spawning }); for (int i = 0; i < 3; i++) em.CreateEntity(typeof(EnemyTag), typeof(RegionTag)); // base husks (RegionTag defaults to Base) group.Update(); using var huskQ = em.CreateEntityQuery(typeof(EnemyTag)); Assert.AreEqual(3, huskQ.CalculateEntityCount(), "the final siege is NOT culled by SiegeTimeout (a cull would fake a Victory)."); } } // ---- review-driven additions: M-3/N-4 (full-pipeline arming) + M-4 (multiplier) ---- static (World world, SimulationSystemGroup group) MakeFullWorld(string name, uint serverTick) { var world = new World(name); var group = world.GetOrCreateSystemManaged(); // Sorted by attributes: ThreatDirector [UpdateBefore CyclePhase] -> CyclePhase -> GoalReached [UpdateAfter]. group.AddSystemToUpdateList(world.GetOrCreateSystem()); group.AddSystemToUpdateList(world.GetOrCreateSystem()); 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 void SetServerTick(World world, uint tick) { var em = world.EntityManager; using var q = em.CreateEntityQuery(typeof(NetworkTime)); em.SetComponentData(q.GetSingletonEntity(), new NetworkTime { ServerTick = new NetworkTick(tick) }); } [Test] public void Final_Siege_Arms_On_Goal_Edge_Through_Pipeline_Not_Stomped_By_Scheduler() { // M-3 + N-4: drive the REAL cross-system handoff (ThreatDirector -> CyclePhase -> GoalReached) over the // Charge edge (now crossed by an EXPEDITION CLEAR in production; PRE-SEEDED at Target here), then prove a DUE scheduled source can't stomp the armed final // siege (the FinalDefense + PendingSiegeSize!=0 guards) and the FINAL size flows through to the wave. var (world, group) = MakeFullWorld("End2Pipeline", serverTick: 200); using (world) { var em = world.EntityManager; var dir = MakeDirector(em, CyclePhase.Siege, defendStartWave: 5, charge: 4, target: 4, core: 100, RunPhaseId.Normal, RunOutcomeId.InProgress); var cfg = Cfg(); cfg.ScheduleEnabled = 1; cfg.ScheduleIntervalTicks = 100; em.SetComponentData(dir, cfg); em.SetComponentData(dir, new ThreatState { NextScheduledTick = 150 }); // a scheduled siege is pending var wave = MakeWave(em, waveNumber: 6, phase: WavePhase.Spawning, remaining: 0); // DefendCleared this tick int expected = ExpectedFinalSize(5, 1, 6); // (5 + 6) * 2.5 = 27 // Tick 1: ThreatDirector (Siege -> no arm) -> CyclePhase (survive -> Calm, Charge stays at cap) -> GoalReached (arm). group.Update(); Assert.AreEqual(4, em.GetComponentData(dir).Charge, "Charge sits at the cap (crossed by an expedition clear in production; survived sieges no longer credit — DR-042)."); Assert.AreEqual(RunPhaseId.FinalDefense, em.GetComponentData(dir).Value, "GoalReached flips FinalDefense the same tick the Charge edge is crossed."); Assert.AreEqual(expected, em.GetComponentData(dir).PendingSiegeSize, "the final siege is armed at the multiplied size."); // Advance the clock so the scheduled source is DUE, then tick: it must NOT stomp the armed final siege; // CyclePhase consumes it into the wave at the FINAL size (not a scheduled SizeBase). SetServerTick(world, 400); group.Update(); Assert.AreEqual(CyclePhase.Siege, em.GetComponentData(dir).Phase, "the final siege starts."); Assert.AreEqual(expected, em.GetComponentData(wave).RemainingToSpawn, "the FINAL size (not a scheduled SizeBase) is seeded -> the due scheduler did not stomp it."); Assert.AreEqual(0, em.GetComponentData(dir).PendingSiegeSize, "consumed exactly once."); } } [Test] public void FinalSiegeMultiplier_LiveOverride_Scales_Final_Size() { var (world, group) = MakeWorld("End2MultOverride", serverTick: 200); using (world) { var em = world.EntityManager; var dir = MakeDirector(em, CyclePhase.Calm, defendStartWave: 0, charge: 4, target: 4, core: 100, RunPhaseId.Normal, RunOutcomeId.InProgress); MakeWave(em, waveNumber: 4, phase: WavePhase.Lull, remaining: 0); var tc = em.CreateEntity(typeof(TuningConfig)); var cfg = TuningConfig.Defaults(); cfg.FinalSiegeMultiplier = 1.5f; em.SetComponentData(tc, cfg); group.Update(); int normal = 5 + 1 * 4; // 9 Assert.AreEqual((int)(normal * 1.5f), em.GetComponentData(dir).PendingSiegeSize, "the final size scales by the LIVE FinalSiegeMultiplier (1.5x), not the 2.5 default."); } } [Test] public void FinalSiegeMultiplier_Below_One_Floors_To_Normal_Size() { var (world, group) = MakeWorld("End2MultFloor", serverTick: 200); using (world) { var em = world.EntityManager; var dir = MakeDirector(em, CyclePhase.Calm, defendStartWave: 0, charge: 4, target: 4, core: 100, RunPhaseId.Normal, RunOutcomeId.InProgress); MakeWave(em, waveNumber: 4, phase: WavePhase.Lull, remaining: 0); var tc = em.CreateEntity(typeof(TuningConfig)); var cfg = TuningConfig.Defaults(); cfg.FinalSiegeMultiplier = 0.5f; // degenerate sub-1 em.SetComponentData(tc, cfg); group.Update(); int normal = 5 + 1 * 4; // 9 Assert.AreEqual(normal, em.GetComponentData(dir).PendingSiegeSize, "a sub-1 multiplier floors at 1x (math.max(1,...)) -> the final siege is never smaller than a normal one."); } } } }