cd607ae156
Phase 1.5 environmental hazard v1 (design-review wf_2cb10454-fdf: 13 findings confirmed + folded; Build Spec in the vault). ~25% of room clutter seeds as EXPLOSIVE (Variant 3 on the existing replicated byte - zero new GhostFields/prefabs/RPCs). The FUSE is the review's fold - one mechanism closes both HIGHs: - Pop sites (projectile sweep + isServer-gated melee harvest) do NOT destroy an explosive: they zero the replicated Remaining + add a server-only BarrelFuse (~36 ticks). The client sees Remaining=0 on a still-alive barrel across snapshots -> unambiguous fuse cue, and booms at despawn ONLY when cached Remaining<=0 - a teardown despawn carries Remaining>0, so portal-advance teardowns can never fire false booms (HIGH #1). - HazardExplosionSystem (server, plain group, presence-gated on BarrelFuse, never lifecycle-gated) detonates at ExplodeTick: radius damage to LIVING enemies AND players (boss-slam player filter verbatim; friendly fire = the bait mechanic), SourceTick stamped at detonation = the authoring moment (HIGH #2: the authored-tick contract dash i-frames negate against), SourceNetworkId=-1 (the environment convention - a player id would consume Charger whiff-punish windows). Fuse rides the RoomTag'd barrel -> teardown cleans lit barrels free. - Lit barrels are unhittable at both snapshot sites (no double-pop). - Client: WorldFeedback caches Variant -> boom-vs-puff split (big burst + light flash + boom SFX vs the old puff); DynamicLightSystem gives Variant-3 barrels a red danger glow that STROBES once fused. Verified: 470/470 EditMode (4 new: fused pop instead of destroy + unhittable, both-sides damage w/ -1 source + authored tick, dead-player + radius filters, unelapsed-fuse inert); live smoke - seeded 3/8 explosive, real-sweep pop, CLIENT observed the fuse cue on the live ghost, pinned bait enemy took exactly 26 (30->4), walk-out escape confirmed; console clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
149 lines
8.4 KiB
C#
149 lines
8.4 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 });
|
|
}
|
|
}
|
|
|
|
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();
|
|
}
|
|
}
|
|
}
|