using NUnit.Framework;
using ProjectM.Server;
using ProjectM.Simulation;
using Unity.Collections;
using Unity.Core;
using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode;
using Unity.Transforms;
namespace ProjectM.Tests
{
///
/// Plain-Entities EditMode tests for (the Step-6 successor of the retired
/// ZoneEnemyDirectorSystem). Pins: the per-RoomEpoch reseed sized by ZoneEnemyMath on the room's DifficultyEpoch;
/// spawns at the ACTIVE sub-slot origin carrying the full tag stack (RegionTag{Expedition} + ZoneEnemyTag +
/// RoomTag) with baked Scale preserved; the Boss room's single scaled boss; the MaxAlive pack-fit wait; and the
/// ExpeditionObjective Cleared/Idle latch written above the early-returns.
///
public class RoomEnemyDirectorSystemTests
{
const uint Seed = 777u;
const uint T0 = 500;
static (World world, SimulationSystemGroup group, Entity runDir, Entity zoneDir) MakeWorld(
int currentRoom, int currentNodeId, byte activeSubSlot, int maxAlive = 10)
{
var world = new World("RoomEnemyTest");
var group = world.GetOrCreateSystemManaged();
group.AddSystemToUpdateList(world.GetOrCreateSystem());
group.SortSystems();
world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
var em = world.EntityManager;
var nt = em.CreateEntity(typeof(NetworkTime));
em.SetComponentData(nt, new NetworkTime { ServerTick = new NetworkTick(T0) });
var map = RunMapMath.Generate(Seed);
var runDir = em.CreateEntity(typeof(RunInfo), typeof(RunRuntime), typeof(ExpeditionObjective));
em.SetComponentData(runDir, new RunInfo
{
Lifecycle = RunLifecycle.InRoom,
CurrentRoom = currentRoom,
RoomCount = map.LayerCount,
});
em.SetComponentData(runDir, new RunRuntime
{
RunSeed = Seed,
RoomEpoch = 1,
CurrentNodeId = currentNodeId,
ActiveSubSlot = activeSubSlot,
});
var grunt = MakeEnemyPrefab(em);
var charger = MakeEnemyPrefab(em);
var zoneDir = em.CreateEntity(typeof(ZoneEnemyDirector), typeof(ZoneEnemyState));
em.SetComponentData(zoneDir, new ZoneEnemyDirector
{
MaxAlive = maxAlive, RingRadius = 14f, RingSlots = 10, SpawnIntervalTicks = 10,
GruntsPerWave = 4, ChargersPerWave = 1, SwarmerPackSize = 3, ClusterTightRadius = 1.5f, RewardOre = 25,
});
var buf = em.AddBuffer(zoneDir);
buf.Add(new ZoneEnemyPrefab { Prefab = grunt });
buf.Add(new ZoneEnemyPrefab { Prefab = charger });
return (world, group, runDir, zoneDir);
}
static Entity MakeEnemyPrefab(EntityManager em)
{
var e = em.CreateEntity(typeof(LocalTransform), typeof(EnemyTag), typeof(Health));
em.SetComponentData(e, LocalTransform.Identity); // Scale = 1 so WithPosition keeps it
em.SetComponentData(e, new Health { Current = 50f, Max = 50f });
em.AddComponent(e);
return e;
}
static MixBands Bands() => new MixBands { GruntBase = 4, ChargerBase = 1 };
static int Alive(EntityManager em)
{
using var q = em.CreateEntityQuery(typeof(ZoneEnemyTag));
return q.CalculateEntityCount();
}
static void AdvanceTick(EntityManager em, uint tick)
{
using var q = em.CreateEntityQuery(typeof(NetworkTime));
em.SetComponentData(q.GetSingletonEntity(), new NetworkTime { ServerTick = new NetworkTick(tick) });
}
[Test]
public void Seeds_ByRoomDifficulty_AndSpawnsTaggedAtActiveSlotOrigin()
{
var (world, group, runDir, zoneDir) = MakeWorld(currentRoom: 0, currentNodeId: RunMap.NodeId(0, 0), activeSubSlot: 0);
using (world)
{
var em = world.EntityManager;
var map = RunMapMath.Generate(Seed);
var plan = RoomLayoutMath.Plan(map.NodeAt(RunMap.NodeId(0, 0)), 0, map.LayerCount);
int slots = ZoneEnemyMath.WaveSlots(plan.DifficultyEpoch, Bands());
group.Update(); // seeds; the landing grace holds the first slot (demo polish)
Assert.AreEqual(slots, em.GetComponentData(zoneDir).RemainingToSpawn,
"grace: nothing spawns on the seed tick");
AdvanceTick(em, T0 + Tuning.RoomEntryGraceTicks + 1);
group.Update(); // grace elapsed -> the first slot drips
var zs = em.GetComponentData(zoneDir);
Assert.AreEqual(1, zs.SeededEpoch, "seeded for RoomEpoch 1");
Assert.AreEqual(slots - 1, zs.RemainingToSpawn, "wave sized by ZoneEnemyMath on the room's DifficultyEpoch");
Assert.AreEqual(1, Alive(em), "first slot drip-spawned");
var q = em.CreateEntityQuery(typeof(ZoneEnemyTag), typeof(RoomTag), typeof(RegionTag), typeof(LocalTransform));
Assert.AreEqual(1, q.CalculateEntityCount(), "spawn carries the FULL tag stack (Zone + Room + Region)");
var xfs = q.ToComponentDataArray(Allocator.Temp);
var regs = q.ToComponentDataArray(Allocator.Temp);
var rooms = q.ToComponentDataArray(Allocator.Temp);
Assert.AreEqual(RegionId.Expedition, regs[0].Region);
Assert.AreEqual(0, rooms[0].Room);
Assert.AreEqual(1f, xfs[0].Scale, 1e-4f, "baked Scale preserved");
float3 origin = RegionMath.ExpeditionRoomOrigin(new float3(0f, 1f, 0f), 0);
Assert.LessOrEqual(math.distance(xfs[0].Position.xz, origin.xz), 14f + 0.01f,
"ring-spawned around the ACTIVE sub-slot origin");
xfs.Dispose(); regs.Dispose(); rooms.Dispose(); q.Dispose(); // dispose BEFORE the worldispose();
}
}
[Test]
public void BossRoom_SpawnsSingleScaledBoss()
{
var map = RunMapMath.Generate(Seed);
int bossLayer = map.LayerCount - 1;
var (world, group, runDir, zoneDir) = MakeWorld(bossLayer, map.BossNodeId, activeSubSlot: (byte)(bossLayer & 1));
using (world)
{
var em = world.EntityManager;
group.Update(); // seed tick (the landing grace holds the boss)
AdvanceTick(em, T0 + Tuning.RoomEntryGraceTicks + 1);
group.Update(); // grace elapsed -> the boss spawns
var zs = em.GetComponentData(zoneDir);
Assert.AreEqual(0, zs.RemainingToSpawn, "a boss room is a single-slot wave, fully spawned");
Assert.AreEqual(1, Alive(em), "exactly one boss");
var q = em.CreateEntityQuery(typeof(ZoneEnemyTag), typeof(Health), typeof(LocalTransform));
var hps = q.ToComponentDataArray(Allocator.Temp);
var xfs = q.ToComponentDataArray(Allocator.Temp);
Assert.AreEqual(50f * Tuning.BossHealthMultiplier, hps[0].Max, 1e-3f, "boss health scaled");
Assert.AreEqual(Tuning.BossScaleMultiplier, xfs[0].Scale, 1e-3f, "boss visual scale bumped");
hps.Dispose(); xfs.Dispose(); q.Dispose(); // dispose BEFORE the world (a using-var here outlives it);
}
}
[Test]
public void Objective_ClearedLatch_AndIdleOutsideRooms()
{
var (world, group, runDir, zoneDir) = MakeWorld(0, RunMap.NodeId(0, 0), 0);
using (world)
{
var em = world.EntityManager;
// Fabricate a fully-spawned, fully-dead wave for the CURRENT room epoch.
em.SetComponentData(zoneDir, new ZoneEnemyState { SeededEpoch = 1, RemainingToSpawn = 0, SpawnCounter = 5 });
group.Update();
Assert.AreEqual(ExpeditionObjectiveState.Cleared, em.GetComponentData(runDir).State,
"fully-spawned + zero-alive latches Cleared for the seeded epoch");
var info = em.GetComponentData(runDir);
info.Lifecycle = RunLifecycle.Staging;
em.SetComponentData(runDir, info);
group.Update();
Assert.AreEqual(ExpeditionObjectiveState.Idle, em.GetComponentData(runDir).State,
"no active room -> Idle (objective still written above the early-return)");
}
}
[Test]
public void MaxAlive_PackFitWaits_WithoutConsumingTheSlot()
{
var (world, group, runDir, zoneDir) = MakeWorld(0, RunMap.NodeId(0, 0), 0, maxAlive: 1);
using (world)
{
var em = world.EntityManager;
// One zone enemy already alive fills the cap.
var blocker = em.CreateEntity(typeof(ZoneEnemyTag));
// Pre-seed so the tick goes straight to the drip branch.
em.SetComponentData(zoneDir, new ZoneEnemyState { SeededEpoch = 1, RemainingToSpawn = 2, NextSpawnTick = 0 });
group.Update();
var zs = em.GetComponentData(zoneDir);
Assert.AreEqual(1, Alive(em), "cap full -> nothing spawned");
Assert.AreEqual(2, zs.RemainingToSpawn, "the slot WAITS (not consumed) until the pack fits");
}
}
}
}