Files
Project-M/Assets/_Project/Tests/EditMode/RoomFieldSystemTests.cs
T
2026-07-02 20:41:43 -07:00

154 lines
7.0 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.Transforms;
namespace ProjectM.Tests
{
/// <summary>
/// Plain-Entities EditMode tests for <see cref="RoomFieldSystem"/> (the Step-5 successor of the retired
/// ExpeditionFieldSystem + its teardown regression). Pins: exactly one scatter per <c>RoomEpoch</c> (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.
/// </summary>
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<SimulationSystemGroup>();
group.AddSystemToUpdateList(world.GetOrCreateSystem<RoomFieldSystem>());
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>(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);
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<RoomTag>(Allocator.Temp);
var xfs = q.ToComponentDataArray<LocalTransform>(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<RunRuntime>(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");
world.Dispose();
}
[Test]
public void Budget_FloorsSpawnCount_AndExhausts()
{
var (world, group, dir, spawnerE, prefab) = MakeWorld(nodeBudget: 1);
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<RunRuntime>(dir).NodeBudgetRemaining);
// Advance to the next room with a DRY budget — nothing more may spawn.
var run = em.GetComponentData<RunRuntime>(dir);
run.RoomEpoch = 2;
run.CurrentNodeId = RunMap.NodeId(1, 0);
run.ActiveSubSlot = 1;
em.SetComponentData(dir, run);
var info = em.GetComponentData<RunInfo>(dir);
info.CurrentRoom = 1;
em.SetComponentData(dir, info);
group.Update();
Assert.AreEqual(1, LiveNodes(em), "a dry budget spawns nothing (scarcity holds run-wide)");
world.Dispose();
}
[Test]
public void StagingSweep_KillsRoomGhosts_SparesUntagged()
{
var (world, group, dir, spawnerE, prefab) = MakeWorld(nodeBudget: 12);
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<RunInfo>(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)");
world.Dispose();
}
}
}