f271d5f47f
Blight geyser (bundle 3) shipped review-first. Records the design-review fold (2 HIGH: no edge-detect on the replicated tick; invert the invalid-guard for a baked scheduled-tick so a born-0 can't storm) and the new CLAUDE.md tick-sentinel corollary for baked [GhostField] scheduled-ticks. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
152 lines
11 KiB
Markdown
152 lines
11 KiB
Markdown
---
|
|
title: Geyser_Build_Spec
|
|
type: note
|
|
permalink: gamevault/03-design/geyser-build-spec
|
|
---
|
|
|
|
# Blight Geyser — Build Spec (Phase 1.5b bundle 3, design-review-hardened)
|
|
|
|
Review: `wf_900e9965-8f0` (3 lenses — netcode/relevancy · determinism/prediction · reuse/scope — 23 agents,
|
|
0 errors, 19 findings → **11 CONFIRMED / 8 refuted**; the [[workflow-agent-quota-failures-look-clean]] check
|
|
passed — no agents died). This spec is the fold; it supersedes the pre-review draft. Mirrors the two shipped
|
|
hazards [[Exploding_Barrels_Build_Spec]] + [[Destructible_Cover_Build_Spec]].
|
|
|
|
**Locked (operator, do not revisit):** Blight-biome rooms ONLY · PERMANENT (never destroyed; `RoomTag`
|
|
teardown is the only removal) · both-sides telegraphed AoE (friendly fire vs living players AND enemies, like
|
|
the shipped exploding barrels) · also a tactical lure tool.
|
|
|
|
## 1. Core mechanism
|
|
|
|
A `Geyser` is an ownerless **interpolated** ghost placed only in Blight rooms. One replicated field —
|
|
`[GhostField] uint NextEruptTick`, the absolute server tick of its next eruption. Server-only
|
|
`GeyserEruptSystem` (plain `SimulationSystemGroup`) fires when `ServerTick` reaches `NextEruptTick`:
|
|
radius-gathers living players + living enemies in XZ, appends `DamageEvent`s (drained next tick by
|
|
`HealthApplyDamageSystem`, exactly the barrel path), then reschedules `NextEruptTick` one period ahead of
|
|
**now**. NEVER destroys itself. The client telegraph derives the countdown from the *absolute* `NextEruptTick`
|
|
vs its own predicted `ServerTick` (never an edge on the replicated value) to grow a warning disc, and fires
|
|
the erupt burst on the tick-crossing, latched once per eruption.
|
|
|
|
## 2. Server
|
|
|
|
- **Component** `Geyser { [GhostField] uint NextEruptTick }` in `ProjectM.Simulation`.
|
|
- **`GeyserEruptSystem`** — `ProjectM.Server`, `[WorldSystemFilter(ServerSimulation)]` +
|
|
`[UpdateInGroup(typeof(SimulationSystemGroup))]`, **NO `[UpdateBefore/After]` edges**,
|
|
`RequireForUpdate<Geyser>`. Mirror `HazardExplosionSystem.cs:25-27` (`[BurstCompile]` fine — no enums, no
|
|
lifted `SystemAPI.Query`). Each tick: `TryGetSingleton<NetworkTime>`; if `!ServerTick.IsValid` → skip.
|
|
Iterate `(RefRW<Geyser>, LocalTransform, Entity)`:
|
|
- **Erupt condition — INVERTED from the barrel (H2):** `until = new NetworkTick(g.NextEruptTick)`. If
|
|
`g.NextEruptTick == 0u || !until.IsValid` → **skip** (unstamped = not-ready; never erupt). If
|
|
`until.IsNewerThan(serverTick)` → not yet, skip. Otherwise erupt.
|
|
- **Gather — inline, do NOT extract a helper (M5):** duplicate the ~12-line both-sides gather verbatim from
|
|
`HazardExplosionSystem.cs:59-89`, parameterized only by this geyser's position + `Tuning.GeyserEruptRadius`
|
|
+ `Tuning.GeyserEruptDamage`. Players `Health>0 && RegionTag==Expedition`, XZ within radius; enemies
|
|
`Health>0`, XZ within radius. `AppendToBuffer(DamageEvent{ Amount=GeyserEruptDamage, SourceNetworkId=-1,
|
|
SourceTick=stamp })`.
|
|
- **SourceTick = NOW, not the scheduled tick (M8):** `uint stamp =
|
|
TickUtil.NonZero(serverTick.TickIndexForValidTick);` — verbatim from `HazardExplosionSystem.cs:57`.
|
|
- **Reschedule = now + period, NOT `+=` (M3):** `g.ValueRW.NextEruptTick =
|
|
TickUtil.NonZero(serverTick.TickIndexForValidTick + Tuning.GeyserPeriodTicks);` via `RefRW<Geyser>.ValueRW`.
|
|
- **Never** `DestroyEntity`.
|
|
|
|
## 3. Client
|
|
|
|
- **New sibling `GeyserTelegraphSystem`** — `ProjectM.Client`, observe-only `SystemBase`,
|
|
`[WorldSystemFilter(ClientSimulation)]` + `[UpdateInGroup(typeof(PresentationSystemGroup))]` (sibling of
|
|
`EnemyDangerTelegraphSystem`). `CompleteDependencyBeforeRO<Geyser>()` before reading. Read the client
|
|
`NetworkTime.ServerTick` (the **predicted** ServerTick — matches shipped enemy/HUD countdowns; NOT
|
|
InterpolationTick). Per-geyser cache keyed by `Entity`, pruned every frame (pruned = silent removal; emit
|
|
nothing on prune).
|
|
- **Disc (absolute-tick derived — the sound half of the original design):** guard `g.NextEruptTick != 0u &&
|
|
new NetworkTick(g.NextEruptTick).IsValid`. `int lead = new NetworkTick(g.NextEruptTick).TicksSince(serverTick)`
|
|
(negative once passed); clamp to `[0, GeyserTelegraphTicks]`. Grow the disc at `GeyserEruptRadius` scaled by
|
|
`(1 - lead/(float)GeyserTelegraphTicks)` while `0 < lead <= GeyserTelegraphTicks`.
|
|
- **Erupt burst — fire on the ServerTick CROSSING, latched (H1):** fire when `lead` crosses `>0 → <=0`
|
|
(`serverTick` first reaches/exceeds `NextEruptTick`). Store `_lastFired[entity] = g.NextEruptTick`; refire
|
|
ONLY when `NextEruptTick` differs (the `EnemyDangerTelegraphSystem.cs:177-185` `_strikeBeeped` latch). Plus a
|
|
**"was counting down" arm-guard** — only fire if a prior frame observed `lead>0` for this entity
|
|
(`EnemyDangerTelegraphSystem.cs:139-140`). Immune to: the `0→stamp` phantom, relevancy re-entry mid-period
|
|
(a freshly re-added ghost has `lead>0` → no crossing on seed), and snapshot loss (absolute tick). Co-locates
|
|
the burst with the disc completion on the ServerTick timeline (also fixes the boom-trails-disc split).
|
|
- **Erupt VFX — reuse shipped statics (no new plumbing):** `FeedbackFx.EmitTinted`/`MakeBurst` particles +
|
|
`DynamicLightSystem.RequestFlash` + `ScorchDecalSystem.RequestScorch(pos, GeyserEruptRadius)` (bundle 2,
|
|
documented "Reusable by the future Blight-geyser hazard").
|
|
- **Promote the disc primitive (M6):** move `BuildDisc` (+ a disc/ring material factory) out of
|
|
`WorldFeedbackSystem` (private `:203`) into `FeedbackFx` so the barrel fuse ring and the geyser telegraph
|
|
share ONE primitive. Do NOT re-implement a second private disc-builder.
|
|
|
|
## 4. Seeding / authoring
|
|
|
|
- **Ghost prefab `Geyser.prefab`:** duplicate **`ResourceNode.prefab`** (or `BlightClutter.prefab`) — NOT
|
|
`CoverRock.prefab` (its Environment-layer collider would block the walk-over hazard). Swap authoring →
|
|
`GeyserAuthoring` (bakes `Geyser` + `RegionTag{Expedition}`); cosmetic vent mesh; NO collider. Ownerless/
|
|
interpolated `GhostAuthoringComponent` + `LinkedEntityGroup` come free. An inherited `HitRadius` is inert
|
|
(server scans by `Tuning.GeyserEruptRadius`) — no strip needed.
|
|
- **`GeyserFieldSpawnerAuthoring` → baked `GeyserFieldSpawner { Entity Prefab; int Count }`** singleton,
|
|
mirroring `ClutterFieldSpawnerAuthoring`. Author into `Gameplay.unity`'s subscene; assign the prefab.
|
|
- **Seeding in `RoomFieldSystem.OnUpdate` InRoom block**, sibling of cover/clutter:
|
|
- **Gate on `plan.Biome == RoomBiomeId.Blight`** (local plan, NOT `info.CurrentBiome`) — matches the cover
|
|
block's `plan.RoomType != RoomTypeId.Boss`. `RoomBiomeId.Blight == 3`.
|
|
- **Never Boss rooms** — an explicit, **commented** design choice (geyser has no collider → NOT the cover
|
|
depenetration concern; it's a boss-fight decision).
|
|
- `TryGetSingleton<GeyserFieldSpawner>` + `Prefab != Entity.Null` (optional singleton; absent → skip).
|
|
- Distinct hash sub-stream `0x6E7`; keep-out ring around the room origin (like cover's `KeepOutFromOrigin=7`).
|
|
- Stamp `RoomTag{room}` with the SAME `room` local every sibling uses → free `RoomTeardown` removal.
|
|
- **Born-correct spawn-stamp (H2):** the block has no `NetworkTime` today — add `TryGetSingleton<NetworkTime>`;
|
|
if `!ServerTick.IsValid` this tick, do NOT seed geysers (the once-per-`RoomEpoch` guard retries). Otherwise
|
|
per instance `ecb.SetComponent(e, new Geyser{ NextEruptTick =
|
|
TickUtil.NonZero(serverTick.TickIndexForValidTick + Tuning.GeyserPeriodTicks) })` in the deferred loop → the
|
|
entity is fully formed before ECB playback / `GhostSendSystem` serialization → the first snapshot never
|
|
carries 0.
|
|
- **Tuning consts** in `Simulation/Tuning.cs` beside the barrel block: `GeyserPeriodTicks = 300` (5s@60),
|
|
`GeyserTelegraphTicks = 78` (~1.3s), `GeyserEruptRadius = 3.0f`, `GeyserEruptDamage = 22`. Single-source only
|
|
the constants (no shared gather helper).
|
|
|
|
## 5. Tests
|
|
|
|
`Assets/_Project/Tests/EditMode/GeyserEruptSystemTests.cs`, plain-Entities, mirroring
|
|
`HazardExplosionSystemTests.cs` (reuse `MakeWorld`/`MakePlayer`/`MakeEnemy`):
|
|
1. **Both-sides erupt:** living player + living enemy in radius each get exactly one `DamageEvent`;
|
|
`SourceNetworkId == -1`.
|
|
2. **Reschedule:** after eruption `NextEruptTick` advances by exactly `GeyserPeriodTicks` from now, never the
|
|
`0` sentinel.
|
|
3. **Never destroyed:** the geyser survives the eruption (opposite of the barrel destroy-once assertion).
|
|
4. **Unstamped-storm regression (H2):** seed a geyser at `serverTick=5000` with `NextEruptTick=0`; assert
|
|
**zero** `DamageEvent`s on the first ticks (the inverted guard skips — no per-tick barrage).
|
|
5. **Out-of-radius / dead filters:** dead or out-of-radius get no `DamageEvent`.
|
|
|
|
## 6. Wire / bake classification
|
|
|
|
- **New ghost type + prefab** `Geyser.prefab` → genuinely NEW ghost hash (NOT a byte re-mean). ONE new
|
|
`[GhostField]` (`Geyser.NextEruptTick`).
|
|
- **New systems:** `GeyserEruptSystem` (server) + `GeyserTelegraphSystem` (client).
|
|
- **New baked singleton** `GeyserFieldSpawner` authored into `Gameplay.unity` subscene (load-bearing — unwired
|
|
= zero geysers, silent).
|
|
- **Modified:** `RoomFieldSystem` (Blight seed block + `NetworkTime` spawn-stamp), `Tuning.cs` (4 consts),
|
|
`FeedbackFx` (promote `BuildDisc` + material factory), `WorldFeedbackSystem` (fuse ring calls the promoted
|
|
primitive).
|
|
- **Relevancy:** free via prefab-baked `RegionTag{Expedition}` → existing `RegionRelevancySystem`
|
|
`SetIsIrrelevant` hides it cross-region. No relevancy-system change.
|
|
- **Ordering:** edge-free server system → no new sort constraint, no Play-only cycle (confirmed).
|
|
- Re-bake: new prefab + subscene spawner + authoring defaults; full asmdef recompile for the new types.
|
|
|
|
## 7. Changes the review FORCED vs the original design
|
|
|
|
**HIGH** — H1 erupt VFX must NOT edge-detect the `NextEruptTick` jump (phantom-fires on `0→stamp` + relevancy
|
|
re-entry + snapshot loss) → absolute-tick `TicksSince` disc + latched `>0→<=0` crossing + arm-guard (mirror
|
|
`EnemyDangerTelegraphSystem`; `AttackWindup.cs:12` "absolute, not an edge"). Also fixes the boom-trails-disc
|
|
timeline split. · H2 INVERT the barrel's invalid-tick guard (unstamped `0` → SKIP, never erupt) + add
|
|
`NetworkTime` to `RoomFieldSystem` and stamp `NextEruptTick` born-correct at spawn (else a baked-0 geyser
|
|
erupts every tick = a party-wiping barrage).
|
|
|
|
**MED** — M3 reschedule `= now + period`, not `+=` (catch-up storm; every in-repo sentinel is `= now + delay`).
|
|
· M5 do NOT extract a shared `HazardDamage` gather helper (`SystemAPI.Query` can't be lifted to a static;
|
|
verbatim-copy is the shipped convention; Burst-ICE risk) — duplicate inline. · M6 promote `BuildDisc` into
|
|
`FeedbackFx`. · M7 gate on `plan.Biome`, not `info.CurrentBiome`. · M8 stamp `SourceTick` at NOW, not the
|
|
scheduled `eruptTick` (tick-batching mis-negates dash i-frames).
|
|
|
|
**LOW** — duplicate `ResourceNode`/`BlightClutter` (no collider), not `CoverRock`. · add `GeyserEruptSystemTests`.
|
|
|
|
**Confirmed correct, no change:** plain-`SimulationSystemGroup` placement · next-tick `DamageEvent` drain ·
|
|
single-apply · `SourceNetworkId=-1` attribution · dash-i-frame negation · edge-free ordering ·
|
|
`RegionTag{Expedition}` relevancy scoping.
|