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