Run Re-Do
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user