using ProjectM.Simulation; using Unity.Burst;using Unity.Collections; using Unity.Entities; using Unity.Mathematics; using Unity.NetCode; namespace ProjectM.Server { /// /// Server-authoritative macro-loop director for the PLAYER-DRIVEN loop. The base sits in Calm /// (persistent, unhurried — build/prep at your pace, no countdown) until the arms a /// siege, then flips to Siege (the base-defense wave) and back to Calm when the wave is cleared. /// There is no global "Expedition" phase — being out on an expedition is per-player presence (server-only /// ), read client-side by the HUD, so one global byte never has to represent /// "player A out / player B home." Maintains the replicated singleton and gates /// (waves spawn only during Siege). Runs in the plain server SimulationSystemGroup /// before WaveSystem. All timing is wrap-safe NetworkTick math ( /// + ), never raw uint compares. Lives on the /// runtime-spawned CycleDirector ghost. Supersedes the forced timed Expedition→Defend→Build cycle. /// [BurstCompile] [WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)] [UpdateInGroup(typeof(SimulationSystemGroup))] [UpdateBefore(typeof(WaveSystem))] public partial struct CyclePhaseSystem : ISystem { [BurstCompile] public void OnCreate(ref SystemState state) { state.RequireForUpdate(); state.RequireForUpdate(); } [BurstCompile] public void OnUpdate(ref SystemState state) { var serverTick = SystemAPI.GetSingleton().ServerTick; if (!serverTick.IsValid) return; uint now = serverTick.TickIndexForValidTick; var cycleEntity = SystemAPI.GetSingletonEntity(); var cycle = SystemAPI.GetComponent(cycleEntity); var runtime = SystemAPI.GetComponent(cycleEntity); if (cycle.Phase == CyclePhase.Calm) { // Default calm: no pending siege => no countdown. cycle.PhaseEndTick = 0; if (SystemAPI.HasComponent(cycleEntity)) { var threat = SystemAPI.GetComponent(cycleEntity); if (threat.PendingSiegeSize > 0) { // Telegraph: mirror the arm tick into the replicated PhaseEndTick so the HUD can show an // "incursion in Ns" countdown (reuses the existing HUD countdown path) while it arms. cycle.PhaseEndTick = threat.ArmTick; bool armed = threat.ArmTick == 0 || !new NetworkTick(threat.ArmTick).IsNewerThan(serverTick); if (armed && SystemAPI.TryGetSingletonEntity(out var waveEntity)) { // ---- Calm -> Siege: seed WaveSystem's own Spawning entry atomically. Writing // Phase=Spawning bypasses its Lull escalation recompute (WaveSystem only recomputes // RemainingToSpawn while Phase==Lull), so the siege spawns EXACTLY the director-chosen // size and WaveSystem stays the sole WaveState writer thereafter. ---- var w = SystemAPI.GetComponent(waveEntity); runtime.DefendStartWave = w.WaveNumber; // capture BEFORE the bump (DefendCleared tests > this) w.WaveNumber += 1; w.Phase = WavePhase.Spawning; w.RemainingToSpawn = math.max(1, threat.PendingSiegeSize); w.NextActionTick = TickUtil.NonZero(now); // spawn the first Husk this tick SystemAPI.SetComponent(waveEntity, w); cycle.Phase = CyclePhase.Siege; cycle.PhaseEndTick = 0; // Siege is wave-driven, not timed. threat.PendingSiegeSize = 0; // consume once threat.ArmTick = 0; SystemAPI.SetComponent(cycleEntity, threat); } } } } else if (cycle.Phase == CyclePhase.Siege) { // END-2: is this the FINAL siege (the goal cap armed it)? Server-only RunPhase marker; HasComponent- // guarded so EditMode worlds without RunPhase keep the pre-END-2 (normal) soft-loss + survival paths. bool isFinal = SystemAPI.HasComponent(cycleEntity) && SystemAPI.GetComponent(cycleEntity).Value == RunPhaseId.FinalDefense; // The Engine Core breached to 0 during the siege (checked BEFORE survival). CyclePhaseSystem stays the // sole Phase/WaveState writer; it is ALSO the sole RunOutcome writer (END-2 single-writer). bool overrun = SystemAPI.HasComponent(cycleEntity) && SystemAPI.GetComponent(cycleEntity).Current <= 0; if (overrun) { cycle.Phase = CyclePhase.Calm; cycle.PhaseEndTick = 0; // The siege ends: despawn the base siege Husks (the locked despawn-on-breach fork) + reset the // wave so the NEXT armed siege starts clean (WaveSystem idles in Calm anyway). Shared by both paths. var ecb = new EntityCommandBuffer(Allocator.Temp); // Slice 3: cull the BASE wave only — an Expedition wave runs in its own region and must // survive a base Core breach. A region-blind EnemyTag wipe would also spuriously trip the // zone director's aliveZone==0 clear/reward edge. Mirrors ThreatDirectorSystem + DefendCleared. foreach (var (hr, he) in SystemAPI.Query>().WithAll().WithEntityAccess()) if (hr.ValueRO.Region == RegionId.Base) ecb.DestroyEntity(he); ecb.Playback(state.EntityManager); ecb.Dispose(); if (SystemAPI.TryGetSingletonEntity(out var waveLost)) { var wl = SystemAPI.GetComponent(waveLost); wl.RemainingToSpawn = 0; wl.Phase = WavePhase.Lull; wl.NextActionTick = 0; SystemAPI.SetComponent(waveLost, wl); } if (isFinal) { // END-2 TERMINAL LOSS: the final stand fell. Latch Loss + halt (the director stops arming). NO // ledger drain and NO OverrunTick stamp -> the client shows the dedicated terminal Loss banner // (from the replicated RunOutcome), not the soft "the Core will recover" flash. SystemAPI.SetComponent(cycleEntity, new RunOutcome { Value = RunOutcomeId.Loss }); } else { // END-1 SOFT LOSS (unchanged): drain a fraction of the shared ledger + stamp the transient // overrun pulse; the base persists wounded and the Core regenerates in Calm (the DR-029 fork). var tuneL = SystemAPI.TryGetSingleton(out var tcfgL) ? tcfgL : TuningConfig.Defaults(); if (SystemAPI.HasBuffer(cycleEntity)) { var ledger = SystemAPI.GetBuffer(cycleEntity); StorageMath.DrainFraction(ledger, tuneL.CoreOverrunDrainPct); } var coreL = SystemAPI.GetComponent(cycleEntity); coreL.OverrunTick = TickUtil.NonZero(now); SystemAPI.SetComponent(cycleEntity, coreL); } // Autosave the checkpoint (a breach / final loss is a meaningful save point). if (SystemAPI.HasComponent(cycleEntity)) SystemAPI.SetComponent(cycleEntity, new SaveRequest { Pending = 1 }); } else if (DefendCleared(ref state, runtime.DefendStartWave)) { cycle.Phase = CyclePhase.Calm; cycle.PhaseEndTick = 0; if (isFinal) { // END-2 TERMINAL WIN: the final siege was survived -> the Engine holds. Latch Victory + halt; // do NOT increment the (already-capped) goal. SystemAPI.SetComponent(cycleEntity, new RunOutcome { Value = RunOutcomeId.Victory }); if (SystemAPI.HasComponent(cycleEntity)) SystemAPI.SetComponent(cycleEntity, new SaveRequest { Pending = 1 }); } // DR-042: a SURVIVED base siege no longer advances the win meter — that was the AFK/passive win // path (scheduled sieges auto-armed + auto-collapsed on timeout, so standing still won). The win- // driver moved to EXPEDITION CLEARS: GoalProgress.Charge is now credited per cleared expedition by // ExpeditionGateSystem on the player's RETURN. Surviving a normal siege is still its own reward // (resources kept, Core intact) but is not progress toward Victory. The final-siege Victory latch // above is unchanged — GoalReachedSystem still arms the climactic final siege once Charge hits Target. } } // Surface the live wave number on the replicated CycleState for the HUD (single writer). if (SystemAPI.TryGetSingleton(out var waveSync)) cycle.WaveNumber = waveSync.WaveNumber; SystemAPI.SetComponent(cycleEntity, cycle); SystemAPI.SetComponent(cycleEntity, runtime); } // The Siege wave has run for this phase (WaveNumber advanced past the captured start), is fully spawned, // and no Husks remain alive. bool DefendCleared(ref SystemState state, int defendStartWave) { if (!SystemAPI.TryGetSingleton(out var wave)) return false; // Cleared only when no BASE husk remains: expedition zone enemies (EnemyTag + RegionTag{Expedition}) // must not hold the base siege open (DR-040 BLOCKER 3 — same global-count soft-lock as WaveSystem). int baseHusks = 0; foreach (var hr in SystemAPI.Query>().WithAll()) if (hr.ValueRO.Region == RegionId.Base) baseHusks++; return wave.WaveNumber > defendStartWave && wave.RemainingToSpawn == 0 && baseHusks == 0; } } }