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