Run Re-Do

This commit is contained in:
2026-07-02 20:41:43 -07:00
parent 86575dd5bc
commit 16e396841e
188 changed files with 8291 additions and 2429 deletions
@@ -1,145 +0,0 @@
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 the server-only <see cref="BaseFieldSpawnSystem"/> — the home-base mining
/// field. A bare world is seeded with a NetworkTime singleton, a BaseAnchor (plot centred on origin), a
/// ResourceNode prefab (Prefab-tagged so it is excluded from the live count) and a BaseFieldSpawner +
/// BaseFieldRuntime. Pins: the first pass seeds the field to TargetCount with every node RegionTag{Base} +
/// ResourceId.Ore inside the [Inner,Outer] annulus; the cadence gate suppresses a respawn before the interval
/// elapses; and a depleted field tops back up to TargetCount once the interval passes (no economy soft-lock).
/// </summary>
public class BaseFieldSpawnSystemTests
{
static (World world, SimulationSystemGroup group, Entity spawner, Entity prefab) MakeWorld(string name, uint serverTick, int target, float inner, float outer, int interval)
{
var world = new World(name);
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
group.AddSystemToUpdateList(world.GetOrCreateSystem<BaseFieldSpawnSystem>());
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) });
// Plot centred on origin: GridOrigin -16, dims 16, cell 2 => PlotCenter = (0,0,0).
var anchor = em.CreateEntity(typeof(BaseAnchor));
em.SetComponentData(anchor, new BaseAnchor
{
AnchorPos = float3.zero,
GridOrigin = new float3(-16f, 0f, -16f),
CellSize = 2f,
GridDims = new int2(16, 16),
});
// The node prefab: Prefab-tagged so the live-count query (and the system's) skip it.
var prefab = em.CreateEntity(typeof(Prefab), typeof(LocalTransform), typeof(ResourceNode), typeof(RegionTag), typeof(HitRadius));
em.SetComponentData(prefab, LocalTransform.FromPosition(float3.zero));
em.SetComponentData(prefab, new ResourceNode { ResourceId = ResourceId.Aether, Remaining = 30, HarvestPerHit = 5f });
em.SetComponentData(prefab, new RegionTag { Region = RegionId.Expedition });
em.SetComponentData(prefab, new HitRadius { Value = 1.2f });
var spawner = em.CreateEntity(typeof(BaseFieldSpawner), typeof(BaseFieldRuntime));
em.SetComponentData(spawner, new BaseFieldSpawner
{
Prefab = prefab,
TargetCount = target,
InnerRadius = inner,
OuterRadius = outer,
RespawnIntervalTicks = interval,
});
em.SetComponentData(spawner, new BaseFieldRuntime { Epoch = 0, NextSpawnTick = 0u });
return (world, group, spawner, prefab);
}
static Entity[] LiveNodes(EntityManager em)
{
// Default query options exclude Prefab + Disabled, so the prefab is not counted.
using var q = em.CreateEntityQuery(ComponentType.ReadOnly<ResourceNode>(), ComponentType.ReadOnly<RegionTag>());
return q.ToEntityArray(Allocator.Temp).ToArray();
}
static void SetServerTick(EntityManager em, uint tick)
{
using var q = em.CreateEntityQuery(ComponentType.ReadWrite<NetworkTime>());
var nt = q.GetSingletonEntity();
em.SetComponentData(nt, new NetworkTime { ServerTick = new NetworkTick(tick) });
}
[Test]
public void First_Pass_Seeds_Target_Count_Base_Ore_Nodes_In_Annulus()
{
var (world, group, _, _) = MakeWorld("BaseFieldSeed", serverTick: 100, target: 8, inner: 10f, outer: 20f, interval: 600);
using (world)
{
var em = world.EntityManager;
group.Update();
var nodes = LiveNodes(em);
Assert.AreEqual(8, nodes.Length, "The first pass seeds exactly TargetCount base nodes.");
foreach (var n in nodes)
{
Assert.AreEqual(RegionId.Base, em.GetComponentData<RegionTag>(n).Region, "Base nodes are RegionTag.Base so RegionRelevancy keeps them for base players.");
Assert.AreEqual(ResourceId.Ore, em.GetComponentData<ResourceNode>(n).ResourceId, "Base nodes are Ore-only (the build currency).");
float2 xz = em.GetComponentData<LocalTransform>(n).Position.xz;
float r = math.length(xz);
Assert.GreaterOrEqual(r, 10f - 0.01f, "A node is no nearer than InnerRadius (clears the build plot).");
Assert.LessOrEqual(r, 20f + 0.01f, "A node is no farther than OuterRadius (stays reachable).");
}
}
}
[Test]
public void Does_Not_Respawn_Before_The_Interval_Elapses()
{
var (world, group, _, _) = MakeWorld("BaseFieldCadence", serverTick: 100, target: 8, inner: 10f, outer: 20f, interval: 600);
using (world)
{
var em = world.EntityManager;
group.Update(); // seeds 8, NextSpawnTick = 700
// Deplete 3 nodes.
var nodes = LiveNodes(em);
for (int i = 0; i < 3; i++)
em.DestroyEntity(nodes[i]);
Assert.AreEqual(5, LiveNodes(em).Length);
// Same tick (100 < 700): the cadence gate suppresses a refill.
group.Update();
Assert.AreEqual(5, LiveNodes(em).Length, "No top-up before RespawnIntervalTicks elapses.");
}
}
[Test]
public void Tops_Up_To_Target_After_Depletion_Once_Interval_Passes()
{
var (world, group, _, _) = MakeWorld("BaseFieldTopUp", serverTick: 100, target: 8, inner: 10f, outer: 20f, interval: 600);
using (world)
{
var em = world.EntityManager;
group.Update(); // seeds 8, NextSpawnTick = 700
var nodes = LiveNodes(em);
for (int i = 0; i < 3; i++)
em.DestroyEntity(nodes[i]);
Assert.AreEqual(5, LiveNodes(em).Length);
// Advance past the interval (800 > 700): refill the 3-node deficit back to TargetCount.
SetServerTick(em, 800);
group.Update();
Assert.AreEqual(8, LiveNodes(em).Length, "A depleted field tops back up to TargetCount (no economy soft-lock).");
}
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: a7a8c39aa67d45d4586a269f136999c5
@@ -0,0 +1,174 @@
using NUnit.Framework;
using ProjectM.Server;
using ProjectM.Simulation;
using Unity.Collections;
using Unity.Core;
using Unity.Entities;
using Unity.NetCode;
using Unity.Transforms;
namespace ProjectM.Tests
{
/// <summary>
/// Pins the two-channel boon lifecycle: <see cref="BoonApplySystem"/> (a valid pick appends exactly ONE
/// boon-band <see cref="StatModifier"/> and clears Pending; out-of-range / not-pending / closed-lifecycle picks
/// are rejected; the grace auto-pick deals Option0) and the RunDirector Returning-edge RANGE STRIP (every
/// boon-band row dies; class/meta/equip bands survive; offers zeroed) — run boons NEVER persist (DR-037).
/// </summary>
public class BoonApplyTests
{
const uint T0 = 3000;
static (World world, SimulationSystemGroup group, Entity dir, Entity catalog) MakeWorld(byte lifecycle)
{
var world = new World("BoonApplyTest");
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
group.AddSystemToUpdateList(world.GetOrCreateSystem<BoonApplySystem>());
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 dir = em.CreateEntity(typeof(RunInfo), typeof(RunRuntime));
em.SetComponentData(dir, new RunInfo { Lifecycle = lifecycle, CurrentRoom = 1 });
em.SetComponentData(dir, new RunRuntime { RunSeed = 7u, RoomEpoch = 2, RewardGraceTick = T0 + 1800 });
var catalog = em.CreateEntity(typeof(BoonCatalog));
em.SetComponentData(catalog, new BoonCatalog { Value = BoonCatalogData.BuildDefault() });
return (world, group, dir, catalog);
}
static Entity MakePicker(EntityManager em, int netId, byte o0 = 1, byte o1 = 4, byte o2 = 5)
{
var e = em.CreateEntity(typeof(PlayerTag), typeof(BoonOffer), typeof(GhostOwner), typeof(RegionTag));
em.AddBuffer<StatModifier>(e);
em.SetComponentData(e, new GhostOwner { NetworkId = netId });
em.SetComponentData(e, new RegionTag { Region = RegionId.Expedition });
em.SetComponentData(e, new BoonOffer { Pending = 1, Option0 = o0, Option1 = o1, Option2 = o2 });
return e;
}
static void SendPick(EntityManager em, int netId, byte index)
{
var conn = em.CreateEntity(typeof(NetworkId));
em.SetComponentData(conn, new NetworkId { Value = netId });
var req = em.CreateEntity(typeof(BoonPickRequest), typeof(ReceiveRpcCommandRequest));
em.SetComponentData(req, new BoonPickRequest { Index = index });
em.SetComponentData(req, new ReceiveRpcCommandRequest { SourceConnection = conn });
}
static int BoonRows(EntityManager em, Entity player)
{
var mods = em.GetBuffer<StatModifier>(player);
int n = 0;
for (int i = 0; i < mods.Length; i++)
if (mods[i].SourceId >= Tuning.BoonSourceIdBase
&& mods[i].SourceId < Tuning.BoonSourceIdBase + Tuning.BoonSourceIdSpan) n++;
return n;
}
[Test]
public void ValidPick_AppendsBoonBandRow_AndClearsPending()
{
var (world, group, dir, catalog) = MakeWorld(RunLifecycle.RoomReward);
var em = world.EntityManager;
var player = MakePicker(em, 1);
SendPick(em, 1, index: 1); // Option1 = id 4 (Fleet Foot, MoveSpeed +12%)
group.Update();
Assert.AreEqual(1, BoonRows(em, player), "exactly one boon-band row appended");
var mods = em.GetBuffer<StatModifier>(player);
Assert.AreEqual((byte)StatTarget.MoveSpeed, mods[0].Target, "the picked def's target");
Assert.AreEqual((byte)ModOp.PercentAdd, mods[0].Op);
Assert.AreEqual(0.12f, mods[0].Value, 1e-4f);
Assert.AreEqual(0, em.GetComponentData<BoonOffer>(player).Pending, "pick consumed");
Assert.AreEqual(1u, em.GetComponentData<RunRuntime>(dir).BoonPickCounter, "band provenance advanced");
world.Dispose();
}
[Test]
public void Rejects_NotPending_ClosedLifecycle_KeepsBufferClean()
{
// Not pending.
var (w1, g1, d1, c1) = MakeWorld(RunLifecycle.RoomReward);
var p1 = MakePicker(w1.EntityManager, 1);
w1.EntityManager.SetComponentData(p1, new BoonOffer { Pending = 0, Option0 = 1 });
SendPick(w1.EntityManager, 1, 0);
g1.Update();
Assert.AreEqual(0, BoonRows(w1.EntityManager, p1), "not-pending pick rejected");
w1.Dispose();
// Lifecycle closed (Returning): the straggler pick dies BEFORE any strip could be out-run (D-F4).
var (w2, g2, d2, c2) = MakeWorld(RunLifecycle.Returning);
var p2 = MakePicker(w2.EntityManager, 1);
SendPick(w2.EntityManager, 1, 0);
g2.Update();
Assert.AreEqual(0, BoonRows(w2.EntityManager, p2), "closed-lifecycle pick rejected");
using (var q = w2.EntityManager.CreateEntityQuery(typeof(BoonPickRequest)))
Assert.AreEqual(0, q.CalculateEntityCount(), "request still consumed");
w2.Dispose();
}
[Test]
public void GraceElapsed_AutoPicksOption0_ForPendingExpeditionPlayers()
{
var (world, group, dir, catalog) = MakeWorld(RunLifecycle.RoomReward);
var em = world.EntityManager;
var afk = MakePicker(em, 1, o0: 5); // Option0 = id 5 (Iron Constitution, +25 MaxHealth)
var run = em.GetComponentData<RunRuntime>(dir);
run.RewardGraceTick = T0 - 10; // already elapsed
em.SetComponentData(dir, run);
group.Update();
Assert.AreEqual(1, BoonRows(em, afk), "AFK player auto-dealt Option0");
var mods = em.GetBuffer<StatModifier>(afk);
Assert.AreEqual((byte)StatTarget.MaxHealth, mods[0].Target);
Assert.AreEqual(0, em.GetComponentData<BoonOffer>(afk).Pending, "gate released");
world.Dispose();
}
[Test]
public void ReturningStrip_KillsBoonBand_SparesClassMetaEquip()
{
// Drive the REAL RunDirectorSystem Returning edge over a player carrying all four bands.
var world = new World("BoonStripTest");
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
group.AddSystemToUpdateList(world.GetOrCreateSystem<RunDirectorSystem>());
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 dir = em.CreateEntity(typeof(RunInfo), typeof(RunRuntime), typeof(RouteCommand));
em.SetComponentData(dir, new RunInfo { Lifecycle = RunLifecycle.Returning, CurrentRoom = 3, RoomCount = 8 });
em.SetComponentData(dir, new RunRuntime { RunSeed = 7u, RunEpoch = 1, RoomsClearedThisRun = 3 });
var player = em.CreateEntity(typeof(PlayerTag), typeof(PlayerReady), typeof(BoonOffer),
typeof(RegionTag), typeof(LocalTransform));
em.SetComponentData(player, new RegionTag { Region = RegionId.Expedition });
em.SetComponentData(player, LocalTransform.Identity);
em.SetComponentData(player, new BoonOffer { Pending = 1, Option0 = 1 });
var mods = em.AddBuffer<StatModifier>(player);
mods.Add(new StatModifier { Target = 0, Op = 1, Value = 0.2f, SourceId = Tuning.BoonSourceIdBase }); // boon
mods.Add(new StatModifier { Target = 0, Op = 1, Value = 0.5f, SourceId = Tuning.BoonSourceIdBase + 1 }); // boon
mods.Add(new StatModifier { Target = 6, Op = 1, Value = 0.1f, SourceId = Tuning.ClassSourceId }); // class
mods.Add(new StatModifier { Target = 8, Op = 0, Value = 10f, SourceId = 0x00E7A000u }); // meta (12a band)
mods.Add(new StatModifier { Target = 0, Op = 0, Value = 5f, SourceId = Tuning.EquipSourceIdBase }); // equip
group.Update(); // Returning: strip + bank + home -> Staging
var after = em.GetBuffer<StatModifier>(player);
Assert.AreEqual(3, after.Length, "both boon rows stripped, all three permanent bands survive");
for (int i = 0; i < after.Length; i++)
Assert.IsFalse(after[i].SourceId >= Tuning.BoonSourceIdBase
&& after[i].SourceId < Tuning.BoonSourceIdBase + Tuning.BoonSourceIdSpan, "no boon-band survivor");
Assert.AreEqual(0, em.GetComponentData<BoonOffer>(player).Pending, "straggler offer zeroed");
Assert.AreEqual(RunLifecycle.Staging, em.GetComponentData<RunInfo>(dir).Lifecycle);
world.Dispose();
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: f703eba535e33a3489b833c59cbd3803
@@ -0,0 +1,104 @@
using NUnit.Framework;
using ProjectM.Server;
using ProjectM.Simulation;
using Unity.Collections;
using Unity.Core;
using Unity.Entities;
using Unity.NetCode;
namespace ProjectM.Tests
{
/// <summary>
/// Pins the boon pool math (<see cref="BoonMath.PickBoons"/>: deterministic, 3 distinct, class-filtered,
/// weight-0 excluded) and <see cref="BoonOfferSystem"/> (one deal per RoomEpoch; expedition players only;
/// owner-seeded per player so co-op offers differ).
/// </summary>
public class BoonOfferTests
{
[Test]
public void PickBoons_Deterministic_Distinct_ClassFiltered()
{
var blob = BoonCatalogData.BuildDefault(Allocator.Temp);
ref var pool = ref blob.Value;
for (byte classId = 0; classId <= 1; classId++)
{
for (uint seed = 1; seed < 200; seed += 7)
{
int n = BoonMath.PickBoons(seed, classId, ref pool, out byte a0, out byte a1, out byte a2);
Assert.AreEqual(3, n, "the default pool always fills 3 options");
Assert.AreNotEqual(a0, a1, "distinct");
Assert.AreNotEqual(a1, a2, "distinct");
Assert.AreNotEqual(a0, a2, "distinct");
// Deterministic re-draw.
BoonMath.PickBoons(seed, classId, ref pool, out byte b0, out byte b1, out byte b2);
Assert.AreEqual(a0, b0);
Assert.AreEqual(a1, b1);
Assert.AreEqual(a2, b2);
// Every option is class-legal.
byte bit = BoonMath.MaskFor(classId);
foreach (var id in new[] { a0, a1, a2 })
{
int idx = BoonMath.FindDef(ref pool, id);
Assert.GreaterOrEqual(idx, 0, "offered id exists");
Assert.AreNotEqual(0, pool.Defs[idx].ClassMask & bit,
$"boon {id} offered to class {classId} must pass its ClassMask");
}
}
}
blob.Dispose();
}
[Test]
public void OfferSystem_DealsOncePerRoom_ExpeditionOnly_PerPlayerSeeds()
{
var world = new World("BoonOfferTest");
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
group.AddSystemToUpdateList(world.GetOrCreateSystem<BoonOfferSystem>());
group.SortSystems();
world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
var em = world.EntityManager;
var dir = em.CreateEntity(typeof(RunInfo), typeof(RunRuntime));
em.SetComponentData(dir, new RunInfo { Lifecycle = RunLifecycle.RoomReward, CurrentRoom = 1 });
em.SetComponentData(dir, new RunRuntime { RunSeed = 4242u, RoomEpoch = 2 });
var catalog = em.CreateEntity(typeof(BoonCatalog));
em.SetComponentData(catalog, new BoonCatalog { Value = BoonCatalogData.BuildDefault() });
Entity MakePlayer(int netId, byte region, byte classId)
{
var e = em.CreateEntity(typeof(PlayerTag), typeof(BoonOffer), typeof(GhostOwner),
typeof(RegionTag), typeof(PlayerClass));
em.SetComponentData(e, new GhostOwner { NetworkId = netId });
em.SetComponentData(e, new RegionTag { Region = region });
em.SetComponentData(e, new PlayerClass { ClassId = classId });
return e;
}
var out1 = MakePlayer(1, RegionId.Expedition, 0);
var out2 = MakePlayer(2, RegionId.Expedition, 1);
var home = MakePlayer(3, RegionId.Base, 0);
group.Update(); // one-shot state attach
group.Update(); // deal
var offer1 = em.GetComponentData<BoonOffer>(out1);
var offer2 = em.GetComponentData<BoonOffer>(out2);
Assert.AreEqual(1, offer1.Pending, "expedition player 1 dealt");
Assert.AreEqual(1, offer2.Pending, "expedition player 2 dealt");
Assert.AreEqual(0, em.GetComponentData<BoonOffer>(home).Pending, "home player dealt NOTHING");
bool differ = offer1.Option0 != offer2.Option0 || offer1.Option1 != offer2.Option1
|| offer1.Option2 != offer2.Option2;
Assert.IsTrue(differ, "per-player seeds -> co-op offers differ (seed folds NetworkId)");
// Same epoch -> no re-deal (clear one offer and confirm it stays cleared).
em.SetComponentData(out1, default(BoonOffer));
group.Update();
Assert.AreEqual(0, em.GetComponentData<BoonOffer>(out1).Pending, "one deal per RoomEpoch (latch)");
world.Dispose();
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 1e10416fe4cd405479a526eddd91bc1f
@@ -1,57 +0,0 @@
using NUnit.Framework;
using ProjectM.Server;
using ProjectM.Simulation;
using Unity.Core;
using Unity.Entities;
using Unity.Mathematics;
using Unity.Transforms;
namespace ProjectM.Tests
{
/// <summary>
/// Regression test for the <see cref="ExpeditionFieldSystem"/> teardown region-filter. When the last player
/// leaves the expedition the field is cleared — but that teardown must destroy ONLY RegionTag{Expedition}
/// nodes, never the permanent RegionTag{Base} home-base mining field. Before the fix the unfiltered teardown
/// wiped every ResourceNode on the empty edge (a despawn storm beside base players that broke the core loop).
/// </summary>
public class ExpeditionFieldTeardownTests
{
[Test]
public void Expedition_Empty_Edge_Destroys_Only_Expedition_Nodes_Base_Field_Survives()
{
var world = new World("ExpeditionTeardown");
using (world)
{
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
group.AddSystemToUpdateList(world.GetOrCreateSystem<ExpeditionFieldSystem>());
group.SortSystems();
world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
var em = world.EntityManager;
// Cycle director: was occupied last tick, nobody out there now => the occupied->empty edge fires.
var cycle = em.CreateEntity(typeof(CycleState), typeof(CycleRuntime));
em.SetComponentData(cycle, new CycleState { Phase = CyclePhase.Calm, CycleNumber = 1 });
em.SetComponentData(cycle, new CycleRuntime { PrevExpeditionOccupied = 1 });
// Spawner singleton (required); null prefab so the spawn branch is inert.
var spawnerE = em.CreateEntity(typeof(ResourceFieldSpawner));
em.SetComponentData(spawnerE, new ResourceFieldSpawner { Prefab = Entity.Null, Count = 5, Radius = 10f });
var baseNode = em.CreateEntity(typeof(LocalTransform), typeof(ResourceNode), typeof(RegionTag));
em.SetComponentData(baseNode, LocalTransform.FromPosition(new float3(20, 0, 0)));
em.SetComponentData(baseNode, new ResourceNode { ResourceId = ResourceId.Ore, Remaining = 30, HarvestPerHit = 5f });
em.SetComponentData(baseNode, new RegionTag { Region = RegionId.Base });
var expNode = em.CreateEntity(typeof(LocalTransform), typeof(ResourceNode), typeof(RegionTag));
em.SetComponentData(expNode, LocalTransform.FromPosition(new float3(1020, 0, 0)));
em.SetComponentData(expNode, new ResourceNode { ResourceId = ResourceId.Aether, Remaining = 30, HarvestPerHit = 5f });
em.SetComponentData(expNode, new RegionTag { Region = RegionId.Expedition });
group.Update();
Assert.IsTrue(em.Exists(baseNode), "The permanent base mining field survives the expedition teardown.");
Assert.IsFalse(em.Exists(expNode), "Only the expedition node is cleared when the last player leaves.");
}
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 269ed0f5b1c5cb6418682ccf05db45dd
@@ -1,137 +0,0 @@
using NUnit.Framework;
using ProjectM.Server;
using ProjectM.Simulation;
using Unity.Core;
using Unity.Entities;
using Unity.Mathematics;
using Unity.Transforms;
namespace ProjectM.Tests
{
/// <summary>
/// Plain-Entities EditMode tests for the once-per-epoch zone-clear reward folded into
/// <see cref="ExpeditionGateSystem"/> (DR-040 BLOCKER 4 + DR-042). A returning player banks flat Ore to the
/// shared ledger AND advances the long-arc win meter (GoalProgress.Charge — DR-042: EXPEDITION CLEARS, not
/// survived sieges, are the win-driver) IFF this epoch's expedition wave was actually cleared and not yet
/// rewarded — and never twice for the same epoch (the co-op same-tick / gate-re-entry de-dup; Ore + Charge
/// share the one LastRewardedEpoch latch so they always share fate).
/// </summary>
public class ExpeditionGateRewardTests
{
static (World world, SimulationSystemGroup group, Entity cycle) MakeWorld(string name,
int epoch, byte clearedThisEpoch, int lastRewardedEpoch)
{
var world = new World(name);
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
group.AddSystemToUpdateList(world.GetOrCreateSystem<ExpeditionGateSystem>());
group.SortSystems();
world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
var em = world.EntityManager;
// CycleDirector-like entity: cycle state/runtime + the shared resource ledger + threat state + goal meter.
var cyc = em.CreateEntity(typeof(CycleState), typeof(CycleRuntime), typeof(ResourceLedger),
typeof(ThreatState), typeof(GoalProgress));
em.SetComponentData(cyc, new CycleState { Phase = CyclePhase.Calm });
em.SetComponentData(cyc, new CycleRuntime
{
ExpeditionEpoch = epoch, ClearedThisEpoch = clearedThisEpoch, LastRewardedEpoch = lastRewardedEpoch,
});
em.SetComponentData(cyc, new GoalProgress { Charge = 0, Target = 4 });
em.AddBuffer<StorageEntry>(cyc);
// Zone-enemy director singleton (only RewardOre matters to the reward fold).
var dir = em.CreateEntity(typeof(ZoneEnemyDirector));
em.SetComponentData(dir, new ZoneEnemyDirector { RewardOre = 25 });
// A gate Expedition->Base sitting at the expedition origin.
var gate = em.CreateEntity(typeof(ExpeditionGate), typeof(LocalTransform));
em.SetComponentData(gate, new ExpeditionGate
{
FromRegion = RegionId.Expedition, ToRegion = RegionId.Base, Radius = 3f, ArrivalPos = new float3(0, 1, 0),
});
em.SetComponentData(gate, LocalTransform.FromPosition(new float3(1000, 1, 0)));
return (world, group, cyc);
}
static Entity MakeExpeditionPlayerAtGate(EntityManager em)
{
var e = em.CreateEntity();
em.AddComponentData(e, new RegionTag { Region = RegionId.Expedition });
em.AddComponentData(e, LocalTransform.FromPosition(new float3(1000, 1, 0)));
em.AddComponent<PlayerTag>(e);
return e;
}
static int OreInLedger(EntityManager em, Entity cyc)
{
var buf = em.GetBuffer<StorageEntry>(cyc);
for (int i = 0; i < buf.Length; i++)
if (buf[i].ItemId == (ushort)ResourceId.Ore) return buf[i].Count;
return 0;
}
[Test]
public void Cleared_Return_Banks_Ore_And_Charge_Once()
{
var (world, group, cyc) = MakeWorld("GateRewardOnce", epoch: 1, clearedThisEpoch: 1, lastRewardedEpoch: 0);
using (world)
{
var em = world.EntityManager;
var player = MakeExpeditionPlayerAtGate(em);
group.Update(); // player walks the gate back to base -> reward
Assert.AreEqual(25, OreInLedger(em, cyc), "a cleared return banks RewardOre to the shared ledger");
Assert.AreEqual(1, em.GetComponentData<GoalProgress>(cyc).Charge,
"DR-042: a cleared return also advances the win meter by one (the new win-driver).");
Assert.AreEqual(1, em.GetComponentData<CycleRuntime>(cyc).LastRewardedEpoch, "the epoch is marked rewarded");
// Force a second same-epoch return (the player is back in the expedition at the gate).
em.SetComponentData(player, new RegionTag { Region = RegionId.Expedition });
em.SetComponentData(player, LocalTransform.FromPosition(new float3(1000, 1, 0)));
group.Update(); // returns again, but the epoch was already rewarded
Assert.AreEqual(25, OreInLedger(em, cyc), "the same epoch never pays twice (co-op / re-entry de-dup)");
Assert.AreEqual(1, em.GetComponentData<GoalProgress>(cyc).Charge,
"the same epoch never double-credits the win meter either (shared LastRewardedEpoch latch).");
}
}
[Test]
public void Cleared_Return_Clamps_Charge_At_Target()
{
// DR-042: the win credit clamps at Target (min(Charge+1, Target)) — a cleared return at the cap never overshoots.
var (world, group, cyc) = MakeWorld("GateRewardClamp", epoch: 1, clearedThisEpoch: 1, lastRewardedEpoch: 0);
using (world)
{
var em = world.EntityManager;
em.SetComponentData(cyc, new GoalProgress { Charge = 4, Target = 4 }); // already at the cap
MakeExpeditionPlayerAtGate(em);
group.Update();
Assert.AreEqual(4, em.GetComponentData<GoalProgress>(cyc).Charge,
"a cleared return at the cap clamps at Target (never overshoots).");
}
}
[Test]
public void Uncleared_Return_Banks_Nothing()
{
var (world, group, cyc) = MakeWorld("GateRewardUncleared", epoch: 1, clearedThisEpoch: 0, lastRewardedEpoch: 0);
using (world)
{
var em = world.EntityManager;
MakeExpeditionPlayerAtGate(em);
group.Update();
Assert.AreEqual(0, OreInLedger(em, cyc), "returning without clearing the wave banks nothing (no farming)");
Assert.AreEqual(0, em.GetComponentData<GoalProgress>(cyc).Charge,
"an uncleared return advances neither Ore nor the win meter.");
}
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 825422a92eb4b1d4eb4cdafc57884a01
@@ -1,125 +0,0 @@
using NUnit.Framework;
using ProjectM.Server;
using ProjectM.Simulation;
using Unity.Core;
using Unity.Entities;
using Unity.Mathematics;
using Unity.Transforms;
namespace ProjectM.Tests
{
/// <summary>
/// Plain-Entities EditMode tests for the server-only <see cref="ExpeditionGateSystem"/> (walk-in region
/// transit). A bare world is seeded with an <c>ExpeditionGate</c> (+ LocalTransform) and a player
/// (RegionTag + LocalTransform + PlayerTag). A player whose region matches the gate's FromRegion and who is
/// within the gate radius is transited (RegionTag flipped + LocalTransform teleported to ArrivalPos).
/// Returning to base signals the ThreatDirector (the post-expedition retaliation source) exactly once. Pins
/// the proximity gate, the region/radius guards, and the return signal.
/// </summary>
public class ExpeditionGateSystemTests
{
static (World world, SimulationSystemGroup group) MakeWorld(string name)
{
var world = new World(name);
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
group.AddSystemToUpdateList(world.GetOrCreateSystem<ExpeditionGateSystem>());
group.SortSystems();
world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
return (world, group);
}
static void MakeGate(EntityManager em, float3 pos, byte from, byte to, float radius, float3 arrival)
{
var e = em.CreateEntity();
em.AddComponentData(e, LocalTransform.FromPosition(pos));
em.AddComponentData(e, new ExpeditionGate { FromRegion = from, ToRegion = to, Radius = radius, ArrivalPos = arrival });
}
static Entity MakePlayer(EntityManager em, float3 pos, byte region)
{
var e = em.CreateEntity();
em.AddComponentData(e, LocalTransform.FromPosition(pos));
em.AddComponentData(e, new RegionTag { Region = region });
em.AddComponent<PlayerTag>(e);
return e;
}
[Test]
public void Player_In_Gate_Radius_Is_Transited_And_Teleported()
{
var (world, group) = MakeWorld("GateTransitWorld");
using (world)
{
var em = world.EntityManager;
var arrival = new float3(1000, 1, 0);
MakeGate(em, new float3(0, 1, 0), RegionId.Base, RegionId.Expedition, radius: 15f, arrival: arrival);
var player = MakePlayer(em, new float3(5, 1, 0), RegionId.Base);
group.Update();
Assert.AreEqual(RegionId.Expedition, em.GetComponentData<RegionTag>(player).Region,
"Region flips to the gate's ToRegion.");
var p = em.GetComponentData<LocalTransform>(player).Position;
Assert.AreEqual(1000f, p.x, 1e-3f, "Player is teleported to the gate's ArrivalPos (x).");
Assert.AreEqual(0f, p.z, 1e-3f, "Player is teleported to the gate's ArrivalPos (z).");
}
}
[Test]
public void Player_Outside_Radius_Is_Not_Transited()
{
var (world, group) = MakeWorld("GateNoTransitWorld");
using (world)
{
var em = world.EntityManager;
MakeGate(em, new float3(0, 1, 0), RegionId.Base, RegionId.Expedition, radius: 15f, arrival: new float3(1000, 1, 0));
var player = MakePlayer(em, new float3(50, 1, 0), RegionId.Base);
group.Update();
Assert.AreEqual(RegionId.Base, em.GetComponentData<RegionTag>(player).Region,
"A player beyond the gate radius stays in its region.");
}
}
[Test]
public void Player_Wrong_Region_Is_Not_Transited()
{
var (world, group) = MakeWorld("GateWrongRegionWorld");
using (world)
{
var em = world.EntityManager;
// Gate only acts on players currently in the Base region.
MakeGate(em, new float3(0, 1, 0), RegionId.Base, RegionId.Expedition, radius: 15f, arrival: new float3(1000, 1, 0));
var player = MakePlayer(em, new float3(1, 1, 0), RegionId.Expedition);
group.Update();
Assert.AreEqual(RegionId.Expedition, em.GetComponentData<RegionTag>(player).Region,
"A player whose region does not match FromRegion is ignored even inside the radius.");
}
}
[Test]
public void Return_To_Base_Signals_ThreatDirector_Once()
{
var (world, group) = MakeWorld("GateReturnSignalWorld");
using (world)
{
var em = world.EntityManager;
MakeGate(em, new float3(0, 1, 0), RegionId.Expedition, RegionId.Base, radius: 15f, arrival: new float3(0, 1, 0));
MakePlayer(em, new float3(3, 1, 0), RegionId.Expedition);
var threat = em.CreateEntity(typeof(ThreatState));
em.SetComponentData(threat, new ThreatState());
group.Update();
var ts = em.GetComponentData<ThreatState>(threat);
Assert.AreEqual(1, ts.PendingReturns,
"Returning to base signals the ThreatDirector exactly once (the gate teleports the returner out of its radius).");
Assert.AreEqual(1, ts.ExpeditionsCompleted, "A completed expedition is counted.");
}
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: dfddde749d3109843901804073127701
@@ -0,0 +1,131 @@
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>
/// Pins Step 12a — the born-correct PERMANENT meta seeding in <see cref="GoInGameServerSystem"/>: a spawning
/// player replays its class's persisted <see cref="MetaTierState"/> tiers as meta-band StatModifiers
/// (Value = ValuePerTier * tier, SourceId = MetaSourceIdBase + id); other-class rows and unknown ids are
/// skipped; an over-MaxTier saved row is CLAMPED (D-F5); and the availability guard blocks the WHOLE spawn
/// (request preserved, nothing consumed) when the catalog is absent (N2).
/// </summary>
public class MetaSeedingTests
{
static (World world, SimulationSystemGroup group) MakeWorld()
{
var world = new World("MetaSeedTest");
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
group.AddSystemToUpdateList(world.GetOrCreateSystem<GoInGameServerSystem>());
group.SortSystems();
world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
return (world, group);
}
static Entity MakePlayerPrefab(EntityManager em)
{
var e = em.CreateEntity(typeof(LocalTransform), typeof(GhostOwner), typeof(AbilityRef), typeof(PlayerTag));
em.SetComponentData(e, LocalTransform.Identity);
em.AddBuffer<StatModifier>(e);
em.AddComponent<Prefab>(e);
return e;
}
static void MakeSpawnRequest(EntityManager em, byte classId)
{
var conn = em.CreateEntity(typeof(NetworkId));
em.SetComponentData(conn, new NetworkId { Value = 1 });
em.AddBuffer<LinkedEntityGroup>(conn);
var req = em.CreateEntity(typeof(GoInGameRequest), typeof(ReceiveRpcCommandRequest));
em.SetComponentData(req, new GoInGameRequest { ClassId = classId });
em.SetComponentData(req, new ReceiveRpcCommandRequest { SourceConnection = conn });
}
static int MetaRows(EntityManager em, Entity player, out float firstValue, out uint firstSource)
{
firstValue = 0f; firstSource = 0;
var mods = em.GetBuffer<StatModifier>(player);
int n = 0;
for (int i = 0; i < mods.Length; i++)
if (mods[i].SourceId >= Tuning.MetaSourceIdBase
&& mods[i].SourceId < Tuning.MetaSourceIdBase + Tuning.MetaSourceIdSpan)
{
if (n == 0) { firstValue = mods[i].Value; firstSource = mods[i].SourceId; }
n++;
}
return n;
}
[Test]
public void Spawn_SeedsClassTiers_SkipsOthers_ClampsOverMax()
{
var (world, group) = MakeWorld();
var em = world.EntityManager;
var prefab = MakePlayerPrefab(em);
var spawnerE = em.CreateEntity(typeof(PlayerSpawner));
em.SetComponentData(spawnerE, new PlayerSpawner { PlayerPrefab = prefab, SpawnRingRadius = 2f, RingSlots = 8 });
var catalogE = em.CreateEntity(typeof(MetaUpgradeCatalog));
em.SetComponentData(catalogE, new MetaUpgradeCatalog { Value = MetaCatalogData.BuildDefault() });
var record = em.AddBuffer<MetaTierState>(catalogE); // the tier record rides any singleton entity in tests
byte warrior = ClassTraits.WarriorClass; // normalized CharacterId (2)
record.Add(new MetaTierState { ClassId = warrior, UpgradeId = 1, Tier = 2 }); // Reinforced Frame t2 -> +30
record.Add(new MetaTierState { ClassId = warrior, UpgradeId = 5, Tier = 9 }); // Warrior's Might, saved OVER MaxTier(4) -> clamp
record.Add(new MetaTierState { ClassId = warrior, UpgradeId = 200, Tier = 1 }); // unknown id -> skipped
record.Add(new MetaTierState { ClassId = ClassTraits.RangerClass, UpgradeId = 2, Tier = 3 }); // other class -> skipped
record.Add(new MetaTierState { ClassId = warrior, UpgradeId = 7, Tier = 1 }); // Ranger-masked (Longshot) -> skipped
MakeSpawnRequest(em, warrior);
group.Update();
var pq = em.CreateEntityQuery(typeof(PlayerTag), typeof(PlayerClass));
var players = pq.ToEntityArray(Allocator.Temp);
Assert.AreEqual(1, players.Length, "player spawned");
var player = players[0];
players.Dispose(); pq.Dispose(); // BEFORE the world
var mods = em.GetBuffer<StatModifier>(player);
float frameValue = 0f, mightValue = 0f;
int metaRows = 0;
for (int i = 0; i < mods.Length; i++)
{
if (mods[i].SourceId == Tuning.MetaSourceIdBase + 1) { frameValue = mods[i].Value; metaRows++; }
else if (mods[i].SourceId == Tuning.MetaSourceIdBase + 5) { mightValue = mods[i].Value; metaRows++; }
else if (mods[i].SourceId >= Tuning.MetaSourceIdBase
&& mods[i].SourceId < Tuning.MetaSourceIdBase + Tuning.MetaSourceIdSpan) metaRows++;
}
Assert.AreEqual(2, metaRows, "exactly the two legal Warrior tiers seeded (unknown/other-class/other-mask skipped)");
Assert.AreEqual(30f, frameValue, 1e-3f, "Reinforced Frame tier 2 = 15 * 2");
Assert.AreEqual(0.40f, mightValue, 1e-3f, "Warrior's Might CLAMPED to MaxTier 4 = 0.10 * 4 (D-F5)");
world.Dispose();
}
[Test]
public void MissingCatalog_BlocksSpawn_PreservesRequest()
{
var (world, group) = MakeWorld();
var em = world.EntityManager;
var prefab = MakePlayerPrefab(em);
var spawnerE = em.CreateEntity(typeof(PlayerSpawner));
em.SetComponentData(spawnerE, new PlayerSpawner { PlayerPrefab = prefab, SpawnRingRadius = 2f, RingSlots = 8 });
// NO catalog, NO tier record.
MakeSpawnRequest(em, ClassTraits.WarriorClass);
group.Update();
var pq = em.CreateEntityQuery(typeof(PlayerClass));
Assert.AreEqual(0, pq.CalculateEntityCount(), "no player spawned while blocked (N2)");
var rq = em.CreateEntityQuery(typeof(GoInGameRequest));
Assert.AreEqual(1, rq.CalculateEntityCount(), "request PRESERVED — the spawn retries when the catalog streams in");
pq.Dispose(); rq.Dispose(); // BEFORE the world (a using-var here would outlive it)in");
world.Dispose();
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 923f2724cee01c34f87f8fda67885b11
@@ -0,0 +1,190 @@
using NUnit.Framework;
using ProjectM.Server;
using ProjectM.Simulation;
using Unity.Collections;
using Unity.Core;
using Unity.Entities;
using Unity.NetCode;
namespace ProjectM.Tests
{
/// <summary>
/// Pins Step 13 — <see cref="MetaSpendSystem"/>: the Staging-gated Aether→tier purchase with DR-014 in-loop
/// ledger atomicity (two same-tick barely-enough purchases → exactly ONE succeeds), the TotalOf pre-check
/// (Withdraw clamps, it never rejects), the ABSOLUTE-value modifier upsert on every live class member (R-F1/2),
/// tier bump-or-append on the director record, the SaveRequest flag, and the reject paths (wrong class mask,
/// MaxTier cap, non-Staging lifecycle) — with requests always consumed.
/// </summary>
public class MetaSpendSystemTests
{
static (World world, SimulationSystemGroup group) MakeWorld()
{
var world = new World("MetaSpendTest");
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
group.AddSystemToUpdateList(world.GetOrCreateSystem<MetaSpendSystem>());
group.SortSystems();
world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
return (world, group);
}
static Entity MakeDirector(EntityManager em, byte lifecycle, int aether)
{
var dir = em.CreateEntity(typeof(RunInfo), typeof(ResourceLedger), typeof(SaveRequest),
typeof(MetaUpgradeCatalog));
em.SetComponentData(dir, new RunInfo { Lifecycle = lifecycle });
em.SetComponentData(dir, new MetaUpgradeCatalog { Value = MetaCatalogData.BuildDefault() });
var ledger = em.AddBuffer<StorageEntry>(dir);
if (aether > 0) ledger.Add(new StorageEntry { ItemId = ResourceId.Aether, Count = aether });
em.AddBuffer<MetaTierState>(dir);
return dir;
}
static Entity MakePlayer(EntityManager em, int networkId, byte classId)
{
var conn = em.CreateEntity(typeof(NetworkId));
em.SetComponentData(conn, new NetworkId { Value = networkId });
var player = em.CreateEntity(typeof(PlayerTag), typeof(GhostOwner), typeof(PlayerClass));
em.SetComponentData(player, new GhostOwner { NetworkId = networkId });
em.SetComponentData(player, new PlayerClass { ClassId = classId });
em.AddBuffer<StatModifier>(player);
em.AddComponentData(player, new ConnRef { Conn = conn });
return player;
}
/// <summary>Test-only pointer so a request can be issued from the player's own connection.</summary>
struct ConnRef : IComponentData { public Entity Conn; }
static void SendRequest(EntityManager em, Entity player, byte upgradeId)
{
var conn = em.GetComponentData<ConnRef>(player).Conn;
var req = em.CreateEntity(typeof(MetaSpendRequest), typeof(ReceiveRpcCommandRequest));
em.SetComponentData(req, new MetaSpendRequest { UpgradeId = upgradeId });
em.SetComponentData(req, new ReceiveRpcCommandRequest { SourceConnection = conn });
}
static int PendingRequests(EntityManager em)
{
var q = em.CreateEntityQuery(typeof(MetaSpendRequest));
int n = q.CalculateEntityCount();
q.Dispose();
return n;
}
static float MetaModValue(EntityManager em, Entity player, byte upgradeId, out int rowCount)
{
var mods = em.GetBuffer<StatModifier>(player, true);
float value = 0f;
rowCount = 0;
for (int i = 0; i < mods.Length; i++)
if (mods[i].SourceId == Tuning.MetaSourceIdBase + upgradeId) { value = mods[i].Value; rowCount++; }
return value;
}
[Test]
public void Purchase_BumpsTier_Withdraws_UpsertsAllClassMembers_FlagsSave()
{
var (world, group) = MakeWorld();
var em = world.EntityManager;
var dir = MakeDirector(em, RunLifecycle.Staging, 25);
var warriorA = MakePlayer(em, 1, ClassTraits.WarriorClass);
var warriorB = MakePlayer(em, 2, ClassTraits.WarriorClass); // classmate: shared per-class pool
var ranger = MakePlayer(em, 3, ClassTraits.RangerClass); // other class: untouched
SendRequest(em, warriorA, 1); // Reinforced Frame: BaseCost 10, +15/tier
group.Update();
var record = em.GetBuffer<MetaTierState>(dir, true);
Assert.AreEqual(1, MetaMath.TierOf(record, ClassTraits.WarriorClass, 1), "tier bumped to 1");
Assert.AreEqual(15, StorageMath.TotalOf(em.GetBuffer<StorageEntry>(dir, true), ResourceId.Aether),
"cost 10 withdrawn from 25");
Assert.AreEqual(15f, MetaModValue(em, warriorA, 1, out int rowsA), 1e-3f, "buyer modifier = 15 * tier1");
Assert.AreEqual(1, rowsA);
Assert.AreEqual(15f, MetaModValue(em, warriorB, 1, out _), 1e-3f, "live classmate upserted too (R-F2)");
Assert.AreEqual(0f, MetaModValue(em, ranger, 1, out int rowsR), 1e-3f, "other class untouched");
Assert.AreEqual(0, rowsR);
Assert.AreEqual(1, em.GetComponentData<SaveRequest>(dir).Pending, "purchase flags the autosave");
Assert.AreEqual(0, PendingRequests(em), "request consumed");
world.Dispose();
}
[Test]
public void TwoSameTick_BarelyEnough_ExactlyOneSucceeds()
{
var (world, group) = MakeWorld();
var em = world.EntityManager;
var dir = MakeDirector(em, RunLifecycle.Staging, 10); // ids 1 and 4 BOTH cost 10 at tier 0
var warrior = MakePlayer(em, 1, ClassTraits.WarriorClass);
SendRequest(em, warrior, 1);
SendRequest(em, warrior, 4);
group.Update();
var record = em.GetBuffer<MetaTierState>(dir, true);
int bought = MetaMath.TierOf(record, ClassTraits.WarriorClass, 1)
+ MetaMath.TierOf(record, ClassTraits.WarriorClass, 4);
Assert.AreEqual(1, bought, "in-loop atomicity: barely-enough Aether buys exactly ONE (DR-014)");
Assert.AreEqual(0, StorageMath.TotalOf(em.GetBuffer<StorageEntry>(dir, true), ResourceId.Aether),
"the single cost fully drained the ledger — and never went negative (TotalOf pre-check)");
Assert.AreEqual(0, PendingRequests(em), "both requests consumed");
world.Dispose();
}
[Test]
public void SecondPurchase_AbsoluteUpsert_SingleRow_RampedCost()
{
var (world, group) = MakeWorld();
var em = world.EntityManager;
var dir = MakeDirector(em, RunLifecycle.Staging, 30); // tier1 = 10, tier2 = 10 + 1*5 = 15
var warrior = MakePlayer(em, 1, ClassTraits.WarriorClass);
SendRequest(em, warrior, 1);
group.Update();
SendRequest(em, warrior, 1);
group.Update();
var record = em.GetBuffer<MetaTierState>(dir, true);
Assert.AreEqual(2, MetaMath.TierOf(record, ClassTraits.WarriorClass, 1), "tier 2 owned");
Assert.AreEqual(1, record.Length, "record row BUMPED in place, not duplicated");
Assert.AreEqual(30f, MetaModValue(em, warrior, 1, out int rows), 1e-3f,
"ABSOLUTE upsert: 15 * tier2 (R-F1)");
Assert.AreEqual(1, rows, "one modifier row — an incremental append would double-count in recompute");
Assert.AreEqual(5, StorageMath.TotalOf(em.GetBuffer<StorageEntry>(dir, true), ResourceId.Aether),
"linear ramp: 30 - 10 - 15");
world.Dispose();
}
[Test]
public void Rejects_WrongClassMask_MaxTierCap_NonStaging()
{
var (world, group) = MakeWorld();
var em = world.EntityManager;
var dir = MakeDirector(em, RunLifecycle.Staging, 999);
var warrior = MakePlayer(em, 1, ClassTraits.WarriorClass);
// (a) Ranger-masked upgrade requested by a Warrior — dropped, nothing withdrawn.
SendRequest(em, warrior, 7); // Ranger's Longshot (mask 2)
group.Update();
Assert.AreEqual(999, StorageMath.TotalOf(em.GetBuffer<StorageEntry>(dir, true), ResourceId.Aether),
"class-mask reject leaves the ledger untouched");
// (b) at MaxTier — dropped. Fleet Stride (id 4) MaxTier 3.
var record = em.GetBuffer<MetaTierState>(dir);
record.Add(new MetaTierState { ClassId = ClassTraits.WarriorClass, UpgradeId = 4, Tier = 3 });
SendRequest(em, warrior, 4);
group.Update();
Assert.AreEqual(3, MetaMath.TierOf(em.GetBuffer<MetaTierState>(dir, true), ClassTraits.WarriorClass, 4),
"MaxTier cap holds");
Assert.AreEqual(999, StorageMath.TotalOf(em.GetBuffer<StorageEntry>(dir, true), ResourceId.Aether));
// (c) mid-run — dropped (N4: the shop is a between-runs surface).
em.SetComponentData(dir, new RunInfo { Lifecycle = RunLifecycle.InRoom });
SendRequest(em, warrior, 1);
group.Update();
Assert.AreEqual(0, MetaMath.TierOf(em.GetBuffer<MetaTierState>(dir, true), ClassTraits.WarriorClass, 1),
"non-Staging purchase dropped");
Assert.AreEqual(999, StorageMath.TotalOf(em.GetBuffer<StorageEntry>(dir, true), ResourceId.Aether));
Assert.AreEqual(0, PendingRequests(em), "every request consumed, accepted or not");
world.Dispose();
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 4b835275276dd3149b248a6b6a031ad3
@@ -0,0 +1,210 @@
using NUnit.Framework;
using ProjectM.Server;
using ProjectM.Simulation;
using Unity.Core;
using Unity.Entities;
using Unity.NetCode;
using Unity.Transforms;
namespace ProjectM.Tests
{
/// <summary>
/// Plain-Entities EditMode tests for the ready-check spine: <see cref="ReadyToggleSystem"/> (RPC → PlayerReady,
/// Staging/Launching-only) ordered before <see cref="RunDirectorSystem"/> (the all-ready rising-edge launch, the
/// un-ready countdown abort, the F2 outcome guard, the sub-slot teleport out/home, and the Returning-edge
/// ready-flag clear). Ticks are driven manually through NetworkTime, so the countdown/dwell paths that Play-mode
/// polling races past are pinned deterministically here.
/// </summary>
public class ReadyCheckSystemTests
{
const uint T0 = 1000;
static (World world, SimulationSystemGroup group, Entity dir) MakeWorld()
{
var world = new World("ReadyCheckTest");
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
group.AddSystemToUpdateList(world.GetOrCreateSystem<ReadyToggleSystem>());
group.AddSystemToUpdateList(world.GetOrCreateSystem<RunDirectorSystem>());
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 dir = em.CreateEntity(typeof(RunInfo), typeof(RunRuntime));
em.SetComponentData(dir, new RunInfo { Lifecycle = RunLifecycle.Staging });
em.SetComponentData(dir, new RunRuntime { HostSalt = 1u });
return (world, group, dir);
}
static void SetTick(World world, uint tick)
{
var em = world.EntityManager;
using var q = em.CreateEntityQuery(typeof(NetworkTime));
em.SetComponentData(q.GetSingletonEntity(), new NetworkTime { ServerTick = new NetworkTick(tick) });
}
static Entity MakePlayer(EntityManager em, int networkId)
{
var e = em.CreateEntity(typeof(PlayerTag), typeof(PlayerReady), typeof(GhostOwner),
typeof(RegionTag), typeof(LocalTransform));
em.SetComponentData(e, new GhostOwner { NetworkId = networkId });
em.SetComponentData(e, new RegionTag { Region = RegionId.Base });
em.SetComponentData(e, LocalTransform.Identity);
return e;
}
static Entity MakeConnection(EntityManager em, int networkId)
{
var e = em.CreateEntity(typeof(NetworkId));
em.SetComponentData(e, new NetworkId { Value = networkId });
return e;
}
static void SendToggle(EntityManager em, Entity conn, byte ready)
{
var e = em.CreateEntity(typeof(ReadyToggleRequest), typeof(ReceiveRpcCommandRequest));
em.SetComponentData(e, new ReadyToggleRequest { Ready = ready });
em.SetComponentData(e, new ReceiveRpcCommandRequest { SourceConnection = conn });
}
static int PendingRequests(EntityManager em)
{
using var q = em.CreateEntityQuery(typeof(ReadyToggleRequest));
return q.CalculateEntityCount();
}
[Test]
public void Toggle_SetsReady_AndSoloLaunches_SameTick()
{
var (world, group, dir) = MakeWorld();
var em = world.EntityManager;
var player = MakePlayer(em, 1);
var conn = MakeConnection(em, 1);
SendToggle(em, conn, 1);
group.Update();
Assert.AreEqual(1, em.GetComponentData<PlayerReady>(player).Value, "toggle landed");
Assert.AreEqual(0, PendingRequests(em), "request consumed");
var info = em.GetComponentData<RunInfo>(dir);
Assert.AreEqual(RunLifecycle.Launching, info.Lifecycle, "1/1 ready -> rising edge -> Launching");
Assert.AreNotEqual(0u, info.LaunchTick, "countdown telegraph armed");
Assert.AreNotEqual(0u, info.RunSeed, "run seeded");
Assert.GreaterOrEqual(info.RoomCount, 6, "seed-varied length floor");
Assert.LessOrEqual(info.RoomCount, 10, "seed-varied length cap");
world.Dispose();
}
[Test]
public void Toggle_Ignored_MidRun()
{
var (world, group, dir) = MakeWorld();
var em = world.EntityManager;
var player = MakePlayer(em, 1);
var conn = MakeConnection(em, 1);
var info = em.GetComponentData<RunInfo>(dir);
info.Lifecycle = RunLifecycle.InRoom;
em.SetComponentData(dir, info);
SendToggle(em, conn, 1);
group.Update();
Assert.AreEqual(0, em.GetComponentData<PlayerReady>(player).Value, "mid-run toggle dropped");
Assert.AreEqual(0, PendingRequests(em), "request still consumed");
world.Dispose();
}
[Test]
public void PartialReady_DoesNotLaunch()
{
var (world, group, dir) = MakeWorld();
var em = world.EntityManager;
MakePlayer(em, 1);
MakePlayer(em, 2);
var conn1 = MakeConnection(em, 1);
SendToggle(em, conn1, 1);
group.Update();
Assert.AreEqual(RunLifecycle.Staging, em.GetComponentData<RunInfo>(dir).Lifecycle,
"1/2 ready must NOT launch");
world.Dispose();
}
[Test]
public void UnReady_DuringCountdown_Aborts()
{
var (world, group, dir) = MakeWorld();
var em = world.EntityManager;
MakePlayer(em, 1);
var conn = MakeConnection(em, 1);
SendToggle(em, conn, 1);
group.Update();
Assert.AreEqual(RunLifecycle.Launching, em.GetComponentData<RunInfo>(dir).Lifecycle);
SendToggle(em, conn, 0); // change of heart during the 3-2-1
group.Update();
var info = em.GetComponentData<RunInfo>(dir);
Assert.AreEqual(RunLifecycle.Staging, info.Lifecycle, "un-ready aborts the countdown");
Assert.AreEqual(0u, info.LaunchTick, "telegraph cleared");
world.Dispose();
}
[Test]
public void Launch_TeleportsPartyOut_ThenHome_AndClearsReady()
{
var (world, group, dir) = MakeWorld();
var em = world.EntityManager;
var player = MakePlayer(em, 1);
var conn = MakeConnection(em, 1);
SendToggle(em, conn, 1);
group.Update(); // Staging -> Launching (countdown armed at T0)
SetTick(world, T0 + 200); // past the 180-tick countdown
group.Update(); // enter room 0 (the real traversal, Step 7)
Assert.AreEqual(RegionId.Expedition, em.GetComponentData<RegionTag>(player).Region,
"party region flipped to Expedition");
Assert.GreaterOrEqual(em.GetComponentData<LocalTransform>(player).Position.x, 999f,
"party teleported to the expedition room origin (sub-slot 0 at +1000)");
Assert.AreEqual(1f, em.GetComponentData<LocalTransform>(player).Scale, 1e-4f,
"Scale preserved through the teleport (never FromPosition)");
Assert.AreEqual(RunLifecycle.InRoom, em.GetComponentData<RunInfo>(dir).Lifecycle, "room 0 active");
// Simulate the all-left abort (disconnect edge): drop the player's region externally.
em.SetComponentData(player, new RegionTag { Region = RegionId.Base });
SetTick(world, T0 + 260);
group.Update(); // InRoom -> Returning (abort, no credit)
SetTick(world, T0 + 320);
group.Update(); // Returning: teleport home + clear ready flags -> StagingStaging
Assert.AreEqual(RegionId.Base, em.GetComponentData<RegionTag>(player).Region, "back home");
Assert.Less(em.GetComponentData<LocalTransform>(player).Position.x, 100f, "position restored to base");
Assert.AreEqual(0, em.GetComponentData<PlayerReady>(player).Value, "ready flag cleared on return");
var run = em.GetComponentData<RunRuntime>(dir);
Assert.AreEqual(run.RunEpoch, run.LastBankedRunEpoch, "terminal bank latch fired once");
Assert.AreEqual(RunLifecycle.Staging, em.GetComponentData<RunInfo>(dir).Lifecycle);
world.Dispose();
}
[Test]
public void LaunchGuard_BlocksWhenOutcomeLatched()
{
var (world, group, dir) = MakeWorld();
var em = world.EntityManager;
MakePlayer(em, 1);
var conn = MakeConnection(em, 1);
em.AddComponentData(dir, new RunOutcome { Value = RunOutcomeId.Victory });
SendToggle(em, conn, 1);
group.Update();
Assert.AreEqual(RunLifecycle.Staging, em.GetComponentData<RunInfo>(dir).Lifecycle,
"a decided run must not launch (F2 guard)");
world.Dispose();
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 046f63edcabb72548843c84fcd99dda1
@@ -0,0 +1,178 @@
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();
}
[Test]
public void Seeds_ByRoomDifficulty_AndSpawnsTaggedAtActiveSlotOrigin()
{
var (world, group, runDir, zoneDir) = MakeWorld(currentRoom: 0, currentNodeId: RunMap.NodeId(0, 0), activeSubSlot: 0);
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 + first slot spawns this tick
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();
world.Dispose();
}
[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));
var em = world.EntityManager;
group.Update();
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);
world.Dispose();
}
[Test]
public void Objective_ClearedLatch_AndIdleOutsideRooms()
{
var (world, group, runDir, zoneDir) = MakeWorld(0, RunMap.NodeId(0, 0), 0);
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)");
world.Dispose();
}
[Test]
public void MaxAlive_PackFitWaits_WithoutConsumingTheSlot()
{
var (world, group, runDir, zoneDir) = MakeWorld(0, RunMap.NodeId(0, 0), 0, maxAlive: 1);
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");
world.Dispose();
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 0a5bf96c5c42e5240a8db55fc3e2a01a
@@ -0,0 +1,153 @@
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();
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 9b72710f2e3958f4a827cb3befcc8850
@@ -0,0 +1,93 @@
using NUnit.Framework;
using ProjectM.Simulation;
using Unity.Mathematics;
namespace ProjectM.Tests
{
/// <summary>
/// Pure-function tests for <see cref="RoomLayoutMath"/> — resolving a map node into a <see cref="RoomPlan"/> and
/// scattering points within a room's shape. Pins the plan mapping, the depth/type difficulty ramp, the per-type
/// node density, and (the swept-scatter safety net) that every scattered point lies within its shape footprint.
/// </summary>
public class RoomLayoutMathTests
{
[Test]
public void Plan_CopiesNodeFields_AndResolvesArchetype()
{
var node = new RunMapNode
{
RoomType = RoomTypeId.Reward,
Biome = RoomBiomeId.Cavern,
ShapeId = RoomShapeId.Wide,
NextMask = 1,
};
var plan = RoomLayoutMath.Plan(node, layer: 3, roomCount: 8);
Assert.AreEqual(RoomTypeId.Reward, plan.RoomType);
Assert.AreEqual(RoomBiomeId.Cavern, plan.Biome);
Assert.AreEqual(RoomShapeId.Wide, plan.ShapeId);
Assert.AreEqual(RoomLayoutMath.ShapeRadius(RoomShapeId.Wide), plan.Radius);
Assert.AreEqual(RoomLayoutMath.BaseNodeCount(RoomTypeId.Reward), plan.NodeCount);
Assert.AreEqual(RoomLayoutMath.DifficultyEpoch(3, RoomTypeId.Reward), plan.DifficultyEpoch);
}
[Test]
public void DifficultyEpoch_DeeperIsHarder_EliteAndBossBump()
{
Assert.AreEqual(1, RoomLayoutMath.DifficultyEpoch(0, RoomTypeId.Combat), "layer 0 floors at 1");
Assert.Greater(RoomLayoutMath.DifficultyEpoch(5, RoomTypeId.Combat),
RoomLayoutMath.DifficultyEpoch(2, RoomTypeId.Combat), "deeper is harder");
Assert.AreEqual(RoomLayoutMath.DifficultyEpoch(4, RoomTypeId.Combat) + 2,
RoomLayoutMath.DifficultyEpoch(4, RoomTypeId.Elite), "Elite +2");
Assert.AreEqual(RoomLayoutMath.DifficultyEpoch(4, RoomTypeId.Combat) + 3,
RoomLayoutMath.DifficultyEpoch(4, RoomTypeId.Boss), "Boss +3");
}
[Test]
public void BaseNodeCount_RewardDense_BossMinimal()
{
Assert.Greater(RoomLayoutMath.BaseNodeCount(RoomTypeId.Reward),
RoomLayoutMath.BaseNodeCount(RoomTypeId.Combat), "reward rooms are resource-dense");
Assert.AreEqual(1, RoomLayoutMath.BaseNodeCount(RoomTypeId.Boss), "boss room is minimal");
Assert.GreaterOrEqual(RoomLayoutMath.BaseNodeCount(RoomTypeId.Combat), 1);
}
[Test]
public void ShapeRadius_AllShapesPositive()
{
for (byte s = 0; s < RoomShapeId.Count; s++)
Assert.Greater(RoomLayoutMath.ShapeRadius(s), 0f, $"shape {s}");
}
[Test]
public void ScatterInShape_AlwaysWithinShapeFootprint()
{
var center = new float3(1000f, 1f, 5f); // offset origin (expedition region) + nonzero Y preserved
for (byte shape = 0; shape < RoomShapeId.Count; shape++)
{
var rng = new Random(9871u + shape);
for (int i = 0; i < 500; i++)
{
float3 p = RoomLayoutMath.ScatterInShape(shape, center, i, 500, ref rng);
Assert.AreEqual(center.y, p.y, 1e-4f, $"shape {shape}: Y preserved");
Assert.IsTrue(RoomLayoutMath.ContainsPoint(shape, center, p),
$"shape {shape}: scattered point {p} escaped the footprint");
}
}
}
[Test]
public void ScatterInShape_Deterministic_ForSameSeedSequence()
{
var center = new float3(0f, 0f, 0f);
var a = new Random(4242u);
var b = new Random(4242u);
for (int i = 0; i < 50; i++)
{
float3 pa = RoomLayoutMath.ScatterInShape(RoomShapeId.Cross, center, i, 50, ref a);
float3 pb = RoomLayoutMath.ScatterInShape(RoomShapeId.Cross, center, i, 50, ref b);
Assert.AreEqual(pa.x, pb.x, 1e-6f);
Assert.AreEqual(pa.z, pb.z, 1e-6f);
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 12aadace80f53cd46a8b2699d232f888
@@ -0,0 +1,71 @@
using NUnit.Framework;
using ProjectM.Simulation;
using Unity.Collections;
using Unity.Entities;
namespace ProjectM.Tests
{
/// <summary>
/// Pins the room-scoped teardown contract (<see cref="RoomTeardown.DestroyRoom"/>): destroying room i kills ONLY
/// room-i entities — the other room survives (the DR-031/DR-040 cross-room-wipe regression, load-bearing for the
/// ping-pong sub-slot handoff where two rooms transiently coexist), untagged entities are untouched, and each
/// entity is destroyed at most once (single-visit ⇒ no double-destroy Playback throw).
/// </summary>
public class RoomTeardownTests
{
static Entity MakeRoomEntity(EntityManager em, byte room, bool asNode)
{
// Mimic real room content: some entities look like resource nodes, some like zone enemies — the
// teardown must be type-agnostic (RoomTag is the only contract).
var e = asNode
? em.CreateEntity(typeof(RoomTag), typeof(ResourceNode))
: em.CreateEntity(typeof(RoomTag), typeof(ZoneEnemyTag));
em.SetComponentData(e, new RoomTag { Room = room });
return e;
}
[Test]
public void DestroyRoom_KillsOnlyThatRoom_SparesOtherRoomAndUntagged()
{
using var world = new World("RoomTeardownTest");
var em = world.EntityManager;
for (int i = 0; i < 3; i++) MakeRoomEntity(em, 0, asNode: i % 2 == 0); // room 0: 3 entities
for (int i = 0; i < 2; i++) MakeRoomEntity(em, 1, asNode: i % 2 == 0); // room 1: 2 entities
var untagged = em.CreateEntity(typeof(ResourceNode)); // e.g. a base-field node
using var roomQuery = em.CreateEntityQuery(typeof(RoomTag));
var ecb = new EntityCommandBuffer(Allocator.Temp);
int destroyed = RoomTeardown.DestroyRoom(roomQuery, ecb, 0);
ecb.Playback(em);
ecb.Dispose();
Assert.AreEqual(3, destroyed, "exactly room-0's entities were queued");
using var remaining = em.CreateEntityQuery(typeof(RoomTag));
var tags = remaining.ToComponentDataArray<RoomTag>(Allocator.Temp);
Assert.AreEqual(2, tags.Length, "room 1 survives intact");
for (int i = 0; i < tags.Length; i++)
Assert.AreEqual(1, tags[i].Room, "every survivor belongs to room 1");
tags.Dispose();
Assert.IsTrue(em.Exists(untagged), "untagged (non-room) entities are never touched");
}
[Test]
public void DestroyRoom_EmptyRoom_IsANoOp()
{
using var world = new World("RoomTeardownTest2");
var em = world.EntityManager;
MakeRoomEntity(em, 1, asNode: true);
using var roomQuery = em.CreateEntityQuery(typeof(RoomTag));
var ecb = new EntityCommandBuffer(Allocator.Temp);
int destroyed = RoomTeardown.DestroyRoom(roomQuery, ecb, 0); // room 0 has nothing
ecb.Playback(em);
ecb.Dispose();
Assert.AreEqual(0, destroyed);
using var remaining = em.CreateEntityQuery(typeof(RoomTag));
Assert.AreEqual(1, remaining.CalculateEntityCount(), "room 1 untouched");
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 95327306dceec754aa635447fc9d04d4
@@ -0,0 +1,167 @@
using NUnit.Framework;
using ProjectM.Server;
using ProjectM.Simulation;
using Unity.Core;
using Unity.Entities;
using Unity.NetCode;
namespace ProjectM.Tests
{
/// <summary>
/// Validation matrix for <see cref="RouteSelectSystem"/> — the co-op route-pick receiver. Pins: a correctly
/// stamped pick latches (the review's non-maskable acceptance criterion — a validation bug here silently
/// degrades to the grace auto-pick and a linear game); the first-commit latch under two same-tick picks; the
/// re-meaned run-identity stale-reject ((uint)ForRunEpoch == RunSeed); the layer stale-reject; index bounds;
/// the N3 base-region sender reject; the closed-gate reject; and that requests are ALWAYS consumed.
/// </summary>
public class RouteSelectSystemTests
{
const uint Seed = 999u;
const int GateLayer = 2;
static (World world, SimulationSystemGroup group, Entity dir) MakeGateWorld(byte lifecycle = RunLifecycle.RouteSelect)
{
var world = new World("RouteSelectTest");
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
group.AddSystemToUpdateList(world.GetOrCreateSystem<RouteSelectSystem>());
group.SortSystems();
world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
var em = world.EntityManager;
var dir = em.CreateEntity(typeof(RunInfo), typeof(RunRuntime), typeof(RouteCommand));
em.SetComponentData(dir, new RunInfo
{
Lifecycle = lifecycle,
CurrentRoom = GateLayer,
RunSeed = Seed,
RouteOptionCount = 2,
RouteOpt0Col = 0,
RouteOpt1Col = 2,
});
em.SetComponentData(dir, new RunRuntime { RunSeed = Seed, RunEpoch = 3 });
return (world, group, dir);
}
static Entity MakePlayer(EntityManager em, int networkId, byte region)
{
var e = em.CreateEntity(typeof(PlayerTag), typeof(GhostOwner), typeof(RegionTag));
em.SetComponentData(e, new GhostOwner { NetworkId = networkId });
em.SetComponentData(e, new RegionTag { Region = region });
return e;
}
static void SendPick(EntityManager em, int networkId, byte optionIndex, int forSeed, int forLayer)
{
var conn = em.CreateEntity(typeof(NetworkId));
em.SetComponentData(conn, new NetworkId { Value = networkId });
var req = em.CreateEntity(typeof(RouteSelectRequest), typeof(ReceiveRpcCommandRequest));
em.SetComponentData(req, new RouteSelectRequest { OptionIndex = optionIndex, ForRunEpoch = forSeed, ForLayer = forLayer });
em.SetComponentData(req, new ReceiveRpcCommandRequest { SourceConnection = conn });
}
static int PendingRequests(EntityManager em)
{
using var q = em.CreateEntityQuery(typeof(RouteSelectRequest));
return q.CalculateEntityCount();
}
[Test]
public void ValidPick_Latches_WithTrueServerEpoch()
{
var (world, group, dir) = MakeGateWorld();
var em = world.EntityManager;
MakePlayer(em, 1, RegionId.Expedition);
SendPick(em, 1, optionIndex: 1, forSeed: (int)Seed, forLayer: GateLayer);
group.Update();
var cmd = em.GetComponentData<RouteCommand>(dir);
Assert.AreEqual(1, cmd.HasPick, "a correctly-stamped pick MUST latch (non-maskable criterion)");
Assert.AreEqual(1, cmd.OptionIndex, "the picked option");
Assert.AreEqual(3, cmd.ForRunEpoch, "stamped from the TRUE server epoch, never the client echo");
Assert.AreEqual(0, PendingRequests(em), "request consumed");
world.Dispose();
}
[Test]
public void TwoSameTickPicks_FirstWins()
{
var (world, group, dir) = MakeGateWorld();
var em = world.EntityManager;
MakePlayer(em, 1, RegionId.Expedition);
MakePlayer(em, 2, RegionId.Expedition);
SendPick(em, 1, optionIndex: 0, forSeed: (int)Seed, forLayer: GateLayer); // created first -> wins
SendPick(em, 2, optionIndex: 1, forSeed: (int)Seed, forLayer: GateLayer);
group.Update();
var cmd = em.GetComponentData<RouteCommand>(dir);
Assert.AreEqual(1, cmd.HasPick, "exactly one commit");
Assert.AreEqual(0, cmd.OptionIndex, "the FIRST accepted pick wins (in-place latch, DR-014)");
Assert.AreEqual(0, PendingRequests(em), "both requests consumed");
world.Dispose();
}
[Test]
public void Rejects_WrongSeed_WrongLayer_OutOfRange_BaseSender_ClosedGate()
{
// Wrong run-identity token (a stale pick from the previous run).
var (w1, g1, d1) = MakeGateWorld();
MakePlayer(w1.EntityManager, 1, RegionId.Expedition);
SendPick(w1.EntityManager, 1, 0, forSeed: (int)Seed + 1, forLayer: GateLayer);
g1.Update();
Assert.AreEqual(0, w1.EntityManager.GetComponentData<RouteCommand>(d1).HasPick, "wrong seed rejected");
Assert.AreEqual(0, PendingRequests(w1.EntityManager));
w1.Dispose();
// Wrong layer (a pick from the previous gate of the SAME run).
var (w2, g2, d2) = MakeGateWorld();
MakePlayer(w2.EntityManager, 1, RegionId.Expedition);
SendPick(w2.EntityManager, 1, 0, (int)Seed, forLayer: GateLayer - 1);
g2.Update();
Assert.AreEqual(0, w2.EntityManager.GetComponentData<RouteCommand>(d2).HasPick, "stale layer rejected");
w2.Dispose();
// Option index out of the published range.
var (w3, g3, d3) = MakeGateWorld();
MakePlayer(w3.EntityManager, 1, RegionId.Expedition);
SendPick(w3.EntityManager, 1, optionIndex: 2, (int)Seed, GateLayer); // count is 2 -> max index 1
g3.Update();
Assert.AreEqual(0, w3.EntityManager.GetComponentData<RouteCommand>(d3).HasPick, "out-of-range rejected");
w3.Dispose();
// Base-region sender (N3): a home-bound joiner cannot commit the party's route.
var (w4, g4, d4) = MakeGateWorld();
MakePlayer(w4.EntityManager, 1, RegionId.Base);
SendPick(w4.EntityManager, 1, 0, (int)Seed, GateLayer);
g4.Update();
Assert.AreEqual(0, w4.EntityManager.GetComponentData<RouteCommand>(d4).HasPick, "base sender rejected (N3)");
w4.Dispose();
// Gate closed (mid-room): the pick is dropped, never queued.
var (w5, g5, d5) = MakeGateWorld(lifecycle: RunLifecycle.InRoom);
MakePlayer(w5.EntityManager, 1, RegionId.Expedition);
SendPick(w5.EntityManager, 1, 0, (int)Seed, GateLayer);
g5.Update();
Assert.AreEqual(0, w5.EntityManager.GetComponentData<RouteCommand>(d5).HasPick, "closed gate rejected");
Assert.AreEqual(0, PendingRequests(w5.EntityManager), "request still consumed");
w5.Dispose();
}
[Test]
public void AlreadyLatched_LaterPickIgnored()
{
var (world, group, dir) = MakeGateWorld();
var em = world.EntityManager;
MakePlayer(em, 1, RegionId.Expedition);
em.SetComponentData(dir, new RouteCommand { HasPick = 1, OptionIndex = 0, ForRunEpoch = 3, ForLayer = GateLayer });
SendPick(em, 1, optionIndex: 1, (int)Seed, GateLayer);
group.Update();
var cmd = em.GetComponentData<RouteCommand>(dir);
Assert.AreEqual(0, cmd.OptionIndex, "an already-latched gate ignores later picks");
world.Dispose();
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: c6b6345371289f64188653851f25d8d1
@@ -0,0 +1,256 @@
using NUnit.Framework;
using ProjectM.Server;
using ProjectM.Simulation;
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="RunDirectorSystem"/>'s Step-7 linear traversal: the objective
/// Cleared edge tears the room down AT RoomReward ENTRY and the next room spawns only on the advance (the
/// teardown-before-spawn empty-tick invariant), the ping-pong sub-slot flip + RoomEpoch bump + teleport, the boss
/// terminal, and the CLEAR-GATED once-per-RunEpoch bank (boss-clear credits Charge/RunsCompleted/retaliation +
/// save; an abort banks ONLY the honest depth high-water — D-F3/F7/C7).
/// </summary>
public class RunDirectorTraversalTests
{
const uint Seed = 777u;
const uint T0 = 2000;
static (World world, SimulationSystemGroup group, Entity dir, Entity player) MakeMidRunWorld(
int currentRoom, out RunMap map)
{
var world = new World("TraversalTest");
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
group.AddSystemToUpdateList(world.GetOrCreateSystem<RunDirectorSystem>());
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) });
map = RunMapMath.Generate(Seed);
var dir = em.CreateEntity(typeof(RunInfo), typeof(RunRuntime), typeof(ExpeditionObjective),
typeof(RouteCommand), typeof(MetaCounters), typeof(GoalProgress), typeof(ThreatState), typeof(SaveRequest));
em.SetComponentData(dir, new RunInfo
{
Lifecycle = RunLifecycle.InRoom,
CurrentRoom = currentRoom,
RoomCount = map.LayerCount,
RunSeed = Seed,
});
em.SetComponentData(dir, new RunRuntime
{
RunSeed = Seed,
RunEpoch = 1,
RoomEpoch = currentRoom + 1,
CurrentNodeId = RunMap.NodeId(currentRoom, 0),
ActiveSubSlot = (byte)(currentRoom & 1),
RoomsClearedThisRun = currentRoom, // rooms before this one were cleared
});
em.SetComponentData(dir, new GoalProgress { Charge = 0, Target = 4 });
var player = em.CreateEntity(typeof(PlayerTag), typeof(PlayerReady), typeof(RegionTag), typeof(LocalTransform));
em.SetComponentData(player, new RegionTag { Region = RegionId.Expedition });
em.SetComponentData(player, LocalTransform.Identity);
return (world, group, dir, player);
}
static void SetTick(World world, uint tick)
{
var em = world.EntityManager;
var q = em.CreateEntityQuery(typeof(NetworkTime));
em.SetComponentData(q.GetSingletonEntity(), new NetworkTime { ServerTick = new NetworkTick(tick) });
q.Dispose();
}
static void MarkCleared(EntityManager em, Entity dir) =>
em.SetComponentData(dir, new ExpeditionObjective { State = ExpeditionObjectiveState.Cleared, Remaining = 0 });
static int RoomEntities(EntityManager em)
{
var q = em.CreateEntityQuery(typeof(RoomTag));
int n = q.CalculateEntityCount();
q.Dispose();
return n;
}
[Test]
public void Cleared_TearsDownAtRewardEntry_ThenAdvancesWithSlotFlipAndEpochBump()
{
var (world, group, dir, player) = MakeMidRunWorld(0, out var map);
var em = world.EntityManager;
// Room-0 content that must die at the RoomReward entry.
var node0 = em.CreateEntity(typeof(RoomTag));
em.SetComponentData(node0, new RoomTag { Room = 0 });
MarkCleared(em, dir);
group.Update(); // InRoom -> RoomReward + teardown
Assert.AreEqual(RunLifecycle.RoomReward, em.GetComponentData<RunInfo>(dir).Lifecycle);
Assert.AreEqual(0, RoomEntities(em), "cleared room torn down AT ENTRY (the empty-tick guarantee)");
Assert.AreEqual(1, em.GetComponentData<RunRuntime>(dir).RoomsClearedThisRun, "honest depth counter");
group.Update(); // RoomReward -> RouteSelect gate (no boons pending yet)
var gateInfo = em.GetComponentData<RunInfo>(dir);
Assert.AreEqual(RunLifecycle.RouteSelect, gateInfo.Lifecycle, "the branching gate opens (Step 8)");
Assert.Greater((int)gateInfo.RouteOptionCount, 0, "authoritative options published");
// Commit the party's pick directly through the server-only latch (the RPC path is pinned in
// RouteSelectSystemTests): choose the LAST option so a non-lowest pick is exercised when count > 1.
byte pickIdx = (byte)(gateInfo.RouteOptionCount - 1);
byte expectedCol = pickIdx == 2 ? gateInfo.RouteOpt2Col
: pickIdx == 1 ? gateInfo.RouteOpt1Col : gateInfo.RouteOpt0Col;
em.SetComponentData(dir, new RouteCommand { HasPick = 1, OptionIndex = pickIdx, ForRunEpoch = 1, ForLayer = 0 });
group.Update(); // RouteSelect -> consume the pick -> InRoom room 1 at the PICKED column
var info = em.GetComponentData<RunInfo>(dir);
var run = em.GetComponentData<RunRuntime>(dir);
Assert.AreEqual(RunLifecycle.InRoom, info.Lifecycle);
Assert.AreEqual(1, info.CurrentRoom);
Assert.AreEqual(expectedCol, info.CurrentCol, "entered the PICKED column (non-maskable criterion)");
Assert.AreEqual(1, run.ActiveSubSlot, "ping-pong sub-slot flipped");
Assert.AreEqual(2, run.RoomEpoch, "RoomEpoch bumped so the room systems reseed");
Assert.AreEqual(RunMap.NodeId(1, expectedCol), run.CurrentNodeId, "single plan authority published");
Assert.AreEqual(map.Node(1, expectedCol).RoomType, run.CurrentRoomType);
Assert.AreEqual(0, em.GetComponentData<RouteCommand>(dir).HasPick, "latch consumed");
Assert.AreEqual(0, (int)info.RouteOptionCount, "gate closed on advance");
Assert.GreaterOrEqual(em.GetComponentData<LocalTransform>(player).Position.x, 1499f,
"party teleported onto the idle sub-slot (+1500)");
world.Dispose();
}
[Test]
public void BossClear_Returns_AndBanksExactlyOnce_ClearGated()
{
RunMap map0;
var (world, group, dir, player) = MakeMidRunWorld(0, out map0);
var em = world.EntityManager;
int bossLayer = map0.LayerCount - 1;
// Jump the state to the boss room.
var info0 = em.GetComponentData<RunInfo>(dir);
info0.CurrentRoom = bossLayer;
em.SetComponentData(dir, info0);
var run0 = em.GetComponentData<RunRuntime>(dir);
run0.CurrentNodeId = map0.BossNodeId;
run0.ActiveSubSlot = (byte)(bossLayer & 1);
run0.RoomsClearedThisRun = bossLayer;
em.SetComponentData(dir, run0);
MarkCleared(em, dir);
group.Update(); // InRoom -> RoomReward (LastTerminalCleared = 1)
group.Update(); // RoomReward -> Returning
group.Update(); // Returning: bank + teleport home -> Staging
var info = em.GetComponentData<RunInfo>(dir);
Assert.AreEqual(RunLifecycle.Staging, info.Lifecycle);
Assert.AreEqual(RegionId.Base, em.GetComponentData<RegionTag>(player).Region, "party home");
Assert.AreEqual(1, em.GetComponentData<GoalProgress>(dir).Charge, "win meter +1 on a boss clear");
var meta = em.GetComponentData<MetaCounters>(dir);
Assert.AreEqual(1, meta.RunsCompleted, "run completed");
Assert.AreEqual(bossLayer + 1, meta.MaxDepthReached, "honest depth = rooms actually cleared");
var threat = em.GetComponentData<ThreatState>(dir);
Assert.AreEqual(1, threat.PendingReturns, "retaliation input carried (C7)");
Assert.AreEqual(1, threat.ExpeditionsCompleted);
Assert.AreEqual(1, em.GetComponentData<SaveRequest>(dir).Pending, "save checkpoint requested");
Assert.AreEqual(1, info.RunsCompleted, "HUD mirror updated");
group.Update(); // extra Staging ticks must not re-bank (once-per-RunEpoch latch)
group.Update();
Assert.AreEqual(1, em.GetComponentData<GoalProgress>(dir).Charge, "no double credit (F7)");
Assert.AreEqual(1, em.GetComponentData<MetaCounters>(dir).RunsCompleted);
world.Dispose();
}
[Test]
public void Abort_BanksDepthOnly_NoWinCredit()
{
var (world, group, dir, player) = MakeMidRunWorld(2, out var map);
var em = world.EntityManager;
// All expedition players gone mid-room-2 (rooms 0-1 cleared) -> abort.
em.SetComponentData(player, new RegionTag { Region = RegionId.Base });
group.Update(); // InRoom -> Returning (abort)
group.Update(); // Returning: depth-only bank -> Staging
Assert.AreEqual(RunLifecycle.Staging, em.GetComponentData<RunInfo>(dir).Lifecycle);
Assert.AreEqual(0, em.GetComponentData<GoalProgress>(dir).Charge, "no win credit on an abort (D-F3)");
var meta = em.GetComponentData<MetaCounters>(dir);
Assert.AreEqual(0, meta.RunsCompleted, "no completed-run credit");
Assert.AreEqual(2, meta.MaxDepthReached, "honest depth: the 2 rooms actually cleared, not the plan");
var threat = em.GetComponentData<ThreatState>(dir);
Assert.AreEqual(0, threat.PendingReturns, "no retaliation provoked by an abort");
Assert.AreEqual(0, em.GetComponentData<SaveRequest>(dir).Pending, "no save spam on abort");
world.Dispose();
}
[Test]
public void RouteGate_PickBeatsSameTickGrace()
{
var (world, group, dir, player) = MakeMidRunWorld(0, out var map);
var em = world.EntityManager;
MarkCleared(em, dir);
group.Update(); // -> RoomReward (teardown)
group.Update(); // -> RouteSelect (gate open, grace armed at T0)
var gate = em.GetComponentData<RunInfo>(dir);
Assert.AreEqual(RunLifecycle.RouteSelect, gate.Lifecycle);
byte pickIdx = (byte)(gate.RouteOptionCount - 1);
byte pickedCol = pickIdx == 2 ? gate.RouteOpt2Col : pickIdx == 1 ? gate.RouteOpt1Col : gate.RouteOpt0Col;
// A pick latches AND the grace expires on the SAME tick -> the pick must win (review F2 precedence).
em.SetComponentData(dir, new RouteCommand { HasPick = 1, OptionIndex = pickIdx, ForRunEpoch = 1, ForLayer = 0 });
SetTick(world, T0 + 100000); // way past any grace
group.Update();
var info = em.GetComponentData<RunInfo>(dir);
Assert.AreEqual(RunLifecycle.InRoom, info.Lifecycle);
Assert.AreEqual(pickedCol, info.CurrentCol, "the accepted pick beats the same-tick grace expiry");
world.Dispose();
}
[Test]
public void RouteGate_GraceAutoPicksLowestOption()
{
var (world, group, dir, player) = MakeMidRunWorld(0, out var map);
var em = world.EntityManager;
MarkCleared(em, dir);
group.Update(); // -> RoomReward
group.Update(); // -> RouteSelect
var gate = em.GetComponentData<RunInfo>(dir);
byte lowestCol = gate.RouteOpt0Col;
SetTick(world, T0 + 100000); // grace elapses, nobody picked
group.Update();
var info = em.GetComponentData<RunInfo>(dir);
Assert.AreEqual(RunLifecycle.InRoom, info.Lifecycle, "the AFK backstop advances the run");
Assert.AreEqual(lowestCol, info.CurrentCol, "deterministic lowest-index reachable auto-pick");
world.Dispose();
}
[Test]
public void RouteGate_Abort_ClosesGateOnTheEdge()
{
var (world, group, dir, player) = MakeMidRunWorld(0, out var map);
var em = world.EntityManager;
MarkCleared(em, dir);
group.Update(); // -> RoomReward
group.Update(); // -> RouteSelect
Assert.Greater((int)em.GetComponentData<RunInfo>(dir).RouteOptionCount, 0);
em.SetComponentData(player, new RegionTag { Region = RegionId.Base }); // all left
group.Update(); // RouteSelect -> Returning (abort)
var info = em.GetComponentData<RunInfo>(dir);
Assert.AreEqual(RunLifecycle.Returning, info.Lifecycle);
Assert.AreEqual(0, (int)info.RouteOptionCount, "gate closed ON the abort edge (review F3 — no 1-tick clickable-panel window)");
world.Dispose();
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 0b3e546718a6f9846a320e56d5b1acb4
@@ -0,0 +1,197 @@
using NUnit.Framework;
using ProjectM.Simulation;
using Unity.Collections;
namespace ProjectM.Tests
{
/// <summary>
/// Pure-function tests for <see cref="RunMapMath"/> — the deterministic branching run-map generator. Pins
/// determinism (server + client must regenerate the SAME map), the structural invariants the traversal + route
/// choice rely on (run length, single landing, single Boss terminal, an all-Elite gate so every path fights an
/// Elite, full reachability, no all-Reward interior layer), and the reachable-options enumeration. No ECS world.
/// </summary>
public class RunMapMathTests
{
// Sweep a spread of seeds so the structural invariants hold generation-wide, not for one lucky map.
static uint[] Seeds()
{
var s = new uint[64];
for (int i = 0; i < s.Length; i++) s[i] = (uint)(i * 2654435761u + 1u);
return s;
}
[Test]
public void Generate_Deterministic_SameSeedSameMap()
{
foreach (var seed in Seeds())
{
var a = RunMapMath.Generate(seed);
var b = RunMapMath.Generate(seed);
Assert.AreEqual(a.LayerCount, b.LayerCount, $"seed {seed}: LayerCount");
for (int layer = 0; layer < a.LayerCount; layer++)
{
Assert.AreEqual(a.Width(layer), b.Width(layer), $"seed {seed}: width L{layer}");
for (int col = 0; col < a.Width(layer); col++)
Assert.IsTrue(a.Node(layer, col).Equals(b.Node(layer, col)),
$"seed {seed}: node ({layer},{col}) differs between regenerations");
}
}
}
[Test]
public void Generate_RunLength_InSixToTenInclusive()
{
foreach (var seed in Seeds())
{
int L = RunMapMath.Generate(seed).LayerCount;
Assert.GreaterOrEqual(L, 6, $"seed {seed}");
Assert.LessOrEqual(L, RunMap.MaxLayers, $"seed {seed}");
}
}
[Test]
public void Generate_Layer0_IsSingleCombatLanding()
{
foreach (var seed in Seeds())
{
var m = RunMapMath.Generate(seed);
Assert.AreEqual(1, m.Width(0), $"seed {seed}: landing width");
Assert.AreEqual(RoomTypeId.Combat, m.Node(0, 0).RoomType, $"seed {seed}: landing type");
}
}
[Test]
public void Generate_LastLayer_IsSingleBossTerminal()
{
foreach (var seed in Seeds())
{
var m = RunMapMath.Generate(seed);
int last = m.LayerCount - 1;
Assert.AreEqual(1, m.Width(last), $"seed {seed}: boss width");
Assert.AreEqual(RoomTypeId.Boss, m.Node(last, 0).RoomType, $"seed {seed}: boss type");
Assert.AreEqual(0, m.Node(last, 0).NextMask, $"seed {seed}: boss is a terminal (NextMask 0)");
}
}
[Test]
public void Generate_SecondLastLayer_IsAllElite_GuaranteesElitePerPath()
{
// The only layer feeding the Boss is L-2; every start->boss path traverses it. All-Elite there ⇒ every
// path fights >= 1 Elite before the boss.
foreach (var seed in Seeds())
{
var m = RunMapMath.Generate(seed);
int gate = m.LayerCount - 2;
for (int col = 0; col < m.Width(gate); col++)
Assert.AreEqual(RoomTypeId.Elite, m.Node(gate, col).RoomType,
$"seed {seed}: gate node ({gate},{col}) must be Elite");
}
}
[Test]
public void Generate_ExactlyOneTerminal_IsTheBoss()
{
foreach (var seed in Seeds())
{
var m = RunMapMath.Generate(seed);
int terminals = 0;
for (int layer = 0; layer < m.LayerCount; layer++)
for (int col = 0; col < m.Width(layer); col++)
if (m.Node(layer, col).NextMask == 0) terminals++;
Assert.AreEqual(1, terminals, $"seed {seed}: exactly one terminal node (the boss)");
}
}
[Test]
public void Generate_EveryNonBossNode_HasAnOutEdge()
{
foreach (var seed in Seeds())
{
var m = RunMapMath.Generate(seed);
for (int layer = 0; layer < m.LayerCount - 1; layer++)
for (int col = 0; col < m.Width(layer); col++)
Assert.AreNotEqual(0, m.Node(layer, col).NextMask,
$"seed {seed}: node ({layer},{col}) has no out-edge");
}
}
[Test]
public void Generate_AllNodesReachableFromRoot()
{
foreach (var seed in Seeds())
Assert.IsTrue(RunMapMath.AllNodesReachable(RunMapMath.Generate(seed)),
$"seed {seed}: a node was stranded (unreachable from the root)");
}
[Test]
public void Generate_InteriorLayerWidths_AreTwoOrThree()
{
foreach (var seed in Seeds())
{
var m = RunMapMath.Generate(seed);
for (int layer = 1; layer < m.LayerCount - 1; layer++)
{
int w = m.Width(layer);
Assert.IsTrue(w == 2 || w == 3, $"seed {seed}: interior layer {layer} width {w} not in {{2,3}}");
}
}
}
[Test]
public void Generate_NoInteriorLayerIsAllReward()
{
foreach (var seed in Seeds())
{
var m = RunMapMath.Generate(seed);
for (int layer = 1; layer < m.LayerCount - 1; layer++)
{
bool anyNonReward = false;
for (int col = 0; col < m.Width(layer); col++)
if (m.Node(layer, col).RoomType != RoomTypeId.Reward) anyNonReward = true;
Assert.IsTrue(anyNonReward, $"seed {seed}: interior layer {layer} is entirely Reward");
}
}
}
[Test]
public void ReachableOptions_MatchNextMask_AndStayInNextWidth()
{
foreach (var seed in Seeds())
{
var m = RunMapMath.Generate(seed);
for (int layer = 0; layer < m.LayerCount - 1; layer++)
for (int col = 0; col < m.Width(layer); col++)
{
int n = RunMapMath.ReachableOptions(m, layer, col, out FixedList32Bytes<byte> cols);
Assert.Greater(n, 0, $"seed {seed}: node ({layer},{col}) offered no options");
Assert.AreEqual(n, cols.Length);
byte mask = m.Node(layer, col).NextMask;
int wn = m.Width(layer + 1);
for (int i = 0; i < cols.Length; i++)
{
Assert.Less(cols[i], (byte)wn, $"seed {seed}: option out of next-layer width");
Assert.AreNotEqual(0, mask & (1 << cols[i]), $"seed {seed}: option not set in NextMask");
}
}
}
}
[Test]
public void ReachableOptions_BossLayer_ReturnsNone()
{
var m = RunMapMath.Generate(12345u);
int n = RunMapMath.ReachableOptions(m, m.LayerCount - 1, 0, out FixedList32Bytes<byte> cols);
Assert.AreEqual(0, n);
Assert.AreEqual(0, cols.Length);
}
[Test]
public void Hash_IsDeterministic_AndSensitiveToInputs()
{
Assert.AreEqual(RunMapMath.Hash(7u, 3u), RunMapMath.Hash(7u, 3u));
Assert.AreEqual(RunMapMath.Hash(1u, 2u, 3u), RunMapMath.Hash(1u, 2u, 3u));
Assert.AreNotEqual(RunMapMath.Hash(7u, 3u), RunMapMath.Hash(3u, 7u), "order-sensitive");
Assert.AreNotEqual(RunMapMath.Hash(1u), RunMapMath.Hash(2u));
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: ed13d85c05c6fc8469ae19c07647e32e
@@ -167,8 +167,8 @@ namespace ProjectM.Tests
{
var data = new SaveData { GoalCharge = 1, GoalTarget = 4, RunOutcome = RunOutcomeId.Victory };
var back = JsonUtility.FromJson<SaveData>(JsonUtility.ToJson(data));
Assert.AreEqual(SaveData.CurrentVersion, back.Version, "END-2: new saves write v5.");
Assert.AreEqual(5, SaveData.CurrentVersion, "SaveData is at v5 (END-2 added RunOutcome).");
Assert.AreEqual(SaveData.CurrentVersion, back.Version, "new saves write the current version.");
Assert.AreEqual(6, SaveData.CurrentVersion, "SaveData is at v6 (permanent meta: tier rows + run counters).");
Assert.AreEqual((int)RunOutcomeId.Victory, back.RunOutcome, "the latched terminal outcome round-trips through JSON.");
}
@@ -183,6 +183,41 @@ namespace ProjectM.Tests
Assert.LessOrEqual(back.Version, SaveData.CurrentVersion);
}
[Test]
public void Pre_v6_Save_Missing_Meta_Defaults_To_Empty()
{
// A v5 save JSON lacks MetaUpgrades/RunsCompleted/MaxDepthReached -> the field initializer keeps the
// array EMPTY (never null) and the counters 0-default. Additive: no field, no break; v5 loads.
var back = JsonUtility.FromJson<SaveData>("{\"Version\":5,\"GoalCharge\":3,\"GoalTarget\":4}");
Assert.IsNotNull(back.MetaUpgrades, "missing MetaUpgrades -> empty array, never null.");
Assert.AreEqual(0, back.MetaUpgrades.Length);
Assert.AreEqual(0, back.RunsCompleted, "missing RunsCompleted -> 0 (StagePendingSave floors it to GoalCharge).");
Assert.AreEqual(0, back.MaxDepthReached);
Assert.GreaterOrEqual(back.Version, SaveData.MinLoadableVersion, "v5 stays within the additive load floor.");
}
[Test]
public void MetaUpgrades_And_Counters_RoundTrip()
{
var data = new SaveData
{
RunsCompleted = 7,
MaxDepthReached = 9,
MetaUpgrades = new[]
{
new MetaUpgradeSave { ClassId = 2, UpgradeId = 1, Tier = 3 },
new MetaUpgradeSave { ClassId = 3, UpgradeId = 200, Tier = 1 }, // unknown id persists verbatim
},
};
var back = JsonUtility.FromJson<SaveData>(JsonUtility.ToJson(data));
Assert.AreEqual(7, back.RunsCompleted);
Assert.AreEqual(9, back.MaxDepthReached);
Assert.AreEqual(2, back.MetaUpgrades.Length, "meta tier rows round-trip through JSON.");
Assert.AreEqual(2, back.MetaUpgrades[0].ClassId);
Assert.AreEqual(1, back.MetaUpgrades[0].UpgradeId);
Assert.AreEqual(3, back.MetaUpgrades[0].Tier);
Assert.AreEqual(200, back.MetaUpgrades[1].UpgradeId, "an unknown upgrade id is preserved on disk, not clamped at save time (preserve-don't-crash).");
}
}
}
@@ -1,193 +0,0 @@
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 the server-only <see cref="ZoneEnemyDirectorSystem"/>. 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.
/// </summary>
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<SimulationSystemGroup>();
group.AddSystemToUpdateList(world.GetOrCreateSystem<ZoneEnemyDirectorSystem>());
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<Prefab>(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<ZoneEnemyPrefab>(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<PlayerTag>(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<RegionTag>(Allocator.Temp);
Assert.AreEqual(RegionId.Expedition, arr[0].Region, "the spawn is tagged RegionTag{Expedition}");
arr.Dispose();
var zs = em.GetComponentData<ZoneEnemyState>(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<LocalTransform>(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<CycleRuntime>(cyc).ClearedThisEpoch,
"wave fully spawned + no live zone enemies -> a real clear is marked");
}
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: f307978f01b668b42ba10eea2029091b