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 ,
/// and singletons kept baking and nothing read
/// them, so the shipping arena spawned ZERO props. Found at the A0 gate — "the world is very static".
///
/// 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 Phase-2
/// pocket generator owns that.
///
/// PLACEMENT POLICY (2026-08-07, top-down-arena practice — the props must be usable, not decorative):
/// - : a clear core around the landing spot. Props there cramp the spawn and cause
/// dash collisions the player never asked for.
/// - ..: 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.
/// - 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 so a barrel never chains into
/// the player at spawn.
///
/// Contracts carried from the original, each load-bearing:
/// - baked.WithPosition, never FromPosition — the latter resets Scale, which is a [GhostField].
/// - Geyser NextEruptTick stamped BORN-CORRECT off the live ServerTick via ,
/// staggered per instance so eruptions desync. 0 stays the "unstamped / not ready" sentinel, never "fire now".
/// - A fixed seed: a gym wants the SAME arena every session, not a fresh scatter to relearn.
///
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
[UpdateInGroup(typeof(SimulationSystemGroup))]
public partial struct ArenaFieldSystem : ISystem
{
/// Clear core around the arena origin — the landing spot and dash room.
const float CoreClear = 5f;
/// Solids start here: inside kiting range and inside the travelling light.
const float BandInner = 6f;
/// Solids stop here — beyond this they ring the player in rather than furnish the fight.
const float BandOuter = 13f;
/// Minimum gap between any two solid props. A NAV guarantee: enemies have no pathfinding.
const float MinSpacing = 2.6f;
/// Explosive clutter stays beyond this so a barrel never chains into the player at spawn.
const float ExplosiveInner = 8f;
/// Rejection-sampling attempts before a piece is dropped rather than placed too close.
const int PlaceAttempts = 24;
const uint ArenaSeed = 0x5EABEDu;
public void OnCreate(ref SystemState state)
{
state.RequireForUpdate();
}
public void OnUpdate(ref SystemState state)
{
// One-shot for the world's lifetime. Disabling beats a bookkeeping component: no structural change,
// no re-read tick, and it cannot double-seed if the subscene streams late.
state.Enabled = false;
var ecb = new EntityCommandBuffer(Allocator.Temp);
float3 origin = float3.zero;
if (SystemAPI.TryGetSingleton(out var anchor))
origin = BaseGridMath.PlotCenter(anchor);
// ONE shared occupancy list so clutter and cover cannot overlap each other either.
var taken = new NativeList(Allocator.Temp);
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.Dispose();
UnityEngine.Debug.Log(
$"[ArenaFieldSystem] arena seeded: {clutter} clutter, {cover} cover, {geysers} geysers " +
$"(band {BandInner}-{BandOuter}u, min spacing {MinSpacing}u).");
}
/// Rejection-sampled placement in the fought-in annulus, respecting the shared min-spacing.
/// Returns false when the band is too crowded to place this piece — the caller DROPS it rather than
/// jamming a prop into a gap a mover cannot pass.
static bool TryPlace(float3 origin, float inner, float outer, ref Random rng,
ref NativeList taken, out float3 pos)
{
for (int attempt = 0; attempt < PlaceAttempts; attempt++)
{
// sqrt-distributed radius = uniform area coverage, so pieces don't bunch toward the inner edge
float u = rng.NextFloat();
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, ref NativeList taken)
{
if (!SystemAPI.TryGetSingleton(out var spawner) || spawner.Prefab == Entity.Null)
return 0;
int want = math.clamp(spawner.Count, 0, 24);
if (want == 0) return 0;
var baked = SystemAPI.GetComponent(spawner.Prefab);
var proto = SystemAPI.GetComponent(spawner.Prefab);
var rng = new Random(ArenaSeed ^ 0xC17u);
float outer = math.max(BandInner + 1f, math.min(BandOuter, spawner.Radius > 0.01f ? spawner.Radius : BandOuter));
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);
ecb.SetComponent(e, baked.WithPosition(pos));
var bc = proto;
bool farEnoughForBlast = math.distance(pos.xz, origin.xz) >= ExplosiveInner;
// ~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);
placed++;
}
return placed;
}
int SeedCover(ref SystemState state, EntityCommandBuffer ecb, float3 origin, ref NativeList taken)
{
if (!SystemAPI.TryGetSingleton(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(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)
{
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++)
{
// 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);
ecb.SetComponent(e, baked.WithPosition(pos));
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;
}
}
}