diff --git a/Assets/_Project/Art/Materials/Staging/M_Staging_Seabed.mat b/Assets/_Project/Art/Materials/Staging/M_Staging_Seabed.mat
index 76b4f3920..d6744a331 100644
--- a/Assets/_Project/Art/Materials/Staging/M_Staging_Seabed.mat
+++ b/Assets/_Project/Art/Materials/Staging/M_Staging_Seabed.mat
@@ -130,7 +130,7 @@ Material:
- _ZWrite: 1
m_Colors:
- _BaseColor: {r: 0.18, g: 0.26, b: 0.3, a: 1}
- - _Color: {r: 0.079999976, g: 0.11999995, b: 0.14999998, a: 1}
+ - _Color: {r: 0.17999998, g: 0.25999996, b: 0.29999998, a: 1}
- _EmissionColor: {r: 0, g: 0, b: 0, a: 1}
- _SpecColor: {r: 0.19999996, g: 0.19999996, b: 0.19999996, a: 1}
m_BuildTextureStacks: []
diff --git a/Assets/_Project/Scripts/Server/Economy/ArenaFieldSystem.cs b/Assets/_Project/Scripts/Server/Economy/ArenaFieldSystem.cs
new file mode 100644
index 000000000..39fd41ddc
--- /dev/null
+++ b/Assets/_Project/Scripts/Server/Economy/ArenaFieldSystem.cs
@@ -0,0 +1,151 @@
+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;
+ }
+ }
+}
diff --git a/Assets/_Project/Scripts/Server/Economy/ArenaFieldSystem.cs.meta b/Assets/_Project/Scripts/Server/Economy/ArenaFieldSystem.cs.meta
new file mode 100644
index 000000000..0570080e6
--- /dev/null
+++ b/Assets/_Project/Scripts/Server/Economy/ArenaFieldSystem.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 108ebb041943cf445a3104347f1af5b6
\ No newline at end of file