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 per-ROOM enemy director — the Step-6 successor of the presence-keyed ZoneEnemyDirectorSystem. /// While the run FSM has a room active ( == InRoom) it seeds ONE wave per /// (int-equality reseed) sized by indexed /// on the room's (deeper rooms + Elite/Boss types skew heavier — the /// grounded MC-2 mix bands are reused verbatim), drip-spawned one SLOT per cadence at the deterministic ring /// around (base, ActiveSubSlot), under the same /// "spawn-the-pack-only-if-it-fits-else-wait" relevancy guard. A /// room spawns ONE beefed boss instead (health × , /// scale × — v1's boss is a scaled Charger). Every spawn keeps the full /// stack — EnemyTag + RegionTag{Expedition} + — PLUS {room} (the /// teardown contract). Scale preserved via baked.WithPosition. /// /// The room CLEAR edge surfaces ONLY through the replicated .State == Cleared /// (wave fully spawned AND zero alive, latched per seeded epoch) — written FIRST, ABOVE every early-return /// (snapshot-above-early-return) so the HUD never freezes; RunDirectorSystem consumes it one-tick-late (Step 7). /// The old CycleRuntime.ClearedThisEpoch write is gone (the C4 collapse), and the old base-siege Calm gate is /// deliberately DROPPED — a home retaliation siege no longer freezes a live sortie (the DR-042 latent gap). /// /// Ordering: [UpdateAfter(RunDirectorSystem)] ONLY — reads the freshly-advanced room state same-tick. /// NO CyclePhase edge may ever return to the room chain (Play-only sort-cycle, invisible to EditMode). /// [BurstCompile] [WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)] [UpdateInGroup(typeof(SimulationSystemGroup))] [UpdateAfter(typeof(RunDirectorSystem))] public partial struct RoomEnemyDirectorSystem : ISystem { EntityQuery m_ZoneEnemies; [BurstCompile] public void OnCreate(ref SystemState state) { state.RequireForUpdate(); state.RequireForUpdate(); state.RequireForUpdate(); state.RequireForUpdate(); m_ZoneEnemies = state.GetEntityQuery(ComponentType.ReadOnly(), ComponentType.Exclude()); // room clear + MaxAlive fit count LIVING only (B3) - corpses neither hold the room open nor crowd out spawns } [BurstCompile] public void OnUpdate(ref SystemState state) { var serverTick = SystemAPI.GetSingleton().ServerTick; if (!serverTick.IsValid) return; uint now = serverTick.TickIndexForValidTick; var runEntity = SystemAPI.GetSingletonEntity(); var info = SystemAPI.GetComponent(runEntity); var run = SystemAPI.GetComponent(runEntity); bool roomActive = info.Lifecycle == RunLifecycle.InRoom; var directorEntity = SystemAPI.GetSingletonEntity(); var dir = SystemAPI.GetComponent(directorEntity); var zs = SystemAPI.GetComponent(directorEntity); int aliveZone = m_ZoneEnemies.CalculateEntityCount(); // REPLICATED objective summary FIRST, above every early-return (snapshot-above-early-return): the HUD // readout must never freeze stale. Cleared latches only for a wave seeded FOR THIS RoomEpoch. if (SystemAPI.HasComponent(runEntity)) { byte objState; short objRemaining; if (roomActive && (aliveZone > 0 || zs.RemainingToSpawn > 0)) { objState = ExpeditionObjectiveState.Active; objRemaining = (short)math.min(aliveZone + zs.RemainingToSpawn, short.MaxValue); } else if (roomActive && zs.SeededEpoch == run.RoomEpoch && zs.RemainingToSpawn == 0 && aliveZone == 0) { objState = ExpeditionObjectiveState.Cleared; // fully spawned + fully dead -> advance-ready objRemaining = 0; } else { objState = ExpeditionObjectiveState.Idle; objRemaining = 0; } SystemAPI.SetComponent(runEntity, new ExpeditionObjective { State = objState, Remaining = objRemaining }); } if (!roomActive) return; var prefabs = SystemAPI.GetBuffer(directorEntity); if (prefabs.Length == 0) return; // Single plan authority: the node RunDirector published — never re-derived here. var map = RunMapMath.Generate(run.RunSeed); var node = map.NodeAt(run.CurrentNodeId); var plan = RoomLayoutMath.Plan(node, info.CurrentRoom, info.RoomCount); byte room = (byte)(info.CurrentRoom & 0xFF); bool bossRoom = plan.RoomType == RoomTypeId.Boss; 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 once per ROOM (its OWN counter, in SLOTS; a swarmer slot is one pack; a boss room is 1 slot). if (zs.SeededEpoch != run.RoomEpoch) { zs.SeededEpoch = run.RoomEpoch; zs.SpawnCounter = 0; zs.RemainingToSpawn = bossRoom ? 1 : ZoneEnemyMath.WaveSlots(plan.DifficultyEpoch, bands); zs.NextSpawnTick = TickUtil.NonZero(now + Tuning.RoomEntryGraceTicks); // landing grace — let the party orient } if (zs.RemainingToSpawn > 0) { bool dueNow = zs.NextSpawnTick == 0 || !new NetworkTick(zs.NextSpawnTick).IsNewerThan(serverTick); if (dueNow) { int slot = (int)zs.SpawnCounter; byte kind = bossRoom ? ZoneEnemyMath.KindCharger : ZoneEnemyMath.KindForSlot(plan.DifficultyEpoch, slot, bands); int packSize = !bossRoom && kind == ZoneEnemyMath.KindSwarmer ? ZoneEnemyMath.PackSizeForSlot(plan.DifficultyEpoch, slot, bands, dir.SwarmerPackSize) : 1; // MaxAlive counts ENTITIES; spawn the whole pack only if it fits (else WAIT — keep 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.ExpeditionRoomOrigin(baseCenter, run.ActiveSubSlot); float3 center = bossRoom ? origin + new float3(0f, 0f, 12f) // the boss anchors the room 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; 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 = ZoneEnemySpawnUtil.Spawn(ecb, prefab, in baked, pos, RegionId.Expedition, room); if (bossRoom) { // Boss = a scaled Charger given a real kit by BossAISystem. Scale the visual AND the // hitbox/reach (so hits register on the big model + its reach matches), multiply Health, // and tag BossState (server-only discriminator) so BossAISystem alone drives it. var bxform = baked.WithPosition(pos); bxform.Scale = baked.Scale * Tuning.BossScaleMultiplier; ecb.SetComponent(enemy, bxform); if (SystemAPI.HasComponent(prefab)) { // B5: party-size HP scaling by LIVING EXPEDITION players at spawn (NOT the // RunParticipant count - dead-respawned members keep the tag while parked at // base). Health.Max is a [GhostField] since DR-046, so the scaled max replicates. int livingParty = 0; foreach (var (pHealth, pRegion) in SystemAPI.Query, RefRO>().WithAll()) if (pHealth.ValueRO.Current > 0f && pRegion.ValueRO.Region == RegionId.Expedition) livingParty++; float partyScale = 1f + Tuning.BossHealthPerExtraPlayer * math.max(0, livingParty - 1); var hp = SystemAPI.GetComponent(prefab); hp.Current *= Tuning.BossHealthMultiplier * partyScale; hp.Max *= Tuning.BossHealthMultiplier * partyScale; ecb.SetComponent(enemy, hp); } if (SystemAPI.HasComponent(prefab)) { var hr = SystemAPI.GetComponent(prefab); hr.Value *= Tuning.BossScaleMultiplier; ecb.SetComponent(enemy, hr); } if (SystemAPI.HasComponent(prefab)) { var es = SystemAPI.GetComponent(prefab); es.AttackRange *= Tuning.BossScaleMultiplier; ecb.SetComponent(enemy, es); } ecb.AddComponent(enemy, new BossState { Phase = 1 }); } } 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)); } } } SystemAPI.SetComponent(directorEntity, zs); } } }