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>
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
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).");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 62c15e84019e90549a538044f708f5dd
|
||||
Reference in New Issue
Block a user