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 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 = 8; 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; // ~25% EXPLOSIVE (Variant 3, the hazard — Exploding_Barrels_Build_Spec); rest stay cosmetic 0-2. bc.Variant = crng.NextFloat() < 0.25f ? (byte)3 : (byte)(i % 3); ecb.SetComponent(e, bc); ecb.AddComponent(e, new RoomTag { Room = room }); } } // DESTRUCTIBLE COVER (OPTIONAL singleton; review wf_e14dd739-069): never in Boss rooms (the boss // has no depenetration backstop) and never inside the origin keep-out ring (player landing + // portal). Variant stays the prefab's 4 — NEVER rerolled (the clutter block's explosive // reroll must not leak into this copy). if (plan.RoomType != RoomTypeId.Boss && SystemAPI.TryGetSingleton(out var cover) && cover.Prefab != Entity.Null) { var kBaked = SystemAPI.GetComponent(cover.Prefab); var krng = new Random(RunMapMath.Hash(run.RunSeed, (uint)run.CurrentNodeId, 0xC0Eu) | 1u); int kCount = math.clamp(cover.Count, 0, 6); const float KeepOutFromOrigin = 7f; for (int i = 0; i < kCount; i++) { float3 pos = origin; for (int attempt = 0; attempt < 8; attempt++) { pos = RoomLayoutMath.ScatterInShape(plan.ShapeId, origin, i, kCount, ref krng); if (math.distance(pos.xz, origin.xz) >= KeepOutFromOrigin) break; } if (math.distance(pos.xz, origin.xz) < KeepOutFromOrigin) continue; // unlucky draws: drop the piece var e = ecb.Instantiate(cover.Prefab); ecb.SetComponent(e, kBaked.WithPosition(pos)); ecb.AddComponent(e, new RoomTag { Room = room }); } } // BLIGHT GEYSER (OPTIONAL singleton; Geyser_Build_Spec / review wf_900e9965-8f0): a PERMANENT // periodic BOTH-SIDES AoE hazard, ONLY in Blight-biome rooms (gate on the LOCAL plan, like the // cover block). Never Boss rooms — a DESIGN choice (the geyser has no collider, so unlike cover it // is NOT the depenetration concern). Distinct hash sub-stream 0x6E7; keep-out ring around origin. // BORN-CORRECT: stamp NextEruptTick from the LIVE ServerTick (staggered per instance so eruptions // desync) so the first snapshot never carries the 0 sentinel; if NetworkTime is invalid this tick // the geyser ships 0 and GeyserEruptSystem lazy-stamps it born-correct instead (never a storm). if (plan.Biome == RoomBiomeId.Blight && plan.RoomType != RoomTypeId.Boss && SystemAPI.TryGetSingleton(out var geyser) && geyser.Prefab != Entity.Null) { uint eruptStamp = 0u; if (SystemAPI.TryGetSingleton(out var gnt) && gnt.ServerTick.IsValid) eruptStamp = gnt.ServerTick.TickIndexForValidTick; var gBaked = SystemAPI.GetComponent(geyser.Prefab); var grng = new Random(RunMapMath.Hash(run.RunSeed, (uint)run.CurrentNodeId, 0x6E7u) | 1u); int gCount = math.clamp(geyser.Count, 0, 4); const float GeyserKeepOut = 7f; for (int i = 0; i < gCount; i++) { float3 pos = origin; for (int attempt = 0; attempt < 8; attempt++) { pos = RoomLayoutMath.ScatterInShape(plan.ShapeId, origin, i, gCount, ref grng); if (math.distance(pos.xz, origin.xz) >= GeyserKeepOut) break; } if (math.distance(pos.xz, origin.xz) < GeyserKeepOut) continue; // unlucky draws: drop the piece var e = ecb.Instantiate(geyser.Prefab); ecb.SetComponent(e, gBaked.WithPosition(pos)); // born-correct + per-instance stagger so geysers desync; 0 only if NetworkTime was invalid. uint next = eruptStamp != 0u ? TickUtil.NonZero(eruptStamp + Tuning.GeyserPeriodTicks + (uint)i * 60u) : 0u; ecb.SetComponent(e, new Geyser { NextEruptTick = next }); 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); ecb.Dispose(); } } }