Files
Project-M/Assets/_Project/Tests/EditMode/GeyserEruptSystemTests.cs
T
kronic 4febf3dfe2 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>
2026-07-12 13:53:48 -07:00

150 lines
7.2 KiB
C#

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
{
/// <summary>
/// Plain-Entities EditMode tests for <see cref="GeyserEruptSystem"/> (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.
/// </summary>
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<SimulationSystemGroup>();
group.AddSystemToUpdateList(world.GetOrCreateSystem<GeyserEruptSystem>());
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<DamageEvent>(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<DamageEvent>(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<DamageEvent>(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<DamageEvent>(enemy).Length, "The living enemy in radius is hit too (bait mechanic).");
Assert.AreEqual(TickUtil.NonZero(T0 + Tuning.GeyserPeriodTicks), em.GetComponentData<Geyser>(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<DamageEvent>(player).Length,
"An unstamped (0) geyser must NEVER erupt — no per-tick barrage.");
Assert.AreEqual(TickUtil.NonZero(5000u + Tuning.GeyserPeriodTicks), em.GetComponentData<Geyser>(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<DamageEvent>(deadPlayer).Length, "A dead player in radius gets NOTHING.");
Assert.AreEqual(0, em.GetBuffer<DamageEvent>(basePlayer).Length, "A non-expedition player gets NOTHING.");
Assert.AreEqual(0, em.GetBuffer<DamageEvent>(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<DamageEvent>(player).Length, "No damage before the eruption tick.");
Assert.AreEqual(T0 + 120, em.GetComponentData<Geyser>(geyser).NextEruptTick, "An un-elapsed geyser is not rescheduled.");
}
}
}
}