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
@@ -0,0 +1,106 @@
using ProjectM.Simulation;
using Unity.Burst;
using Unity.Collections;
using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode;
using Unity.Transforms;
namespace ProjectM.Server
{
/// <summary>
/// Erupts PERMANENT Blight <see cref="Geyser"/>s (Geyser_Build_Spec, design-review wf_900e9965-8f0): when a
/// geyser's replicated <see cref="Geyser.NextEruptTick"/> elapses, radius-damage LIVING enemies AND players
/// (friendly fire — the bait/lure mechanic, exactly like the exploding-barrel hazard), then RESCHEDULE
/// NextEruptTick one period ahead of NOW and leave the geyser alive (it is permanent — <see cref="RoomTag"/>
/// teardown is the only removal; NEVER DestroyEntity here). Player + enemy gather mirrors
/// <see cref="HazardExplosionSystem"/> VERBATIM (Health &gt; 0; players also RegionTag == Expedition;
/// <see cref="DamageEvent.SourceNetworkId"/> = -1 environment convention; SourceTick stamped at NOW, the
/// authoring tick, so dash i-frames negate against it).
/// <para>
/// ★ The invalid-tick guard is INVERTED from the barrel (review HIGH H2): an unstamped/invalid NextEruptTick
/// (0, the "ready" sentinel) is NOT-READY and is SKIPPED — it must NEVER erupt. Copying the barrel's
/// "invalid -&gt; detonate" fall-through onto a born-0 [GhostField] would erupt every tick and wipe the party.
/// The reschedule is <c>= now + period</c> (NEVER <c>+=</c> — an anchored accumulator catch-up-storms after any
/// multi-period skip; every tick-sentinel in the project is <c>= now + delay</c>). Plain server
/// <see cref="SimulationSystemGroup"/>, NO ordering edges; presence-gated on <see cref="Geyser"/>.
/// </para>
/// </summary>
[BurstCompile]
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
[UpdateInGroup(typeof(SimulationSystemGroup))]
public partial struct GeyserEruptSystem : ISystem
{
[BurstCompile]
public void OnCreate(ref SystemState state)
{
state.RequireForUpdate<Geyser>();
}
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
if (!SystemAPI.TryGetSingleton<NetworkTime>(out var netTime) || !netTime.ServerTick.IsValid)
return;
var serverTick = netTime.ServerTick;
uint stamp = TickUtil.NonZero(serverTick.TickIndexForValidTick); // SourceTick = NOW (authoring tick)
uint reschedule = TickUtil.NonZero(serverTick.TickIndexForValidTick + Tuning.GeyserPeriodTicks); // = now + period
var erupts = new NativeList<float3>(Allocator.Temp);
foreach (var (geyser, lt) in SystemAPI.Query<RefRW<Geyser>, RefRO<LocalTransform>>())
{
uint next = geyser.ValueRO.NextEruptTick;
var until = new NetworkTick(next);
// INVERTED guard (review H2): an unstamped/invalid NextEruptTick (0) is NOT-READY and must NEVER
// erupt. LAZY-STAMP it born-correct (now + period) instead — self-heals the rare case where the
// seed-time stamp missed (NetworkTime invalid at seed), so a 0 can neither storm nor stay inert.
if (next == 0u || !until.IsValid) { geyser.ValueRW.NextEruptTick = reschedule; continue; }
if (until.IsNewerThan(serverTick)) continue; // still counting down
erupts.Add(lt.ValueRO.Position);
geyser.ValueRW.NextEruptTick = reschedule; // reschedule in place; permanent, never destroyed
}
if (erupts.Length > 0)
{
float radiusSq = Tuning.GeyserEruptRadius * Tuning.GeyserEruptRadius;
var ecb = new EntityCommandBuffer(Allocator.Temp);
// Players: living + expedition only (the HazardExplosionSystem gather verbatim).
foreach (var (hp, region, plt, player) in
SystemAPI.Query<RefRO<Health>, RefRO<RegionTag>, RefRO<LocalTransform>>()
.WithAll<PlayerTag>().WithEntityAccess())
{
if (hp.ValueRO.Current <= 0f || region.ValueRO.Region != RegionId.Expedition) continue;
for (int i = 0; i < erupts.Length; i++)
if (math.distancesq(plt.ValueRO.Position.xz, erupts[i].xz) <= radiusSq)
ecb.AppendToBuffer(player, new DamageEvent
{
Amount = Tuning.GeyserEruptDamage,
SourceNetworkId = -1,
SourceTick = stamp,
});
}
// Enemies: living only (Health > 0 also excludes Dying corpses, the established convention).
foreach (var (hp, elt, enemy) in
SystemAPI.Query<RefRO<Health>, RefRO<LocalTransform>>()
.WithAll<EnemyTag>().WithEntityAccess())
{
if (hp.ValueRO.Current <= 0f) continue;
for (int i = 0; i < erupts.Length; i++)
if (math.distancesq(elt.ValueRO.Position.xz, erupts[i].xz) <= radiusSq)
ecb.AppendToBuffer(enemy, new DamageEvent
{
Amount = Tuning.GeyserEruptDamage,
SourceNetworkId = -1,
SourceTick = stamp,
});
}
ecb.Playback(state.EntityManager);
ecb.Dispose();
}
erupts.Dispose();
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: d2e98116af8019a488692870c6f09985