using NUnit.Framework; using ProjectM.Server; using ProjectM.Simulation; using Unity.Collections; using Unity.Core; using Unity.Entities; using Unity.Mathematics; using Unity.Transforms; namespace ProjectM.Tests { /// /// Plain-Entities EditMode tests for (the Step-5 successor of the retired /// ExpeditionFieldSystem + its teardown regression). Pins: exactly one scatter per RoomEpoch (int-equality /// reseed), the run-wide scarcity budget flooring + spend-down, RoomTag stamping + baked-Scale preservation + /// in-shape placement at the active sub-slot origin, and the Staging defensive sweep that kills room ghosts while /// UNTAGGED entities (the old base-field-survives regression, now structural) are untouched. /// public class RoomFieldSystemTests { const uint Seed = 777u; static (World world, SimulationSystemGroup group, Entity dir, Entity spawnerE, Entity prefab) MakeWorld( int nodeBudget) { var world = new World("RoomFieldTest"); var group = world.GetOrCreateSystemManaged(); group.AddSystemToUpdateList(world.GetOrCreateSystem()); group.SortSystems(); world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f)); var em = world.EntityManager; var map = RunMapMath.Generate(Seed); var dir = em.CreateEntity(typeof(RunInfo), typeof(RunRuntime)); em.SetComponentData(dir, new RunInfo { Lifecycle = RunLifecycle.InRoom, CurrentRoom = 0, RoomCount = map.LayerCount, }); em.SetComponentData(dir, new RunRuntime { RunSeed = Seed, RoomEpoch = 1, CurrentNodeId = RunMap.NodeId(0, 0), ActiveSubSlot = 0, NodeBudgetRemaining = nodeBudget, }); // Node ghost prefab: Scale=2 pins the WithPosition (never FromPosition) preservation. var prefab = em.CreateEntity(typeof(LocalTransform), typeof(ResourceNode)); em.SetComponentData(prefab, new LocalTransform { Position = float3.zero, Rotation = quaternion.identity, Scale = 2f }); em.SetComponentData(prefab, new ResourceNode { ResourceId = ResourceId.Ore, Remaining = 30, HarvestPerHit = 5f }); em.AddComponent(prefab); // Spawner singleton with the runtime state PRE-attached (skips the one-shot attach tick). var spawnerE = em.CreateEntity(typeof(ResourceFieldSpawner), typeof(RoomFieldState)); em.SetComponentData(spawnerE, new ResourceFieldSpawner { Prefab = prefab, Count = 99, Radius = 0f }); return (world, group, dir, spawnerE, prefab); } static int LiveNodes(EntityManager em) { using var q = em.CreateEntityQuery(typeof(ResourceNode), typeof(RoomTag)); return q.CalculateEntityCount(); } [Test] public void Spawns_OncePerRoomEpoch_PlanCount_TaggedInShape_ScalePreserved() { var (world, group, dir, spawnerE, prefab) = MakeWorld(nodeBudget: 12); 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 expected = math.min(plan.NodeCount, 12); float3 origin = RegionMath.ExpeditionRoomOrigin(new float3(0f, 1f, 0f), 0); group.Update(); Assert.AreEqual(expected, LiveNodes(em), "one room's plan-count scatter"); using (var q = em.CreateEntityQuery(typeof(ResourceNode), typeof(RoomTag), typeof(LocalTransform))) { var tags = q.ToComponentDataArray(Allocator.Temp); var xfs = q.ToComponentDataArray(Allocator.Temp); for (int i = 0; i < tags.Length; i++) { Assert.AreEqual(0, tags[i].Room, "stamped with the active room index"); Assert.AreEqual(2f, xfs[i].Scale, 1e-4f, "baked Scale preserved (WithPosition, never FromPosition)"); Assert.IsTrue(RoomLayoutMath.ContainsPoint(plan.ShapeId, origin, xfs[i].Position), "scattered inside the room shape at the sub-slot-0 origin"); Assert.GreaterOrEqual(xfs[i].Position.x, 900f, "placed at the EXPEDITION origin, not the base"); } tags.Dispose(); xfs.Dispose(); } Assert.AreEqual(12 - expected, em.GetComponentData(dir).NodeBudgetRemaining, "budget spent down by exactly the scattered count"); group.Update(); // same RoomEpoch — must NOT scatter again Assert.AreEqual(expected, LiveNodes(em), "int-equality reseed: one scatter per RoomEpoch"); } } [Test] public void Budget_FloorsSpawnCount_AndExhausts() { var (world, group, dir, spawnerE, prefab) = MakeWorld(nodeBudget: 1); using (world) { var em = world.EntityManager; group.Update(); Assert.AreEqual(1, LiveNodes(em), "budget of 1 floors the room to a single node"); Assert.AreEqual(0, em.GetComponentData(dir).NodeBudgetRemaining); // Advance to the next room with a DRY budget — nothing more may spawn. var run = em.GetComponentData(dir); run.RoomEpoch = 2; run.CurrentNodeId = RunMap.NodeId(1, 0); run.ActiveSubSlot = 1; em.SetComponentData(dir, run); var info = em.GetComponentData(dir); info.CurrentRoom = 1; em.SetComponentData(dir, info); group.Update(); Assert.AreEqual(1, LiveNodes(em), "a dry budget spawns nothing (scarcity holds run-wide)"); } } [Test] public void StagingSweep_KillsRoomGhosts_SparesUntagged() { var (world, group, dir, spawnerE, prefab) = MakeWorld(nodeBudget: 12); using (world) { var em = world.EntityManager; group.Update(); Assert.Greater(LiveNodes(em), 0, "room content exists"); // An untagged node (e.g. the base mining field) must be structurally untouchable. var baseNode = em.CreateEntity(typeof(LocalTransform), typeof(ResourceNode)); em.SetComponentData(baseNode, LocalTransform.FromPosition(new float3(20f, 0f, 0f))); var info = em.GetComponentData(dir); info.Lifecycle = RunLifecycle.Staging; em.SetComponentData(dir, info); group.Update(); Assert.AreEqual(0, LiveNodes(em), "Staging sweep cleared every room ghost"); Assert.IsTrue(em.Exists(baseNode), "untagged entities survive (the old base-field regression, structural now)"); } } } }