diff --git a/Assets/_Project/Scripts/Server/Economy/ArenaFieldSystem.cs b/Assets/_Project/Scripts/Server/Economy/ArenaFieldSystem.cs index 39fd41ddc..5d3b574f5 100644 --- a/Assets/_Project/Scripts/Server/Economy/ArenaFieldSystem.cs +++ b/Assets/_Project/Scripts/Server/Economy/ArenaFieldSystem.cs @@ -10,48 +10,61 @@ 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". + /// 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". /// - /// 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. + /// 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. /// - /// Contracts kept from the original, because each was load-bearing: + /// 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 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. + /// - 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 { - /// Nothing spawns inside this radius of the arena origin — the player's landing spot. - const float KeepOut = 6f; + /// 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; - /// 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. + // 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); @@ -59,49 +72,99 @@ namespace ProjectM.Server 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); + // 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] seeded arena: {clutter} clutter, {geysers} geysers, {cover} cover."); + UnityEngine.Debug.Log( + $"[ArenaFieldSystem] arena seeded: {clutter} clutter, {cover} cover, {geysers} geysers " + + $"(band {BandInner}-{BandOuter}u, min spacing {MinSpacing}u)."); } - /// 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) + /// 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) { - 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); + 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) + 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 count = math.clamp(spawner.Count, 0, 24); - if (count == 0) 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 radius = spawner.Radius > 0.01f ? spawner.Radius : 14f; + float outer = math.max(BandInner + 1f, math.min(BandOuter, spawner.Radius > 0.01f ? spawner.Radius : BandOuter)); - for (int i = 0; i < count; i++) + 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(Scatter(origin, radius, i, count, ref rng))); + ecb.SetComponent(e, baked.WithPosition(pos)); + 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); + 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 count; + 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) @@ -121,8 +184,14 @@ namespace ProjectM.Server 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(Scatter(origin, 12f, i, count, ref rng))); + ecb.SetComponent(e, baked.WithPosition(pos)); uint next = stamp != 0u ? TickUtil.NonZero(stamp + Tuning.GeyserPeriodTicks + (uint)i * 60u) // stagger so they desync : 0u; @@ -130,22 +199,5 @@ namespace ProjectM.Server } 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; - } } }