36950ca21d
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>
128 lines
5.8 KiB
C#
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).");
|
|
}
|
|
}
|
|
}
|
|
}
|