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 home-base mining-field manager. Keeps the live RegionTag{Base} ResourceNode count topped up to /// so the gather -> build -> survive loop lives AT the base (no /// expedition trip). Unlike (edge-triggered on player presence) this is a /// TICK-CADENCED top-up: every it counts live base nodes /// and instantiates (TargetCount - liveCount) more, scattered UNIFORMLY-IN-RADIUS (rad = inner + r*(outer-inner), /// NOT the area-weighted sqrt that piles nodes on the outer wall) in the [Inner,Outer] annulus around /// BaseGridMath.PlotCenter, each overridden via SetComponent (NOT Add — the prefab already bakes /// RegionTag{Expedition}) to RegionTag{Base} + ResourceId.Ore. The FIRST pass fires immediately /// (NextSpawnTick seeded 0) so the field seeds without waiting. Deterministic: the scatter RNG is seeded from /// a monotonic Epoch (never the tick); the cadence gate is wrap-safe NetworkTick math (TickUtil.NonZero + /// IsNewerThan), never raw uint. Runtime-spawned ghosts dodge the prespawn handshake. Plain server /// SimulationSystemGroup; server-only, never predicted. /// [BurstCompile] [WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)] [UpdateInGroup(typeof(SimulationSystemGroup))] public partial struct BaseFieldSpawnSystem : ISystem { [BurstCompile] public void OnCreate(ref SystemState state) { state.RequireForUpdate(); state.RequireForUpdate(); state.RequireForUpdate(); } [BurstCompile] public void OnUpdate(ref SystemState state) { var serverTick = SystemAPI.GetSingleton().ServerTick; if (!serverTick.IsValid) return; uint now = serverTick.TickIndexForValidTick; var spawnerEntity = SystemAPI.GetSingletonEntity(); var spawner = SystemAPI.GetComponent(spawnerEntity); var runtime = SystemAPI.GetComponent(spawnerEntity); if (spawner.Prefab == Entity.Null) return; // Cadence gate: first pass (NextSpawnTick == 0) fires immediately; thereafter every RespawnIntervalTicks. if (runtime.NextSpawnTick != 0u && new NetworkTick(runtime.NextSpawnTick).IsNewerThan(serverTick)) return; // Count LIVE base-region nodes only (expedition nodes share the ResourceNode type; exclude by region). int liveBase = 0; foreach (var region in SystemAPI.Query>().WithAll()) if (region.ValueRO.Region == RegionId.Base) liveBase++; int deficit = spawner.TargetCount - liveBase; if (deficit > 0) { var anchor = SystemAPI.GetSingleton(); float3 center = BaseGridMath.PlotCenter(anchor); var baseXform = SystemAPI.GetComponent(spawner.Prefab); var prefabNode = SystemAPI.GetComponent(spawner.Prefab); runtime.Epoch += 1; var rng = new Random(((uint)runtime.Epoch * 747796405u) | 1u); // epoch-seeded (never the tick), nonzero float inner = math.max(0f, spawner.InnerRadius); float outer = math.max(inner + 0.01f, spawner.OuterRadius); var ecb = new EntityCommandBuffer(Allocator.Temp); for (int i = 0; i < deficit; i++) { var node = ecb.Instantiate(spawner.Prefab); float ang = rng.NextFloat(0f, math.PI * 2f); float rad = inner + rng.NextFloat(0f, 1f) * (outer - inner); // UNIFORM in radius var xform = baseXform; xform.Position = center + new float3(math.cos(ang) * rad, 0f, math.sin(ang) * rad); ecb.SetComponent(node, xform); // Override the baked RegionTag{Expedition} -> Base (else RegionRelevancy hides it from base // players) and force the resource to Ore (the build currency; base field stays Ore-only). ecb.SetComponent(node, new RegionTag { Region = RegionId.Base }); var rn = prefabNode; rn.ResourceId = ResourceId.Ore; ecb.SetComponent(node, rn); } ecb.Playback(state.EntityManager); ecb.Dispose(); } runtime.NextSpawnTick = TickUtil.NonZero(now + (uint)math.max(1, spawner.RespawnIntervalTicks)); SystemAPI.SetComponent(spawnerEntity, runtime); } } }