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
{
///
/// Plain-Entities EditMode tests for the Husk attack TELEGRAPH (the 2-phase strike in EnemyAISystem). The
/// strike no longer fires instantly: when a Husk is first in-range + cooldown-ready it commits a wind-up
/// ( = now + Tuning.AttackWindupTicks, replicated so the client can
/// cue it) and damages NOTHING; the strike lands only when the wind-up tick elapses, and leaving range
/// mid-wind-up cancels it. Server timing is fully headless (the replication + client cue are the Play check).
///
public class TelegraphTests
{
static void SetServerTick(World world, uint tick)
{
var em = world.EntityManager;
using var q = em.CreateEntityQuery(typeof(NetworkTime));
Entity e = q.IsEmpty ? em.CreateEntity(typeof(NetworkTime)) : q.GetSingletonEntity();
em.SetComponentData(e, new NetworkTime { ServerTick = new NetworkTick(tick) });
}
static (World world, SimulationSystemGroup group) MakeWorld(string name, uint serverTick)
{
var world = new World(name);
var group = world.GetOrCreateSystemManaged();
group.AddSystemToUpdateList(world.GetOrCreateSystem());
group.SortSystems();
world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
SetServerTick(world, serverTick);
return (world, group);
}
static Entity MakePlayer(EntityManager em, float3 pos)
{
var e = em.CreateEntity();
em.AddComponentData(e, LocalTransform.FromPosition(pos));
em.AddComponentData(e, new Health { Current = 100f, Max = 100f });
em.AddComponent(e);
em.AddBuffer(e);
return e;
}
static Entity MakeHusk(EntityManager em, float3 pos)
{
var e = em.CreateEntity();
em.AddComponentData(e, LocalTransform.FromPosition(pos));
em.AddComponentData(e, new EnemyStats { MoveSpeed = 3f, AttackRange = 1.6f, AttackDamage = 8f, AttackCooldownTicks = 36 });
em.AddComponentData(e, new EnemyAttackCooldown { NextAttackTick = 0 });
em.AddComponentData(e, new KnockbackState());
em.AddComponentData(e, new AttackWindup());
em.AddComponent(e);
return e;
}
[Test]
public void Husk_Winds_Up_First_Then_Strikes_At_Expiry()
{
var (world, group) = MakeWorld("TelegraphStrike", 200);
using (world)
{
var em = world.EntityManager;
var player = MakePlayer(em, new float3(10, 1, 0));
var husk = MakeHusk(em, new float3(9, 1, 0)); // distance 1 < AttackRange 1.6 -> in range
group.Update(); // tick 200: begins the wind-up, deals NO damage yet
uint expected = TickUtil.NonZero(200 + (uint)Tuning.AttackWindupTicks);
Assert.AreEqual(expected, em.GetComponentData(husk).WindUpUntilTick,
"An in-range, ready Husk commits a wind-up until now + AttackWindupTicks.");
Assert.AreEqual(0, em.GetBuffer(player).Length, "No damage lands during the wind-up.");
SetServerTick(world, expected);
group.Update(); // wind-up elapsed -> strike lands
Assert.AreEqual(1, em.GetBuffer(player).Length, "The strike lands exactly when the wind-up elapses.");
Assert.AreEqual(0u, em.GetComponentData(husk).WindUpUntilTick, "The wind-up resets after the strike.");
Assert.AreNotEqual(0u, em.GetComponentData(husk).NextAttackTick, "The strike cooldown is stamped.");
}
}
[Test]
public void Leaving_Range_Mid_WindUp_Cancels_The_Strike()
{
var (world, group) = MakeWorld("TelegraphCancel", 200);
using (world)
{
var em = world.EntityManager;
var player = MakePlayer(em, new float3(10, 1, 0));
var husk = MakeHusk(em, new float3(9, 1, 0));
group.Update(); // begins the wind-up
uint windTick = em.GetComponentData(husk).WindUpUntilTick;
Assert.AreNotEqual(0u, windTick);
// Player flees far out of range before the wind-up completes.
em.SetComponentData(player, LocalTransform.FromPosition(new float3(60, 1, 0)));
SetServerTick(world, windTick);
group.Update();
Assert.AreEqual(0, em.GetBuffer(player).Length, "Leaving range mid-wind-up cancels the strike.");
Assert.AreEqual(0u, em.GetComponentData(husk).WindUpUntilTick, "The cancelled wind-up is cleared.");
}
}
}
}