Files
Project-M/Assets/_Project/Scripts/Server/Economy/RoomFieldSystem.cs
T
kronic bbf418e779 Hazard: destructible cover — carve-able rocks that genuinely block until broken
Phase 1.5 long-term #8, the LAST 1.5 item (design review wf_e14dd739-069:
both HIGHs + the distinct MEDs verified before the session limit ate the
duplicate verifiers; unverified leftovers reasoned-closed in the spec).

Cover = a BlightClutter ghost (Variant 4) with a baked Environment-layer
BoxCollider: the runtime ghost's collider joins BuildPhysicsWorld ->
blocks the player CC and the enemy sweep while alive, and the gap opens
the instant the entity dies. Both hit paths already break it (the
clutter chassis); 8 hits to carve.

Review folds:
- HIGH: the 07-07 anti-stuck nudge would phase enemies THROUGH cover
  after ~1.5s (it exists to prevent room soft-locks). The nudge is now
  COVER-AWARE: a SphereCast identifies the blocker; a live BlightClutter
  ghost suppresses the phase (no soft-lock possible - the blocker is
  destructible); static-geometry wedges still nudge.
- HIGH: yield==durability made tanky cover an economy faucet. Deposit
  is DECOUPLED from the decrement at both hit sites: ScrapPerHit=0 =
  breaks in Remaining hits, yields NOTHING; fractional POSITIVE yields
  still credit >=1 (the B1 immortal-sink test pinned this - zero means
  zero, positive floors to 1).
- MEDs: HitRadius 1.2 (visual core, not a shot-eating disc), NO cover
  in Boss rooms (no boss depenetration backstop), scatter keep-out >=7u
  from the room origin (player landing + portal), Variant=4 explicit
  (the clutter block's explosive reroll cannot leak).
- Old static layer removed: 8 WorldColliders_RoomCover colliders
  (subscene) + the RoomCover_Visuals root (Game.unity).

Verified live: 3 cover ghosts spawned with SOLID baked colliders;
BLOCK - NudgeUntilTick stayed 0 across 3.5s of forced zero-progress
(old code arms at 1.5s); DEPENETRATE - an enemy shoved inside a live
rock exited to 1.7u (backstop holds against ghost colliders); CARVE -
the rock died to the real sweep and the spot probed OPEN. 470/470
EditMode; console clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 16:15:38 -07:00

176 lines
10 KiB
C#

using ProjectM.Simulation;
using Unity.Burst;
using Unity.Collections;
using Unity.Entities;
using Unity.Mathematics;
using Unity.Transforms;
namespace ProjectM.Server
{
/// <summary>
/// Server-only per-ROOM field seeder — the Step-5 successor of the presence-keyed <c>ExpeditionFieldSystem</c>.
/// When the run FSM has a room active (<see cref="RunInfo.Lifecycle"/> == InRoom) and
/// <see cref="RunRuntime.RoomEpoch"/> has advanced past the epoch this system last seeded (int equality, never
/// tick math), it resolves the active room's <see cref="RoomPlan"/> from the map node RunDirectorSystem published
/// (<see cref="RunRuntime.CurrentNodeId"/> — the single plan authority; NEVER re-derived here) and scatters
/// <c>plan.NodeCount</c> resource nodes — FLOORED by the run-wide scarcity budget
/// <see cref="RunRuntime.NodeBudgetRemaining"/>, which this system spends down (documented co-write: RunDirector
/// STAGES the budget at launch; this system only decrements it) — plus a light Blight-clutter dressing, all
/// inside the room's shape at <see cref="RegionMath.ExpeditionRoomOrigin"/>(base, ActiveSubSlot). Every spawn is
/// stamped <see cref="RoomTag"/>{room} (the teardown contract) on top of the prefab-baked RegionTag{Expedition};
/// Scale is preserved via <c>baked.WithPosition</c> (never FromPosition).
///
/// Teardown: room-advance/return teardown belongs to RunDirectorSystem (RoomTeardown, Step 7). This system keeps
/// ONE defensive sweep — Staging with any <see cref="RoomTag"/> alive → destroy them all (idempotent; covers
/// abort/disconnect edges). Untagged ghosts (base field, structures) are structurally untouchable.
///
/// Ordering: <c>[UpdateAfter(RunDirectorSystem)]</c> so it reads the freshly-advanced room state same-tick.
/// The old inherited <c>[UpdateAfter(CyclePhaseSystem)]</c> is deliberately DROPPED and NO CyclePhase edge may
/// ever return to the room chain (the Play-only sort-cycle rule — invisible to EditMode).
/// </summary>
[BurstCompile]
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
[UpdateInGroup(typeof(SimulationSystemGroup))]
[UpdateAfter(typeof(RunDirectorSystem))]
public partial struct RoomFieldSystem : ISystem
{
/// <summary>Max clutter pieces per room — cosmetic ghosts still cost relevancy, keep the dressing light.</summary>
const int MaxClutterPerRoom = 8;
EntityQuery m_RoomTagged;
[BurstCompile]
public void OnCreate(ref SystemState state)
{
state.RequireForUpdate<ResourceFieldSpawner>();
state.RequireForUpdate<RunInfo>();
state.RequireForUpdate<RunRuntime>();
m_RoomTagged = state.GetEntityQuery(ComponentType.ReadOnly<RoomTag>());
}
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
var dirEntity = SystemAPI.GetSingletonEntity<RunInfo>();
var info = SystemAPI.GetComponent<RunInfo>(dirEntity);
var run = SystemAPI.GetComponent<RunRuntime>(dirEntity);
var spawnerEntity = SystemAPI.GetSingletonEntity<ResourceFieldSpawner>();
var spawner = SystemAPI.GetComponent<ResourceFieldSpawner>(spawnerEntity);
// One-shot: attach this system's server-only bookkeeping beside the baked spawner singleton.
if (!SystemAPI.HasComponent<RoomFieldState>(spawnerEntity))
{
state.EntityManager.AddComponentData(spawnerEntity, new RoomFieldState());
return; // structural change — clean re-read next tick
}
var rf = SystemAPI.GetComponent<RoomFieldState>(spawnerEntity);
var ecb = new EntityCommandBuffer(Allocator.Temp);
if (info.Lifecycle == RunLifecycle.InRoom)
{
if (rf.LastSpawnedRoomEpoch != run.RoomEpoch && spawner.Prefab != Entity.Null)
{
float3 baseCenter = new float3(0f, 1f, 0f);
if (SystemAPI.TryGetSingleton<BaseAnchor>(out var anchor))
baseCenter = BaseGridMath.PlotCenter(anchor);
float3 origin = RegionMath.ExpeditionRoomOrigin(baseCenter, run.ActiveSubSlot);
// Single plan authority: the node RunDirector published — never re-derived from the col/path.
var map = RunMapMath.Generate(run.RunSeed);
var node = map.NodeAt(run.CurrentNodeId);
var plan = RoomLayoutMath.Plan(node, info.CurrentRoom, info.RoomCount);
byte room = (byte)(info.CurrentRoom & 0xFF);
// Scarcity: the run-wide budget floors this room's count and is spent down (never negative).
int count = math.min(plan.NodeCount, math.max(0, run.NodeBudgetRemaining));
if (count > 0)
{
var baked = SystemAPI.GetComponent<LocalTransform>(spawner.Prefab);
var prefabNode = SystemAPI.GetComponent<ResourceNode>(spawner.Prefab);
var rng = new Random(RunMapMath.Hash(run.RunSeed, (uint)run.CurrentNodeId, 0x0DEu) | 1u);
for (int i = 0; i < count; i++)
{
var e = ecb.Instantiate(spawner.Prefab);
float3 pos = RoomLayoutMath.ScatterInShape(plan.ShapeId, origin, i, count, ref rng);
ecb.SetComponent(e, baked.WithPosition(pos));
// Rarity-weighted resource type (Step 11): Ore 45% (building) / Biomass 40% (walls,
// fabricator) / AETHER 15% — the scarce permanent-meta currency, felt when it drops.
var rn = prefabNode;
int roll = rng.NextInt(0, 100);
rn.ResourceId = roll < 15 ? ResourceId.Aether : roll < 60 ? ResourceId.Ore : ResourceId.Biomass;
ecb.SetComponent(e, rn);
ecb.AddComponent(e, new RoomTag { Room = room });
}
run.NodeBudgetRemaining -= count;
SystemAPI.SetComponent(dirEntity, run); // the documented budget co-write (spend only)
}
// Clutter dressing (OPTIONAL singleton) — a DISTINCT seed so it never co-locates with nodes.
if (SystemAPI.TryGetSingleton<ClutterFieldSpawner>(out var clutter)
&& clutter.Prefab != Entity.Null)
{
var cBaked = SystemAPI.GetComponent<LocalTransform>(clutter.Prefab);
var cProto = SystemAPI.GetComponent<BlightClutter>(clutter.Prefab);
var crng = new Random(RunMapMath.Hash(run.RunSeed, (uint)run.CurrentNodeId, 0xC17u) | 1u);
int cCount = math.min(math.max(1, clutter.Count), MaxClutterPerRoom);
for (int i = 0; i < cCount; i++)
{
var e = ecb.Instantiate(clutter.Prefab);
float3 pos = RoomLayoutMath.ScatterInShape(plan.ShapeId, origin, i, cCount, ref crng);
ecb.SetComponent(e, cBaked.WithPosition(pos));
var bc = cProto;
// ~25% EXPLOSIVE (Variant 3, the hazard — Exploding_Barrels_Build_Spec); rest stay cosmetic 0-2.
bc.Variant = crng.NextFloat() < 0.25f ? (byte)3 : (byte)(i % 3);
ecb.SetComponent(e, bc);
ecb.AddComponent(e, new RoomTag { Room = room });
}
}
// DESTRUCTIBLE COVER (OPTIONAL singleton; review wf_e14dd739-069): never in Boss rooms (the boss
// has no depenetration backstop) and never inside the origin keep-out ring (player landing +
// portal). Variant stays the prefab's 4 — NEVER rerolled (the clutter block's explosive
// reroll must not leak into this copy).
if (plan.RoomType != RoomTypeId.Boss
&& SystemAPI.TryGetSingleton<CoverFieldSpawner>(out var cover)
&& cover.Prefab != Entity.Null)
{
var kBaked = SystemAPI.GetComponent<LocalTransform>(cover.Prefab);
var krng = new Random(RunMapMath.Hash(run.RunSeed, (uint)run.CurrentNodeId, 0xC0Eu) | 1u);
int kCount = math.clamp(cover.Count, 0, 6);
const float KeepOutFromOrigin = 7f;
for (int i = 0; i < kCount; i++)
{
float3 pos = origin;
for (int attempt = 0; attempt < 8; attempt++)
{
pos = RoomLayoutMath.ScatterInShape(plan.ShapeId, origin, i, kCount, ref krng);
if (math.distance(pos.xz, origin.xz) >= KeepOutFromOrigin) break;
}
if (math.distance(pos.xz, origin.xz) < KeepOutFromOrigin) continue; // unlucky draws: drop the piece
var e = ecb.Instantiate(cover.Prefab);
ecb.SetComponent(e, kBaked.WithPosition(pos));
ecb.AddComponent(e, new RoomTag { Room = room });
}
}
rf.LastSpawnedRoomEpoch = run.RoomEpoch;
SystemAPI.SetComponent(spawnerEntity, rf);
}
}
else if (info.Lifecycle == RunLifecycle.Staging && !m_RoomTagged.IsEmpty)
{
// Defensive sweep: no run active but room ghosts linger (abort/disconnect edge) — clear every room.
var ents = m_RoomTagged.ToEntityArray(Allocator.Temp);
for (int i = 0; i < ents.Length; i++)
ecb.DestroyEntity(ents[i]);
ents.Dispose();
}
ecb.Playback(state.EntityManager);
ecb.Dispose();
}
}
}