using ProjectM.Simulation; using Unity.Burst; using Unity.Collections; using Unity.Entities; using Unity.Mathematics; using Unity.NetCode; namespace ProjectM.Server { /// /// Server-only composite ThreatDirector — the data-driven base-attack SCHEDULER. It owns the decision of WHEN /// and HOW BIG a siege is; owns the Calm↔Siege transition. The single documented /// hand-off is (this system sets it; CyclePhaseSystem consumes it). /// This slice wires ONE source — POST-EXPEDITION retaliation: a player returning to base (counted as /// by ) arms a siege of /// Husks after a /// telegraph. The Heat/Schedule sources are reserved (config baked-but-inert) so they drop in additively with /// no re-bake. It also enforces a BOUNDED siege lifetime (): an /// unattended siege (e.g. an empty base) auto-collapses so the loop can never soft-lock. Runs in the plain /// server SimulationSystemGroup, ordered Gate -> ThreatDirector -> RunState(CyclePhaseSystem) -> Wave so a /// return is consumed the same tick. All timing is wrap-safe NetworkTick math (TickUtil.NonZero + /// NetworkTick.IsNewerThan / TicksSince), never raw uint. /// [BurstCompile] [WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)] [UpdateInGroup(typeof(SimulationSystemGroup))] [UpdateAfter(typeof(ExpeditionGateSystem))] [UpdateBefore(typeof(CyclePhaseSystem))] public partial struct ThreatDirectorSystem : ISystem { EntityQuery m_Husks; [BurstCompile] public void OnCreate(ref SystemState state) { state.RequireForUpdate(); state.RequireForUpdate(); state.RequireForUpdate(); state.RequireForUpdate(); m_Husks = state.GetEntityQuery(ComponentType.ReadOnly()); } [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 threat = SystemAPI.GetComponent(cycleEntity); var config = SystemAPI.GetComponent(cycleEntity); // ---- SOURCE: post-expedition retaliation. A returning player arms ONE siege (simultaneous returns // collapse to a single arming — extending the de-dup the gate's one-increment-per-return starts). ---- if (config.PostExpeditionEnabled != 0 && threat.PendingReturns > 0) { if (cycle.Phase == CyclePhase.Calm && threat.PendingSiegeSize == 0) { int size = config.SizeBase + config.SizePerExpeditionResource * 0; // haul-scaling deferred (field baked) threat.PendingSiegeSize = math.max(1, size); threat.ArmTick = TickUtil.NonZero(now + config.PostExpeditionDelayTicks); } threat.PendingReturns = 0; // consume regardless so returns can't pile up } // ---- BOUNDED RESOLUTION: a Siege can't drag forever. Record its start; after SiegeTimeoutTicks cull // the remaining Husks + stop spawning so CyclePhaseSystem's DefendCleared returns the base to Calm. ---- if (cycle.Phase == CyclePhase.Siege) { if (threat.SiegeStartTick == 0) { threat.SiegeStartTick = TickUtil.NonZero(now); } else if (config.SiegeTimeoutTicks > 0) { var start = new NetworkTick(threat.SiegeStartTick); if (start.IsValid && serverTick.TicksSince(start) > (int)config.SiegeTimeoutTicks) { // Collapse the siege: cull every remaining Husk (a cached tag query, never RefRO on a tag). var husks = m_Husks.ToEntityArray(Allocator.Temp); if (husks.Length > 0) { var ecb = new EntityCommandBuffer(Allocator.Temp); for (int i = 0; i < husks.Length; i++) ecb.DestroyEntity(husks[i]); ecb.Playback(state.EntityManager); ecb.Dispose(); } husks.Dispose(); if (SystemAPI.TryGetSingletonEntity(out var waveEntity)) { var w = SystemAPI.GetComponent(waveEntity); w.RemainingToSpawn = 0; SystemAPI.SetComponent(waveEntity, w); } threat.SiegeStartTick = 0; } } } else { threat.SiegeStartTick = 0; // not under siege } SystemAPI.SetComponent(cycleEntity, threat); } } }