using ProjectM.Simulation; using Unity.Burst; using Unity.Collections; using Unity.Entities; using Unity.Mathematics; using Unity.Transforms; namespace ProjectM.Server { /// /// Server-only per-ROOM field seeder — the Step-5 successor of the presence-keyed ExpeditionFieldSystem. /// When the run FSM has a room active ( == InRoom) and /// has advanced past the epoch this system last seeded (int equality, never /// tick math), it resolves the active room's from the map node RunDirectorSystem published /// ( — the single plan authority; NEVER re-derived here) and scatters /// plan.NodeCount resource nodes — FLOORED by the run-wide scarcity budget /// , which this system spends down (documented co-write: RunDirector /// STAGES the budget at launch; this system only decrements it) — plus a light Blight-clutter dressing, all /// inside the room's shape at (base, ActiveSubSlot). Every spawn is /// stamped {room} (the teardown contract) on top of the prefab-baked RegionTag{Expedition}; /// Scale is preserved via baked.WithPosition (never FromPosition). /// /// Teardown: room-advance/return teardown belongs to RunDirectorSystem (RoomTeardown, Step 7). This system keeps /// ONE defensive sweep — Staging with any alive → destroy them all (idempotent; covers /// abort/disconnect edges). Untagged ghosts (base field, structures) are structurally untouchable. /// /// Ordering: [UpdateAfter(RunDirectorSystem)] so it reads the freshly-advanced room state same-tick. /// The old inherited [UpdateAfter(CyclePhaseSystem)] is deliberately DROPPED and NO CyclePhase edge may /// ever return to the room chain (the Play-only sort-cycle rule — invisible to EditMode). /// [BurstCompile] [WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)] [UpdateInGroup(typeof(SimulationSystemGroup))] [UpdateAfter(typeof(RunDirectorSystem))] public partial struct RoomFieldSystem : ISystem { /// Max clutter pieces per room — cosmetic ghosts still cost relevancy, keep the dressing light. const int MaxClutterPerRoom = 6; EntityQuery m_RoomTagged; [BurstCompile] public void OnCreate(ref SystemState state) { state.RequireForUpdate(); state.RequireForUpdate(); state.RequireForUpdate(); m_RoomTagged = state.GetEntityQuery(ComponentType.ReadOnly()); } [BurstCompile] public void OnUpdate(ref SystemState state) { var dirEntity = SystemAPI.GetSingletonEntity(); var info = SystemAPI.GetComponent(dirEntity); var run = SystemAPI.GetComponent(dirEntity); var spawnerEntity = SystemAPI.GetSingletonEntity(); var spawner = SystemAPI.GetComponent(spawnerEntity); // One-shot: attach this system's server-only bookkeeping beside the baked spawner singleton. if (!SystemAPI.HasComponent(spawnerEntity)) { state.EntityManager.AddComponentData(spawnerEntity, new RoomFieldState()); return; // structural change — clean re-read next tick } var rf = SystemAPI.GetComponent(spawnerEntity); var ecb = new EntityCommandBuffer(Allocator.Temp); if (info.Lifecycle == RunLifecycle.InRoom) { if (rf.LastSpawnedRoomEpoch != run.RoomEpoch && spawner.Prefab != Entity.Null) { float3 baseCenter = new float3(0f, 1f, 0f); if (SystemAPI.TryGetSingleton(out var anchor)) baseCenter = BaseGridMath.PlotCenter(anchor); float3 origin = RegionMath.ExpeditionRoomOrigin(baseCenter, run.ActiveSubSlot); // Single plan authority: the node RunDirector published — never re-derived from the col/path. 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); // Scarcity: the run-wide budget floors this room's count and is spent down (never negative). int count = math.min(plan.NodeCount, math.max(0, run.NodeBudgetRemaining)); if (count > 0) { var baked = SystemAPI.GetComponent(spawner.Prefab); var prefabNode = SystemAPI.GetComponent(spawner.Prefab); var rng = new Random(RunMapMath.Hash(run.RunSeed, (uint)run.CurrentNodeId, 0x0DEu) | 1u); for (int i = 0; i < count; i++) { var e = ecb.Instantiate(spawner.Prefab); float3 pos = RoomLayoutMath.ScatterInShape(plan.ShapeId, origin, i, count, ref rng); ecb.SetComponent(e, baked.WithPosition(pos)); // Rarity-weighted resource type (Step 11): Ore 45% (building) / Biomass 40% (walls, // fabricator) / AETHER 15% — the scarce permanent-meta currency, felt when it drops. var rn = prefabNode; int roll = rng.NextInt(0, 100); rn.ResourceId = roll < 15 ? ResourceId.Aether : roll < 60 ? ResourceId.Ore : ResourceId.Biomass; ecb.SetComponent(e, rn); ecb.AddComponent(e, new RoomTag { Room = room }); } run.NodeBudgetRemaining -= count; SystemAPI.SetComponent(dirEntity, run); // the documented budget co-write (spend only) } // Clutter dressing (OPTIONAL singleton) — a DISTINCT seed so it never co-locates with nodes. if (SystemAPI.TryGetSingleton(out var clutter) && clutter.Prefab != Entity.Null) { var cBaked = SystemAPI.GetComponent(clutter.Prefab); var cProto = SystemAPI.GetComponent(clutter.Prefab); var crng = new Random(RunMapMath.Hash(run.RunSeed, (uint)run.CurrentNodeId, 0xC17u) | 1u); int cCount = math.min(math.max(1, clutter.Count), MaxClutterPerRoom); for (int i = 0; i < cCount; i++) { var e = ecb.Instantiate(clutter.Prefab); float3 pos = RoomLayoutMath.ScatterInShape(plan.ShapeId, origin, i, cCount, ref crng); ecb.SetComponent(e, cBaked.WithPosition(pos)); var bc = cProto; bc.Variant = (byte)(i % 3); ecb.SetComponent(e, bc); ecb.AddComponent(e, new RoomTag { Room = room }); } } rf.LastSpawnedRoomEpoch = run.RoomEpoch; SystemAPI.SetComponent(spawnerEntity, rf); } } else if (info.Lifecycle == RunLifecycle.Staging && !m_RoomTagged.IsEmpty) { // Defensive sweep: no run active but room ghosts linger (abort/disconnect edge) — clear every room. var ents = m_RoomTagged.ToEntityArray(Allocator.Temp); for (int i = 0; i < ents.Length; i++) ecb.DestroyEntity(ents[i]); ents.Dispose(); } ecb.Playback(state.EntityManager); } } }