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); // END-2: a decided run (Victory/Loss) or one already in the FINAL siege arms NO further sieges. The // SiegeTimeout cull is also disabled during the final siege (a cull -> false Victory). Guarded with // HasComponent so EditMode worlds without RunPhase/RunOutcome keep the pre-END-2 behaviour. byte runPhase = SystemAPI.HasComponent(cycleEntity) ? SystemAPI.GetComponent(cycleEntity).Value : RunPhaseId.Normal; byte runOutcome = SystemAPI.HasComponent(cycleEntity) ? SystemAPI.GetComponent(cycleEntity).Value : RunOutcomeId.InProgress; bool canArm = runPhase == RunPhaseId.Normal && runOutcome == RunOutcomeId.InProgress; // ---- 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 && canArm) { 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 } // ---- SOURCE: scheduled base sieges. A timed cadence arms a siege even with NO expedition trip, so // the base-defense loop has stakes on its own. The first fire is one full interval out (a mine/build // grace window); size escalates by the live wave number. All ticks wrap-safe (TickUtil.NonZero). ---- if (config.ScheduleEnabled != 0 && config.ScheduleIntervalTicks > 0) { if (threat.NextScheduledTick == 0 || cycle.Phase != CyclePhase.Calm) { // Seed, and DEFER while a siege runs, so the next scheduled siege is always one full interval // AFTER the current one resolves -> a guaranteed calm/build window even if a siege runs long. threat.NextScheduledTick = TickUtil.NonZero(now + config.ScheduleIntervalTicks); } else if (cycle.Phase == CyclePhase.Calm && threat.PendingSiegeSize == 0 && canArm && !new NetworkTick(threat.NextScheduledTick).IsNewerThan(serverTick)) { int wave = SystemAPI.TryGetSingleton(out var ws) ? ws.WaveNumber : 0; threat.PendingSiegeSize = math.max(1, config.SizeBase + config.ScheduleSizePerWave * wave); threat.ArmTick = TickUtil.NonZero(now + config.PostExpeditionDelayTicks); threat.NextScheduledTick = TickUtil.NonZero(now + config.ScheduleIntervalTicks); } } // ---- 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 && runPhase != RunPhaseId.FinalDefense) { 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); } } }