Hazard: Blight geyser — permanent periodic both-sides AoE (Phase 1.5b bundle 3)

Review-first netcode slice (design review wf_900e9965-8f0 -> 11 folded;
post-impl wf_5c8299f8-299 clean). A PERMANENT periodic telegraphed AoE in
Blight-biome rooms only; one new [GhostField] uint NextEruptTick.

- Geyser component + GeyserEruptSystem (server): inverted invalid-tick guard
  (a baked-0 tick must NEVER erupt -> lazy-stamps born-correct instead of the
  party-wiping per-tick barrage), inline both-sides gather (SourceNetworkId=-1),
  reschedule = now + period (never +=), never destroyed.
- GeyserTelegraphSystem (client): absolute-tick growing warning disc +
  erupt burst on the >0->=<0 crossing, latched per NextEruptTick + arm-guard
  (never edge-detects the replicated field -> no phantom on 0->stamp /
  relevancy re-entry). Reuses ScorchDecalSystem + DynamicLightSystem.
- Seeded in RoomFieldSystem (Blight-gated on plan.Biome, born-correct staggered
  stamp from live ServerTick), GeyserFieldSpawner + authoring wired into the
  Gameplay subscene. Geyser.prefab duplicated from ResourceNode (no collider).
- BuildDisc promoted to FeedbackFx (shared with the barrel fuse ring).
- 474/474 EditMode incl. the unstamped-storm regression; live no-storm end-to-end.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-12 13:53:48 -07:00
parent f271d5f47f
commit 4febf3dfe2
21 changed files with 826 additions and 21 deletions
@@ -3,6 +3,7 @@ using Unity.Burst;
using Unity.Collections;
using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode;
using Unity.Transforms;
namespace ProjectM.Server
@@ -155,6 +156,45 @@ namespace ProjectM.Server
}
}
// BLIGHT GEYSER (OPTIONAL singleton; Geyser_Build_Spec / review wf_900e9965-8f0): a PERMANENT
// periodic BOTH-SIDES AoE hazard, ONLY in Blight-biome rooms (gate on the LOCAL plan, like the
// cover block). Never Boss rooms — a DESIGN choice (the geyser has no collider, so unlike cover it
// is NOT the depenetration concern). Distinct hash sub-stream 0x6E7; keep-out ring around origin.
// BORN-CORRECT: stamp NextEruptTick from the LIVE ServerTick (staggered per instance so eruptions
// desync) so the first snapshot never carries the 0 sentinel; if NetworkTime is invalid this tick
// the geyser ships 0 and GeyserEruptSystem lazy-stamps it born-correct instead (never a storm).
if (plan.Biome == RoomBiomeId.Blight
&& plan.RoomType != RoomTypeId.Boss
&& SystemAPI.TryGetSingleton<GeyserFieldSpawner>(out var geyser)
&& geyser.Prefab != Entity.Null)
{
uint eruptStamp = 0u;
if (SystemAPI.TryGetSingleton<NetworkTime>(out var gnt) && gnt.ServerTick.IsValid)
eruptStamp = gnt.ServerTick.TickIndexForValidTick;
var gBaked = SystemAPI.GetComponent<LocalTransform>(geyser.Prefab);
var grng = new Random(RunMapMath.Hash(run.RunSeed, (uint)run.CurrentNodeId, 0x6E7u) | 1u);
int gCount = math.clamp(geyser.Count, 0, 4);
const float GeyserKeepOut = 7f;
for (int i = 0; i < gCount; i++)
{
float3 pos = origin;
for (int attempt = 0; attempt < 8; attempt++)
{
pos = RoomLayoutMath.ScatterInShape(plan.ShapeId, origin, i, gCount, ref grng);
if (math.distance(pos.xz, origin.xz) >= GeyserKeepOut) break;
}
if (math.distance(pos.xz, origin.xz) < GeyserKeepOut) continue; // unlucky draws: drop the piece
var e = ecb.Instantiate(geyser.Prefab);
ecb.SetComponent(e, gBaked.WithPosition(pos));
// born-correct + per-instance stagger so geysers desync; 0 only if NetworkTime was invalid.
uint next = eruptStamp != 0u
? TickUtil.NonZero(eruptStamp + Tuning.GeyserPeriodTicks + (uint)i * 60u)
: 0u;
ecb.SetComponent(e, new Geyser { NextEruptTick = next });
ecb.AddComponent(e, new RoomTag { Room = room });
}
}
rf.LastSpawnedRoomEpoch = run.RoomEpoch;
SystemAPI.SetComponent(spawnerEntity, rf);
}