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:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user