diff --git a/Assets/_Project/Prefabs/Geyser.prefab b/Assets/_Project/Prefabs/Geyser.prefab
new file mode 100644
index 000000000..529439b9f
--- /dev/null
+++ b/Assets/_Project/Prefabs/Geyser.prefab
@@ -0,0 +1,177 @@
+%YAML 1.1
+%TAG !u! tag:unity3d.com,2011:
+--- !u!1 &2427603878820464664
+GameObject:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ serializedVersion: 6
+ m_Component:
+ - component: {fileID: 8054832990839364730}
+ - component: {fileID: 7899507094755352317}
+ - component: {fileID: 4642700531704847346}
+ m_Layer: 0
+ m_Name: Model
+ m_TagString: Untagged
+ m_Icon: {fileID: 0}
+ m_NavMeshLayer: 0
+ m_StaticEditorFlags: 0
+ m_IsActive: 1
+--- !u!4 &8054832990839364730
+Transform:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 2427603878820464664}
+ serializedVersion: 2
+ m_LocalRotation: {x: 0, y: -0, z: -0, w: 1}
+ m_LocalPosition: {x: 0, y: 0, z: 0}
+ m_LocalScale: {x: 2.2, y: 2.2, z: 2.2}
+ m_ConstrainProportionsScale: 0
+ m_Children: []
+ m_Father: {fileID: 3572766465862231365}
+ m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
+--- !u!33 &7899507094755352317
+MeshFilter:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 2427603878820464664}
+ m_Mesh: {fileID: 4300000, guid: a52e2d101bf32694d9012060774e97fd, type: 3}
+--- !u!23 &4642700531704847346
+MeshRenderer:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 2427603878820464664}
+ m_Enabled: 1
+ m_CastShadows: 1
+ m_ReceiveShadows: 1
+ m_DynamicOccludee: 1
+ m_StaticShadowCaster: 0
+ m_MotionVectors: 1
+ m_LightProbeUsage: 1
+ m_ReflectionProbeUsage: 1
+ m_RayTracingMode: 2
+ m_RayTraceProcedural: 0
+ m_RayTracingAccelStructBuildFlagsOverride: 0
+ m_RayTracingAccelStructBuildFlags: 1
+ m_SmallMeshCulling: 1
+ m_ForceMeshLod: -1
+ m_MeshLodSelectionBias: 0
+ m_RenderingLayerMask: 1
+ m_RendererPriority: 0
+ m_Materials:
+ - {fileID: 2100000, guid: 1da6f4366b5f0ed40b87287df95b5a81, type: 2}
+ m_StaticBatchInfo:
+ firstSubMesh: 0
+ subMeshCount: 0
+ m_StaticBatchRoot: {fileID: 0}
+ m_ProbeAnchor: {fileID: 0}
+ m_LightProbeVolumeOverride: {fileID: 0}
+ m_ScaleInLightmap: 1
+ m_ReceiveGI: 1
+ m_PreserveUVs: 0
+ m_IgnoreNormalsForChartDetection: 0
+ m_ImportantGI: 0
+ m_StitchLightmapSeams: 1
+ m_SelectedEditorRenderState: 3
+ m_MinimumChartSize: 4
+ m_AutoUVMaxDistance: 0.5
+ m_AutoUVMaxAngle: 89
+ m_LightmapParameters: {fileID: 0}
+ m_GlobalIlluminationMeshLod: 0
+ m_SortingLayerID: 0
+ m_SortingLayer: 0
+ m_SortingOrder: 0
+ m_MaskInteraction: 0
+ m_AdditionalVertexStreams: {fileID: 0}
+--- !u!1 &3885353946372160549
+GameObject:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ serializedVersion: 6
+ m_Component:
+ - component: {fileID: 3572766465862231365}
+ - component: {fileID: 9053853372340598254}
+ - component: {fileID: 6834786618115927220}
+ - component: {fileID: 5589603174154228860}
+ m_Layer: 0
+ m_Name: Geyser
+ m_TagString: Untagged
+ m_Icon: {fileID: 0}
+ m_NavMeshLayer: 0
+ m_StaticEditorFlags: 0
+ m_IsActive: 1
+--- !u!4 &3572766465862231365
+Transform:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 3885353946372160549}
+ serializedVersion: 2
+ m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
+ m_LocalPosition: {x: 0, y: 0, z: 0}
+ m_LocalScale: {x: 1, y: 1, z: 1}
+ m_ConstrainProportionsScale: 0
+ m_Children:
+ - {fileID: 8054832990839364730}
+ m_Father: {fileID: 0}
+ m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
+--- !u!114 &9053853372340598254
+MonoBehaviour:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 3885353946372160549}
+ m_Enabled: 1
+ m_EditorHideFlags: 0
+ m_Script: {fileID: 11500000, guid: c16549610bfe4458aa9389201d072bb6, type: 3}
+ m_Name:
+ m_EditorClassIdentifier: Unity.Entities.Hybrid::Unity.Entities.Hybrid.Baking.LinkedEntityGroupAuthoring
+--- !u!114 &6834786618115927220
+MonoBehaviour:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 3885353946372160549}
+ m_Enabled: 1
+ m_EditorHideFlags: 0
+ m_Script: {fileID: 11500000, guid: 7c79d771cedb4794bf100ce60df5f764, type: 3}
+ m_Name:
+ m_EditorClassIdentifier: Unity.NetCode.Authoring.Hybrid::Unity.NetCode.GhostAuthoringComponent
+ HasOwner: 0
+ SupportAutoCommandTarget: 1
+ TrackInterpolationDelay: 0
+ GhostGroup: 0
+ UsePreSerialization: 0
+ UseSingleBaseline: 0
+ RollbackPredictedSpawnedGhostState: 0
+ RollbackPredictionOnStructuralChanges: 1
+ DefaultGhostMode: 0
+ SupportedGhostModes: 3
+ OptimizationMode: 0
+ Importance: 1
+ MaxSendRate: 0
+ prefabId: 8565e5eb00679fb45b8b7dac1e2ae9f3
+--- !u!114 &5589603174154228860
+MonoBehaviour:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 3885353946372160549}
+ m_Enabled: 1
+ m_EditorHideFlags: 0
+ m_Script: {fileID: 11500000, guid: e45c85bf47692824eacc671035712f38, type: 3}
+ m_Name:
+ m_EditorClassIdentifier: ProjectM.Authoring::ProjectM.Authoring.GeyserAuthoring
diff --git a/Assets/_Project/Prefabs/Geyser.prefab.meta b/Assets/_Project/Prefabs/Geyser.prefab.meta
new file mode 100644
index 000000000..0efbe488a
--- /dev/null
+++ b/Assets/_Project/Prefabs/Geyser.prefab.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: 8425e38ad7271e44693e65e8d574a993
+PrefabImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/_Project/Scripts/Authoring/Combat/GeyserAuthoring.cs b/Assets/_Project/Scripts/Authoring/Combat/GeyserAuthoring.cs
new file mode 100644
index 000000000..02b2d362f
--- /dev/null
+++ b/Assets/_Project/Scripts/Authoring/Combat/GeyserAuthoring.cs
@@ -0,0 +1,27 @@
+using ProjectM.Simulation;
+using Unity.Entities;
+using UnityEngine;
+
+namespace ProjectM.Authoring
+{
+ ///
+ /// Authoring for a Blight-geyser ghost prefab (ownerless interpolated — duplicate from ResourceNode.prefab so the
+ /// GhostAuthoringComponent + LinkedEntityGroup come free; keep a cosmetic vent mesh, NO physics collider — the
+ /// hazard is the AoE, you walk over it). Bakes (NextEruptTick left 0 — the server stamps it
+ /// born-correct from the live ServerTick at spawn in RoomFieldSystem; a 0 must NEVER erupt) +
+ /// {Expedition} so GhostRelevancy scopes it to expedition players. The field spawner
+ /// overrides Position + the born-correct NextEruptTick per instance.
+ ///
+ public class GeyserAuthoring : MonoBehaviour
+ {
+ private class GeyserBaker : Baker
+ {
+ public override void Bake(GeyserAuthoring authoring)
+ {
+ var entity = GetEntity(authoring, TransformUsageFlags.Dynamic);
+ AddComponent(entity, new Geyser { NextEruptTick = 0u }); // 0 = unstamped; server stamps born-correct at spawn
+ AddComponent(entity, new RegionTag { Region = RegionId.Expedition });
+ }
+ }
+ }
+}
diff --git a/Assets/_Project/Scripts/Authoring/Combat/GeyserAuthoring.cs.meta b/Assets/_Project/Scripts/Authoring/Combat/GeyserAuthoring.cs.meta
new file mode 100644
index 000000000..5ca7c014c
--- /dev/null
+++ b/Assets/_Project/Scripts/Authoring/Combat/GeyserAuthoring.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: e45c85bf47692824eacc671035712f38
\ No newline at end of file
diff --git a/Assets/_Project/Scripts/Authoring/Economy/GeyserFieldSpawnerAuthoring.cs b/Assets/_Project/Scripts/Authoring/Economy/GeyserFieldSpawnerAuthoring.cs
new file mode 100644
index 000000000..409853a1c
--- /dev/null
+++ b/Assets/_Project/Scripts/Authoring/Economy/GeyserFieldSpawnerAuthoring.cs
@@ -0,0 +1,35 @@
+using ProjectM.Simulation;
+using Unity.Entities;
+using UnityEngine;
+
+namespace ProjectM.Authoring
+{
+ ///
+ /// Authoring for the baked singleton (mirrors ClutterFieldSpawnerAuthoring /
+ /// CoverFieldSpawnerAuthoring). Place once in the gameplay subscene and assign the geyser ghost prefab;
+ /// RoomFieldSystem scatters it in Blight-biome rooms (never Boss). Carries no transform.
+ ///
+ public class GeyserFieldSpawnerAuthoring : MonoBehaviour
+ {
+ [Tooltip("Geyser ghost prefab. Must carry GeyserAuthoring + a GhostAuthoringComponent (ownerless, interpolated).")]
+ public GameObject GeyserPrefab;
+
+ [Tooltip("Number of geysers per Blight-biome room (Boss rooms get none).")]
+ [Min(0)] public int Count = 2;
+
+ private class GeyserFieldSpawnerBaker : Baker
+ {
+ public override void Bake(GeyserFieldSpawnerAuthoring authoring)
+ {
+ var entity = GetEntity(authoring, TransformUsageFlags.None);
+ AddComponent(entity, new GeyserFieldSpawner
+ {
+ Prefab = authoring.GeyserPrefab != null
+ ? GetEntity(authoring.GeyserPrefab, TransformUsageFlags.Dynamic)
+ : Entity.Null,
+ Count = authoring.Count,
+ });
+ }
+ }
+ }
+}
diff --git a/Assets/_Project/Scripts/Authoring/Economy/GeyserFieldSpawnerAuthoring.cs.meta b/Assets/_Project/Scripts/Authoring/Economy/GeyserFieldSpawnerAuthoring.cs.meta
new file mode 100644
index 000000000..cee5e5177
--- /dev/null
+++ b/Assets/_Project/Scripts/Authoring/Economy/GeyserFieldSpawnerAuthoring.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: e5d1544d6afe18245b563de599240493
\ No newline at end of file
diff --git a/Assets/_Project/Scripts/Client/Presentation/FeedbackFx.cs b/Assets/_Project/Scripts/Client/Presentation/FeedbackFx.cs
index 8ea5dc44e..74c7373a0 100644
--- a/Assets/_Project/Scripts/Client/Presentation/FeedbackFx.cs
+++ b/Assets/_Project/Scripts/Client/Presentation/FeedbackFx.cs
@@ -180,5 +180,29 @@ namespace ProjectM.Client
m.RecalculateBounds();
return m;
}
+
+ // Unit-radius upward-facing disc fan on the XZ plane (centre vertex + rim) for ground telegraph / blast-radius
+ // rings — scale x/z to the radius. No vertex colours (white default) so the material or a per-renderer _Color
+ // MPB fully tints + fades it. Promoted from WorldFeedbackSystem so the barrel fuse ring + the geyser telegraph
+ // share ONE primitive (review M6).
+ public static Mesh BuildDisc(int segments)
+ {
+ if (segments < 6) segments = 6;
+ var m = new Mesh { name = "Disc" };
+ var v = new Vector3[segments + 1];
+ var tris = new int[segments * 3];
+ v[0] = Vector3.zero;
+ for (int i = 0; i < segments; i++)
+ {
+ float a = i / (float)segments * Mathf.PI * 2f;
+ v[i + 1] = new Vector3(Mathf.Cos(a), 0f, Mathf.Sin(a));
+ int n = (i + 1) % segments;
+ tris[i * 3] = 0; tris[i * 3 + 1] = n + 1; tris[i * 3 + 2] = i + 1;
+ }
+ m.vertices = v;
+ m.triangles = tris;
+ m.RecalculateBounds();
+ return m;
+ }
}
}
diff --git a/Assets/_Project/Scripts/Client/Presentation/GeyserTelegraphSystem.cs b/Assets/_Project/Scripts/Client/Presentation/GeyserTelegraphSystem.cs
new file mode 100644
index 000000000..909bc42f7
--- /dev/null
+++ b/Assets/_Project/Scripts/Client/Presentation/GeyserTelegraphSystem.cs
@@ -0,0 +1,173 @@
+using System.Collections.Generic;
+using ProjectM.Simulation;
+using Unity.Entities;
+using Unity.Mathematics;
+using Unity.NetCode;
+using Unity.Transforms;
+using UnityEngine;
+using static ProjectM.Client.FeedbackFx;
+
+namespace ProjectM.Client
+{
+ ///
+ /// Client-only BLIGHT-GEYSER telegraph + erupt VFX (Phase 1.5b bundle 3; Geyser_Build_Spec). Observe-only
+ /// in — sibling of
+ /// , never mutates the sim. Reads each geyser's replicated
+ /// against the client's PREDICTED NetworkTime.ServerTick and derives
+ /// everything from that ABSOLUTE tick (never an edge on the field — so it survives snapshot loss + relevancy
+ /// re-entry; the design-review-hardened contract, review wf_900e9965-8f0 H1):
+ ///
+ /// - a growing/pulsing warning DISC at over the last
+ /// before the eruption (the absolute-tick countdown, exactly the
+ /// enemy-telegraph idiom);
+ /// - the erupt BURST fired when the countdown CROSSES >0 → <=0, LATCHED once per
+ /// NextEruptTick value + an ARM-guard (only if a prior frame saw it counting down) — immune to the
+ /// 0→stamp phantom, relevancy re-entry mid-period, and snapshot loss.
+ ///
+ /// Erupt VFX reuse the shipped statics — burst +
+ /// + (bundle 2, built for this). Pooled disc GameObjects under a
+ /// private root, pruned every frame; a pruned geyser (teardown / relevancy drop) is SILENT.
+ ///
+ [WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
+ [UpdateInGroup(typeof(PresentationSystemGroup))]
+ public partial class GeyserTelegraphSystem : SystemBase
+ {
+ static readonly Color DiscColor = new Color(2.6f, 0.35f, 2.4f); // hot blight magenta-purple (danger + biome id)
+ static readonly Color EruptColor = new Color(2.9f, 0.5f, 3.2f); // brighter burst
+ static readonly int ColorId = Shader.PropertyToID("_Color");
+
+ Transform _fxRoot;
+ Material _discMat;
+ Material _burstMat;
+ Mesh _discMesh;
+ ParticleSystem _eruptFx;
+ AudioClip _eruptClip;
+ MaterialPropertyBlock _mpb;
+
+ readonly Dictionary _discs = new();
+ readonly Dictionary _armed = new(); // NextEruptTick we've seen counting down for
+ readonly Dictionary _lastFired = new(); // NextEruptTick we last fired the burst for (latch)
+ readonly HashSet _seen = new();
+ readonly List _stale = new();
+
+ protected override void OnCreate()
+ {
+ _eruptClip = MakeClip("geyser_erupt", 90f, 300f, 0.30f, 0.55f, noise: true, decay: 5f);
+ _mpb = new MaterialPropertyBlock();
+ }
+
+ protected override void OnStartRunning()
+ {
+ if (_fxRoot != null) return;
+ _fxRoot = new GameObject("~GeyserTelegraphFX").transform;
+ _discMat = MakeParticleMaterial("GeyserDisc");
+ _discMat.color = DiscColor;
+ _discMesh = BuildDisc(40);
+ _burstMat = MakeParticleMaterial("GeyserErupt");
+ _eruptFx = MakeBurst(_fxRoot, "GeyserErupt", _burstMat, EruptColor, 0.18f, 8f, 0.5f, 256, -0.15f, 0.25f, 0.25f);
+ }
+
+ protected override void OnDestroy()
+ {
+ if (_fxRoot != null) Object.Destroy(_fxRoot.gameObject);
+ if (_discMat != null) Object.Destroy(_discMat);
+ if (_burstMat != null) Object.Destroy(_burstMat);
+ if (_discMesh != null) Object.Destroy(_discMesh);
+ }
+
+ protected override void OnUpdate()
+ {
+ if (_fxRoot == null || _discMat == null) return;
+ if (!SystemAPI.TryGetSingleton(out var nt) || !nt.ServerTick.IsValid) return;
+ var serverTick = nt.ServerTick;
+
+ EntityManager.CompleteDependencyBeforeRO();
+ EntityManager.CompleteDependencyBeforeRO();
+
+ _seen.Clear();
+ foreach (var (geyser, xf, e) in
+ SystemAPI.Query, RefRO>().WithEntityAccess())
+ {
+ uint next = geyser.ValueRO.NextEruptTick;
+ if (next == 0u) continue; // unstamped -> no telegraph yet (born-correct pending)
+ var untilTick = new NetworkTick(next);
+ if (!untilTick.IsValid) continue;
+ _seen.Add(e);
+
+ int lead = untilTick.TicksSince(serverTick); // >0 = erupt in the future, <=0 = erupt now/past
+ float3 pos = xf.ValueRO.Position;
+
+ if (lead > 0)
+ {
+ _armed[e] = next; // arm this eruption while it counts down
+ }
+ else // lead <= 0: the erupt tick has arrived / passed
+ {
+ bool armedForThis = _armed.TryGetValue(e, out var av) && av == next;
+ bool alreadyFired = _lastFired.TryGetValue(e, out var lf) && lf == next;
+ if (armedForThis && !alreadyFired)
+ {
+ // fire ONCE per eruption, on the ServerTick crossing (co-located with the disc completion)
+ EmitTinted(_eruptFx, (Vector3)pos + Vector3.up * 0.3f, 44, EruptColor);
+ DynamicLightSystem.RequestFlash((Vector3)pos, new Color(0.85f, 0.35f, 1f), 1.4f);
+ ScorchDecalSystem.RequestScorch((Vector3)pos, Tuning.GeyserEruptRadius);
+ PlayClip(_eruptClip, (Vector3)pos, 0.7f);
+ _lastFired[e] = next;
+ }
+ }
+
+ // Warning disc: grow + pulse over the telegraph window; hidden when dormant or erupted.
+ bool showing = lead > 0 && lead <= Tuning.GeyserTelegraphTicks;
+ if (showing)
+ {
+ float charge = 1f - lead / (float)Tuning.GeyserTelegraphTicks; // 0 at window open -> 1 at erupt
+ if (!_discs.TryGetValue(e, out var go) || go == null)
+ {
+ go = new GameObject("GeyserWarning");
+ go.transform.SetParent(_fxRoot, false);
+ go.AddComponent().sharedMesh = _discMesh;
+ var mr = go.AddComponent();
+ mr.sharedMaterial = _discMat;
+ mr.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off;
+ mr.receiveShadows = false;
+ mr.lightProbeUsage = UnityEngine.Rendering.LightProbeUsage.Off;
+ _discs[e] = go;
+ }
+ if (!go.activeSelf) go.SetActive(true);
+ float radius = Tuning.GeyserEruptRadius * (0.55f + 0.45f * charge);
+ go.transform.position = new Vector3(pos.x, 0.055f, pos.z);
+ go.transform.localScale = new Vector3(radius, 1f, radius);
+ float alpha = 0.14f + 0.5f * charge + 0.12f * Mathf.Abs(Mathf.Sin(UnityEngine.Time.time * 10f));
+ _mpb.SetColor(ColorId, new Color(DiscColor.r, DiscColor.g, DiscColor.b, alpha));
+ go.GetComponent().SetPropertyBlock(_mpb);
+ }
+ else if (_discs.TryGetValue(e, out var go) && go != null && go.activeSelf)
+ {
+ go.SetActive(false); // dormant (lead > window) or just erupted (lead <= 0)
+ }
+ }
+
+ // Prune despawned geysers (teardown / relevancy drop) — destroy the disc, drop tracking, emit nothing.
+ if (_discs.Count > 0)
+ {
+ _stale.Clear();
+ foreach (var kv in _discs) if (!_seen.Contains(kv.Key)) _stale.Add(kv.Key);
+ for (int i = 0; i < _stale.Count; i++)
+ {
+ if (_discs[_stale[i]] != null) Object.Destroy(_discs[_stale[i]]);
+ _discs.Remove(_stale[i]);
+ }
+ }
+ PruneMap(_armed);
+ PruneMap(_lastFired);
+ }
+
+ void PruneMap(Dictionary dict)
+ {
+ if (dict.Count == 0) return;
+ _stale.Clear();
+ foreach (var kv in dict) if (!_seen.Contains(kv.Key)) _stale.Add(kv.Key);
+ for (int i = 0; i < _stale.Count; i++) dict.Remove(_stale[i]);
+ }
+ }
+}
diff --git a/Assets/_Project/Scripts/Client/Presentation/GeyserTelegraphSystem.cs.meta b/Assets/_Project/Scripts/Client/Presentation/GeyserTelegraphSystem.cs.meta
new file mode 100644
index 000000000..876a63930
--- /dev/null
+++ b/Assets/_Project/Scripts/Client/Presentation/GeyserTelegraphSystem.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 9720b14cfcb422046a945abddddd8cfc
\ No newline at end of file
diff --git a/Assets/_Project/Scripts/Client/Presentation/WorldFeedbackSystem.cs b/Assets/_Project/Scripts/Client/Presentation/WorldFeedbackSystem.cs
index 350508f0f..f03dc7194 100644
--- a/Assets/_Project/Scripts/Client/Presentation/WorldFeedbackSystem.cs
+++ b/Assets/_Project/Scripts/Client/Presentation/WorldFeedbackSystem.cs
@@ -199,27 +199,6 @@ void Observe(Entity e, int remaining, float3 pos, bool isClutter, Color tint, by
go.transform.position = new Vector3(pos.x, 0.06f, pos.z);
}
- // Unit-radius upward-facing disc fan; scaled per ring to the blast radius.
- static Mesh BuildDisc(int segments)
- {
- var m = new Mesh { name = "FuseDisc" };
- var v = new Vector3[segments + 1];
- var tris = new int[segments * 3];
- v[0] = Vector3.zero;
- for (int i = 0; i < segments; i++)
- {
- float a = i / (float)segments * Mathf.PI * 2f;
- v[i + 1] = new Vector3(Mathf.Cos(a), 0f, Mathf.Sin(a));
- int n = (i + 1) % segments;
- tris[i * 3] = 0; tris[i * 3 + 1] = n + 1; tris[i * 3 + 2] = i + 1;
- }
- m.vertices = v;
- m.triangles = tris;
- m.RecalculateBounds();
- return m;
- }
-
-
static Color TintForResource(byte resourceId)
{
if (resourceId == ResourceId.Ore) return WorldFeelConfig.OreTint;
diff --git a/Assets/_Project/Scripts/Server/Combat/GeyserEruptSystem.cs b/Assets/_Project/Scripts/Server/Combat/GeyserEruptSystem.cs
new file mode 100644
index 000000000..c1af9e606
--- /dev/null
+++ b/Assets/_Project/Scripts/Server/Combat/GeyserEruptSystem.cs
@@ -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
+{
+ ///
+ /// 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();
+ }
+ }
+}
diff --git a/Assets/_Project/Scripts/Server/Combat/GeyserEruptSystem.cs.meta b/Assets/_Project/Scripts/Server/Combat/GeyserEruptSystem.cs.meta
new file mode 100644
index 000000000..b27104e27
--- /dev/null
+++ b/Assets/_Project/Scripts/Server/Combat/GeyserEruptSystem.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: d2e98116af8019a488692870c6f09985
\ No newline at end of file
diff --git a/Assets/_Project/Scripts/Server/Economy/RoomFieldSystem.cs b/Assets/_Project/Scripts/Server/Economy/RoomFieldSystem.cs
index 270020e87..49757abad 100644
--- a/Assets/_Project/Scripts/Server/Economy/RoomFieldSystem.cs
+++ b/Assets/_Project/Scripts/Server/Economy/RoomFieldSystem.cs
@@ -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(out var geyser)
+ && geyser.Prefab != Entity.Null)
+ {
+ uint eruptStamp = 0u;
+ if (SystemAPI.TryGetSingleton(out var gnt) && gnt.ServerTick.IsValid)
+ eruptStamp = gnt.ServerTick.TickIndexForValidTick;
+ var gBaked = SystemAPI.GetComponent(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);
}
diff --git a/Assets/_Project/Scripts/Simulation/Combat/Geyser.cs b/Assets/_Project/Scripts/Simulation/Combat/Geyser.cs
new file mode 100644
index 000000000..8003c9117
--- /dev/null
+++ b/Assets/_Project/Scripts/Simulation/Combat/Geyser.cs
@@ -0,0 +1,23 @@
+using Unity.Entities;
+using Unity.NetCode;
+
+namespace ProjectM.Simulation
+{
+ ///
+ /// A PERMANENT periodic BLIGHT hazard fixture (Phase 1.5b bundle 3; see Geyser_Build_Spec). An ownerless
+ /// INTERPOLATED ghost placed only in Blight-biome expedition rooms. is the ABSOLUTE
+ /// server tick of the next eruption — the ONLY replicated field; the client derives its telegraph countdown and
+ /// erupt-fire from it against its own predicted ServerTick (absolute-tick, NEVER an edge on the field — so it
+ /// survives snapshot loss + relevancy re-entry; the design-review-hardened contract, see Geyser_Build_Spec §7).
+ /// Server-only GeyserEruptSystem radius-damages LIVING players AND enemies (friendly
+ /// fire, like exploding barrels — also a tactical lure) when it elapses, then reschedules one period ahead of NOW
+ /// (never destroyed; RoomTag teardown removes it). Born-correct: RoomFieldSystem stamps NextEruptTick from the
+ /// live ServerTick at spawn, so the first snapshot never carries the 0 "ready/unstamped" sentinel (a baked 0 must
+ /// be treated as not-ready and NEVER erupted — the inverted invalid-tick guard).
+ ///
+ public struct Geyser : IComponentData
+ {
+ /// Absolute server tick of the next eruption (via TickUtil.NonZero; 0 = unstamped = never erupt).
+ [GhostField] public uint NextEruptTick;
+ }
+}
diff --git a/Assets/_Project/Scripts/Simulation/Combat/Geyser.cs.meta b/Assets/_Project/Scripts/Simulation/Combat/Geyser.cs.meta
new file mode 100644
index 000000000..01455dd0f
--- /dev/null
+++ b/Assets/_Project/Scripts/Simulation/Combat/Geyser.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 686fc215cf5ebd84ab06f560f9a16d9e
\ No newline at end of file
diff --git a/Assets/_Project/Scripts/Simulation/Economy/GeyserFieldSpawner.cs b/Assets/_Project/Scripts/Simulation/Economy/GeyserFieldSpawner.cs
new file mode 100644
index 000000000..11ce2f162
--- /dev/null
+++ b/Assets/_Project/Scripts/Simulation/Economy/GeyserFieldSpawner.cs
@@ -0,0 +1,20 @@
+using Unity.Entities;
+
+namespace ProjectM.Simulation
+{
+ ///
+ /// Baked singleton holding the BLIGHT-GEYSER ghost prefab (Geyser_Build_Spec; a ResourceNode sibling — a
+ /// carrier with RegionTag{Expedition} and NO collider). RoomFieldSystem scatters
+ /// geysers per room epoch ONLY in Blight-biome rooms (plan.Biome == Blight) and NEVER in
+ /// Boss rooms, with a keep-out ring around the room origin (player landing + portal). OPTIONAL: absent
+ /// singleton = no geysers. Mirrors ; carries no transform.
+ ///
+ public struct GeyserFieldSpawner : IComponentData
+ {
+ /// Baked geyser ghost prefab to instantiate.
+ public Entity Prefab;
+
+ /// Geysers per Blight-biome room (Boss rooms get none).
+ public int Count;
+ }
+}
diff --git a/Assets/_Project/Scripts/Simulation/Economy/GeyserFieldSpawner.cs.meta b/Assets/_Project/Scripts/Simulation/Economy/GeyserFieldSpawner.cs.meta
new file mode 100644
index 000000000..5af4b3efb
--- /dev/null
+++ b/Assets/_Project/Scripts/Simulation/Economy/GeyserFieldSpawner.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 3e7f72ba05b64124fbe572dbadb2a05f
\ No newline at end of file
diff --git a/Assets/_Project/Scripts/Simulation/Tuning.cs b/Assets/_Project/Scripts/Simulation/Tuning.cs
index 3eed64daf..1be5f2015 100644
--- a/Assets/_Project/Scripts/Simulation/Tuning.cs
+++ b/Assets/_Project/Scripts/Simulation/Tuning.cs
@@ -121,6 +121,22 @@ namespace ProjectM.Simulation
/// Detonation damage — hits LIVING enemies AND players alike (friendly fire = the bait mechanic).
public const float BarrelExplodeDamage = 26f;
+ // ---- Blight geyser (Phase 1.5b bundle-3 hazard; periodic BOTH-SIDES AoE — see Geyser_Build_Spec) ----
+
+ /// Ticks between eruptions (~5 s @60). Server reschedules NextEruptTick = now + this each erupt
+ /// (never +=, per the review — anchored += catch-up-storms after a skip).
+ public const uint GeyserPeriodTicks = 300;
+
+ /// Telegraph window (ticks, ~1.3 s @60): the client grows the warning disc over the last this-many
+ /// ticks before an eruption (int so the client lead comparison stays signed).
+ public const int GeyserTelegraphTicks = 78;
+
+ /// Eruption AoE radius (world units, XZ) — hits LIVING players AND enemies (friendly fire = lure bait).
+ public const float GeyserEruptRadius = 3.0f;
+
+ /// Eruption damage per hit.
+ public const float GeyserEruptDamage = 22f;
+
// ---- Expedition BOSS (a scaled Charger given a real kit by BossAISystem; server-only feel consts) ----
/// Radial SLAM AoE radius (world units): a player inside this ring at wind-up elapse eats the hit
diff --git a/Assets/_Project/Subscenes/Gameplay.unity b/Assets/_Project/Subscenes/Gameplay.unity
index cfe6c941b..b52205c58 100644
--- a/Assets/_Project/Subscenes/Gameplay.unity
+++ b/Assets/_Project/Subscenes/Gameplay.unity
@@ -344,6 +344,7 @@ GameObject:
- component: {fileID: 17637047}
- component: {fileID: 17637046}
- component: {fileID: 17637048}
+ - component: {fileID: 17637049}
m_Layer: 0
m_Name: ResourceFieldSpawner
m_TagString: Untagged
@@ -396,6 +397,20 @@ MonoBehaviour:
ClutterPrefab: {fileID: 3885353946372160549, guid: 6ffeddcc4482c0f44880dc9a555884dd, type: 3}
Count: 14
Radius: 14
+--- !u!114 &17637049
+MonoBehaviour:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 17637045}
+ m_Enabled: 1
+ m_EditorHideFlags: 0
+ m_Script: {fileID: 11500000, guid: e5d1544d6afe18245b563de599240493, type: 3}
+ m_Name:
+ m_EditorClassIdentifier: ProjectM.Authoring::ProjectM.Authoring.GeyserFieldSpawnerAuthoring
+ GeyserPrefab: {fileID: 3885353946372160549, guid: 8425e38ad7271e44693e65e8d574a993, type: 3}
+ Count: 2
--- !u!1 &25399149
GameObject:
m_ObjectHideFlags: 0
diff --git a/Assets/_Project/Tests/EditMode/GeyserEruptSystemTests.cs b/Assets/_Project/Tests/EditMode/GeyserEruptSystemTests.cs
new file mode 100644
index 000000000..5147c446d
--- /dev/null
+++ b/Assets/_Project/Tests/EditMode/GeyserEruptSystemTests.cs
@@ -0,0 +1,149 @@
+using NUnit.Framework;
+using ProjectM.Server;
+using ProjectM.Simulation;
+using Unity.Core;
+using Unity.Entities;
+using Unity.Mathematics;
+using Unity.NetCode;
+using Unity.Transforms;
+
+namespace ProjectM.Tests
+{
+ ///
+ /// Plain-Entities EditMode tests for (Geyser_Build_Spec; design-review
+ /// wf_900e9965-8f0). Pins the review-hardened contract: an ELAPSED geyser radius-damages LIVING players AND
+ /// enemies (friendly fire) with SourceNetworkId = -1, RESCHEDULES NextEruptTick = now + period (never +=, never
+ /// the 0 sentinel), and is NEVER destroyed (opposite of the barrel). The load-bearing HIGH regression: a geyser
+ /// that ships an UNSTAMPED NextEruptTick (0) must NEVER erupt — the inverted guard lazy-stamps it born-correct
+ /// instead of the party-wiping per-tick barrage that copying the barrel's invalid-guard verbatim would cause.
+ ///
+ public class GeyserEruptSystemTests
+ {
+ const uint T0 = 1000;
+
+ static (World world, SimulationSystemGroup group) MakeWorld(string name, uint serverTick = T0)
+ {
+ var world = new World(name);
+ var group = world.GetOrCreateSystemManaged();
+ group.AddSystemToUpdateList(world.GetOrCreateSystem());
+ group.SortSystems();
+ world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
+ var nt = world.EntityManager.CreateEntity(typeof(NetworkTime));
+ world.EntityManager.SetComponentData(nt, new NetworkTime { ServerTick = new NetworkTick(serverTick) });
+ return (world, group);
+ }
+
+ static Entity MakeGeyser(EntityManager em, float3 pos, uint nextEruptTick)
+ {
+ var e = em.CreateEntity();
+ em.AddComponentData(e, LocalTransform.FromPosition(pos));
+ em.AddComponentData(e, new Geyser { NextEruptTick = nextEruptTick });
+ return e;
+ }
+
+ static Entity MakePlayer(EntityManager em, float3 pos, float health, byte region)
+ {
+ var e = em.CreateEntity();
+ em.AddComponentData(e, LocalTransform.FromPosition(pos));
+ em.AddComponentData(e, new PlayerTag());
+ em.AddComponentData(e, new Health { Current = health, Max = 130f });
+ em.AddComponentData(e, new RegionTag { Region = region });
+ em.AddBuffer(e);
+ return e;
+ }
+
+ static Entity MakeEnemy(EntityManager em, float3 pos, float health)
+ {
+ var e = em.CreateEntity();
+ em.AddComponentData(e, LocalTransform.FromPosition(pos));
+ em.AddComponentData(e, new EnemyTag());
+ em.AddComponentData(e, new Health { Current = health, Max = 30f });
+ em.AddBuffer(e);
+ return e;
+ }
+
+ [Test]
+ public void Elapsed_Geyser_Damages_Living_Both_Sides_Reschedules_And_Survives()
+ {
+ var (world, group) = MakeWorld("EruptBoth");
+ using (world)
+ {
+ var em = world.EntityManager;
+ var geyser = MakeGeyser(em, new float3(10, 0, 10), nextEruptTick: T0); // due exactly now
+ var player = MakePlayer(em, new float3(11, 0, 10), health: 100f, region: RegionId.Expedition);
+ var enemy = MakeEnemy(em, new float3(10, 0, 12), health: 30f);
+
+ group.Update();
+
+ Assert.IsTrue(em.Exists(geyser), "A geyser is PERMANENT — it is never destroyed on erupt.");
+ var pd = em.GetBuffer(player);
+ Assert.AreEqual(1, pd.Length, "Friendly fire: the living expedition player in radius is hit.");
+ Assert.AreEqual(Tuning.GeyserEruptDamage, pd[0].Amount);
+ Assert.AreEqual(-1, pd[0].SourceNetworkId, "Environment convention: never a player id.");
+ Assert.AreNotEqual(0u, pd[0].SourceTick, "SourceTick stamped at NOW (the authored-tick contract).");
+ Assert.AreEqual(1, em.GetBuffer(enemy).Length, "The living enemy in radius is hit too (bait mechanic).");
+
+ Assert.AreEqual(TickUtil.NonZero(T0 + Tuning.GeyserPeriodTicks), em.GetComponentData(geyser).NextEruptTick,
+ "Reschedule = now + period (never += ; never the 0 sentinel).");
+ }
+ }
+
+ [Test]
+ public void Unstamped_Geyser_Never_Erupts_And_Is_Lazy_Stamped()
+ {
+ // The HIGH regression: a baked-0 NextEruptTick at a high serverTick must NOT storm (the inverted guard).
+ var (world, group) = MakeWorld("UnstampedNoStorm", serverTick: 5000);
+ using (world)
+ {
+ var em = world.EntityManager;
+ var geyser = MakeGeyser(em, new float3(10, 0, 10), nextEruptTick: 0u); // unstamped
+ var player = MakePlayer(em, new float3(10, 0, 10), health: 130f, region: RegionId.Expedition);
+
+ group.Update();
+
+ Assert.AreEqual(0, em.GetBuffer(player).Length,
+ "An unstamped (0) geyser must NEVER erupt — no per-tick barrage.");
+ Assert.AreEqual(TickUtil.NonZero(5000u + Tuning.GeyserPeriodTicks), em.GetComponentData(geyser).NextEruptTick,
+ "0 is lazy-stamped born-correct (now + period) instead of erupting.");
+ Assert.IsTrue(em.Exists(geyser));
+ }
+ }
+
+ [Test]
+ public void Dead_Player_And_Out_Of_Radius_Get_Nothing()
+ {
+ var (world, group) = MakeWorld("EruptFilters");
+ using (world)
+ {
+ var em = world.EntityManager;
+ MakeGeyser(em, new float3(10, 0, 10), nextEruptTick: T0);
+ var deadPlayer = MakePlayer(em, new float3(10.5f, 0, 10), health: 0f, region: RegionId.Expedition);
+ var basePlayer = MakePlayer(em, new float3(10.5f, 0, 10), health: 100f, region: RegionId.Base);
+ var farEnemy = MakeEnemy(em, new float3(10 + Tuning.GeyserEruptRadius + 1f, 0, 10), health: 30f);
+
+ group.Update();
+
+ Assert.AreEqual(0, em.GetBuffer(deadPlayer).Length, "A dead player in radius gets NOTHING.");
+ Assert.AreEqual(0, em.GetBuffer(basePlayer).Length, "A non-expedition player gets NOTHING.");
+ Assert.AreEqual(0, em.GetBuffer(farEnemy).Length, "Out of radius gets nothing.");
+ }
+ }
+
+ [Test]
+ public void Unelapsed_Geyser_Does_Nothing_And_Keeps_Counting_Down()
+ {
+ var (world, group) = MakeWorld("EruptWait");
+ using (world)
+ {
+ var em = world.EntityManager;
+ var geyser = MakeGeyser(em, new float3(10, 0, 10), nextEruptTick: T0 + 120); // still counting down
+ var player = MakePlayer(em, new float3(10, 0, 10), health: 100f, region: RegionId.Expedition);
+
+ group.Update();
+
+ Assert.AreEqual(0, em.GetBuffer(player).Length, "No damage before the eruption tick.");
+ Assert.AreEqual(T0 + 120, em.GetComponentData(geyser).NextEruptTick, "An un-elapsed geyser is not rescheduled.");
+ }
+ }
+ }
+}
diff --git a/Assets/_Project/Tests/EditMode/GeyserEruptSystemTests.cs.meta b/Assets/_Project/Tests/EditMode/GeyserEruptSystemTests.cs.meta
new file mode 100644
index 000000000..8a16625fc
--- /dev/null
+++ b/Assets/_Project/Tests/EditMode/GeyserEruptSystemTests.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: a02ba4950608fb347a8491783ebc4746
\ No newline at end of file