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"); } } } }