using ProjectM.Simulation; using Unity.Collections; using Unity.Entities; using Unity.Mathematics; using Unity.NetCode; using Unity.Transforms; namespace ProjectM.Server { /// /// Server-only ONE-SHOT arena dressing seeder — the gym-scoped successor to RoomFieldSystem, which was /// deleted with the run/room FSM in the 2026-08-07 audit purge (DR-054). That deletion was correct for the FSM /// but silently took the arena's furniture with it: the baked , /// and singletons kept baking and nothing read /// them, so the shipping arena spawned ZERO props. Found at the A0 gate — "the world is very static". /// /// Phase 1 is "no world — a gym", so this seeds ONCE at world start and never tears down. Everything /// room-shaped (scatter-in-shape, budget spend-down, per-room reroll) is deliberately dropped; the Phase-2 /// pocket generator owns that. /// /// PLACEMENT POLICY (2026-08-07, top-down-arena practice — the props must be usable, not decorative): /// - : a clear core around the landing spot. Props there cramp the spawn and cause /// dash collisions the player never asked for. /// - ..: solids live in the FOUGHT-IN band, which is also /// roughly where the diver's travelling light reaches. A prop outside the lit radius reads as an invisible /// wall, which is the worst failure mode in a dark game. /// - is enforced across clutter AND cover on ONE shared occupancy list. This is a /// navigation guarantee, not an aesthetic: enemy movement has NO pathfinding (a CollisionWorld sphere-cast /// slide plus a depenetrate/nudge backstop), so gaps must stay comfortably wider than a body or movers wedge. /// CLAUDE.md's standing rule is to re-validate movers whenever Environment cover is added. /// - Explosive clutter (variant 3) is kept beyond so a barrel never chains into /// the player at spawn. /// /// Contracts carried from the original, each load-bearing: /// - baked.WithPosition, never FromPosition — the latter resets Scale, which is a [GhostField]. /// - Geyser NextEruptTick stamped BORN-CORRECT off the live ServerTick via , /// staggered per instance so eruptions desync. 0 stays the "unstamped / not ready" sentinel, never "fire now". /// - A fixed seed: a gym wants the SAME arena every session, not a fresh scatter to relearn. /// [WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)] [UpdateInGroup(typeof(SimulationSystemGroup))] public partial struct ArenaFieldSystem : ISystem { /// Clear core around the arena origin — the landing spot and dash room. const float CoreClear = 5f; /// Solids start here: inside kiting range and inside the travelling light. const float BandInner = 6f; /// Solids stop here — beyond this they ring the player in rather than furnish the fight. const float BandOuter = 13f; /// Minimum gap between any two solid props. A NAV guarantee: enemies have no pathfinding. const float MinSpacing = 2.6f; /// Explosive clutter stays beyond this so a barrel never chains into the player at spawn. const float ExplosiveInner = 8f; /// Rejection-sampling attempts before a piece is dropped rather than placed too close. const int PlaceAttempts = 24; const uint ArenaSeed = 0x5EABEDu; public void OnCreate(ref SystemState state) { state.RequireForUpdate(); } public void OnUpdate(ref SystemState state) { // One-shot for the world's lifetime. Disabling beats a bookkeeping component: no structural change, // no re-read tick, and it cannot double-seed if the subscene streams late. state.Enabled = false; var ecb = new EntityCommandBuffer(Allocator.Temp); float3 origin = float3.zero; if (SystemAPI.TryGetSingleton(out var anchor)) origin = BaseGridMath.PlotCenter(anchor); // ONE shared occupancy list so clutter and cover cannot overlap each other either. var taken = new NativeList(Allocator.Temp); int clutter = SeedClutter(ref state, ecb, origin, ref taken); int cover = SeedCover(ref state, ecb, origin, ref taken); int geysers = SeedGeysers(ref state, ecb, origin); taken.Dispose(); ecb.Playback(state.EntityManager); ecb.Dispose(); UnityEngine.Debug.Log( $"[ArenaFieldSystem] arena seeded: {clutter} clutter, {cover} cover, {geysers} geysers " + $"(band {BandInner}-{BandOuter}u, min spacing {MinSpacing}u)."); } /// Rejection-sampled placement in the fought-in annulus, respecting the shared min-spacing. /// Returns false when the band is too crowded to place this piece — the caller DROPS it rather than /// jamming a prop into a gap a mover cannot pass. static bool TryPlace(float3 origin, float inner, float outer, ref Random rng, ref NativeList taken, out float3 pos) { for (int attempt = 0; attempt < PlaceAttempts; attempt++) { // sqrt-distributed radius = uniform area coverage, so pieces don't bunch toward the inner edge float u = rng.NextFloat(); float r = math.sqrt(math.lerp(inner * inner, outer * outer, u)); float a = rng.NextFloat(0f, 2f * math.PI); var p = origin + new float3(math.cos(a) * r, 0f, math.sin(a) * r); bool clear = true; for (int i = 0; i < taken.Length; i++) { if (math.distancesq(p.xz, taken[i].xz) < MinSpacing * MinSpacing) { clear = false; break; } } if (!clear) continue; taken.Add(p); pos = p; return true; } pos = default; return false; } int SeedClutter(ref SystemState state, EntityCommandBuffer ecb, float3 origin, ref NativeList taken) { if (!SystemAPI.TryGetSingleton(out var spawner) || spawner.Prefab == Entity.Null) return 0; int want = math.clamp(spawner.Count, 0, 24); if (want == 0) return 0; var baked = SystemAPI.GetComponent(spawner.Prefab); var proto = SystemAPI.GetComponent(spawner.Prefab); var rng = new Random(ArenaSeed ^ 0xC17u); float outer = math.max(BandInner + 1f, math.min(BandOuter, spawner.Radius > 0.01f ? spawner.Radius : BandOuter)); int placed = 0; for (int i = 0; i < want; i++) { if (!TryPlace(origin, BandInner, outer, ref rng, ref taken, out var pos)) continue; var e = ecb.Instantiate(spawner.Prefab); ecb.SetComponent(e, baked.WithPosition(pos)); var bc = proto; bool farEnoughForBlast = math.distance(pos.xz, origin.xz) >= ExplosiveInner; // ~25% EXPLOSIVE (variant 3, the hazard) but never inside the blast keep-out; 0-2 are inert dressing. bc.Variant = (farEnoughForBlast && rng.NextFloat() < 0.25f) ? (byte)3 : (byte)(i % 3); ecb.SetComponent(e, bc); placed++; } return placed; } int SeedCover(ref SystemState state, EntityCommandBuffer ecb, float3 origin, ref NativeList taken) { if (!SystemAPI.TryGetSingleton(out var spawner) || spawner.Prefab == Entity.Null) return 0; int want = math.clamp(spawner.Count, 0, 6); if (want == 0) return 0; var baked = SystemAPI.GetComponent(spawner.Prefab); var rng = new Random(ArenaSeed ^ 0xC0Eu); int placed = 0; for (int i = 0; i < want; i++) { if (!TryPlace(origin, BandInner, BandOuter, ref rng, ref taken, out var pos)) continue; var e = ecb.Instantiate(spawner.Prefab); ecb.SetComponent(e, baked.WithPosition(pos)); placed++; } return placed; } int SeedGeysers(ref SystemState state, EntityCommandBuffer ecb, float3 origin) { if (!SystemAPI.TryGetSingleton(out var spawner) || spawner.Prefab == Entity.Null) return 0; int count = math.clamp(spawner.Count, 0, 4); if (count == 0) return 0; // Born-correct scheduling: read the LIVE tick. 0 stays the "unstamped" sentinel and means NOT ready. uint stamp = 0u; if (SystemAPI.TryGetSingleton(out var nt) && nt.ServerTick.IsValid) stamp = nt.ServerTick.TickIndexForValidTick; var baked = SystemAPI.GetComponent(spawner.Prefab); var rng = new Random(ArenaSeed ^ 0x6E7u); for (int i = 0; i < count; i++) { // Geysers carry no collider, so they are exempt from the spacing list — but they still respect the // clear core: an eruption on the landing spot is an unfair hit the player never saw coming. float r = math.lerp(CoreClear + 2f, BandOuter, (i + 0.5f) / count); float a = i * 2.399963f + rng.NextFloat(-0.4f, 0.4f); // golden angle: spread, not clumped var pos = origin + new float3(math.cos(a) * r, 0f, math.sin(a) * r); var e = ecb.Instantiate(spawner.Prefab); ecb.SetComponent(e, baked.WithPosition(pos)); uint next = stamp != 0u ? TickUtil.NonZero(stamp + Tuning.GeyserPeriodTicks + (uint)i * 60u) // stagger so they desync : 0u; ecb.SetComponent(e, new Geyser { NextEruptTick = next }); } return count; } } }