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 (14) and /// (2) singletons kept baking, and nothing read them, so the shipping arena /// spawned ZERO clutter and ZERO geysers. Found at the A0 gate — the operator's "the world is very static". /// /// The old system keyed every spawn off room epochs, room plans, biome gates and RoomTag teardown. None /// of that exists now and none of it should come back for a gym: 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 pocket generator owns that in Phase 2. /// /// Contracts kept from the original, because each was load-bearing: /// - baked.WithPosition, never FromPosition — the latter resets Scale, which is a [GhostField]. /// - Geyser NextEruptTick is stamped BORN-CORRECT off the live ServerTick through /// , staggered per instance so eruptions desync. If NetworkTime is not valid /// this tick we ship 0 and let GeyserEruptSystem lazy-stamp — 0 means "not ready", never "fire now". /// - A keep-out ring around the arena origin so nothing spawns on top of the player's landing spot. /// - Clutter variant 3 is the EXPLOSIVE hazard (Exploding_Barrels_Build_Spec); 0-2 are inert dressing. /// /// Colliders: clutter and cover carry colliders, and enemy movement has NO pathfinding (a CollisionWorld /// sphere-cast slide plus a depenetrate/nudge backstop). CLAUDE.md's standing rule is to re-validate that /// movers are not frozen whenever Environment cover is added. The keep-out ring and the modest counts here are /// the mitigation; the validation is a live mover check, not a unit test. /// [WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)] [UpdateInGroup(typeof(SimulationSystemGroup))] public partial struct ArenaFieldSystem : ISystem { /// Nothing spawns inside this radius of the arena origin — the player's landing spot. const float KeepOut = 6f; /// Deterministic base seed. A gym wants the SAME arena every session, not a fresh scatter. const uint ArenaSeed = 0x5EABEDu; public void OnCreate(ref SystemState state) { // Either spawner is enough to have work to do; the arena may legitimately carry only one. state.RequireForUpdate(); } public void OnUpdate(ref SystemState state) { // One-shot for the lifetime of the world. Disabling beats a bookkeeping component: no structural // change, no re-read tick, and it cannot double-seed on a late subscene stream. state.Enabled = false; var ecb = new EntityCommandBuffer(Allocator.Temp); float3 origin = float3.zero; if (SystemAPI.TryGetSingleton(out var anchor)) origin = BaseGridMath.PlotCenter(anchor); int clutter = SeedClutter(ref state, ecb, origin); int geysers = SeedGeysers(ref state, ecb, origin); int cover = SeedCover(ref state, ecb, origin); ecb.Playback(state.EntityManager); ecb.Dispose(); UnityEngine.Debug.Log($"[ArenaFieldSystem] seeded arena: {clutter} clutter, {geysers} geysers, {cover} cover."); } /// Golden-angle spiral scatter jittered per index: even coverage, no clumping, and no rejection /// loop — the old ScatterInShape needed a room plan that no longer exists. static float3 Scatter(float3 origin, float radius, int i, int count, ref Random rng) { float t = count <= 1 ? 0.5f : (i + 0.5f) / count; float r = KeepOut + math.sqrt(t) * math.max(0.01f, radius - KeepOut); float a = i * 2.399963f + rng.NextFloat(-0.35f, 0.35f); // golden angle + jitter r = math.max(KeepOut, r + rng.NextFloat(-0.6f, 0.6f)); return origin + new float3(math.cos(a) * r, 0f, math.sin(a) * r); } int SeedClutter(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, 24); if (count == 0) return 0; var baked = SystemAPI.GetComponent(spawner.Prefab); var proto = SystemAPI.GetComponent(spawner.Prefab); var rng = new Random(ArenaSeed ^ 0xC17u); float radius = spawner.Radius > 0.01f ? spawner.Radius : 14f; for (int i = 0; i < count; i++) { var e = ecb.Instantiate(spawner.Prefab); ecb.SetComponent(e, baked.WithPosition(Scatter(origin, radius, i, count, ref rng))); var bc = proto; // ~25% EXPLOSIVE (variant 3, the hazard); the rest cycle the inert dressing meshes 0-2. bc.Variant = rng.NextFloat() < 0.25f ? (byte)3 : (byte)(i % 3); ecb.SetComponent(e, bc); } return count; } 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++) { var e = ecb.Instantiate(spawner.Prefab); ecb.SetComponent(e, baked.WithPosition(Scatter(origin, 12f, i, count, ref rng))); 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; } int SeedCover(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, 6); if (count == 0) return 0; var baked = SystemAPI.GetComponent(spawner.Prefab); var rng = new Random(ArenaSeed ^ 0xC0Eu); for (int i = 0; i < count; i++) { var e = ecb.Instantiate(spawner.Prefab); ecb.SetComponent(e, baked.WithPosition(Scatter(origin, 13f, i, count, ref rng))); } return count; } } }