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 server-only . A bare world is /// seeded with NetworkTime, a CycleDirector entity (CycleState + CycleRuntime) and a zone-enemy director /// (ZoneEnemyDirector + ZoneEnemyState + a Prefab-tagged enemy in the ZoneEnemyPrefab buffer). Pins: it spawns /// only while a player is OUT in the expedition AND the base is Calm; tags spawns RegionTag{Expedition} + /// ZoneEnemyTag at the deterministic ring origin (Scale preserved); and marks CycleRuntime.ClearedThisEpoch on a /// real clear. /// public class ZoneEnemyDirectorSystemTests { static (World world, SimulationSystemGroup group, Entity cycle) MakeWorld(string name, uint serverTick, byte phase, int epoch) { var world = new World(name); 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(serverTick) }); var cyc = em.CreateEntity(typeof(CycleState), typeof(CycleRuntime)); em.SetComponentData(cyc, new CycleState { Phase = phase }); em.SetComponentData(cyc, new CycleRuntime { ExpeditionEpoch = epoch }); return (world, group, cyc); } static Entity MakeZonePrefab(EntityManager em) { var e = em.CreateEntity(typeof(LocalTransform), typeof(EnemyTag)); em.SetComponentData(e, LocalTransform.Identity); // Scale = 1 so WithPosition keeps it em.AddComponent(e); return e; } static Entity MakeDirector(EntityManager em, Entity grunt, Entity charger, int maxAlive, int gruntsPerWave, int chargersPerWave, uint nextSpawnTick, int remainingToSpawn, int seededEpoch, uint spawnCounter) { var e = em.CreateEntity(typeof(ZoneEnemyDirector), typeof(ZoneEnemyState)); em.SetComponentData(e, new ZoneEnemyDirector { MaxAlive = maxAlive, RingRadius = 14f, RingSlots = 10, SpawnIntervalTicks = 10, GruntsPerWave = gruntsPerWave, ChargersPerWave = chargersPerWave, RewardOre = 25, }); em.SetComponentData(e, new ZoneEnemyState { SpawnCounter = spawnCounter, RemainingToSpawn = remainingToSpawn, NextSpawnTick = nextSpawnTick, SeededEpoch = seededEpoch, }); var buf = em.AddBuffer(e); buf.Add(new ZoneEnemyPrefab { Prefab = grunt }); buf.Add(new ZoneEnemyPrefab { Prefab = charger }); return e; } static Entity MakeExpeditionPlayer(EntityManager em, float3 pos) { var e = em.CreateEntity(); em.AddComponentData(e, new RegionTag { Region = RegionId.Expedition }); em.AddComponentData(e, LocalTransform.FromPosition(pos)); em.AddComponent(e); return e; } static int ZoneCount(EntityManager em) { using var q = em.CreateEntityQuery(typeof(ZoneEnemyTag)); return q.CalculateEntityCount(); } [Test] public void Spawns_Expedition_Tagged_Enemy_When_Occupied_And_Calm() { var (world, group, _) = MakeWorld("ZoneSpawn", serverTick: 100, phase: CyclePhase.Calm, epoch: 1); using (world) { var em = world.EntityManager; var grunt = MakeZonePrefab(em); var charger = MakeZonePrefab(em); var dir = MakeDirector(em, grunt, charger, maxAlive: 12, gruntsPerWave: 2, chargersPerWave: 0, nextSpawnTick: 0, remainingToSpawn: 0, seededEpoch: 0, spawnCounter: 0); MakeExpeditionPlayer(em, new float3(1000, 1, 0)); group.Update(); Assert.AreEqual(1, ZoneCount(em), "one zone enemy spawns this tick"); using var q = em.CreateEntityQuery(typeof(ZoneEnemyTag), typeof(RegionTag)); var arr = q.ToComponentDataArray(Allocator.Temp); Assert.AreEqual(RegionId.Expedition, arr[0].Region, "the spawn is tagged RegionTag{Expedition}"); arr.Dispose(); var zs = em.GetComponentData(dir); Assert.AreEqual(1u, zs.SpawnCounter, "spawn counter advanced"); Assert.AreEqual(1, zs.RemainingToSpawn, "wave size 2 seeded, 1 spawned -> 1 remaining"); } } [Test] public void Spawn_Lands_On_Expedition_Ring_Origin_With_Scale_Preserved() { var (world, group, _) = MakeWorld("ZoneRing", serverTick: 100, phase: CyclePhase.Calm, epoch: 1); using (world) { var em = world.EntityManager; var grunt = MakeZonePrefab(em); var charger = MakeZonePrefab(em); MakeDirector(em, grunt, charger, maxAlive: 12, gruntsPerWave: 2, chargersPerWave: 0, nextSpawnTick: 0, remainingToSpawn: 0, seededEpoch: 0, spawnCounter: 0); MakeExpeditionPlayer(em, new float3(1000, 1, 0)); group.Update(); using var q = em.CreateEntityQuery(typeof(ZoneEnemyTag), typeof(LocalTransform)); var arr = q.ToComponentDataArray(Allocator.Temp); // origin = base(0,1,0) + (1000,0,0); ring slot 0 of a 10-slot radius-14 ring -> +X. Assert.AreEqual(1014f, arr[0].Position.x, 1e-2f, "deterministic ring slot 0 at the expedition origin (+radius on X)"); Assert.AreEqual(1f, arr[0].Scale, 1e-3f, "baked Scale preserved (WithPosition, not FromPosition)"); arr.Dispose(); } } [Test] public void Does_Not_Spawn_When_No_Expedition_Player() { var (world, group, _) = MakeWorld("ZoneEmpty", serverTick: 100, phase: CyclePhase.Calm, epoch: 1); using (world) { var em = world.EntityManager; var grunt = MakeZonePrefab(em); var charger = MakeZonePrefab(em); MakeDirector(em, grunt, charger, maxAlive: 12, gruntsPerWave: 2, chargersPerWave: 0, nextSpawnTick: 0, remainingToSpawn: 0, seededEpoch: 0, spawnCounter: 0); // no expedition player out there group.Update(); Assert.AreEqual(0, ZoneCount(em), "nobody out in the expedition -> nothing spawns"); } } [Test] public void Does_Not_Spawn_During_Base_Siege() { var (world, group, _) = MakeWorld("ZoneSiege", serverTick: 100, phase: CyclePhase.Siege, epoch: 1); using (world) { var em = world.EntityManager; var grunt = MakeZonePrefab(em); var charger = MakeZonePrefab(em); MakeDirector(em, grunt, charger, maxAlive: 12, gruntsPerWave: 2, chargersPerWave: 0, nextSpawnTick: 0, remainingToSpawn: 0, seededEpoch: 0, spawnCounter: 0); MakeExpeditionPlayer(em, new float3(1000, 1, 0)); group.Update(); Assert.AreEqual(0, ZoneCount(em), "the expedition wave pauses while the base is under siege (Calm-only spawning)"); } } [Test] public void Cleared_Wave_Marks_ClearedThisEpoch() { var (world, group, cyc) = MakeWorld("ZoneCleared", serverTick: 100, phase: CyclePhase.Calm, epoch: 1); using (world) { var em = world.EntityManager; var grunt = MakeZonePrefab(em); var charger = MakeZonePrefab(em); // already seeded this epoch + fully spawned (RemainingToSpawn 0) + no live zone enemies. MakeDirector(em, grunt, charger, maxAlive: 12, gruntsPerWave: 2, chargersPerWave: 0, nextSpawnTick: 0, remainingToSpawn: 0, seededEpoch: 1, spawnCounter: 2); MakeExpeditionPlayer(em, new float3(1000, 1, 0)); group.Update(); Assert.AreEqual((byte)1, em.GetComponentData(cyc).ClearedThisEpoch, "wave fully spawned + no live zone enemies -> a real clear is marked"); } } } }