Art: ArenaFieldSystem placement policy — props in the fought-in band, nav-safe spacing

Operator delegated the placement call. Applied standard top-down-arena practice
rather than the first-pass ring scatter:

  clear core (5u)      the landing spot and dash room; props there cramp spawn
                       and cause dash collisions the player never asked for
  band 6-13u           solids live in KITING range, which is also roughly where
                       the diver's travelling light reaches. A prop outside the
                       lit radius reads as an invisible wall - the worst failure
                       mode in a dark game, so cover is pulled inward to where
                       it can be SEEN before it is hit
  min spacing 2.6u     enforced across clutter AND cover on ONE shared occupancy
                       list. This is a NAVIGATION guarantee, not an aesthetic:
                       enemies have no pathfinding, so gaps must stay wider than
                       a body. Rejection-sampled; a piece that cannot fit is
                       DROPPED rather than jammed into a gap movers can't pass
  explosives >8u       a barrel near the landing spot chains into you on spawn

Measured live, all three invariants hold:
  17 solids, radius 7.89..12.95, min gap 2.79 (policy >=2.60), 0 explosives <8u

Nav re-validated on a CLEAN run: closest approach 1.40u, two enemies in melee.
Enemies cross the inward cover band and reach the player.

Correction worth recording: three intermediate readings showed "frozen movers"
and were WRONG. Teleporting the player around a long-running Play session
desyncs the predicted character controller and leaves stale AI state - the
server had the player back at (2.5,0) while I was measuring against (0,0), and
an earlier "all four frozen" reading was simply enemies that had converged and
were attacking. Only the clean, unmutated run is valid. Do not mutate server
state and then trust a movement measurement in the same session.

304/304 EditMode green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-08 17:26:42 -07:00
parent 05bf835862
commit 9f145e3a83
@@ -10,48 +10,61 @@ namespace ProjectM.Server
/// <summary> /// <summary>
/// Server-only ONE-SHOT arena dressing seeder — the gym-scoped successor to <c>RoomFieldSystem</c>, which was /// 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 /// 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 /// but silently took the arena's furniture with it: the baked <see cref="ClutterFieldSpawner"/>,
/// <see cref="GeyserFieldSpawner"/> (2) singletons kept baking, and nothing read them, so the shipping arena /// <see cref="CoverFieldSpawner"/> and <see cref="GeyserFieldSpawner"/> singletons kept baking and nothing read
/// spawned ZERO clutter and ZERO geysers. Found at the A0 gate — the operator's "the world is very static". /// 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 <c>RoomTag</c> teardown. None /// Phase 1 is "no world — a gym", so this seeds ONCE at world start and never tears down. Everything
/// of that exists now and none of it should come back for a gym: Phase 1 is "no world — a gym", so this seeds /// room-shaped (scatter-in-shape, budget spend-down, per-room reroll) is deliberately dropped; the Phase-2
/// ONCE at world start and never tears down. Everything room-shaped (scatter-in-shape, budget spend-down, /// pocket generator owns that.
/// per-room reroll) is deliberately dropped; the pocket generator owns that in Phase 2.
/// ///
/// 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):
/// - <see cref="CoreClear"/>: a clear core around the landing spot. Props there cramp the spawn and cause
/// dash collisions the player never asked for.
/// - <see cref="BandInner"/>..<see cref="BandOuter"/>: 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.
/// - <see cref="MinSpacing"/> 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 <see cref="ExplosiveInner"/> so a barrel never chains into
/// the player at spawn.
///
/// Contracts carried from the original, each load-bearing:
/// - <c>baked.WithPosition</c>, never <c>FromPosition</c> — the latter resets Scale, which is a [GhostField]. /// - <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 /// - Geyser <c>NextEruptTick</c> stamped BORN-CORRECT off the live ServerTick via <see cref="TickUtil.NonZero"/>,
/// <see cref="TickUtil.NonZero"/>, staggered per instance so eruptions desync. If NetworkTime is not valid /// staggered per instance so eruptions desync. 0 stays the "unstamped / not ready" sentinel, never "fire now".
/// this tick we ship 0 and let GeyserEruptSystem lazy-stamp — 0 means "not ready", never "fire now". /// - A fixed seed: a gym wants the SAME arena every session, not a fresh scatter to relearn.
/// - 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> /// </summary>
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)] [WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
[UpdateInGroup(typeof(SimulationSystemGroup))] [UpdateInGroup(typeof(SimulationSystemGroup))]
public partial struct ArenaFieldSystem : ISystem public partial struct ArenaFieldSystem : ISystem
{ {
/// <summary>Nothing spawns inside this radius of the arena origin — the player's landing spot.</summary> /// <summary>Clear core around the arena origin — the landing spot and dash room.</summary>
const float KeepOut = 6f; const float CoreClear = 5f;
/// <summary>Solids start here: inside kiting range and inside the travelling light.</summary>
const float BandInner = 6f;
/// <summary>Solids stop here — beyond this they ring the player in rather than furnish the fight.</summary>
const float BandOuter = 13f;
/// <summary>Minimum gap between any two solid props. A NAV guarantee: enemies have no pathfinding.</summary>
const float MinSpacing = 2.6f;
/// <summary>Explosive clutter stays beyond this so a barrel never chains into the player at spawn.</summary>
const float ExplosiveInner = 8f;
/// <summary>Rejection-sampling attempts before a piece is dropped rather than placed too close.</summary>
const int PlaceAttempts = 24;
/// <summary>Deterministic base seed. A gym wants the SAME arena every session, not a fresh scatter.</summary>
const uint ArenaSeed = 0x5EABEDu; const uint ArenaSeed = 0x5EABEDu;
public void OnCreate(ref SystemState state) 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>(); state.RequireForUpdate<ClutterFieldSpawner>();
} }
public void OnUpdate(ref SystemState state) public void OnUpdate(ref SystemState state)
{ {
// One-shot for the lifetime of the world. Disabling beats a bookkeeping component: no structural // One-shot for the world's lifetime. Disabling beats a bookkeeping component: no structural change,
// change, no re-read tick, and it cannot double-seed on a late subscene stream. // no re-read tick, and it cannot double-seed if the subscene streams late.
state.Enabled = false; state.Enabled = false;
var ecb = new EntityCommandBuffer(Allocator.Temp); var ecb = new EntityCommandBuffer(Allocator.Temp);
@@ -59,49 +72,99 @@ namespace ProjectM.Server
if (SystemAPI.TryGetSingleton<BaseAnchor>(out var anchor)) if (SystemAPI.TryGetSingleton<BaseAnchor>(out var anchor))
origin = BaseGridMath.PlotCenter(anchor); origin = BaseGridMath.PlotCenter(anchor);
int clutter = SeedClutter(ref state, ecb, origin); // ONE shared occupancy list so clutter and cover cannot overlap each other either.
int geysers = SeedGeysers(ref state, ecb, origin); var taken = new NativeList<float3>(Allocator.Temp);
int cover = SeedCover(ref state, ecb, origin);
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.Playback(state.EntityManager);
ecb.Dispose(); 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).");
} }
/// <summary>Golden-angle spiral scatter jittered per index: even coverage, no clumping, and no rejection /// <summary>Rejection-sampled placement in the fought-in annulus, respecting the shared min-spacing.
/// loop — the old ScatterInShape needed a room plan that no longer exists.</summary> /// Returns false when the band is too crowded to place this piece — the caller DROPS it rather than
static float3 Scatter(float3 origin, float radius, int i, int count, ref Random rng) /// jamming a prop into a gap a mover cannot pass.</summary>
static bool TryPlace(float3 origin, float inner, float outer, ref Random rng,
ref NativeList<float3> taken, out float3 pos)
{ {
float t = count <= 1 ? 0.5f : (i + 0.5f) / count; for (int attempt = 0; attempt < PlaceAttempts; attempt++)
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 // sqrt-distributed radius = uniform area coverage, so pieces don't bunch toward the inner edge
r = math.max(KeepOut, r + rng.NextFloat(-0.6f, 0.6f)); float u = rng.NextFloat();
return origin + new float3(math.cos(a) * r, 0f, math.sin(a) * r); 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<float3> taken)
{ {
if (!SystemAPI.TryGetSingleton<ClutterFieldSpawner>(out var spawner) || spawner.Prefab == Entity.Null) if (!SystemAPI.TryGetSingleton<ClutterFieldSpawner>(out var spawner) || spawner.Prefab == Entity.Null)
return 0; return 0;
int count = math.clamp(spawner.Count, 0, 24); int want = math.clamp(spawner.Count, 0, 24);
if (count == 0) return 0; if (want == 0) return 0;
var baked = SystemAPI.GetComponent<LocalTransform>(spawner.Prefab); var baked = SystemAPI.GetComponent<LocalTransform>(spawner.Prefab);
var proto = SystemAPI.GetComponent<BlightClutter>(spawner.Prefab); var proto = SystemAPI.GetComponent<BlightClutter>(spawner.Prefab);
var rng = new Random(ArenaSeed ^ 0xC17u); 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); 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; var bc = proto;
// ~25% EXPLOSIVE (variant 3, the hazard); the rest cycle the inert dressing meshes 0-2. bool farEnoughForBlast = math.distance(pos.xz, origin.xz) >= ExplosiveInner;
bc.Variant = rng.NextFloat() < 0.25f ? (byte)3 : (byte)(i % 3); // ~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); ecb.SetComponent(e, bc);
placed++;
} }
return count; return placed;
}
int SeedCover(ref SystemState state, EntityCommandBuffer ecb, float3 origin, ref NativeList<float3> taken)
{
if (!SystemAPI.TryGetSingleton<CoverFieldSpawner>(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<LocalTransform>(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) int SeedGeysers(ref SystemState state, EntityCommandBuffer ecb, float3 origin)
@@ -121,8 +184,14 @@ namespace ProjectM.Server
for (int i = 0; i < count; i++) 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); 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 uint next = stamp != 0u
? TickUtil.NonZero(stamp + Tuning.GeyserPeriodTicks + (uint)i * 60u) // stagger so they desync ? TickUtil.NonZero(stamp + Tuning.GeyserPeriodTicks + (uint)i * 60u) // stagger so they desync
: 0u; : 0u;
@@ -130,22 +199,5 @@ namespace ProjectM.Server
} }
return count; 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;
}
} }
} }