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. /// /// 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: only run while someone is OUT in the expedition (mirrors ExpeditionFieldSystem). int expeditionPlayers = 0; foreach (var region in SystemAPI.Query>().WithAll()) if (region.ValueRO.Region == RegionId.Expedition) expeditionPlayers++; if (expeditionPlayers == 0) return; // nobody out there: the field manager owns teardown, we do nothing var directorEntity = SystemAPI.GetSingletonEntity(); var dir = SystemAPI.GetComponent(directorEntity); var zs = SystemAPI.GetComponent(directorEntity); var prefabs = SystemAPI.GetBuffer(directorEntity); if (prefabs.Length == 0) return; var cycleEntity = SystemAPI.GetSingletonEntity(); var cycle = SystemAPI.GetComponent(cycleEntity); var runtime = SystemAPI.GetComponent(cycleEntity); int epoch = runtime.ExpeditionEpoch; // (Re)seed this epoch's wave once — its OWN counter, never WaveState's. if (zs.SeededEpoch != epoch) { zs.SeededEpoch = epoch; zs.SpawnCounter = 0; zs.RemainingToSpawn = ZoneEnemyMath.WaveSize(epoch, dir.GruntsPerWave, dir.ChargersPerWave); zs.NextSpawnTick = TickUtil.NonZero(now); // first enemy this tick } int aliveZone = m_ZoneEnemies.CalculateEntityCount(); if (zs.RemainingToSpawn > 0) { // Spawn only in Calm (a base Siege pauses the expedition wave; it resumes when the base is safe), one // per cadence, and only while under the concurrent cap. bool calm = cycle.Phase == CyclePhase.Calm; bool dueNow = zs.NextSpawnTick == 0 || !new NetworkTick(zs.NextSpawnTick).IsNewerThan(serverTick); if (calm && dueNow && aliveZone < 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); int slot = (int)zs.SpawnCounter; bool charger = ZoneEnemyMath.IsChargerSlot(epoch, slot, dir.GruntsPerWave, dir.ChargersPerWave); int prefabIdx = charger ? 1 : 0; if (prefabIdx >= prefabs.Length) prefabIdx = prefabs.Length - 1; var prefab = prefabs[prefabIdx].Prefab; float3 pos = EnemyAIMath.RingPosition(origin, slot, math.max(1, dir.RingSlots), dir.RingRadius); pos.y = origin.y; var ecb = new EntityCommandBuffer(Allocator.Temp); var enemy = ecb.Instantiate(prefab); // Preserve the prefab's baked Scale ([GhostField]) + rotation — FromPosition would reset Scale->1. var baked = state.EntityManager.GetComponentData(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; 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 (the player killed them; the empty-edge // teardown can't reach here because we early-return when no one is out). 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); } } }