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