4febf3dfe2
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>
150 lines
7.2 KiB
C#
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.");
|
|
}
|
|
}
|
|
}
|
|
}
|