Files
Project-M/Assets/_Project/Tests/EditMode/ZonePulseSystemTests.cs
T
kronic 36950ca21d LANTERN P1 Group B: 5-Spark systems — Aoe/Hitscan dispatch + reel/zone/decoy
Aoe/Hitscan archetype dispatch in AbilityFireSystem (both-worlds cooldown;
server-only ecb.Instantiate — the client-never-instantiates invariant; ZoneEffect
vs DecoyTag chosen by prefab membership). Harpooner REEL as a homing state
(ReelState + ReelSystem re-aim the hit Husk's KnockbackState toward the caster's
live position each tick; EnemyAISystem stays sole Position writer), stamped by
ProjectileDamageSystem on a Reel-flag hit. ZonePulseSystem (enemy-only periodic
AoE, caster attribution, no friendly fire, Vortex pull, born-0 lazy-stamp).
Decoy aggro in EnemyAISystem + DecoySystem despawn. AbilityDefBlob gains
EffectFlags (Reel) + DurationTicks. +3 ZonePulseSystem EditMode tests (497 green).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 00:00:41 -07:00

128 lines
5.8 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 the server-only <see cref="ZonePulseSystem"/> (LANTERN Vortex/LightZone).
/// Guards the Build Spec RS-5 invariants: a player-cast zone damages ENEMIES ONLY (no friendly fire), credits
/// the CASTER's NetworkId (kills feed KillReward), respects its radius, and despawns on lifetime elapse. The
/// system reads a NetworkTime singleton (constructed here) and plays its ECB back immediately, so one
/// group.Update() resolves within a single tick. [WorldSystemFilter] is ignored under manual registration.
/// </summary>
public class ZonePulseSystemTests
{
const uint kTick = 100; // "now" server tick for the fixture
static (World world, SimulationSystemGroup group) MakeWorld(string name)
{
var world = new World(name);
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
group.AddSystemToUpdateList(world.GetOrCreateSystem<ZonePulseSystem>());
group.SortSystems();
world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
// NetworkTime singleton at a valid, non-zero ServerTick.
var nt = world.EntityManager.CreateEntity(typeof(NetworkTime));
world.EntityManager.SetComponentData(nt, new NetworkTime { ServerTick = new NetworkTick(kTick) });
return (world, group);
}
static Entity MakeEnemy(EntityManager em, float3 pos, float hp = 100f)
{
var e = em.CreateEntity(typeof(EnemyTag), typeof(Health), typeof(DamageEvent), typeof(LocalTransform));
em.SetComponentData(e, new Health { Current = hp, Max = hp });
em.SetComponentData(e, LocalTransform.FromPosition(pos));
return e;
}
static Entity MakeZone(EntityManager em, float3 pos, int casterId, float radius, float dmg, uint nextTick, uint expireTick, byte flags = 0)
{
var z = em.CreateEntity(typeof(ZoneEffect), typeof(LocalTransform));
em.SetComponentData(z, LocalTransform.FromPosition(pos));
em.SetComponentData(z, new ZoneEffect
{
CasterNetworkId = casterId,
Radius = radius,
DamagePerPulse = dmg,
NextTick = nextTick,
ExpireTick = expireTick,
Flags = flags,
});
return z;
}
[Test]
public void Pulse_Damages_Enemies_In_Radius_Credits_Caster_And_Spares_Players()
{
var (world, group) = MakeWorld("ZonePulse_EnemyOnly");
using (world)
{
var em = world.EntityManager;
var inRange = MakeEnemy(em, new float3(2f, 0f, 0f));
var outRange = MakeEnemy(em, new float3(20f, 0f, 0f));
// A living player standing INSIDE the zone must take NO friendly fire.
var player = em.CreateEntity(typeof(PlayerTag), typeof(Health), typeof(DamageEvent), typeof(LocalTransform));
em.SetComponentData(player, new Health { Current = 100f, Max = 100f });
em.SetComponentData(player, LocalTransform.FromPosition(new float3(1f, 0f, 0f)));
// NextTick == now → DUE this tick (radius 5, 10 dmg/pulse, caster 42, never expires).
MakeZone(em, float3.zero, casterId: 42, radius: 5f, dmg: 10f, nextTick: kTick, expireTick: 0u);
group.Update();
var inBuf = em.GetBuffer<DamageEvent>(inRange);
Assert.AreEqual(1, inBuf.Length, "An enemy inside the radius takes exactly one pulse.");
Assert.AreEqual(10f, inBuf[0].Amount, 1e-4f, "Pulse deals DamagePerPulse.");
Assert.AreEqual(42, inBuf[0].SourceNetworkId, "The pulse credits the caster's NetworkId (kill attribution).");
Assert.AreEqual(0, em.GetBuffer<DamageEvent>(outRange).Length, "An enemy outside the radius is untouched.");
Assert.AreEqual(0, em.GetBuffer<DamageEvent>(player).Length, "A player in the zone takes NO friendly fire (enemy-only).");
}
}
[Test]
public void Born_Zero_NextTick_Lazy_Stamps_And_Does_Not_Storm_Fire()
{
var (world, group) = MakeWorld("ZonePulse_BornZero");
using (world)
{
var em = world.EntityManager;
var enemy = MakeEnemy(em, new float3(1f, 0f, 0f));
// NextTick 0 = unscheduled: the INVERTED guard must lazy-stamp born-correct, NOT fire this tick.
var zone = MakeZone(em, float3.zero, casterId: 7, radius: 5f, dmg: 10f, nextTick: 0u, expireTick: 0u);
group.Update();
Assert.AreEqual(0, em.GetBuffer<DamageEvent>(enemy).Length,
"A born-0 NextTick must lazy-stamp (never storm-fire) on the first tick.");
Assert.AreNotEqual(0u, em.GetComponentData<ZoneEffect>(zone).NextTick,
"NextTick must be stamped born-correct (non-zero) after the first tick.");
}
}
[Test]
public void Expired_Zone_Is_Despawned()
{
var (world, group) = MakeWorld("ZonePulse_Expire");
using (world)
{
var em = world.EntityManager;
// ExpireTick in the past (50 < now 100) → despawn this tick.
var zone = MakeZone(em, float3.zero, casterId: 1, radius: 5f, dmg: 10f, nextTick: kTick, expireTick: 50u);
group.Update();
Assert.IsFalse(em.Exists(zone), "A zone past its ExpireTick is destroyed (server-authoritative despawn).");
}
}
}
}