Fix: ArenaFieldSystem — restore the arena's clutter, cover and geysers (purge regression)

Operator at the A0 gate: "the world is very static." Part of that was a
regression from today's purge, and the audit's own signature pattern for the
third time: the system exists, the authoring is placed, and it silently
produces nothing.

Server/Economy/RoomFieldSystem.cs was deleted with the run/room FSM in
62e48a3b0. That was correct for the FSM, but the system was also the only
consumer of the ClutterFieldSpawner / CoverFieldSpawner / GeyserFieldSpawner
singletons. Those kept baking into Gameplay.unity and nothing read them:

  before:  Geyser 0   BlightClutter 0   (spawners configured for 14 + 2 + 3)
  after:   Geyser 2   BlightClutter 17

So the arena lost 14 destructible clutter pieces, 3 cover pieces and 2
periodically erupting geysers — a large share of everything that moved or
could be interacted with.

The old system could not be revived: it keyed every spawn off RunInfo,
RunRuntime, RoomPlan, RunMapMath, RegionMath and RoomTag teardown, all gone.
ArenaFieldSystem is a gym-scoped replacement that seeds ONCE at world start
and never tears down — Phase 1 is "no world, a gym", and everything
room-shaped belongs to the Phase-2 pocket generator, not here.

Contracts carried over deliberately, each load-bearing:
  - baked.WithPosition, never FromPosition (FromPosition resets Scale, a GhostField)
  - Geyser NextEruptTick born-correct off the live ServerTick via TickUtil.NonZero,
    staggered 60 ticks per instance so eruptions desync; 0 stays "not ready"
  - a 6 u keep-out ring around the arena origin (the player's landing spot)
  - clutter variant 3 = the explosive hazard, 0-2 inert dressing
  - a fixed arena seed: a gym wants the same arena every session

Verified live: geysers stamped 306 / 366 (staggered, Scale 1.00 preserved).

Navigation re-validated per the standing rule that adding Environment cover
can freeze movers, since enemies have NO pathfinding. First measurement showed
all four "frozen" — that was my error: they had already converged and were
attacking. Re-tested by moving the player across the arena: all four closed
23.5->20.4, 24.2->21.2, 23.9->20.9, 22.8->19.7 u through the cover field. No
soft-lock.

304/304 EditMode green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-08 17:15:26 -07:00
parent 501c52d809
commit 05bf835862
3 changed files with 154 additions and 1 deletions
@@ -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
{
/// <summary>
/// Server-only ONE-SHOT arena dressing seeder — the gym-scoped successor to <c>RoomFieldSystem</c>, 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 <see cref="ClutterFieldSpawner"/> (14) and
/// <see cref="GeyserFieldSpawner"/> (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 <c>RoomTag</c> 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:
/// - <c>baked.WithPosition</c>, never <c>FromPosition</c> — the latter resets Scale, which is a [GhostField].
/// - Geyser <c>NextEruptTick</c> is stamped BORN-CORRECT off the live ServerTick through
/// <see cref="TickUtil.NonZero"/>, 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.
/// </summary>
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
[UpdateInGroup(typeof(SimulationSystemGroup))]
public partial struct ArenaFieldSystem : ISystem
{
/// <summary>Nothing spawns inside this radius of the arena origin — the player's landing spot.</summary>
const float KeepOut = 6f;
/// <summary>Deterministic base seed. A gym wants the SAME arena every session, not a fresh scatter.</summary>
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<ClutterFieldSpawner>();
}
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<BaseAnchor>(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.");
}
/// <summary>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.</summary>
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<ClutterFieldSpawner>(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<LocalTransform>(spawner.Prefab);
var proto = SystemAPI.GetComponent<BlightClutter>(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<GeyserFieldSpawner>(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<NetworkTime>(out var nt) && nt.ServerTick.IsValid)
stamp = nt.ServerTick.TickIndexForValidTick;
var baked = SystemAPI.GetComponent<LocalTransform>(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<CoverFieldSpawner>(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<LocalTransform>(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;
}
}
}