e27a495530
- 10 EditMode files: every bare trailing world.Dispose() now inside using(world){} (single-world) or a [TearDown]+List<World> (the multi-world reject-matrix tests in BoonApplyTests/RouteSelectSystemTests) — an assertion failure can no longer leak the World and mask the true first failure. No test logic changed.
- HudSystem: collapse the blank-line run before the class close; trim the extracted route-map clause from the READY-panel comment.
466/466 EditMode tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
199 lines
9.5 KiB
C#
199 lines
9.5 KiB
C#
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
|
|
{
|
|
/// <summary>
|
|
/// Plain-Entities EditMode tests for <see cref="RoomEnemyDirectorSystem"/> (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.
|
|
/// </summary>
|
|
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<SimulationSystemGroup>();
|
|
group.AddSystemToUpdateList(world.GetOrCreateSystem<RoomEnemyDirectorSystem>());
|
|
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<ZoneEnemyPrefab>(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<Prefab>(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<ZoneEnemyState>(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<ZoneEnemyState>(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<LocalTransform>(Allocator.Temp);
|
|
var regs = q.ToComponentDataArray<RegionTag>(Allocator.Temp);
|
|
var rooms = q.ToComponentDataArray<RoomTag>(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<ZoneEnemyState>(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<Health>(Allocator.Temp);
|
|
var xfs = q.ToComponentDataArray<LocalTransform>(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<ExpeditionObjective>(runDir).State,
|
|
"fully-spawned + zero-alive latches Cleared for the seeded epoch");
|
|
|
|
var info = em.GetComponentData<RunInfo>(runDir);
|
|
info.Lifecycle = RunLifecycle.Staging;
|
|
em.SetComponentData(runDir, info);
|
|
group.Update();
|
|
Assert.AreEqual(ExpeditionObjectiveState.Idle, em.GetComponentData<ExpeditionObjective>(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<ZoneEnemyState>(zoneDir);
|
|
Assert.AreEqual(1, Alive(em), "cap full -> nothing spawned");
|
|
Assert.AreEqual(2, zs.RemainingToSpawn, "the slot WAITS (not consumed) until the pack fits");
|
|
}
|
|
}
|
|
}
|
|
}
|