using ProjectM.Simulation;
using Unity.Burst;
using Unity.Collections;
using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode;
using Unity.Transforms;
namespace ProjectM.Server
{
///
/// Server-only expedition zone-enemy director: while a player is OUT in the expedition region and the base is in
/// , it seeds and drip-spawns one epoch-seeded combat wave around the expedition
/// origin. The wave size + grunt/charger composition is pure of the
/// (grunt-heavy -> charger-heavy as the epoch climbs), spawned one
/// every at a deterministic ring, capped at
/// concurrent (the v1 ghost-relevancy budget). Each enemy is the
/// existing Husk ghost prefab + RegionTag{Expedition} + , so it reuses the whole
/// combat/readability/AI stack (the per-region AI filter keeps it seeking the expedition player only). When the
/// wave is fully spawned and every zone enemy is dead, it marks once —
/// the gate's once-per-epoch Ore reward reads that on the player's return.
///
/// DR-042 C7b: it ALSO writes the replicated summary every tick, ABOVE the
/// presence early-return (snapshot-above-early-return), so the client HUD's "enemies remaining / cleared" readout
/// never freezes stale even when nobody is out.
///
/// Ordering: [UpdateAfter(ExpeditionFieldSystem)] ONLY. ExpeditionFieldSystem is itself
/// [UpdateAfter(CyclePhaseSystem)], so ALSO declaring [UpdateBefore(CyclePhaseSystem)] here (as the
/// v1 plan first sketched) would close a CyclePhase->Field->Zone->CyclePhase sort cycle that throws at Play
/// world creation and is invisible to EditMode. Running after the field manager also reads the freshly-bumped
/// epoch + current phase. Zone enemies are interpolated ghosts, moved server-only — no prediction.
///
[BurstCompile]
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
[UpdateInGroup(typeof(SimulationSystemGroup))]
[UpdateAfter(typeof(ExpeditionFieldSystem))]
public partial struct ZoneEnemyDirectorSystem : ISystem
{
EntityQuery m_ZoneEnemies;
[BurstCompile]
public void OnCreate(ref SystemState state)
{
state.RequireForUpdate();
state.RequireForUpdate();
state.RequireForUpdate();
m_ZoneEnemies = state.GetEntityQuery(ComponentType.ReadOnly());
}
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
var serverTick = SystemAPI.GetSingleton().ServerTick;
if (!serverTick.IsValid)
return;
uint now = serverTick.TickIndexForValidTick;
// Per-player presence: the SPAWNER only runs while someone is OUT in the expedition (mirrors
// ExpeditionFieldSystem). The objective readout below is written FIRST, every tick, even when nobody's out.
int expeditionPlayers = 0;
foreach (var region in SystemAPI.Query>().WithAll())
if (region.ValueRO.Region == RegionId.Expedition)
expeditionPlayers++;
var directorEntity = SystemAPI.GetSingletonEntity();
var dir = SystemAPI.GetComponent(directorEntity);
var zs = SystemAPI.GetComponent(directorEntity);
var cycleEntity = SystemAPI.GetSingletonEntity();
var cycle = SystemAPI.GetComponent(cycleEntity);
var runtime = SystemAPI.GetComponent(cycleEntity);
int epoch = runtime.ExpeditionEpoch;
int aliveZone = m_ZoneEnemies.CalculateEntityCount();
// DR-042 C7b: write the REPLICATED objective summary FIRST, above the early-returns (snapshot-above-
// early-return) so the HUD never freezes stale. Rides the untagged CycleDirector ghost (cross-region safe).
if (SystemAPI.HasComponent(cycleEntity))
{
byte objState;
short objRemaining;
if (runtime.ClearedThisEpoch != 0 && runtime.LastRewardedEpoch != runtime.ExpeditionEpoch)
{
objState = ExpeditionObjectiveState.Cleared; // cleared but not yet claimed -> "return to claim"
objRemaining = 0;
}
else if (expeditionPlayers > 0 && (aliveZone > 0 || zs.RemainingToSpawn > 0))
{
objState = ExpeditionObjectiveState.Active;
objRemaining = (short)math.min(aliveZone + zs.RemainingToSpawn, short.MaxValue);
}
else
{
objState = ExpeditionObjectiveState.Idle;
objRemaining = 0;
}
SystemAPI.SetComponent(cycleEntity, new ExpeditionObjective { State = objState, Remaining = objRemaining });
}
if (expeditionPlayers == 0)
return; // nobody out there: the field manager owns teardown, the spawner does nothing
var prefabs = SystemAPI.GetBuffer(directorEntity);
if (prefabs.Length == 0)
return;
// MC-2: build the 4-type weighted mix band from the director's baked weights (shared math with the base
// siege). GruntsPerWave/ChargersPerWave are the Grunt/Charger base counts.
var bands = new MixBands
{
GruntBase = dir.GruntsPerWave,
ChargerBase = dir.ChargersPerWave,
SpitterBase = dir.SpitterBase,
SwarmerSlotBase = dir.SwarmerSlotBase,
ChargerPerEpoch = dir.ChargerPerEpoch,
SpitterPerEpoch = dir.SpitterPerEpoch,
SwarmerSlotPerEpoch = dir.SwarmerSlotPerEpoch,
SwarmerPackPerEpoch = dir.SwarmerPackPerEpoch,
};
// (Re)seed this epoch's wave once — its OWN counter (in SLOTS; a swarmer slot is one pack).
if (zs.SeededEpoch != epoch)
{
zs.SeededEpoch = epoch;
zs.SpawnCounter = 0;
zs.RemainingToSpawn = ZoneEnemyMath.WaveSlots(epoch, bands);
zs.NextSpawnTick = TickUtil.NonZero(now); // first slot this tick
}
if (zs.RemainingToSpawn > 0)
{
// Spawn only in Calm (a base Siege pauses the expedition wave), one SLOT per cadence, under the cap.
bool calm = cycle.Phase == CyclePhase.Calm;
bool dueNow = zs.NextSpawnTick == 0 || !new NetworkTick(zs.NextSpawnTick).IsNewerThan(serverTick);
if (calm && dueNow)
{
int slot = (int)zs.SpawnCounter;
byte kind = ZoneEnemyMath.KindForSlot(epoch, slot, bands);
int packSize = kind == ZoneEnemyMath.KindSwarmer
? ZoneEnemyMath.PackSizeForSlot(epoch, slot, bands, dir.SwarmerPackSize) : 1;
// MaxAlive counts ENTITIES; spawn the whole pack only if it fits (else WAIT — don't consume the slot).
if (aliveZone + packSize <= math.max(1, dir.MaxAlive))
{
float3 baseCenter = new float3(0f, 1f, 0f);
if (SystemAPI.TryGetSingleton(out var anchor))
baseCenter = BaseGridMath.PlotCenter(anchor);
float3 origin = RegionMath.RegionOrigin(RegionId.Expedition, baseCenter);
float3 center = EnemyAIMath.RingPosition(origin, slot, math.max(1, dir.RingSlots), dir.RingRadius);
center.y = origin.y;
int prefabIdx = kind;
if (prefabIdx >= prefabs.Length) prefabIdx = 0; // 4-entry buffer expected; clamp defensively
var prefab = prefabs[prefabIdx].Prefab;
// Preserve the prefab's baked Scale ([GhostField]) — FromPosition would reset Scale->1.
var baked = state.EntityManager.GetComponentData(prefab);
var ecb = new EntityCommandBuffer(Allocator.Temp);
for (int k = 0; k < packSize; k++)
{
float3 pos = packSize > 1
? EnemyAIMath.ClusterOffset(center, k, packSize, dir.ClusterTightRadius) : center;
pos.y = origin.y;
var enemy = ecb.Instantiate(prefab);
ecb.SetComponent(enemy, baked.WithPosition(pos));
ecb.AddComponent(enemy, new RegionTag { Region = RegionId.Expedition });
ecb.AddComponent(enemy);
}
ecb.Playback(state.EntityManager);
ecb.Dispose();
zs.SpawnCounter += 1; // ONE slot consumed even for a pack
zs.RemainingToSpawn -= 1;
zs.NextSpawnTick = TickUtil.NonZero(now + (uint)math.max(1, dir.SpawnIntervalTicks));
}
}
}
else if (aliveZone == 0 && runtime.ClearedThisEpoch == 0)
{
// Wave fully spawned AND every zone enemy dead -> a REAL clear. Mark once; the gate pays the
// once-per-epoch Ore reward on the player's return to base.
runtime.ClearedThisEpoch = 1;
SystemAPI.SetComponent(cycleEntity, runtime);
}
SystemAPI.SetComponent(directorEntity, zs);
}
}
}