using ProjectM.Simulation; using Unity.Burst; using Unity.Collections; using Unity.Entities; using Unity.Mathematics; using Unity.NetCode; using Unity.Transforms; namespace ProjectM.Server { /// /// Erupts PERMANENT Blight s (Geyser_Build_Spec, design-review wf_900e9965-8f0): when a /// geyser's replicated 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 — /// teardown is the only removal; NEVER DestroyEntity here). Player + enemy gather mirrors /// VERBATIM (Health > 0; players also RegionTag == Expedition; /// = -1 environment convention; SourceTick stamped at NOW, the /// authoring tick, so dash i-frames negate against it). /// /// ★ 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 -> detonate" fall-through onto a born-0 [GhostField] would erupt every tick and wipe the party. /// The reschedule is = now + period (NEVER += — an anchored accumulator catch-up-storms after any /// multi-period skip; every tick-sentinel in the project is = now + delay). Plain server /// , NO ordering edges; presence-gated on . /// /// [BurstCompile] [WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)] [UpdateInGroup(typeof(SimulationSystemGroup))] public partial struct GeyserEruptSystem : ISystem { [BurstCompile] public void OnCreate(ref SystemState state) { state.RequireForUpdate(); } [BurstCompile] public void OnUpdate(ref SystemState state) { if (!SystemAPI.TryGetSingleton(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(Allocator.Temp); foreach (var (geyser, lt) in SystemAPI.Query, RefRO>()) { 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, RefRO>() .WithAll().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>() .WithAll().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(); } } }