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;
- }
}
}