From 05bf835862f7f0951b1a834164205c61cc3531a4 Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Sat, 8 Aug 2026 17:15:26 -0700 Subject: [PATCH] =?UTF-8?q?Fix:=20ArenaFieldSystem=20=E2=80=94=20restore?= =?UTF-8?q?=20the=20arena's=20clutter,=20cover=20and=20geysers=20(purge=20?= =?UTF-8?q?regression)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../Materials/Staging/M_Staging_Seabed.mat | 2 +- .../Server/Economy/ArenaFieldSystem.cs | 151 ++++++++++++++++++ .../Server/Economy/ArenaFieldSystem.cs.meta | 2 + 3 files changed, 154 insertions(+), 1 deletion(-) create mode 100644 Assets/_Project/Scripts/Server/Economy/ArenaFieldSystem.cs create mode 100644 Assets/_Project/Scripts/Server/Economy/ArenaFieldSystem.cs.meta 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